chore: commit remaining workspace updates
This commit is contained in:
parent
a474076330
commit
4c5a752342
24 changed files with 6761 additions and 63 deletions
|
|
@ -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`
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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' }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue