chore: commit remaining workspace updates

This commit is contained in:
777genius 2026-05-13 22:34:13 +03:00
parent a474076330
commit 4c5a752342
24 changed files with 6761 additions and 63 deletions

View file

@ -8,6 +8,11 @@ Start here:
- Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
- Agent team launch/runtime debugging runbook: [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md)
Default local run target:
- Use the desktop Electron app: `pnpm dev`
- Do not start the browser/web dev mode for normal development or smoke checks. The browser path is limited and lacks the full desktop runtime, IPC, terminal, provider auth, and team lifecycle behavior.
- When documenting or recommending startup commands, point contributors to the desktop app unless a task explicitly asks for browser-mode internals.
For new features:
- Default home for medium and large features: `src/features/<feature-name>/`
- Reference implementation: `src/features/recent-projects`

View file

@ -201,10 +201,12 @@ Fact sources checked on May 5, 2026: [detailed research notes](docs/research/gas
## Quick start
1. **Download** the app for your platform (see [Installation](#installation))
2. **Launch** — On first run, the setup wizard will detect the runtime and guide provider authentication
2. **Launch the desktop app** - On first run, the setup wizard will detect the runtime and guide provider authentication
3. **Create a team** — Pick a project, define roles, write a provisioning prompt
4. **Watch** — Agents spawn, create tasks, and work. You see it all on the kanban board
Use the desktop app as the primary product. The browser/web path is not needed for normal use and does not provide the full desktop runtime, IPC, terminal, provider auth, or team lifecycle behavior.
---
@ -274,7 +276,9 @@ pnpm install
pnpm dev
```
The app auto-discovers Claude Code projects from `~/.claude/`.
`pnpm dev` starts the desktop Electron app. Do not start a browser/web dev server for normal development; that path is limited and is not the supported way to run agent teams locally.
The desktop app auto-discovers Claude Code projects from `~/.claude/`.
### Debug teammate runtimes
@ -303,7 +307,7 @@ pnpm dist # macOS + Windows + Linux
| Command | Description |
|---------|-------------|
| `pnpm dev` | Development with hot reload |
| `pnpm dev` | Desktop app development with hot reload |
| `pnpm build` | Production build |
| `pnpm typecheck` | TypeScript type checking |
| `pnpm lint` | Lint (no auto-fix) |

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,6 @@
"main": "dist-electron/main/index.cjs",
"scripts": {
"dev": "node ./scripts/dev-with-runtime.mjs",
"dev:web": "node ./scripts/dev-web.mjs",
"dev:kill": "node bin/kill-dev.js",
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
"opencode:prove-semantic-gauntlet": "node ./scripts/prove-opencode-semantic-gauntlet.mjs",

View file

@ -113,9 +113,25 @@ function resolveEmptyText(
loading: boolean,
error: string | null
): string {
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
const hasCodexUnsupportedWarning = preview?.warnings.some(
(warning) => warning.code === 'codex_member_wide_not_supported'
);
const hasOnlyCodexUnsupportedCoverage =
hasCodexUnsupportedWarning === true &&
(preview?.coverage.length ?? 0) > 0 &&
preview?.coverage.every((coverage) => coverage.provider === 'codex_native_trace');
if (hasOnlyCodexUnsupportedCoverage) {
return 'Unsupported provider';
}
const hasOpenCodeRuntimeWarning = preview?.warnings.some(
(warning) =>
warning.code === 'opencode_runtime_timeout' ||
warning.code === 'opencode_runtime_unavailable' ||
warning.code === 'opencode_ambiguous_lane'
);
if ((preview?.items.length ?? 0) === 0 && hasOpenCodeRuntimeWarning) {
return 'Logs unavailable';
}
if (loading && !preview) return 'Loading logs';
if (error && !preview) return 'Logs unavailable';
return 'No recent logs';

View file

@ -16,7 +16,7 @@ export const DEFAULT_MEMBER_LOG_PREVIEW_BUDGET: MemberLogPreviewBudget = {
maxTranscriptFiles: 8,
maxSourceMessagesPerProvider: 120,
openCodeMessageLimit: 80,
openCodeTimeoutMs: 2_500,
openCodeTimeoutMs: 5_000,
cacheTtlMs: 3_000,
};

View file

@ -134,6 +134,75 @@ API Error: 500 hidden MCP protocol instructions.
expect(result.items[1]?.preview).not.toContain('{"type"');
});
it('marks OpenCode system runtime errors as latest error previews', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
sourceId: 'ses-real-opencode',
sourceLabel: 'OpenCode runtime',
sessionId: 'ses-real-opencode',
laneId: 'secondary:opencode:bob',
messages: [
message({
uuid: 'assistant-before-error',
timestamp: '2026-05-13T17:04:24.347Z',
content: [
{
type: 'text',
text: 'All done. Task #622701b8 completed and approved.',
},
],
}),
message({
uuid: 'opencode-system-error',
type: 'system',
role: 'system',
timestamp: '2026-05-13T17:04:45.546Z',
content: 'OpenCode runtime error - UnknownError: database or disk is full',
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'text',
title: 'Runtime error',
preview: 'OpenCode runtime error - UnknownError: database or disk is full',
tone: 'error',
sourceLabel: 'OpenCode runtime',
sessionId: 'ses-real-opencode',
laneId: 'secondary:opencode:bob',
});
expect(result.items[1]).toMatchObject({
title: 'Assistant',
tone: 'neutral',
});
});
it('does not flag normal runtime-error discussion as a runtime failure', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',
maxItems: 3,
textLimit: 160,
messages: [
message({
uuid: 'normal-runtime-error-discussion',
timestamp: '2026-05-13T17:04:24.347Z',
content: [
{ type: 'text', text: 'Fixed OpenCode runtime error handling for the preview.' },
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'text',
title: 'Assistant',
preview: 'Fixed OpenCode runtime error handling for the preview.',
tone: 'neutral',
});
});
it('extracts readable inbound task and comment messages without agent-only blocks', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',

View file

@ -45,6 +45,7 @@ describe('memberLogPreviewMergePolicy', () => {
'claude_transcript',
'opencode_runtime',
]);
expect(member.warnings).toEqual([{ code: 'large_log_window_limited', message: 'limited' }]);
expect(member.truncated).toBe(true);
expect(member.overflowCount).toBe(2);
});

View file

@ -403,6 +403,7 @@ function formatRuntimeErrorText(
const payload = parseJsonObjectFromText(compact);
const hasErrorSignal =
/^(api error|runtime error|provider error|tool error)\b/i.test(compact) ||
/^(opencode|codex|claude|openai|anthropic|provider)\s+runtime\s+error\b/i.test(compact) ||
/\b(api|codex|claude|openai|anthropic)\s+api\s+error\b/i.test(compact) ||
/\b(api|codex|claude|openai|anthropic|provider)\s+error\s*:\s*\d{3}\b/i.test(compact) ||
unknownPayloadLooksLikeError(payload);
@ -415,10 +416,11 @@ function formatRuntimeErrorText(
const jsonStart = compact.indexOf('{');
const header = jsonStart > 0 ? compact.slice(0, jsonStart).trim() : '';
const payloadMessage = payload ? payloadErrorMessage(payload) : null;
const fallbackText = payloadMessage ?? header;
const text =
payloadMessage && header && !header.toLowerCase().includes(payloadMessage.toLowerCase())
? `${header} - ${payloadMessage}`
: (payloadMessage ?? header);
: fallbackText || compact;
return { ...truncatePreview(text || 'Runtime error', limit), title };
}
@ -2390,6 +2392,33 @@ export function extractMemberLogPreviewItems(
}
}
if (role === 'system') {
const runtimeErrorPreview = formatRuntimeErrorText(
textFromPreviewContent(message.content),
textLimit
);
if (runtimeErrorPreview) {
candidates.push(
buildCandidate({
provider: input.provider,
sourceId,
message,
messageIndex,
blockIndex: 10,
kind: 'text',
title: runtimeErrorPreview.title,
preview: runtimeErrorPreview.preview,
tone: 'error',
sourceLabel: input.sourceLabel,
sessionId: input.sessionId ?? message.sessionId,
laneId: input.laneId,
token: 'system-runtime-error',
textTruncated: runtimeErrorPreview.truncated,
})
);
}
}
if (role === 'user' && message.isMeta !== true && !messageHasToolResult(message)) {
const inboundPreview = extractInboundTextPreview(message.content, textLimit);
if (inboundPreview) {

View file

@ -1,11 +1,19 @@
import * as path from 'node:path';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import {
createOpenCodePromptDeliveryLedgerStore,
type OpenCodePromptDeliveryLedgerRecord,
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
import { getOpenCodeTeamRuntimeLaneDirectory } from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { mapOpenCodeRuntimeTranscriptMessagesToParsedMessages } from '@main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { extractMemberLogPreviewItems } from '../../../../core/domain/policies/memberLogPreviewExtractor';
import { normalizeMemberName } from './memberLogStreamSourceUtils';
import type { MemberLogStreamWarning } from '../../../../contracts';
import type { MemberLogPreviewItem, MemberLogStreamWarning } from '../../../../contracts';
import type {
MemberLogPreviewSource,
MemberLogPreviewSourceInput,
@ -13,23 +21,83 @@ import type {
} from '../../../../core/application/ports/MemberLogPreviewSource';
import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
const OPENCODE_PROMPT_DELIVERY_LEDGER_FILE = 'opencode-prompt-delivery-ledger.json';
const MAX_LEDGER_RECORDS_TO_CONSIDER = 24;
const HIDDEN_PREVIEW_BLOCK_TAGS = [
'info_for_agent',
'opencode_runtime_identity',
'opencode_app_message_delivery',
'system-reminder',
] as const;
const ERROR_RESPONSE_STATES: ReadonlySet<string> = new Set([
'permission_blocked',
'tool_error',
'session_error',
'reconcile_failed',
] as const);
interface BinaryResolverLike {
resolve(): Promise<string | null>;
}
interface OpenCodePromptDeliveryLedgerPreviewReader {
list(input: {
teamName: string;
memberName: string;
laneId: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord[]>;
}
class FileOpenCodePromptDeliveryLedgerPreviewReader implements OpenCodePromptDeliveryLedgerPreviewReader {
async list(input: {
teamName: string;
memberName: string;
laneId: string;
}): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
const laneDir = getOpenCodeTeamRuntimeLaneDirectory(
getTeamsBasePath(),
input.teamName,
input.laneId
);
const store = createOpenCodePromptDeliveryLedgerStore({
filePath: path.join(laneDir, OPENCODE_PROMPT_DELIVERY_LEDGER_FILE),
});
const normalizedMemberName = normalizeMemberName(input.memberName);
return (await store.list()).filter(
(record) =>
record.teamName === input.teamName &&
normalizeMemberName(record.memberName) === normalizedMemberName &&
record.laneId === input.laneId
);
}
}
const DEFAULT_LEDGER_PREVIEW_READER = new FileOpenCodePromptDeliveryLedgerPreviewReader();
function classifyOpenCodePreviewError(error: unknown): MemberLogStreamWarning {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
if (normalized.includes('timed out') || normalized.includes('timeout')) {
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : null;
const code = typeof record?.code === 'string' ? record.code : '';
const signal = typeof record?.signal === 'string' ? record.signal : '';
const killed = record?.killed === true ? 'killed' : '';
const normalized = [message, code, signal, killed].join(' ').toLowerCase();
if (
normalized.includes('timed out') ||
normalized.includes('timeout') ||
normalized.includes('code 143') ||
normalized.includes('signal sigterm') ||
normalized.includes('killed')
) {
return {
code: 'opencode_runtime_timeout',
message: 'OpenCode runtime preview timed out; graph preview will use other sources.',
};
}
if (
normalized.includes('--lane') ||
normalized.includes('multiple') ||
normalized.includes('ambiguous')
normalized.includes('ambiguous') ||
normalized.includes('without a safe lane') ||
normalized.includes('requires --lane') ||
(normalized.includes('multiple') && normalized.includes('lane'))
) {
return {
code: 'opencode_ambiguous_lane',
@ -42,6 +110,246 @@ function classifyOpenCodePreviewError(error: unknown): MemberLogStreamWarning {
};
}
function parseTimestampMs(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function ledgerRecordTimestampMs(record: OpenCodePromptDeliveryLedgerRecord): number {
return Math.max(
parseTimestampMs(record.respondedAt),
parseTimestampMs(record.lastObservedAt),
parseTimestampMs(record.failedAt),
parseTimestampMs(record.acceptedAt),
parseTimestampMs(record.lastAttemptAt),
parseTimestampMs(record.updatedAt),
parseTimestampMs(record.createdAt),
parseTimestampMs(record.inboxTimestamp)
);
}
function ledgerRecordTimestampIso(record: OpenCodePromptDeliveryLedgerRecord): string {
return new Date(ledgerRecordTimestampMs(record) || 0).toISOString();
}
function removeHiddenPreviewBlocks(value: string): string {
let result = value;
for (const tag of HIDDEN_PREVIEW_BLOCK_TAGS) {
result = result.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), ' ');
}
return result;
}
function stripAngleTags(value: string): string {
let result = '';
let insideTag = false;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (!insideTag && char === '<') {
const next = value[index + 1] ?? '';
if (/[A-Za-z/!]/.test(next)) {
insideTag = true;
result += ' ';
continue;
}
}
if (insideTag) {
if (char === '>') {
insideTag = false;
result += ' ';
}
continue;
}
result += char;
}
return result;
}
function sanitizeLedgerPreviewText(value: string, limit: number): string {
const compact = stripAngleTags(removeHiddenPreviewBlocks(value))
.replace(/\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '$1')
.replace(/\s+/g, ' ')
.trim();
if (compact.length <= limit) {
return compact;
}
const allowed = Math.max(1, limit - 3);
return `${compact.slice(0, allowed)}...`;
}
function formatLedgerToolNames(toolNames: readonly string[], limit: number): string {
const uniqueNames = [...new Set(toolNames.map((name) => name.trim()).filter(Boolean))];
const visibleNames = uniqueNames.slice(0, 5);
const suffix = uniqueNames.length > visibleNames.length ? ` +${uniqueNames.length - 5} more` : '';
return sanitizeLedgerPreviewText(`${visibleNames.join(', ')}${suffix}`, limit);
}
function formatTaskRefs(record: OpenCodePromptDeliveryLedgerRecord): string {
const refs = record.taskRefs
.map((taskRef) => taskRef.displayId || taskRef.taskId.slice(0, 8))
.filter(Boolean)
.slice(0, 2);
return refs.length > 0 ? ` for #${refs.join(', #')}` : '';
}
function ledgerStatusText(record: OpenCodePromptDeliveryLedgerRecord): string {
const taskSuffix = formatTaskRefs(record);
switch (record.status) {
case 'pending':
return `Prompt queued${taskSuffix}`;
case 'accepted':
return `Prompt accepted${taskSuffix}`;
case 'responded':
return `Response observed${taskSuffix}`;
case 'unanswered':
return `Prompt delivered, response not observed yet${taskSuffix}`;
case 'retry_scheduled':
return `Delivery retry scheduled${taskSuffix}`;
case 'retried':
return `Prompt retried${taskSuffix}`;
case 'failed_retryable':
return `Delivery retry pending${taskSuffix}`;
case 'failed_terminal':
return `Delivery failed${taskSuffix}`;
default:
return `OpenCode delivery updated${taskSuffix}`;
}
}
function ledgerErrorTitle(record: OpenCodePromptDeliveryLedgerRecord): string {
const reason = record.lastReason?.toLowerCase() ?? '';
if (reason.includes('visible_reply')) {
return 'Visible reply missing';
}
switch (record.responseState) {
case 'empty_assistant_turn':
return 'Empty assistant turn';
case 'permission_blocked':
return 'Permission blocked';
case 'prompt_delivered_no_assistant_message':
return 'No assistant reply';
case 'responded_non_visible_tool':
return 'Visible reply missing';
case 'session_stale':
return 'OpenCode session stale';
case 'tool_error':
return 'Tool error';
case 'session_error':
return 'OpenCode session error';
case 'reconcile_failed':
return 'OpenCode reconcile failed';
default:
return 'OpenCode delivery failed';
}
}
function ledgerRecordIsError(record: OpenCodePromptDeliveryLedgerRecord): boolean {
return record.status === 'failed_terminal' || ERROR_RESPONSE_STATES.has(record.responseState);
}
function firstNonEmptyText(values: readonly (string | null | undefined)[]): string {
return values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim() ?? '';
}
function humanizeShortReason(value: string): string {
return /^[a-z0-9_]+$/i.test(value) ? value.replace(/_/g, ' ') : value;
}
function buildLedgerPreviewItem(
record: OpenCodePromptDeliveryLedgerRecord,
input: MemberLogPreviewSourceInput
): MemberLogPreviewItem | null {
const timestamp = ledgerRecordTimestampIso(record);
const sourceBase = {
provider: 'opencode_runtime' as const,
timestamp,
sourceLabel: 'OpenCode delivery',
sessionId: record.runtimeSessionId ?? undefined,
laneId: input.laneId,
};
if (ledgerRecordIsError(record)) {
const preview = sanitizeLedgerPreviewText(
humanizeShortReason(
firstNonEmptyText([
record.lastReason,
record.diagnostics[0],
record.observedAssistantPreview,
ledgerStatusText(record),
])
),
input.textLimit
);
return {
...sourceBase,
id: `opencode-ledger:${record.id}:error`,
kind: 'tool_result',
title: ledgerErrorTitle(record),
preview,
tone: 'error',
};
}
if (record.observedAssistantPreview?.trim()) {
return {
...sourceBase,
id: `opencode-ledger:${record.id}:assistant`,
kind: 'text',
title:
record.responseState === 'responded_visible_message' ||
record.responseState === 'responded_plain_text'
? 'OpenCode reply'
: 'Assistant',
preview: sanitizeLedgerPreviewText(record.observedAssistantPreview, input.textLimit),
tone: 'neutral',
};
}
if (record.observedToolCallNames.length > 0) {
return {
...sourceBase,
id: `opencode-ledger:${record.id}:tools`,
kind: 'tool_use',
title: 'Tool activity',
preview: formatLedgerToolNames(record.observedToolCallNames, input.textLimit),
tone: 'neutral',
};
}
if (
record.responseState === 'responded_visible_message' ||
record.responseState === 'responded_plain_text'
) {
return {
...sourceBase,
id: `opencode-ledger:${record.id}:reply`,
kind: 'text',
title: 'OpenCode reply',
preview: sanitizeLedgerPreviewText(ledgerStatusText(record), input.textLimit),
tone: 'success',
};
}
const statusText = sanitizeLedgerPreviewText(
firstNonEmptyText([record.lastReason, ledgerStatusText(record)]),
input.textLimit
);
return statusText
? {
...sourceBase,
id: `opencode-ledger:${record.id}:status`,
kind: 'text',
title: 'OpenCode status',
preview: statusText,
tone:
record.status === 'failed_retryable' || record.status === 'retry_scheduled'
? 'warning'
: 'neutral',
}
: null;
}
export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSource {
readonly provider = 'opencode_runtime' as const;
private readonly cache = new Map<
@ -52,7 +360,8 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
constructor(
private readonly runtimeBridge: ClaudeMultimodelBridgeService,
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver,
private readonly ledgerReader: OpenCodePromptDeliveryLedgerPreviewReader = DEFAULT_LEDGER_PREVIEW_READER
) {}
async loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult> {
@ -108,11 +417,73 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
};
}
const ledgerResult = await this.buildLedgerResult(input);
if (ledgerResult.status === 'included') {
return ledgerResult;
}
return this.buildTranscriptResult(input, ledgerResult.warnings);
}
private async buildLedgerResult(
input: MemberLogPreviewSourceInput
): Promise<MemberLogPreviewSourceResult> {
try {
const orderedRecords = (
await this.ledgerReader.list({
teamName: input.teamName,
memberName: input.memberName,
laneId: input.laneId ?? '',
})
).sort((left, right) => {
const byTime = ledgerRecordTimestampMs(right) - ledgerRecordTimestampMs(left);
return byTime !== 0 ? byTime : right.id.localeCompare(left.id);
});
const records = orderedRecords.slice(0, MAX_LEDGER_RECORDS_TO_CONSIDER);
const candidates = records
.map((record) => buildLedgerPreviewItem(record, input))
.filter((item): item is MemberLogPreviewItem => Boolean(item));
const items = candidates.slice(0, input.maxItems);
const overflowCount = Math.max(
0,
Math.max(candidates.length, orderedRecords.length) - items.length
);
return {
provider: this.provider,
status: items.length > 0 ? 'included' : 'skipped',
reason: items.length > 0 ? undefined : 'opencode_delivery_ledger_empty',
items,
warnings: [],
truncated: overflowCount > 0,
overflowCount,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return this.skipped(
'opencode_runtime_unavailable',
'OpenCode delivery ledger preview is unavailable.',
{
code: 'opencode_runtime_unavailable',
message: `OpenCode delivery ledger preview is unavailable: ${message}`,
}
);
}
}
private async buildTranscriptResult(
input: MemberLogPreviewSourceInput,
extraWarnings: readonly MemberLogStreamWarning[]
): Promise<MemberLogPreviewSourceResult> {
const binaryPath = await this.binaryResolver.resolve();
if (!binaryPath) {
return this.skipped(
'opencode_runtime_unavailable',
'OpenCode runtime bridge is unavailable.'
'OpenCode runtime bridge is unavailable.',
{
code: 'opencode_runtime_unavailable',
message: 'OpenCode runtime bridge is unavailable.',
},
extraWarnings
);
}
@ -134,7 +505,7 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
status: 'skipped',
reason: 'opencode_missing_runtime_session',
items: [],
warnings: [],
warnings: [...extraWarnings],
truncated: false,
overflowCount: 0,
};
@ -160,27 +531,28 @@ export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSourc
status: extracted.items.length > 0 ? 'included' : 'skipped',
reason: extracted.items.length > 0 ? undefined : 'opencode_no_renderable_preview',
items: extracted.items,
warnings: [],
warnings: [...extraWarnings],
truncated: extracted.truncated,
overflowCount: extracted.overflowCount,
};
} catch (error) {
const warning = classifyOpenCodePreviewError(error);
return this.skipped(warning.code, warning.message, warning);
return this.skipped(warning.code, warning.message, warning, extraWarnings);
}
}
private skipped(
code: MemberLogStreamWarning['code'],
reason: string,
warning: MemberLogStreamWarning = { code, message: reason }
warning: MemberLogStreamWarning | undefined = { code, message: reason },
extraWarnings: readonly MemberLogStreamWarning[] = []
): MemberLogPreviewSourceResult {
return {
provider: this.provider,
status: 'skipped',
reason,
items: [],
warnings: [warning],
warnings: [...extraWarnings, ...(warning ? [warning] : [])],
truncated: false,
overflowCount: 0,
};

View file

@ -1,4 +1,13 @@
import { describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import {
OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION,
type OpenCodePromptDeliveryLedgerRecord,
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_MEMBER_LOG_PREVIEW_BUDGET } from '../../../../../core/domain/models/MemberLogPreviewBudget';
import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../../../core/domain/models/MemberLogStreamBudget';
@ -78,6 +87,102 @@ function previewInput(
};
}
const tempClaudeRoots: string[] = [];
afterEach(async () => {
setClaudeBasePathOverride(null);
const roots = tempClaudeRoots.splice(0);
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
});
async function createTempClaudeRoot(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'member-log-source-'));
tempClaudeRoots.push(root);
setClaudeBasePathOverride(root);
return root;
}
async function writeOpenCodePromptLedger(input: {
claudeRoot: string;
teamName: string;
laneId: string;
records: OpenCodePromptDeliveryLedgerRecord[];
}): Promise<string> {
const ledgerPath = path.join(
input.claudeRoot,
'teams',
input.teamName,
'.opencode-runtime',
'lanes',
encodeURIComponent(input.laneId),
'opencode-prompt-delivery-ledger.json'
);
await mkdir(path.dirname(ledgerPath), { recursive: true });
await writeFile(
ledgerPath,
`${JSON.stringify(
{
schemaVersion: OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION,
updatedAt: '2026-04-04T00:00:00.000Z',
data: input.records,
},
null,
2
)}\n`
);
return ledgerPath;
}
function openCodeLedgerRecord(
overrides: Partial<OpenCodePromptDeliveryLedgerRecord> = {}
): OpenCodePromptDeliveryLedgerRecord {
const now = overrides.updatedAt ?? '2026-04-04T00:00:00.000Z';
return {
id: overrides.id ?? 'opencode-prompt:record-1',
teamName: overrides.teamName ?? 'alpha-team',
memberName: overrides.memberName ?? 'alice',
laneId: overrides.laneId ?? 'secondary:opencode:alice',
runId: 'opencode-run-1',
runtimeSessionId: 'opencode-session',
inboxMessageId: 'inbox-message-1',
inboxTimestamp: now,
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: 'do',
taskRefs: [{ taskId: 'task-1', displayId: 'abc12345', teamName: 'alpha-team' }],
payloadHash: 'sha256:test',
status: 'responded',
responseState: 'responded_visible_message',
attempts: 1,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: now,
lastObservedAt: now,
acceptedAt: now,
respondedAt: now,
failedAt: null,
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'opencode-user-message',
observedAssistantMessageId: 'opencode-assistant-message',
observedAssistantPreview: 'Implemented the calculator updates and verified tests.',
observedToolCallNames: [],
observedVisibleMessageId: null,
visibleReplyMessageId: 'visible-reply-1',
visibleReplyInbox: 'team-lead',
visibleReplyCorrelation: 'plain_assistant_text',
lastReason: null,
diagnostics: [],
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe('ClaudeMemberTranscriptStreamSource', () => {
it('dedupes cumulative subagent refs by member/session before parsing and keeps path-safe segment ids', async () => {
const parseFiles = vi.fn().mockImplementation(async (paths: string[]) => {
@ -349,6 +454,558 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('uses lane delivery ledger previews before touching the runtime bridge', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
openCodeLedgerRecord({
laneId,
observedAssistantPreview:
'Finished #abc12345 after reading context. <system-reminder>hidden</system-reminder>',
}),
],
});
const getOpenCodeTranscript = vi.fn();
const resolve = vi.fn();
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve,
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'text',
title: 'OpenCode reply',
preview: 'Finished #abc12345 after reading context.',
sourceLabel: 'OpenCode delivery',
sessionId: 'opencode-session',
laneId,
});
expect(result.truncated).toBe(false);
expect(result.overflowCount).toBe(0);
expect(resolve).not.toHaveBeenCalled();
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('renders OpenCode non-visible tool activity from the delivery ledger', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
openCodeLedgerRecord({
laneId,
responseState: 'responded_non_visible_tool',
observedAssistantPreview: null,
observedToolCallNames: ['task_get', 'read', 'bash', 'read'],
}),
],
});
const source = new OpenCodeMemberRuntimePreviewSource(
{ getOpenCodeTranscript: vi.fn() } as never,
{ resolve: vi.fn() }
);
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'tool_use',
title: 'Tool activity',
preview: 'task_get, read, bash',
tone: 'neutral',
});
});
it('renders terminal OpenCode delivery errors as error-toned previews', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
openCodeLedgerRecord({
laneId,
status: 'failed_terminal',
responseState: 'tool_error',
observedAssistantPreview: null,
lastReason: 'tool failed with stderr output',
diagnostics: ['stderr: permission denied'],
failedAt: '2026-04-04T00:01:00.000Z',
updatedAt: '2026-04-04T00:01:00.000Z',
}),
],
});
const source = new OpenCodeMemberRuntimePreviewSource(
{ getOpenCodeTranscript: vi.fn() } as never,
{ resolve: vi.fn() }
);
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Tool error',
preview: 'tool failed with stderr output',
tone: 'error',
});
});
it('renders the real relay-works bob ledger shape as tool activity without runtime transcript', async () => {
const claudeRoot = await createTempClaudeRoot();
const teamName = 'relay-works';
const memberName = 'bob';
const laneId = 'secondary:opencode:bob';
await writeOpenCodePromptLedger({
claudeRoot,
teamName,
laneId,
records: [
openCodeLedgerRecord({
id: 'opencode-prompt:relay-bob-real-shape',
teamName,
memberName,
laneId,
runId: 'relay-bob-run',
runtimeSessionId: 'relay-bob-session',
inboxMessageId: 'relay-bob-inbox-message',
inboxTimestamp: '2026-05-06T21:55:58.077Z',
actionMode: null,
taskRefs: [
{
taskId: '20eb14b4-144d-4c52-89c1-bdeb7b9a14ef',
displayId: '20eb14b4',
teamName,
},
],
status: 'responded',
responseState: 'responded_non_visible_tool',
attempts: 2,
nextAttemptAt: '2026-05-06T21:56:26.767Z',
lastAttemptAt: '2026-05-06T21:57:03.644Z',
lastObservedAt: '2026-05-06T21:57:03.644Z',
acceptedAt: '2026-05-06T21:56:11.751Z',
respondedAt: '2026-05-06T21:56:11.751Z',
inboxReadCommittedAt: '2026-05-06T21:57:03.692Z',
observedAssistantPreview: null,
observedToolCallNames: ['task_get', 'bash', 'task_start', 'read', 'glob'],
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
lastReason: 'non_visible_tool_without_task_progress',
diagnostics: [
'OpenCode app MCP was reattached before message delivery.',
'OpenCode bootstrap MCP did not complete required tools before assistant response: runtime_bootstrap_checkin, member_briefing',
],
createdAt: '2026-05-06T21:55:58.224Z',
updatedAt: '2026-05-06T21:57:03.692Z',
}),
],
});
const getOpenCodeTranscript = vi.fn();
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn(),
});
const result = await source.loadPreview(
previewInput({ teamName, memberName, laneId, textLimit: 160 })
);
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'tool_use',
title: 'Tool activity',
preview: 'task_get, bash, task_start, read, glob',
tone: 'neutral',
sessionId: 'relay-bob-session',
laneId,
});
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('renders the real relay-works jack visible-reply failure as a readable error', async () => {
const claudeRoot = await createTempClaudeRoot();
const teamName = 'relay-works';
const memberName = 'jack';
const laneId = 'secondary:opencode:jack';
await writeOpenCodePromptLedger({
claudeRoot,
teamName,
laneId,
records: [
openCodeLedgerRecord({
id: 'opencode-prompt:relay-jack-real-shape',
teamName,
memberName,
laneId,
runId: 'relay-jack-run',
runtimeSessionId: 'relay-jack-session',
inboxMessageId: 'relay-jack-stall-message',
inboxTimestamp: '2026-05-06T22:06:32.842Z',
source: 'watchdog',
replyRecipient: 'user',
actionMode: 'do',
taskRefs: [
{
taskId: '32cc252b-7c6f-4f1d-af0a-c13c18861a4e',
displayId: '32cc252b',
teamName,
},
],
status: 'failed_terminal',
responseState: 'responded_visible_message',
attempts: 3,
nextAttemptAt: null,
lastAttemptAt: '2026-05-06T22:08:03.447Z',
lastObservedAt: '2026-05-06T22:08:03.447Z',
acceptedAt: '2026-05-06T22:07:00.236Z',
respondedAt: '2026-05-06T22:07:00.236Z',
failedAt: '2026-05-06T22:08:03.471Z',
inboxReadCommittedAt: null,
observedAssistantPreview: null,
observedToolCallNames: ['task_get', 'message_send'],
observedVisibleMessageId: 'functions.agent-teams_message_send:1',
visibleReplyMessageId: null,
visibleReplyCorrelation: 'direct_child_message_send',
lastReason: 'visible_reply_destination_not_found_yet',
diagnostics: [
'OpenCode app MCP was reattached before message delivery.',
'visible_reply_destination_not_found_yet',
],
createdAt: '2026-05-06T22:06:32.865Z',
updatedAt: '2026-05-06T22:08:03.471Z',
}),
],
});
const getOpenCodeTranscript = vi.fn();
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn(),
});
const result = await source.loadPreview(previewInput({ teamName, memberName, laneId }));
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Visible reply missing',
preview: 'visible reply destination not found yet',
tone: 'error',
sessionId: 'relay-jack-session',
laneId,
});
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('reports ledger overflow from the full filtered lane record count', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: Array.from({ length: 5 }, (_, index) =>
openCodeLedgerRecord({
id: `opencode-prompt:record-${index}`,
laneId,
observedAssistantPreview: `Ledger event ${index}`,
updatedAt: `2026-04-04T00:00:0${index}.000Z`,
})
),
});
const source = new OpenCodeMemberRuntimePreviewSource(
{ getOpenCodeTranscript: vi.fn() } as never,
{ resolve: vi.fn() }
);
const result = await source.loadPreview(previewInput({ laneId, maxItems: 3 }));
expect(result.status).toBe('included');
expect(result.truncated).toBe(true);
expect(result.overflowCount).toBe(2);
expect(result.items.map((item) => item.preview)).toEqual([
'Ledger event 4',
'Ledger event 3',
'Ledger event 2',
]);
});
it('falls back to OpenCode transcript when the delivery ledger has no renderable records', async () => {
await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
sessionId: 'opencode-session',
logProjection: {
messages: [
{
uuid: 'opencode-transcript-1',
parentUuid: null,
type: 'assistant',
timestamp: '2026-04-04T00:00:00.000Z',
role: 'assistant',
content: 'Transcript response from OpenCode.',
toolCalls: [],
toolResults: [],
isMeta: false,
sessionId: 'opencode-session',
},
],
},
});
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'text',
title: 'Assistant',
preview: 'Transcript response from OpenCode.',
});
expect(result.warnings).toEqual([]);
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
});
it('reports runtime unavailable when no ledger exists and the OpenCode binary is missing', async () => {
await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
const getOpenCodeTranscript = vi.fn();
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue(null),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result).toMatchObject({
provider: 'opencode_runtime',
status: 'skipped',
reason: 'OpenCode runtime bridge is unavailable.',
warnings: [
{
code: 'opencode_runtime_unavailable',
message: 'OpenCode runtime bridge is unavailable.',
},
],
});
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
});
it('renders the real comet-hub OpenCode transcript error shape when no ledger exists', async () => {
await createTempClaudeRoot();
const teamName = 'comet-hub';
const memberName = 'bob';
const laneId = 'secondary:opencode:bob';
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
sessionId: 'ses_1ddb71a6affexQQR3lRdHRfAOX',
logProjection: {
messages: [
{
uuid: 'msg_e224bf71b0017NamfjVeIoBdP7',
parentUuid: null,
type: 'assistant',
timestamp: '2026-05-13T17:04:24.347Z',
role: 'assistant',
content: [
{
type: 'thinking',
thinking:
'I need to stop. The protocol instruction says to stop after the message_send succeeds.',
signature: 'opencode',
},
{
type: 'text',
text: 'All done. Task #622701b8 completed and approved.',
},
],
toolCalls: [],
toolResults: [],
isMeta: false,
sessionId: 'ses_1ddb71a6affexQQR3lRdHRfAOX',
},
{
uuid: 'msg_e224c0ae2001F76RknnkihymsV::error',
parentUuid: 'msg_e224bf71b0017NamfjVeIoBdP7',
type: 'system',
timestamp: '2026-05-13T17:04:45.546Z',
role: 'system',
content: 'OpenCode runtime error - UnknownError: database or disk is full',
toolCalls: [],
toolResults: [],
isMeta: false,
sessionId: 'ses_1ddb71a6affexQQR3lRdHRfAOX',
},
],
},
});
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const result = await source.loadPreview(previewInput({ teamName, memberName, laneId }));
expect(result.status).toBe('included');
expect(result.items[0]).toMatchObject({
kind: 'text',
title: 'Runtime error',
preview: 'OpenCode runtime error - UnknownError: database or disk is full',
tone: 'error',
sourceLabel: 'OpenCode runtime',
sessionId: 'ses_1ddb71a6affexQQR3lRdHRfAOX',
laneId,
});
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
});
it('does not classify command text containing --lane as an ambiguous lane failure', async () => {
await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
const getOpenCodeTranscript = vi
.fn()
.mockRejectedValue(
new Error(`Command failed: runtime transcript --lane ${laneId} exited with code 1`)
);
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('skipped');
expect(result.warnings[0]?.code).toBe('opencode_runtime_unavailable');
});
it('classifies killed OpenCode transcript calls as timeouts even when command text includes --lane', async () => {
await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
const getOpenCodeTranscript = vi
.fn()
.mockRejectedValue(
new Error(`Command failed: runtime transcript --lane ${laneId} exited with code 143`)
);
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('skipped');
expect(result.warnings[0]?.code).toBe('opencode_runtime_timeout');
});
it('classifies exec timeout objects as OpenCode runtime timeouts', async () => {
await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
const timeoutError = Object.assign(
new Error(`Command failed: runtime transcript --lane ${laneId}`),
{
killed: true,
signal: 'SIGTERM',
}
);
const getOpenCodeTranscript = vi.fn().mockRejectedValue(timeoutError);
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('skipped');
expect(result.warnings[0]?.code).toBe('opencode_runtime_timeout');
});
it('keeps batch preview working when the delivery ledger is corrupt', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:alice';
const ledgerPath = path.join(
claudeRoot,
'teams',
'alpha-team',
'.opencode-runtime',
'lanes',
encodeURIComponent(laneId),
'opencode-prompt-delivery-ledger.json'
);
await mkdir(path.dirname(ledgerPath), { recursive: true });
await writeFile(ledgerPath, '{not-json');
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
sessionId: 'opencode-session',
logProjection: {
messages: [
{
uuid: 'opencode-transcript-1',
parentUuid: null,
type: 'assistant',
timestamp: '2026-04-04T00:00:00.000Z',
role: 'assistant',
content: 'Transcript still works.',
toolCalls: [],
toolResults: [],
isMeta: false,
sessionId: 'opencode-session',
},
],
},
});
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const result = await source.loadPreview(previewInput({ laneId }));
expect(result.status).toBe('included');
expect(result.items[0]?.preview).toBe('Transcript still works.');
expect(result.warnings.map((warning) => warning.code)).toContain(
'opencode_runtime_unavailable'
);
});
it('uses encoded lane-specific ledger paths and filters unrelated records', async () => {
const claudeRoot = await createTempClaudeRoot();
const laneId = 'secondary:opencode:ali/ce?x';
const ledgerPath = await writeOpenCodePromptLedger({
claudeRoot,
teamName: 'alpha-team',
laneId,
records: [
openCodeLedgerRecord({
id: 'opencode-prompt:other-member',
laneId,
memberName: 'bob',
observedAssistantPreview: 'Wrong member',
}),
openCodeLedgerRecord({
id: 'opencode-prompt:other-lane',
laneId: 'secondary:opencode:other',
observedAssistantPreview: 'Wrong lane',
}),
openCodeLedgerRecord({
id: 'opencode-prompt:valid',
laneId,
observedAssistantPreview: 'Correct lane preview',
}),
],
});
const source = new OpenCodeMemberRuntimePreviewSource(
{ getOpenCodeTranscript: vi.fn() } as never,
{ resolve: vi.fn() }
);
const result = await source.loadPreview(previewInput({ laneId }));
expect(ledgerPath).toContain(encodeURIComponent(laneId));
expect(result.status).toBe('included');
expect(result.items.map((item) => item.preview)).toEqual(['Correct lane preview']);
});
it('uses bounded OpenCode projection messages and preserves safe lane ids', async () => {
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
sessionId: 'opencode-session',
@ -376,9 +1033,15 @@ describe('OpenCodeMemberRuntimePreviewSource', () => {
],
},
});
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
});
const source = new OpenCodeMemberRuntimePreviewSource(
{ getOpenCodeTranscript } as never,
{
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
},
{
list: vi.fn().mockResolvedValue([]),
}
);
const result = await source.loadPreview(previewInput({ laneId: 'secondary:opencode:alice' }));

View file

@ -143,6 +143,7 @@ import {
createOpenCodeBridgeClientIdentity,
OpenCodeBridgeCommandHandshakePort,
} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
import { cleanupManagedOpenCodeServeProcesses } from './services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup';
import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import {
@ -221,6 +222,8 @@ import type { FileChangeEvent } from '@main/types';
import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types';
const logger = createLogger('App');
const appStartedAtMs = Date.now();
const openCodeManagedHostInstanceId = `${process.pid}-${appStartedAtMs}`;
let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null;
if (
earlyElectronUserDataMigrationResult.migrated &&
@ -346,6 +349,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
reportProgress('runtime-environment', 'Preparing runtime environment...');
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
try {
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
@ -419,23 +423,63 @@ async function createOpenCodeRuntimeAdapterRegistry(
}
async function cleanupOpenCodeHostsForLifecycle(reason: 'startup' | 'shutdown'): Promise<void> {
if (!openCodeLifecycleBridge) {
return;
}
const result = await openCodeLifecycleBridge.cleanupOpenCodeHosts({
reason,
mode: reason === 'shutdown' ? 'force' : 'stale',
staleAgeMs: reason === 'startup' ? 5 * 60_000 : null,
leaseStaleAgeMs: reason === 'startup' ? 24 * 60 * 60_000 : null,
preflightLeaseStaleAgeMs: reason === 'startup' ? 2 * 60_000 : null,
});
if (result.cleaned > 0) {
logger.info(
`[OpenCode] ${reason} host cleanup removed ${result.cleaned} registry host(s), ${result.remaining} remaining`
let registryHostPids = new Set<number>();
let registryCleanupAvailable = false;
if (openCodeLifecycleBridge) {
const result = await openCodeLifecycleBridge.cleanupOpenCodeHosts({
reason,
mode: reason === 'shutdown' ? 'force' : 'stale',
staleAgeMs: reason === 'startup' ? 5 * 60_000 : null,
leaseStaleAgeMs: reason === 'startup' ? 24 * 60 * 60_000 : null,
preflightLeaseStaleAgeMs: reason === 'startup' ? 2 * 60_000 : null,
});
registryHostPids = new Set(
result.hosts
.filter((host) => host.action.startsWith('kept_'))
.map((host) => host.pid)
.filter((pid) => Number.isFinite(pid) && pid > 0)
);
if (result.cleaned > 0) {
logger.info(
`[OpenCode] ${reason} host cleanup removed ${result.cleaned} registry host(s), ${result.remaining} remaining`
);
}
for (const diagnostic of result.diagnostics) {
logger.warn(`[OpenCode] ${reason} host cleanup: ${diagnostic}`);
}
registryCleanupAvailable = !result.diagnostics.some((diagnostic) =>
diagnostic.startsWith('OpenCode host cleanup bridge failed:')
);
}
for (const diagnostic of result.diagnostics) {
logger.warn(`[OpenCode] ${reason} host cleanup: ${diagnostic}`);
if (reason === 'startup' && !registryCleanupAvailable) {
logger.warn(
'[OpenCode] Startup fallback cleanup skipped because host registry cleanup is unavailable'
);
return;
}
await cleanupOpenCodeHostProcessFallback(`${reason} fallback`, {
mode: reason === 'shutdown' ? 'force' : 'orphaned',
excludePids: reason === 'startup' ? registryHostPids : undefined,
requiredDetailsMarkers:
reason === 'shutdown'
? [`CLAUDE_TEAM_APP_INSTANCE_ID=${openCodeManagedHostInstanceId}`]
: undefined,
startedBeforeMs: reason === 'startup' ? appStartedAtMs : null,
});
}
async function cleanupOpenCodeHostProcessFallback(
label: string,
options: Parameters<typeof cleanupManagedOpenCodeServeProcesses>[0]
): Promise<void> {
const fallback = await cleanupManagedOpenCodeServeProcesses(options);
if (fallback.killed > 0) {
logger.info(`[OpenCode] ${label} cleanup killed ${fallback.killed} managed host(s)`);
}
for (const diagnostic of fallback.diagnostics) {
logger.warn(`[OpenCode] ${label} cleanup: ${diagnostic}`);
}
}
@ -1980,6 +2024,15 @@ async function shutdownServices(): Promise<void> {
await runShutdownStep('tracked CLI subprocess cleanup', () =>
killTrackedCliProcesses('SIGKILL')
);
await runShutdownStep(
'OpenCode post-subprocess fallback cleanup',
() =>
cleanupOpenCodeHostProcessFallback('post-subprocess shutdown fallback', {
mode: 'force',
requiredDetailsMarkers: [`CLAUDE_TEAM_APP_INSTANCE_ID=${openCodeManagedHostInstanceId}`],
}),
5_000
);
await runShutdownStep('MCP config GC', () => new TeamMcpConfigBuilder().gcOwnConfigs());

View file

@ -214,6 +214,19 @@ async function resolveBundledOrchestratorBinary(): Promise<string | null> {
return resolveFromCandidateList([path.join(resourcesPath, 'runtime', binaryName)]);
}
function getConfiguredRuntimeOverrideRaw(flavor: 'claude' | 'agent_teams_orchestrator'): string {
return (
(flavor === 'agent_teams_orchestrator'
? (process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ??
process.env.CLAUDE_CLI_PATH?.trim())
: process.env.CLAUDE_CLI_PATH?.trim()) ?? ''
);
}
function looksLikeExplicitPath(value: string): boolean {
return path.isAbsolute(value) || value.includes('\\') || value.includes('/');
}
let cachedPath: string | null | undefined;
/** Timestamp of last successful cache verification (ms). */
@ -269,25 +282,31 @@ export class ClaudeBinaryResolver {
}
private static async runResolve(options: ClaudeBinaryResolveOptions): Promise<string | null> {
const flavor = getConfiguredCliFlavor();
emitProgress(options, 'flavor', `Using ${flavor} runtime mode...`);
const overrideRaw = getConfiguredRuntimeOverrideRaw(flavor);
const overrideIsExplicitPath = overrideRaw ? looksLikeExplicitPath(overrideRaw) : false;
if (overrideRaw && overrideIsExplicitPath) {
emitProgress(options, 'configured-path', 'Checking configured runtime path...');
const resolvedOverride = await resolveFromExplicitPath(overrideRaw);
if (resolvedOverride) {
cachedPath = resolvedOverride;
cacheVerifiedAt = Date.now();
emitProgress(options, 'configured-path-found', 'Using configured runtime path...');
return cachedPath;
}
}
await resolveInteractiveShellEnv({
onProgress: (progress) => emitProgress(options, progress.phase, progress.message),
});
const enrichedPath = buildMergedCliPath(null);
const flavor = getConfiguredCliFlavor();
emitProgress(options, 'flavor', `Using ${flavor} runtime mode...`);
const overrideRaw =
flavor === 'agent_teams_orchestrator'
? (process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ??
process.env.CLAUDE_CLI_PATH?.trim())
: process.env.CLAUDE_CLI_PATH?.trim();
if (overrideRaw) {
if (overrideRaw && !overrideIsExplicitPath) {
emitProgress(options, 'configured-path', 'Checking configured runtime path...');
const looksLikePath =
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
const resolvedOverride = looksLikePath
? await resolveFromExplicitPath(overrideRaw)
: await resolveFromPathEnv(overrideRaw, enrichedPath);
const resolvedOverride = await resolveFromPathEnv(overrideRaw, enrichedPath);
if (resolvedOverride) {
cachedPath = resolvedOverride;

View file

@ -0,0 +1,291 @@
import {
listRuntimeProcessesForCurrentTmuxPlatform,
type RuntimeProcessTableRow,
} from '@features/tmux-installer/main';
import { killProcessByPid } from '@main/utils/processKill';
import { execFile, type ExecFileException } from 'child_process';
export type OpenCodeManagedHostCleanupMode = 'orphaned' | 'force';
export interface OpenCodeManagedHostCleanupCandidate {
pid: number;
ppid: number;
action: 'killed' | 'kept_excluded' | 'kept_recent' | 'kept_unmanaged' | 'failed';
reason: string;
}
export interface OpenCodeManagedHostCleanupResult {
scanned: number;
killed: number;
candidates: OpenCodeManagedHostCleanupCandidate[];
diagnostics: string[];
}
export interface OpenCodeManagedHostProcessCleanupOptions {
mode: OpenCodeManagedHostCleanupMode;
excludePids?: ReadonlySet<number>;
requiredDetailsMarkers?: readonly string[];
startedBeforeMs?: number | null;
platform?: NodeJS.Platform;
listProcessRows?: () => Promise<RuntimeProcessTableRow[]>;
readProcessDetails?: (pid: number) => Promise<string | null>;
readProcessStartTimeMs?: (pid: number) => Promise<number | null>;
disposeServeHost?: (baseUrl: string) => Promise<void>;
killProcess?: (pid: number) => void;
forceKillProcess?: (pid: number) => void;
isProcessAlive?: (pid: number) => boolean;
sleepMs?: (ms: number) => Promise<void>;
}
const OPENCODE_SERVE_COMMAND_RE = /(^|[/\\\s])opencode(?:\.exe)?(?=\s|$).*?(?:^|\s)serve(?=\s|$)/i;
const MANAGED_ENV_MARKERS = ['CLAUDE_MULTIMODEL_DATA_HOME=', 'OPENCODE_CONFIG_CONTENT='] as const;
const MANAGED_ENV_IDENTITY_MARKERS = [
'AGENT_TEAMS_MCP_CLAUDE_DIR=',
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=',
] as const;
export async function cleanupManagedOpenCodeServeProcesses(
options: OpenCodeManagedHostProcessCleanupOptions
): Promise<OpenCodeManagedHostCleanupResult> {
const platform = options.platform ?? process.platform;
const result: OpenCodeManagedHostCleanupResult = {
scanned: 0,
killed: 0,
candidates: [],
diagnostics: [],
};
if (platform === 'win32') {
result.diagnostics.push(
'Managed OpenCode serve process fallback cleanup is skipped on Windows.'
);
return result;
}
const rows = await (options.listProcessRows ?? listRuntimeProcessesForCurrentTmuxPlatform)();
const excludePids = options.excludePids ?? new Set<number>();
const requiredDetailsMarkers = options.requiredDetailsMarkers ?? [];
const readDetails = options.readProcessDetails ?? readNativeProcessCommandWithEnv;
const readStartTimeMs = options.readProcessStartTimeMs ?? readNativeProcessStartTimeMs;
const disposeServeHost = options.disposeServeHost ?? disposeOpenCodeServeHost;
const killProcess = options.killProcess ?? killProcessByPid;
const forceKillProcess =
options.forceKillProcess ?? ((pid: number) => process.kill(pid, 'SIGKILL'));
const isProcessAlive = options.isProcessAlive ?? isNativeProcessAlive;
const sleepMs = options.sleepMs ?? sleep;
for (const row of rows) {
if (!isOpenCodeServeCommand(row.command)) {
continue;
}
result.scanned += 1;
if (excludePids.has(row.pid)) {
result.candidates.push({
pid: row.pid,
ppid: row.ppid,
action: 'kept_excluded',
reason: 'pid is known to the bridge host registry cleanup result',
});
continue;
}
const details = await readDetails(row.pid);
if (
!details ||
!isManagedOpenCodeServeProcessDetails(details) ||
!processDetailsIncludeMarkers(details, requiredDetailsMarkers)
) {
result.candidates.push({
pid: row.pid,
ppid: row.ppid,
action: 'kept_unmanaged',
reason: 'process does not carry Agent Teams managed OpenCode environment markers',
});
continue;
}
if (options.mode === 'orphaned') {
const startedAtMs =
typeof options.startedBeforeMs === 'number' ? await readStartTimeMs(row.pid) : null;
if (
typeof options.startedBeforeMs === 'number' &&
(!Number.isFinite(startedAtMs) ||
startedAtMs === null ||
startedAtMs >= options.startedBeforeMs)
) {
result.candidates.push({
pid: row.pid,
ppid: row.ppid,
action: 'kept_recent',
reason: 'process started after this app instance began',
});
continue;
}
if (row.ppid !== 1) {
result.candidates.push({
pid: row.pid,
ppid: row.ppid,
action: 'kept_recent',
reason: 'process is still parented and may belong to an active bridge command',
});
continue;
}
}
try {
const baseUrl = getOpenCodeServeLoopbackBaseUrl(row.command);
if (baseUrl) {
await disposeServeHost(baseUrl).catch(() => undefined);
}
killProcess(row.pid);
if (options.mode === 'force' && isProcessAlive(row.pid)) {
await sleepMs(250);
if (isProcessAlive(row.pid)) {
try {
forceKillProcess(row.pid);
} catch (error) {
if (isProcessAlive(row.pid)) {
throw error;
}
}
}
}
result.killed += 1;
result.candidates.push({
pid: row.pid,
ppid: row.ppid,
action: 'killed',
reason: `managed OpenCode serve ${options.mode === 'force' ? 'cleanup' : 'orphan cleanup'}`,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result.diagnostics.push(`Failed to kill managed OpenCode serve pid=${row.pid}: ${message}`);
result.candidates.push({
pid: row.pid,
ppid: row.ppid,
action: 'failed',
reason: message,
});
}
}
return result;
}
export function isOpenCodeServeCommand(command: string): boolean {
return OPENCODE_SERVE_COMMAND_RE.test(command.trim());
}
export function isManagedOpenCodeServeProcessDetails(details: string): boolean {
return (
processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) &&
MANAGED_ENV_IDENTITY_MARKERS.some((marker) => processDetailsIncludeMarker(details, marker))
);
}
export function getOpenCodeServeLoopbackBaseUrl(command: string): string | null {
const portMatch = /(?:^|\s)--port(?:=|\s+)(\d{1,5})(?=\s|$)/.exec(command);
if (!portMatch) {
return null;
}
const port = Number.parseInt(portMatch[1], 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
return null;
}
const hostnameMatch = /(?:^|\s)--hostname(?:=|\s+)(\S+)(?=\s|$)/.exec(command);
const hostname = hostnameMatch?.[1] ?? '127.0.0.1';
if (!isLoopbackHostname(hostname)) {
return null;
}
const normalizedHostname = hostname === '::1' ? '[::1]' : hostname;
return `http://${normalizedHostname}:${port}`;
}
function processDetailsIncludeMarkers(details: string, markers: readonly string[]): boolean {
return markers.every((marker) => processDetailsIncludeMarker(details, marker));
}
function processDetailsIncludeMarker(details: string, marker: string): boolean {
const valueBoundary = marker.endsWith('=') ? '' : '(?=\\s|$)';
return new RegExp(`(^|\\s)${escapeRegExp(marker)}${valueBoundary}`).test(details);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isLoopbackHostname(hostname: string): boolean {
return (
hostname === '127.0.0.1' ||
hostname === 'localhost' ||
hostname === '::1' ||
hostname === '[::1]'
);
}
async function disposeOpenCodeServeHost(baseUrl: string): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1_000);
try {
await fetch(`${baseUrl}/global/dispose`, {
method: 'POST',
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
async function readNativeProcessCommandWithEnv(pid: number): Promise<string | null> {
return execFileText('ps', ['eww', '-p', String(pid), '-o', 'command='], 2_000, 2 * 1024 * 1024);
}
async function readNativeProcessStartTimeMs(pid: number): Promise<number | null> {
const output = await execFileText('ps', ['-p', String(pid), '-o', 'lstart='], 2_000, 64 * 1024);
if (!output) {
return null;
}
const parsed = Date.parse(output.trim());
return Number.isFinite(parsed) ? parsed : null;
}
function isNativeProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function execFileText(
command: string,
args: string[],
timeout: number,
maxBuffer: number
): Promise<string | null> {
return new Promise((resolve) => {
execFile(
command,
args,
{
encoding: 'utf8',
timeout,
maxBuffer,
},
(error: ExecFileException | null, stdout: string | Buffer) => {
if (error) {
resolve(null);
return;
}
resolve(String(stdout));
}
);
});
}

View file

@ -82,6 +82,21 @@ interface OpenCodeModelGroup {
options: TeamRuntimeModelOption[];
}
type ProviderModelCatalogItem = NonNullable<CliProviderStatus['modelCatalog']>['models'][number];
interface OpenCodeModelCostRates {
input: number | null;
output: number | null;
cacheRead: number | null;
cacheWrite: number | null;
}
interface OpenCodeModelPricingInfo {
free: boolean;
summary: string | null;
title: string | undefined;
}
const PROVIDERS: ProviderDef[] = [
{ id: 'anthropic', label: 'Anthropic', comingSoon: false },
{ id: 'codex', label: 'Codex', comingSoon: false },
@ -101,6 +116,97 @@ function getOpenCodeSourceInfo(model: string): { id: string; label: string } | n
};
}
function getRecordValue(record: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (key in record) {
return record[key];
}
}
return undefined;
}
function getFiniteCostNumber(record: Record<string, unknown>, keys: string[]): number | null {
const value = getRecordValue(record, keys);
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function extractOpenCodeCostRates(cost: unknown): OpenCodeModelCostRates | null {
if (!cost || typeof cost !== 'object' || Array.isArray(cost)) {
return null;
}
const record = cost as Record<string, unknown>;
const rates: OpenCodeModelCostRates = {
input: getFiniteCostNumber(record, ['input']),
output: getFiniteCostNumber(record, ['output']),
cacheRead: getFiniteCostNumber(record, ['cache_read', 'cacheRead', 'cached_read']),
cacheWrite: getFiniteCostNumber(record, ['cache_write', 'cacheWrite', 'cached_write']),
};
return Object.values(rates).some((rate) => rate !== null) ? rates : null;
}
function formatOpenCodeCostRate(rate: number): string {
if (rate === 0) {
return 'Free';
}
const formatted = rate.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: rate >= 1 ? 2 : 4,
});
return `$${formatted}`;
}
function formatOpenCodeCostSummary(rates: OpenCodeModelCostRates): string | null {
const summaryParts: string[] = [];
if (rates.input !== null) {
summaryParts.push(`in ${formatOpenCodeCostRate(rates.input)}`);
}
if (rates.output !== null) {
summaryParts.push(`out ${formatOpenCodeCostRate(rates.output)}`);
}
if (summaryParts.length === 0) {
return null;
}
return `${summaryParts.join(' · ')} / 1M`;
}
function formatOpenCodeCostTitle(rates: OpenCodeModelCostRates): string {
const titleParts: string[] = [];
if (rates.input !== null) {
titleParts.push(`Input: ${formatOpenCodeCostRate(rates.input)} per 1M tokens`);
}
if (rates.output !== null) {
titleParts.push(`Output: ${formatOpenCodeCostRate(rates.output)} per 1M tokens`);
}
if (rates.cacheRead !== null) {
titleParts.push(`Cache read: ${formatOpenCodeCostRate(rates.cacheRead)} per 1M tokens`);
}
if (rates.cacheWrite !== null) {
titleParts.push(`Cache write: ${formatOpenCodeCostRate(rates.cacheWrite)} per 1M tokens`);
}
return titleParts.join('\n');
}
function getOpenCodeModelPricingInfo(
catalogModel: ProviderModelCatalogItem | null | undefined
): OpenCodeModelPricingInfo | null {
const metadata = catalogModel?.metadata;
if (!metadata) {
return null;
}
const rates = extractOpenCodeCostRates(metadata.cost);
return {
free: metadata.free === true,
summary: rates ? formatOpenCodeCostSummary(rates) : null,
title: rates ? formatOpenCodeCostTitle(rates) : undefined,
};
}
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
export const OPENCODE_ONE_SHOT_DISABLED_REASON =
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.';
@ -352,6 +458,26 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
}, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]);
const openCodeCatalogModelById = useMemo(() => {
const catalog = runtimeProviderStatus?.modelCatalog;
const modelById = new Map<string, ProviderModelCatalogItem>();
if (effectiveProviderId !== 'opencode' || catalog?.providerId !== 'opencode') {
return modelById;
}
for (const model of catalog.models) {
const launchModel = model.launchModel.trim();
const id = model.id.trim();
if (launchModel) {
modelById.set(launchModel, model);
}
if (id) {
modelById.set(id, model);
}
}
return modelById;
}, [effectiveProviderId, runtimeProviderStatus?.modelCatalog]);
const hasRecommendedOpenCodeModels = useMemo(
() =>
effectiveProviderId === 'opencode' &&
@ -474,6 +600,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return true;
}
const modelRecommendation = getTeamModelRecommendation(effectiveProviderId, option.value);
const openCodePricingInfo = getOpenCodeModelPricingInfo(
openCodeCatalogModelById.get(option.value)
);
return [
option.value,
option.label,
@ -481,6 +610,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
getOpenCodeSourceInfo(option.value)?.label ?? '',
modelRecommendation?.label ?? '',
modelRecommendation?.reason ?? '',
openCodePricingInfo?.free ? 'free' : '',
openCodePricingInfo?.summary ?? '',
]
.join(' ')
.toLowerCase()
@ -524,7 +655,14 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
...modelOptions.filter((option) => option.value.trim().length === 0),
...concreteOptions,
].filter(matchesModelQuery);
}, [effectiveProviderId, modelOptions, modelQuery, recommendedOnly, selectedOpenCodeSourceIds]);
}, [
effectiveProviderId,
modelOptions,
modelQuery,
openCodeCatalogModelById,
recommendedOnly,
selectedOpenCodeSourceIds,
]);
const visibleOpenCodeModelGroups = useMemo<OpenCodeModelGroup[]>(() => {
if (effectiveProviderId !== 'opencode') {
return [];
@ -598,6 +736,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
availabilityReason ??
null;
const modelRecommendation = getTeamModelRecommendation(effectiveProviderId, opt.value);
const openCodePricingInfo =
effectiveProviderId === 'opencode'
? getOpenCodeModelPricingInfo(openCodeCatalogModelById.get(opt.value))
: null;
return (
<button
@ -626,9 +768,32 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}}
>
<span className="flex flex-col items-center justify-center gap-0.5">
<span className={cn('leading-tight', opt.value === 'gpt-5.5' && 'font-bold')}>
<span
className={cn(
'max-w-full break-words leading-tight',
opt.value === 'gpt-5.5' && 'font-bold'
)}
>
{opt.label}
</span>
{openCodePricingInfo?.summary ? (
<span
data-testid="team-model-selector-model-pricing"
className="max-w-full text-balance text-[9px] font-normal leading-[1.1] text-[var(--color-text-muted)]"
title={openCodePricingInfo.title}
>
{openCodePricingInfo.summary}
</span>
) : null}
{openCodePricingInfo?.free ? (
<span
data-testid="team-model-selector-model-free-badge"
className="inline-flex items-center justify-center rounded-full border border-emerald-300/30 bg-emerald-300/10 px-1.5 py-0 text-[9px] font-semibold uppercase text-emerald-200"
title="OpenCode marks this model as free."
>
Free
</span>
) : null}
{modelRecommendation ? (
<span
className={cn(

View file

@ -117,6 +117,7 @@ export function filterTeamMessages(
options: {
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
includeAutomationEvents?: boolean;
includeMemberWorkSyncNudges?: boolean;
leadNames?: Iterable<string>;
timeWindow?: { start: number; end: number } | null;
filter: TeamMessagesFilter;
@ -126,6 +127,7 @@ export function filterTeamMessages(
const {
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
includeAutomationEvents = false,
includeMemberWorkSyncNudges = false,
leadNames: rawLeadNames,
timeWindow,
filter,
@ -136,8 +138,8 @@ export function filterTeamMessages(
let list = messages.filter(
(m) =>
m.messageKind !== 'task_comment_notification' &&
(includeAutomationEvents ||
(!isTaskStallRemediationMessage(m) && !isMemberWorkSyncNudgeMessage(m))) &&
(includeAutomationEvents || !isTaskStallRemediationMessage(m)) &&
(includeMemberWorkSyncNudges || !isMemberWorkSyncNudgeMessage(m)) &&
!isReviewPickupEscalationMessage(m) &&
!isTeamInternalControlMessageEnvelope(m)
);

View file

@ -13,7 +13,13 @@ import type { TeamProviderId } from '@shared/types';
export type TeamModelRecommendationLevel = OpenCodeTeamModelRecommendationLevel;
export type TeamModelRecommendation = OpenCodeTeamModelRecommendation;
const CODEX_TEAM_RECOMMENDED_MODELS = new Set<string>(['gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.5']);
const CODEX_TEAM_RECOMMENDED_MODELS = new Set<string>([
'gpt-5.5',
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.3-codex',
'gpt-5.2',
]);
const CODEX_RECOMMENDED_REASON =
'This Codex model passed real Agent Teams launch and task-flow stress testing and is selected for stable team-agent behavior.';

View file

@ -136,6 +136,25 @@ describe('ClaudeBinaryResolver', () => {
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});
it('does not wait for shell env before using an explicit absolute runtime override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = expectedBinary;
mockResolveInteractiveShellEnv.mockRejectedValue(new Error('shell env should not be needed'));
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
const { ClaudeBinaryResolver } = await import('@main/services/team/ClaudeBinaryResolver');
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(mockResolveInteractiveShellEnv).not.toHaveBeenCalled();
});
it('resolves extensionless Windows explicit overrides to a real executable file first', async () => {
Object.defineProperty(process, 'platform', {
value: 'win32',

View file

@ -0,0 +1,372 @@
import { describe, expect, it, vi } from 'vitest';
import {
cleanupManagedOpenCodeServeProcesses,
getOpenCodeServeLoopbackBaseUrl,
isManagedOpenCodeServeProcessDetails,
isOpenCodeServeCommand,
} from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup';
const MANAGED_DETAILS = [
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
'OPENCODE_CONFIG_CONTENT={}',
'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude',
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=/tmp/mcp-entry.js',
].join(' ');
const MANAGED_DETAILS_WITH_WORKSPACE_MCP = [
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
'OPENCODE_CONFIG_CONTENT={}',
'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude',
].join(' ');
function resolved<T>(value: T): Promise<T> {
return Promise.resolve(value);
}
describe('OpenCodeManagedHostProcessCleanup', () => {
it('identifies OpenCode serve commands without matching other OpenCode commands', () => {
expect(isOpenCodeServeCommand('/opt/homebrew/bin/opencode serve --hostname 127.0.0.1')).toBe(
true
);
expect(isOpenCodeServeCommand('opencode runtime opencode-command --json')).toBe(false);
expect(isOpenCodeServeCommand('node mcp-server/src/index.ts')).toBe(false);
});
it('requires Agent Teams managed environment markers', () => {
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS)).toBe(true);
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_WORKSPACE_MCP)).toBe(true);
expect(
isManagedOpenCodeServeProcessDetails(
'opencode serve CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={}'
)
).toBe(false);
expect(
isManagedOpenCodeServeProcessDetails(
'opencode serve OPENCODE_CONFIG_CONTENT={} AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude'
)
).toBe(false);
expect(
isManagedOpenCodeServeProcessDetails(
'opencode serve NOT_CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={} AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude'
)
).toBe(false);
});
it('extracts only loopback OpenCode serve base URLs for disposal', () => {
expect(
getOpenCodeServeLoopbackBaseUrl(
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171'
)
).toBe('http://127.0.0.1:54171');
expect(getOpenCodeServeLoopbackBaseUrl('opencode serve --hostname=localhost --port=3000')).toBe(
'http://localhost:3000'
);
expect(getOpenCodeServeLoopbackBaseUrl('opencode serve --hostname ::1 --port 3001')).toBe(
['http:', '//[::1]:3001'].join('')
);
expect(getOpenCodeServeLoopbackBaseUrl('opencode serve --hostname 0.0.0.0 --port 3000')).toBe(
null
);
expect(
getOpenCodeServeLoopbackBaseUrl('opencode serve --hostname 127.0.0.1 --port 70000')
).toBe(null);
});
it('kills old orphaned managed OpenCode serve processes that are missing from registry cleanup', async () => {
const killProcess = vi.fn();
const disposeServeHost = vi.fn(() => resolved(undefined));
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'orphaned',
platform: 'darwin',
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
listProcessRows: () =>
resolved([
{
pid: 51569,
ppid: 1,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
},
{
pid: 51570,
ppid: 1,
command: '/opt/homebrew/bin/opencode runtime opencode-command --json',
},
]),
readProcessDetails: () => resolved(MANAGED_DETAILS),
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T16:27:14.000Z')),
disposeServeHost,
killProcess,
});
expect(disposeServeHost).toHaveBeenCalledWith('http://127.0.0.1:54171');
expect(killProcess).toHaveBeenCalledWith(51569);
expect(result.killed).toBe(1);
expect(result.scanned).toBe(1);
expect(result.candidates[0]).toMatchObject({ pid: 51569, action: 'killed' });
});
it('keeps registry-known pids during startup fallback cleanup', async () => {
const killProcess = vi.fn();
const readProcessDetails = vi.fn(() => resolved(MANAGED_DETAILS));
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'orphaned',
platform: 'darwin',
excludePids: new Set([99469]),
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
listProcessRows: () =>
resolved([
{
pid: 99469,
ppid: 1,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 60130',
},
]),
readProcessDetails,
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T16:27:14.000Z')),
killProcess,
});
expect(killProcess).not.toHaveBeenCalled();
expect(readProcessDetails).not.toHaveBeenCalled();
expect(result.candidates[0]).toMatchObject({ pid: 99469, action: 'kept_excluded' });
});
it('does not kill unmanaged OpenCode serve processes', async () => {
const killProcess = vi.fn();
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'orphaned',
platform: 'darwin',
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
listProcessRows: () =>
resolved([
{
pid: 200,
ppid: 1,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
},
]),
readProcessDetails: () => resolved('opencode serve HOME=/Users/belief'),
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T16:27:14.000Z')),
killProcess,
});
expect(killProcess).not.toHaveBeenCalled();
expect(result.candidates[0]).toMatchObject({ pid: 200, action: 'kept_unmanaged' });
});
it('continues killing a managed orphan when loopback dispose fails', async () => {
const killProcess = vi.fn();
const disposeServeHost = vi.fn(() => Promise.reject(new Error('dispose failed')));
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'orphaned',
platform: 'darwin',
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
listProcessRows: () =>
resolved([
{
pid: 210,
ppid: 1,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
},
]),
readProcessDetails: () => resolved(MANAGED_DETAILS),
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T16:27:14.000Z')),
disposeServeHost,
killProcess,
});
expect(disposeServeHost).toHaveBeenCalledWith('http://127.0.0.1:3000');
expect(killProcess).toHaveBeenCalledWith(210);
expect(result.diagnostics).toEqual([]);
});
it('keeps orphaned managed processes that started after this app instance began', async () => {
const killProcess = vi.fn();
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'orphaned',
platform: 'darwin',
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
listProcessRows: () =>
resolved([
{
pid: 300,
ppid: 1,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
},
]),
readProcessDetails: () => resolved(MANAGED_DETAILS),
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T17:00:01.000Z')),
killProcess,
});
expect(killProcess).not.toHaveBeenCalled();
expect(result.candidates[0]).toMatchObject({ pid: 300, action: 'kept_recent' });
});
it('force-cleans managed OpenCode serve processes regardless of parent pid', async () => {
const killProcess = vi.fn();
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'force',
platform: 'darwin',
listProcessRows: () =>
resolved([
{
pid: 400,
ppid: 123,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
},
]),
readProcessDetails: () => resolved(MANAGED_DETAILS),
disposeServeHost: () => resolved(undefined),
isProcessAlive: () => false,
killProcess,
});
expect(killProcess).toHaveBeenCalledWith(400);
expect(result.candidates[0]).toMatchObject({ pid: 400, action: 'killed' });
});
it('escalates force cleanup when a managed OpenCode serve process survives SIGTERM', async () => {
const killProcess = vi.fn();
const forceKillProcess = vi.fn();
const isProcessAlive = vi.fn(() => true);
const sleepMs = vi.fn(() => resolved(undefined));
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'force',
platform: 'darwin',
listProcessRows: () =>
resolved([
{
pid: 401,
ppid: 123,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
},
]),
readProcessDetails: () => resolved(MANAGED_DETAILS),
disposeServeHost: () => resolved(undefined),
killProcess,
forceKillProcess,
isProcessAlive,
sleepMs,
});
expect(killProcess).toHaveBeenCalledWith(401);
expect(sleepMs).toHaveBeenCalledWith(250);
expect(forceKillProcess).toHaveBeenCalledWith(401);
expect(result.killed).toBe(1);
});
it('treats a raced force-kill ESRCH as success when the process is already gone', async () => {
const killProcess = vi.fn();
const forceKillProcess = vi.fn(() => {
throw new Error('ESRCH');
});
const isProcessAlive = vi
.fn()
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValue(false);
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'force',
platform: 'darwin',
listProcessRows: () =>
resolved([
{
pid: 402,
ppid: 123,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
},
]),
readProcessDetails: () => resolved(MANAGED_DETAILS),
disposeServeHost: () => resolved(undefined),
killProcess,
forceKillProcess,
isProcessAlive,
sleepMs: () => resolved(undefined),
});
expect(result.killed).toBe(1);
expect(result.diagnostics).toEqual([]);
});
it('requires additional process detail markers when provided', async () => {
const killProcess = vi.fn();
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'force',
platform: 'darwin',
requiredDetailsMarkers: ['CLAUDE_TEAM_APP_INSTANCE_ID=app-1'],
listProcessRows: () =>
resolved([
{
pid: 410,
ppid: 123,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
},
{
pid: 411,
ppid: 123,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3001',
},
{
pid: 412,
ppid: 123,
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3002',
},
]),
readProcessDetails: (pid) => {
if (pid === 410) {
return resolved(`${MANAGED_DETAILS} CLAUDE_TEAM_APP_INSTANCE_ID=app-1`);
}
if (pid === 412) {
return resolved(`${MANAGED_DETAILS} CLAUDE_TEAM_APP_INSTANCE_ID=app-10`);
}
return resolved(MANAGED_DETAILS);
},
disposeServeHost: () => resolved(undefined),
isProcessAlive: () => false,
killProcess,
});
expect(killProcess).toHaveBeenCalledTimes(1);
expect(killProcess).toHaveBeenCalledWith(410);
expect(result.candidates.map((candidate) => [candidate.pid, candidate.action])).toEqual([
[410, 'killed'],
[411, 'kept_unmanaged'],
[412, 'kept_unmanaged'],
]);
});
it('skips fallback cleanup on Windows because environment markers are unavailable', async () => {
const killProcess = vi.fn();
const result = await cleanupManagedOpenCodeServeProcesses({
mode: 'force',
platform: 'win32',
listProcessRows: () =>
resolved([
{
pid: 500,
ppid: 1,
command: 'opencode.exe serve --hostname 127.0.0.1',
},
]),
readProcessDetails: () => resolved(MANAGED_DETAILS),
killProcess,
});
expect(killProcess).not.toHaveBeenCalled();
expect(result.scanned).toBe(0);
expect(result.diagnostics[0]).toContain('skipped on Windows');
});
});

View file

@ -1382,6 +1382,118 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('renders OpenCode free badges and tiny model pricing from runtime catalog metadata', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
providers: [
{
providerId: 'opencode',
supported: true,
authenticated: true,
detailMessage: null,
statusMessage: null,
capabilities: {
teamLaunch: true,
},
models: ['opencode/big-pickle', 'opencode/minimax-m2.7'],
modelCatalog: {
schemaVersion: 1,
providerId: 'opencode',
source: 'app-server',
status: 'ready',
fetchedAt: '2026-05-13T00:00:00.000Z',
staleAt: '2026-05-13T00:10:00.000Z',
defaultModelId: null,
defaultLaunchModel: null,
models: [
{
id: 'opencode/big-pickle',
launchModel: 'opencode/big-pickle',
displayName: 'big-pickle',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'app-server',
metadata: {
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
context: 200000,
limits: null,
free: true,
},
},
{
id: 'opencode/minimax-m2.7',
launchModel: 'opencode/minimax-m2.7',
displayName: 'minimax-m2.7',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
inputModalities: ['text'],
supportsPersonality: false,
isDefault: false,
upgrade: false,
source: 'app-server',
metadata: {
cost: { input: 0.3, output: 1.2, cache_read: 0.06, cache_write: 0.375 },
context: 200000,
limits: null,
free: false,
},
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
message: null,
code: null,
},
},
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'opencode',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('in Free · out Free / 1M');
expect(host.textContent).toContain('in $0.30 · out $1.20 / 1M');
expect(host.textContent).toContain('Free');
const pricingRows = Array.from(
host.querySelectorAll<HTMLElement>('[data-testid="team-model-selector-model-pricing"]')
);
expect(pricingRows).toHaveLength(2);
expect(pricingRows[0]?.className).toContain('text-[9px]');
expect(pricingRows[1]?.getAttribute('title')).toContain('Cache write: $0.375 per 1M tokens');
const freeBadges = host.querySelectorAll(
'[data-testid="team-model-selector-model-free-badge"]'
);
expect(freeBadges).toHaveLength(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('filters OpenCode model groups by selected source providers', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {

View file

@ -732,6 +732,147 @@ describe('GraphMemberLogPreviewHud', () => {
});
});
it('does not label Codex members unsupported while transcript coverage can still provide logs', async () => {
const leadNode: GraphNode = {
id: 'member:alpha-team:team-lead',
kind: 'member',
label: 'team-lead',
state: 'idle',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'team-lead' },
};
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'team-lead',
{
memberName: 'team-lead',
items: [],
coverage: [
{
provider: 'claude_transcript',
status: 'skipped',
reason: 'no_member_transcripts',
},
{
provider: 'codex_native_trace',
status: 'skipped',
reason: 'codex_member_wide_not_supported',
},
],
warnings: [
{
code: 'codex_member_wide_not_supported',
message: 'Codex member-wide native trace is not available in this variant yet.',
},
],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:00:00.000Z',
},
],
]);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[leadNode]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
await Promise.resolve();
});
expect(host.textContent).not.toContain('Unsupported provider');
expect(host.textContent).toContain('No recent logs');
act(() => {
root.unmount();
});
});
it('does not present OpenCode runtime failures as no recent logs', async () => {
const node: GraphNode = {
id: 'member:alpha-team:alice',
kind: 'member',
label: 'alice',
state: 'idle',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
};
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'alice',
{
memberName: 'alice',
items: [],
coverage: [
{
provider: 'opencode_runtime',
status: 'skipped',
reason: 'opencode_runtime_timeout',
},
],
warnings: [
{
code: 'opencode_runtime_timeout',
message: 'OpenCode runtime preview timed out.',
},
],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:00:00.000Z',
},
],
]);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
await Promise.resolve();
});
expect(host.textContent).toContain('Logs unavailable');
expect(host.textContent).not.toContain('No recent logs');
act(() => {
root.unmount();
});
});
it('shows loading only before a member preview has been loaded', async () => {
const quietNode: GraphNode = {
id: 'member:alpha-team:quiet-dev',

View file

@ -298,7 +298,9 @@ describe('useGraphMemberLogPreviews', () => {
const root = createRoot(host);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />);
root.render(
<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />
);
await Promise.resolve();
});
act(() => {
@ -783,7 +785,9 @@ describe('useGraphMemberLogPreviews', () => {
const root = createRoot(host);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />);
root.render(
<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />
);
await Promise.resolve();
});
await act(async () => {

View file

@ -660,7 +660,7 @@ Messages:
]);
});
it('can include member work sync nudges for the activity timeline', () => {
it('keeps member work sync nudges hidden from the activity timeline by default', () => {
const messages = [
makeMessage({
messageId: 'member-work-sync:demo:jack:agenda-a',
@ -680,6 +680,30 @@ Messages:
searchQuery: '',
});
expect(result).toEqual([]);
});
it('can include member work sync nudges for diagnostics when explicitly requested', () => {
const messages = [
makeMessage({
messageId: 'member-work-sync:demo:jack:agenda-a',
from: 'system',
to: 'jack',
source: 'system_notification',
messageKind: 'member_work_sync_nudge',
summary: 'Work sync check',
text: 'Work sync check: call member_work_sync_status.',
}),
];
const result = filterTeamMessages(messages, {
includeAutomationEvents: true,
includeMemberWorkSyncNudges: true,
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
});
expect(result.map((message) => message.messageId)).toEqual([
'member-work-sync:demo:jack:agenda-a',
]);

View file

@ -6,8 +6,8 @@ import {
} from '@renderer/utils/teamModelRecommendations';
describe('getTeamModelRecommendation', () => {
it('marks only the selected Codex Agent Teams models as recommended', () => {
for (const modelId of ['gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.5']) {
it('marks all visible Codex Agent Teams models as recommended', () => {
for (const modelId of ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2']) {
expect(getTeamModelRecommendation('codex', modelId)).toMatchObject({
level: 'recommended',
label: 'Recommended',
@ -15,7 +15,7 @@ describe('getTeamModelRecommendation', () => {
expect(isTeamModelRecommended('codex', modelId)).toBe(true);
}
for (const modelId of ['gpt-5.4', 'gpt-5.2', 'gpt-5.3-codex-spark']) {
for (const modelId of ['gpt-5.3-codex-spark']) {
expect(getTeamModelRecommendation('codex', modelId)).toBeNull();
expect(isTeamModelRecommended('codex', modelId)).toBe(false);
}