feat(opencode): harden delivery and provider UI
This commit is contained in:
parent
ade312ad87
commit
19b6937446
93 changed files with 13243 additions and 1314 deletions
|
|
@ -1,7 +1,36 @@
|
|||
const messageStore = require('./messageStore.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const { isOpenCodeMember } = require('./memberMessagingProtocol.js');
|
||||
|
||||
const PLACEHOLDER_TASK_REF_PREFIX = /^\s*#0{8}\b\s*(?:[:.-]\s*)?/i;
|
||||
const IDLE_ACK_MAX_CHARS = 180;
|
||||
const IDLE_ACK_EXACT_TEXT = new Set([
|
||||
'ok',
|
||||
'okay',
|
||||
'understood',
|
||||
'got it',
|
||||
'ready',
|
||||
'waiting',
|
||||
'waiting for tasks',
|
||||
'awaiting tasks',
|
||||
'no tasks',
|
||||
'no assigned tasks',
|
||||
'no actionable tasks',
|
||||
'понял',
|
||||
'поняла',
|
||||
'понял жду',
|
||||
'понял жду задачи',
|
||||
'принял',
|
||||
'приняла',
|
||||
'ок',
|
||||
'окей',
|
||||
'готов',
|
||||
'готов к работе',
|
||||
'жду',
|
||||
'жду задачи',
|
||||
'нет задач',
|
||||
'нет назначенных задач',
|
||||
]);
|
||||
|
||||
function stripPlaceholderTaskRefPrefix(value) {
|
||||
if (typeof value !== 'string' || !PLACEHOLDER_TASK_REF_PREFIX.test(value)) {
|
||||
|
|
@ -22,6 +51,82 @@ function normalizePlaceholderTaskRefPrefixes(flags) {
|
|||
return next;
|
||||
}
|
||||
|
||||
function normalizeIdleAckText(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[#*_`"'“”‘’«»()[\]{}.,!?;:<>/\\|-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function looksLikeIdleAckOnlyText(value) {
|
||||
const normalized = normalizeIdleAckText(value);
|
||||
if (!normalized || normalized.length > IDLE_ACK_MAX_CHARS) {
|
||||
return false;
|
||||
}
|
||||
if (IDLE_ACK_EXACT_TEXT.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasNoTaskPhrase =
|
||||
normalized.includes('нет назначенных задач') ||
|
||||
normalized.includes('нет задач') ||
|
||||
normalized.includes('no assigned tasks') ||
|
||||
normalized.includes('no actionable tasks') ||
|
||||
normalized.includes('no tasks');
|
||||
const hasWaitingPhrase =
|
||||
normalized.includes('жду задачи') ||
|
||||
normalized.includes('ожидаю задачи') ||
|
||||
normalized.includes('waiting for tasks') ||
|
||||
normalized.includes('awaiting tasks');
|
||||
const hasReadyPhrase =
|
||||
normalized.includes('готов к работе') ||
|
||||
normalized.includes('готов работать') ||
|
||||
normalized.includes('ready to work');
|
||||
const hasNoMoreMessagingPhrase =
|
||||
normalized.includes('больше не буду') &&
|
||||
(normalized.includes('писать') ||
|
||||
normalized.includes('отправлять') ||
|
||||
normalized.includes('message') ||
|
||||
normalized.includes('send'));
|
||||
const hasIdlePhrase =
|
||||
normalized.includes('idle') &&
|
||||
(normalized.includes('task') || normalized.includes('wait') || normalized.includes('ready'));
|
||||
|
||||
return (
|
||||
hasNoTaskPhrase ||
|
||||
hasWaitingPhrase ||
|
||||
hasReadyPhrase ||
|
||||
hasNoMoreMessagingPhrase ||
|
||||
hasIdlePhrase
|
||||
);
|
||||
}
|
||||
|
||||
function hasExplicitDeliveryContext(flags) {
|
||||
if (typeof flags.relayOfMessageId === 'string' && flags.relayOfMessageId.trim()) return true;
|
||||
if (Array.isArray(flags.taskRefs) && flags.taskRefs.length > 0) return true;
|
||||
if (Array.isArray(flags.attachments) && flags.attachments.length > 0) return true;
|
||||
if (typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function findResolvedMember(paths, memberName) {
|
||||
const resolvedName = runtimeHelpers.resolveExplicitTeamMemberName(paths, memberName, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
if (!resolvedName) return null;
|
||||
const key = resolvedName.toLowerCase();
|
||||
const members = runtimeHelpers.resolveTeamMembers(paths).members || [];
|
||||
return members.find((member) => String(member?.name || '').trim().toLowerCase() === key) || null;
|
||||
}
|
||||
|
||||
function isLeadRecipient(paths, to) {
|
||||
const target = String(to || '').trim().toLowerCase();
|
||||
if (!target) return false;
|
||||
const lead = runtimeHelpers.inferLeadName(paths).trim().toLowerCase();
|
||||
return target === 'lead' || target === 'team-lead' || (lead && target === lead);
|
||||
}
|
||||
|
||||
function normalizeMessageSendFlags(context, flags) {
|
||||
const next = { ...(flags || {}) };
|
||||
const rawTo =
|
||||
|
|
@ -81,9 +186,31 @@ function assertUserDirectedMessageHasSender(context, flags) {
|
|||
});
|
||||
}
|
||||
|
||||
function assertOpenCodeMessageIsNotBootstrapNoise(context, flags) {
|
||||
const to = typeof flags.to === 'string' ? flags.to.trim().toLowerCase() : '';
|
||||
if (to !== 'user' && !isLeadRecipient(context.paths, to)) {
|
||||
return;
|
||||
}
|
||||
if (hasExplicitDeliveryContext(flags)) {
|
||||
return;
|
||||
}
|
||||
const from = typeof flags.from === 'string' ? flags.from.trim() : '';
|
||||
const sender = findResolvedMember(context.paths, from);
|
||||
if (!isOpenCodeMember(sender)) {
|
||||
return;
|
||||
}
|
||||
if (!looksLikeIdleAckOnlyText(flags.text) && !looksLikeIdleAckOnlyText(flags.summary)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'OpenCode idle/ack-only message_send was not delivered. Wait silently unless replying to an app-delivered message or actionable task.'
|
||||
);
|
||||
}
|
||||
|
||||
function sendMessage(context, flags) {
|
||||
const normalized = normalizeMessageSendFlags(context, normalizePlaceholderTaskRefPrefixes(flags));
|
||||
assertUserDirectedMessageHasSender(context, normalized);
|
||||
assertOpenCodeMessageIsNotBootstrapNoise(context, normalized);
|
||||
return messageStore.sendInboxMessage(context.paths, normalized);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,37 @@ function buildAssignmentMessage(context, task, options = {}) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildTaskRef(context, task) {
|
||||
return {
|
||||
taskId: task.id,
|
||||
displayId: task.displayId || task.id,
|
||||
teamName: context.teamName,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeTaskRefs(primaryTaskRef, extraTaskRefs) {
|
||||
const refs = [primaryTaskRef, ...(Array.isArray(extraTaskRefs) ? extraTaskRefs : [])]
|
||||
.filter((ref) => ref && typeof ref === 'object');
|
||||
const seen = new Set();
|
||||
const merged = [];
|
||||
for (const ref of refs) {
|
||||
const taskId = typeof ref.taskId === 'string' ? ref.taskId.trim() : '';
|
||||
const displayId = typeof ref.displayId === 'string' ? ref.displayId.trim() : '';
|
||||
const teamName = typeof ref.teamName === 'string' ? ref.teamName.trim() : '';
|
||||
const key = `${teamName || ''}:${taskId || displayId}`;
|
||||
if ((!taskId && !displayId) || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
merged.push({
|
||||
...(taskId ? { taskId } : {}),
|
||||
...(displayId ? { displayId } : {}),
|
||||
...(teamName ? { teamName } : {}),
|
||||
});
|
||||
}
|
||||
return merged.length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
function buildCommentNotificationMessage(context, task, comment) {
|
||||
const taskLabel = `#${task.displayId || task.id}`;
|
||||
return [
|
||||
|
|
@ -171,7 +202,7 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
|
|||
...options,
|
||||
messagingProtocol,
|
||||
}),
|
||||
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
|
||||
taskRefs: mergeTaskRefs(buildTaskRef(context, task), options.taskRefs),
|
||||
summary,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
@ -869,6 +900,9 @@ async function memberBriefing(context, memberName, options = {}) {
|
|||
...(messagingProtocol.runtimeProvider === 'opencode'
|
||||
? [
|
||||
'OpenCode visible messaging rule: call agent-teams_message_send for normal replies to the human user, lead, or same-team teammates. Always include teamName, to, from, text, and summary. Do not use SendMessage or runtime_deliver_message for ordinary replies.',
|
||||
'OpenCode bootstrap silence rule: if this briefing was requested because the desktop app attached or reconnected you, do not send readiness, understood, idle, or no-task acknowledgements to the user, lead, or teammates.',
|
||||
'This briefing already includes your current Task briefing. If it shows no actionable tasks, stop and wait silently. Do not call task_briefing again in the same bootstrap turn just to check for work.',
|
||||
'Use agent-teams_message_send only for actual app-delivered messages, actionable task coordination, blockers, or task results.',
|
||||
'For cross-team replies or messages to another team, call agent-teams_cross_team_send with toTeam/fromMember. Do not put "cross_team_send" or a remote team name into message_send.to.',
|
||||
]
|
||||
: []),
|
||||
|
|
|
|||
|
|
@ -173,6 +173,10 @@ describe('agent-teams-controller API', () => {
|
|||
'After task_complete, notify your team lead via MCP tool agent-teams_message_send.'
|
||||
);
|
||||
expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
|
||||
expect(briefing).toContain('OpenCode bootstrap silence rule');
|
||||
expect(briefing).toContain(
|
||||
'If it shows no actionable tasks, stop and wait silently.'
|
||||
);
|
||||
expect(briefing).toContain(
|
||||
'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
|
||||
);
|
||||
|
|
@ -182,6 +186,54 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).not.toContain('notify your team lead via SendMessage');
|
||||
});
|
||||
|
||||
it('rejects OpenCode idle acknowledgements without explicit delivery context', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
config.members = [
|
||||
{ name: 'alice', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer', providerId: 'opencode', model: 'opencode/test-model' },
|
||||
];
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
expect(() =>
|
||||
controller.messages.sendMessage({
|
||||
to: 'user',
|
||||
from: 'bob',
|
||||
text: 'Понял.',
|
||||
})
|
||||
).toThrow('OpenCode idle/ack-only message_send was not delivered');
|
||||
|
||||
expect(() =>
|
||||
controller.messages.sendMessage({
|
||||
to: 'team-lead',
|
||||
from: 'bob',
|
||||
text: 'Нет назначенных задач.',
|
||||
})
|
||||
).toThrow('OpenCode idle/ack-only message_send was not delivered');
|
||||
|
||||
expect(() =>
|
||||
controller.messages.sendMessage({
|
||||
to: 'user',
|
||||
from: 'bob',
|
||||
text: 'Понял.',
|
||||
source: 'runtime_delivery',
|
||||
})
|
||||
).toThrow('OpenCode idle/ack-only message_send was not delivered');
|
||||
|
||||
const delivered = controller.messages.sendMessage({
|
||||
to: 'user',
|
||||
from: 'bob',
|
||||
text: 'Понял.',
|
||||
source: 'runtime_delivery',
|
||||
relayOfMessageId: 'msg-inbound-1',
|
||||
});
|
||||
|
||||
expect(delivered.deliveredToInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('strips hallucinated zero task placeholder prefixes from visible messages', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -1047,6 +1099,26 @@ describe('agent-teams-controller API', () => {
|
|||
expect(rows[0].leadSessionId).toBe('lead-session-1');
|
||||
});
|
||||
|
||||
it('includes the assigned task ref in owner assignment notifications', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Implement runtime handoff',
|
||||
owner: 'bob',
|
||||
descriptionTaskRefs: [{ taskId: 'related-task', displayId: 'rel12345', teamName: 'my-team' }],
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].summary).toBe(`New task #${task.displayId} assigned`);
|
||||
expect(rows[0].taskRefs).toEqual([
|
||||
{ taskId: task.id, displayId: task.displayId, teamName: 'my-team' },
|
||||
{ taskId: 'related-task', displayId: 'rel12345', teamName: 'my-team' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not wake owner for self-comments and keeps user clarification sticky until explicitly cleared', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
2257
docs/team-management/opencode-delivery-watchdog-plan.md
Normal file
2257
docs/team-management/opencode-delivery-watchdog-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
server.addTool({
|
||||
name: 'message_send',
|
||||
description:
|
||||
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
|
||||
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. When to is "user", from is required and must be your configured teammate name. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
to: z.string().min(1),
|
||||
|
|
@ -22,6 +22,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
from: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
relayOfMessageId: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
|
|
@ -51,6 +52,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
from,
|
||||
summary,
|
||||
source,
|
||||
relayOfMessageId,
|
||||
leadSessionId,
|
||||
attachments,
|
||||
taskRefs,
|
||||
|
|
@ -64,6 +66,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(from ? { from } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(relayOfMessageId ? { relayOfMessageId } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
...(attachments?.length ? { attachments } : {}),
|
||||
...(taskRefs?.length ? { taskRefs } : {}),
|
||||
|
|
|
|||
|
|
@ -591,6 +591,8 @@ describe('agent-teams-mcp tools', () => {
|
|||
openCodeMemberBriefing as { content: Array<{ text: string }> }
|
||||
).content[0]?.text;
|
||||
expect(openCodeMemberBriefingText).toContain('agent-teams_message_send');
|
||||
expect(openCodeMemberBriefingText).toContain('OpenCode bootstrap silence rule');
|
||||
expect(openCodeMemberBriefingText).toContain('stop and wait silently');
|
||||
expect(openCodeMemberBriefingText).toContain('Full details in task comment e5f6a7b8');
|
||||
expect(openCodeMemberBriefingText).toContain(
|
||||
'Never invent placeholder task refs such as #00000000'
|
||||
|
|
@ -1224,6 +1226,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
from: 'lead',
|
||||
summary: 'Metadata test',
|
||||
source: 'system_notification',
|
||||
relayOfMessageId: 'msg-original-1',
|
||||
leadSessionId: 'session-42',
|
||||
attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }],
|
||||
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName }],
|
||||
|
|
@ -1234,6 +1237,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows[0].source).toBe('system_notification');
|
||||
expect(rows[0].relayOfMessageId).toBe('msg-original-1');
|
||||
expect(rows[0].leadSessionId).toBe('session-42');
|
||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||
expect(rows[0].taskRefs).toEqual([{ taskId: 'task-1', displayId: 'abcd1234', teamName }]);
|
||||
|
|
|
|||
|
|
@ -18,48 +18,60 @@ export interface UseGraphInteractionResult {
|
|||
handleDoubleClick: (wx: number, wy: number, nodes: GraphNode[]) => string | null;
|
||||
}
|
||||
|
||||
export interface UseGraphInteractionOptions {
|
||||
canDragNode?: (node: GraphNode) => boolean;
|
||||
}
|
||||
|
||||
export function useGraphInteraction(
|
||||
onDragNode?: (nodeId: string, x: number, y: number) => void,
|
||||
options?: UseGraphInteractionOptions
|
||||
): UseGraphInteractionResult {
|
||||
const hoveredNodeId = useRef<string | null>(null);
|
||||
const dragNodeId = useRef<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
|
||||
const clickedNodeId = useRef<string | null>(null);
|
||||
const canDragNode = options?.canDragNode;
|
||||
|
||||
const handleMouseDown = useCallback((wx: number, wy: number, nodes: GraphNode[]) => {
|
||||
mouseDownPos.current = { x: wx, y: wy };
|
||||
const hit = findNodeAt(wx, wy, nodes);
|
||||
clickedNodeId.current = hit;
|
||||
const handleMouseDown = useCallback(
|
||||
(wx: number, wy: number, nodes: GraphNode[]) => {
|
||||
mouseDownPos.current = { x: wx, y: wy };
|
||||
const hit = findNodeAt(wx, wy, nodes);
|
||||
clickedNodeId.current = hit;
|
||||
|
||||
if (hit) {
|
||||
// Stable-slot layout keeps lead fixed in the center. Only members can be dragged between slots.
|
||||
const hitNode = nodes.find((n) => n.id === hit);
|
||||
if (hitNode?.kind === 'member') {
|
||||
dragNodeId.current = hit;
|
||||
if (hit) {
|
||||
// Stable-slot layout keeps lead fixed in the center. Only members can be dragged between slots.
|
||||
const hitNode = nodes.find((n) => n.id === hit);
|
||||
if (hitNode?.kind === 'member' && (canDragNode?.(hitNode) ?? true)) {
|
||||
dragNodeId.current = hit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[canDragNode]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback((wx: number, wy: number, nodes: GraphNode[]) => {
|
||||
// Check drag threshold
|
||||
if (mouseDownPos.current && dragNodeId.current) {
|
||||
const dx = wx - mouseDownPos.current.x;
|
||||
const dy = wy - mouseDownPos.current.y;
|
||||
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
|
||||
isDragging.current = true;
|
||||
const handleMouseMove = useCallback(
|
||||
(wx: number, wy: number, nodes: GraphNode[]) => {
|
||||
// Check drag threshold
|
||||
if (mouseDownPos.current && dragNodeId.current) {
|
||||
const dx = wx - mouseDownPos.current.x;
|
||||
const dy = wy - mouseDownPos.current.y;
|
||||
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
|
||||
isDragging.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drag node
|
||||
if (isDragging.current && dragNodeId.current) {
|
||||
onDragNode?.(dragNodeId.current, wx, wy);
|
||||
return;
|
||||
}
|
||||
// Drag node
|
||||
if (isDragging.current && dragNodeId.current) {
|
||||
onDragNode?.(dragNodeId.current, wx, wy);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hover detection
|
||||
hoveredNodeId.current = findNodeAt(wx, wy, nodes);
|
||||
}, [onDragNode]);
|
||||
// Hover detection
|
||||
hoveredNodeId.current = findNodeAt(wx, wy, nodes);
|
||||
},
|
||||
[onDragNode]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback((): string | null => {
|
||||
const wasDragging = isDragging.current;
|
||||
|
|
@ -77,9 +89,12 @@ export function useGraphInteraction(
|
|||
return null;
|
||||
}, []);
|
||||
|
||||
const handleDoubleClick = useCallback((wx: number, wy: number, nodes: GraphNode[]): string | null => {
|
||||
return findNodeAt(wx, wy, nodes);
|
||||
}, []);
|
||||
const handleDoubleClick = useCallback(
|
||||
(wx: number, wy: number, nodes: GraphNode[]): string | null => {
|
||||
return findNodeAt(wx, wy, nodes);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
|||
import { getStateColor } from '../constants/colors';
|
||||
import {
|
||||
buildStableSlotLayoutSnapshot,
|
||||
resolveNearestGridOwnerTarget,
|
||||
resolveNearestSlotAssignment,
|
||||
snapshotToWorldBounds,
|
||||
translateSlotFrame,
|
||||
|
|
@ -14,7 +15,13 @@ import {
|
|||
} from '../layout/stableSlots';
|
||||
import { KanbanLayoutEngine } from '../layout/kanbanLayout';
|
||||
|
||||
import type { GraphEdge, GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment, GraphParticle } from '../ports/types';
|
||||
import type {
|
||||
GraphEdge,
|
||||
GraphLayoutPort,
|
||||
GraphNode,
|
||||
GraphOwnerSlotAssignment,
|
||||
GraphParticle,
|
||||
} from '../ports/types';
|
||||
import type { WorldBounds } from '../layout/launchAnchor';
|
||||
import { createCompleteEffect, createSpawnEffect, type VisualEffect } from '../canvas/draw-effects';
|
||||
|
||||
|
|
@ -50,6 +57,15 @@ export interface UseGraphSimulationResult {
|
|||
previewOwnerX: number;
|
||||
previewOwnerY: number;
|
||||
} | null;
|
||||
resolveNearestOwnerGridTarget: (
|
||||
nodeId: string,
|
||||
x: number,
|
||||
y: number
|
||||
) => {
|
||||
targetOwnerId: string;
|
||||
previewOwnerX: number;
|
||||
previewOwnerY: number;
|
||||
} | null;
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
|
||||
getActivityWorldRect: (nodeId: string) => StableRect | null;
|
||||
getExtraWorldBounds: () => WorldBounds[];
|
||||
|
|
@ -145,7 +161,12 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
layoutRef.current = layout;
|
||||
|
||||
preserveReusableNodePositions(nodes, state.nodes);
|
||||
recordNodeLifecycleEffects(state.effects, nodes, prevNodeStatesRef.current, allKnownNodeIdsRef.current);
|
||||
recordNodeLifecycleEffects(
|
||||
state.effects,
|
||||
nodes,
|
||||
prevNodeStatesRef.current,
|
||||
allKnownNodeIdsRef.current
|
||||
);
|
||||
prevNodeIdsRef.current = new Set(nodes.map((node) => node.id));
|
||||
prevNodeStatesRef.current = new Map(nodes.map((node) => [node.id, node.state]));
|
||||
|
||||
|
|
@ -210,23 +231,33 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
applyCurrentLayout();
|
||||
}, [applyCurrentLayout]);
|
||||
|
||||
const resolveNearestOwnerSlot = useCallback(
|
||||
(nodeId: string, x: number, y: number) => {
|
||||
const snapshot = layoutSnapshotRef.current;
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
return resolveNearestSlotAssignment({
|
||||
ownerId: nodeId,
|
||||
ownerX: x,
|
||||
ownerY: y,
|
||||
nodes: stateRef.current.nodes,
|
||||
snapshot,
|
||||
layout: layoutRef.current,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
const resolveNearestOwnerSlot = useCallback((nodeId: string, x: number, y: number) => {
|
||||
const snapshot = layoutSnapshotRef.current;
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
return resolveNearestSlotAssignment({
|
||||
ownerId: nodeId,
|
||||
ownerX: x,
|
||||
ownerY: y,
|
||||
nodes: stateRef.current.nodes,
|
||||
snapshot,
|
||||
layout: layoutRef.current,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resolveNearestOwnerGridTarget = useCallback((nodeId: string, x: number, y: number) => {
|
||||
const snapshot = layoutSnapshotRef.current;
|
||||
if (!snapshot || layoutRef.current?.mode !== 'grid-under-lead') {
|
||||
return null;
|
||||
}
|
||||
return resolveNearestGridOwnerTarget({
|
||||
ownerId: nodeId,
|
||||
ownerX: x,
|
||||
ownerY: y,
|
||||
snapshot,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -248,6 +279,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
resolveNearestOwnerGridTarget,
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
|
|
@ -260,6 +292,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
resolveNearestOwnerGridTarget,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -381,17 +414,13 @@ function resetToFallbackLayout(args: {
|
|||
KanbanLayoutEngine.layout(nodes);
|
||||
}
|
||||
|
||||
function preserveReusableNodePositions(
|
||||
nodes: GraphNode[],
|
||||
previousNodes: GraphNode[]
|
||||
): void {
|
||||
function preserveReusableNodePositions(nodes: GraphNode[], previousNodes: GraphNode[]): void {
|
||||
const previousPositionById = new Map(
|
||||
previousNodes
|
||||
.filter((node) => node.x != null && node.y != null)
|
||||
.map((node) => [
|
||||
node.id,
|
||||
{ x: node.x!, y: node.y!, vx: node.vx ?? 0, vy: node.vy ?? 0 },
|
||||
] as const)
|
||||
.map(
|
||||
(node) => [node.id, { x: node.x!, y: node.y!, vx: node.vx ?? 0, vy: node.vy ?? 0 }] as const
|
||||
)
|
||||
);
|
||||
|
||||
for (const node of nodes) {
|
||||
|
|
@ -498,7 +527,10 @@ function positionProcessNodes(nodes: GraphNode[], frames: readonly SlotFrame[]):
|
|||
}
|
||||
}
|
||||
|
||||
function positionCrossTeamNodes(nodes: GraphNode[], fitBounds: StableSlotLayoutSnapshot['fitBounds']): void {
|
||||
function positionCrossTeamNodes(
|
||||
nodes: GraphNode[],
|
||||
fitBounds: StableSlotLayoutSnapshot['fitBounds']
|
||||
): void {
|
||||
const crossTeamNodes = nodes.filter((node) => node.kind === 'crossteam');
|
||||
if (crossTeamNodes.length === 0) {
|
||||
return;
|
||||
|
|
@ -515,8 +547,7 @@ function positionCrossTeamNodes(nodes: GraphNode[], fitBounds: StableSlotLayoutS
|
|||
const endAngle = (150 * Math.PI) / 180;
|
||||
|
||||
crossTeamNodes.forEach((node, index) => {
|
||||
const t =
|
||||
crossTeamNodes.length === 1 ? 0.5 : index / Math.max(crossTeamNodes.length - 1, 1);
|
||||
const t = crossTeamNodes.length === 1 ? 0.5 : index / Math.max(crossTeamNodes.length - 1, 1);
|
||||
const angle = startAngle + (endAngle - startAngle) * t;
|
||||
const x = Math.cos(angle) * radius;
|
||||
const y = Math.sin(angle) * radius;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export type {
|
|||
GraphActivityItem,
|
||||
GraphOwnerSlotAssignment,
|
||||
GraphLayoutPort,
|
||||
GraphLayoutMode,
|
||||
GraphLayoutVersion,
|
||||
GraphNodeKind,
|
||||
GraphNodeState,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
|||
import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
||||
import { ACTIVITY_LANE } from './activityLane';
|
||||
import type { WorldBounds } from './launchAnchor';
|
||||
import {
|
||||
STABLE_SLOT_GEOMETRY,
|
||||
STABLE_SLOT_SECTOR_VECTORS,
|
||||
} from './stableSlotGeometry';
|
||||
import { STABLE_SLOT_GEOMETRY, STABLE_SLOT_SECTOR_VECTORS } from './stableSlotGeometry';
|
||||
|
||||
export type StableSlotWidthBucket = 'S' | 'M' | 'L';
|
||||
|
||||
|
|
@ -81,6 +78,12 @@ interface NearestSlotAssignmentResult {
|
|||
previewOwnerY: number;
|
||||
}
|
||||
|
||||
interface NearestGridOwnerTargetResult {
|
||||
targetOwnerId: string;
|
||||
previewOwnerX: number;
|
||||
previewOwnerY: number;
|
||||
}
|
||||
|
||||
interface RankedNearestSlotAssignmentResult extends NearestSlotAssignmentResult {
|
||||
distanceSquared: number;
|
||||
}
|
||||
|
|
@ -110,8 +113,7 @@ const SLOT_GEOMETRY = {
|
|||
boardColumnGap: 24,
|
||||
processRailMinWidth: STABLE_SLOT_GEOMETRY.processRailWidth,
|
||||
kanbanBandHeight:
|
||||
KANBAN_ZONE.headerHeight +
|
||||
STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight,
|
||||
KANBAN_ZONE.headerHeight + STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight,
|
||||
centralPadding: STABLE_SLOT_GEOMETRY.centralSafetyPadding,
|
||||
} as const;
|
||||
|
||||
|
|
@ -120,6 +122,9 @@ const PROCESS_RAIL_NODE_FOOTPRINT = 28;
|
|||
const GEOMETRY_EPSILON = 0.001;
|
||||
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
|
||||
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
|
||||
const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
|
||||
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
|
||||
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
|
||||
|
||||
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
|
||||
const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
|
||||
|
|
@ -166,12 +171,8 @@ export function buildStableSlotLayoutSnapshot({
|
|||
}
|
||||
|
||||
const leadCoreRect = createCenteredRect(0, 0, 200, 96);
|
||||
const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id);
|
||||
const leadSlotFrame = buildSlotFrameAtRadius(
|
||||
leadFootprint,
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
0
|
||||
);
|
||||
const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id, layout);
|
||||
const leadSlotFrame = buildSlotFrameAtRadius(leadFootprint, { ringIndex: 0, sectorIndex: 0 }, 0);
|
||||
const leadActivityRect = leadSlotFrame.activityColumnRect;
|
||||
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
|
||||
const leadCentralReservedBlock = buildLeadCentralReservedBlock({
|
||||
|
|
@ -191,20 +192,15 @@ export function buildStableSlotLayoutSnapshot({
|
|||
SLOT_GEOMETRY.centralPadding
|
||||
);
|
||||
|
||||
const memberSlotFrames = planOwnerSlots(
|
||||
ownerFootprints,
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion,
|
||||
layout
|
||||
);
|
||||
const memberSlotFrames =
|
||||
(layout?.mode ?? 'radial') === 'grid-under-lead'
|
||||
? planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects)
|
||||
: planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout);
|
||||
const memberSlotFrameByOwnerId = new Map(
|
||||
memberSlotFrames.map((frame) => [frame.ownerId, frame] as const)
|
||||
);
|
||||
const fitBounds = unionRects(
|
||||
[
|
||||
runtimeCentralExclusion,
|
||||
...memberSlotFrames.map((frame) => frame.bounds),
|
||||
].filter(Boolean)
|
||||
[runtimeCentralExclusion, ...memberSlotFrames.map((frame) => frame.bounds)].filter(Boolean)
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -255,10 +251,7 @@ function buildLeadCentralReservedBlock(args: {
|
|||
]);
|
||||
}
|
||||
|
||||
function padCentralCollisionRects(
|
||||
rects: readonly StableRect[],
|
||||
padding: number
|
||||
): StableRect[] {
|
||||
function padCentralCollisionRects(rects: readonly StableRect[], padding: number): StableRect[] {
|
||||
return rects.map((rect) => padRect(rect, padding));
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +269,7 @@ export function computeOwnerFootprints(
|
|||
layout?: GraphLayoutPort
|
||||
): OwnerFootprint[] {
|
||||
const ownerNodes = nodes.filter((node) => node.kind === 'member');
|
||||
const showActivity = layout?.showActivity ?? true;
|
||||
const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const));
|
||||
const taskColumnsByOwnerId = new Map<string, Set<string>>();
|
||||
const processCountByOwnerId = new Map<string, number>();
|
||||
|
|
@ -309,6 +303,7 @@ export function computeOwnerFootprints(
|
|||
ownerId,
|
||||
taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0,
|
||||
processCount: processCountByOwnerId.get(ownerId) ?? 0,
|
||||
showActivity,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
|
@ -316,7 +311,8 @@ export function computeOwnerFootprints(
|
|||
|
||||
function computeOwnerFootprintForOwnerId(
|
||||
nodes: readonly GraphNode[],
|
||||
ownerId: string
|
||||
ownerId: string,
|
||||
layout?: GraphLayoutPort
|
||||
): OwnerFootprint {
|
||||
const taskColumns = new Set<string>();
|
||||
let processCount = 0;
|
||||
|
|
@ -334,6 +330,7 @@ function computeOwnerFootprintForOwnerId(
|
|||
ownerId,
|
||||
taskColumnCount: taskColumns.size,
|
||||
processCount,
|
||||
showActivity: layout?.showActivity ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -341,25 +338,19 @@ function buildOwnerFootprint(args: {
|
|||
ownerId: string;
|
||||
taskColumnCount: number;
|
||||
processCount: number;
|
||||
showActivity: boolean;
|
||||
}): OwnerFootprint {
|
||||
const activityColumnWidth = args.showActivity ? SLOT_GEOMETRY.activityColumnWidth : 0;
|
||||
const activityColumnHeight = args.showActivity ? SLOT_GEOMETRY.activityColumnHeight : 0;
|
||||
const activityToKanbanGap = args.showActivity ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const kanbanBandWidth =
|
||||
args.taskColumnCount <= 1
|
||||
? TASK_PILL.width
|
||||
: TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
|
||||
const processBandWidth = computeProcessBandWidth(args.processCount);
|
||||
const boardBandWidth =
|
||||
SLOT_GEOMETRY.activityColumnWidth +
|
||||
SLOT_GEOMETRY.boardColumnGap +
|
||||
kanbanBandWidth;
|
||||
const boardBandHeight = Math.max(
|
||||
SLOT_GEOMETRY.activityColumnHeight,
|
||||
SLOT_GEOMETRY.kanbanBandHeight
|
||||
);
|
||||
const innerContentWidth = Math.max(
|
||||
SLOT_GEOMETRY.ownerMinWidth,
|
||||
processBandWidth,
|
||||
boardBandWidth
|
||||
);
|
||||
const boardBandWidth = activityColumnWidth + activityToKanbanGap + kanbanBandWidth;
|
||||
const boardBandHeight = Math.max(activityColumnHeight, SLOT_GEOMETRY.kanbanBandHeight);
|
||||
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
|
||||
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
|
||||
const slotHeight =
|
||||
SLOT_GEOMETRY.memberSlotInnerPadding * 2 +
|
||||
|
|
@ -384,8 +375,8 @@ function buildOwnerFootprint(args: {
|
|||
slotHeight,
|
||||
widthBucket: classifyWidthBucket(slotWidth),
|
||||
radialDepth,
|
||||
activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth,
|
||||
activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight,
|
||||
activityColumnWidth,
|
||||
activityColumnHeight,
|
||||
processBandWidth,
|
||||
kanbanBandWidth,
|
||||
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
|
||||
|
|
@ -411,8 +402,7 @@ export function computeProcessBandWidth(processCount: number): number {
|
|||
return SLOT_GEOMETRY.processRailMinWidth;
|
||||
}
|
||||
|
||||
const occupiedWidth =
|
||||
(processCount - 1) * PROCESS_RAIL_NODE_GAP + PROCESS_RAIL_NODE_FOOTPRINT;
|
||||
const occupiedWidth = (processCount - 1) * PROCESS_RAIL_NODE_GAP + PROCESS_RAIL_NODE_FOOTPRINT;
|
||||
return Math.max(SLOT_GEOMETRY.processRailMinWidth, occupiedWidth);
|
||||
}
|
||||
|
||||
|
|
@ -424,10 +414,12 @@ export function resolveNearestSlotAssignment(args: {
|
|||
snapshot: StableSlotLayoutSnapshot;
|
||||
layout?: GraphLayoutPort;
|
||||
}): NearestSlotAssignmentResult | null {
|
||||
if ((args.layout?.mode ?? 'radial') === 'grid-under-lead') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allFootprints = computeOwnerFootprints(args.nodes, args.layout);
|
||||
const footprintByOwnerId = new Map(
|
||||
allFootprints.map((item) => [item.ownerId, item] as const)
|
||||
);
|
||||
const footprintByOwnerId = new Map(allFootprints.map((item) => [item.ownerId, item] as const));
|
||||
const footprint = footprintByOwnerId.get(args.ownerId);
|
||||
if (!footprint) {
|
||||
return null;
|
||||
|
|
@ -449,7 +441,9 @@ export function resolveNearestSlotAssignment(args: {
|
|||
return strictSmallTeamCandidate;
|
||||
}
|
||||
|
||||
const existingFrames = args.snapshot.memberSlotFrames.filter((frame) => frame.ownerId !== args.ownerId);
|
||||
const existingFrames = args.snapshot.memberSlotFrames.filter(
|
||||
(frame) => frame.ownerId !== args.ownerId
|
||||
);
|
||||
const maxOccupiedRing = existingFrames.reduce((max, frame) => Math.max(max, frame.ringIndex), 0);
|
||||
const candidateAssignments = buildCandidateAssignments(
|
||||
Math.max(SLOT_GEOMETRY.maxGeneratedRings, maxOccupiedRing + allFootprints.length + 2)
|
||||
|
|
@ -500,6 +494,41 @@ export function resolveNearestSlotAssignment(args: {
|
|||
: null;
|
||||
}
|
||||
|
||||
export function resolveNearestGridOwnerTarget(args: {
|
||||
ownerId: string;
|
||||
ownerX: number;
|
||||
ownerY: number;
|
||||
snapshot: StableSlotLayoutSnapshot;
|
||||
}): NearestGridOwnerTargetResult | null {
|
||||
if (!args.snapshot.memberSlotFrameByOwnerId.has(args.ownerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let best: {
|
||||
frame: SlotFrame;
|
||||
distanceSquared: number;
|
||||
} | null = null;
|
||||
|
||||
for (const frame of args.snapshot.memberSlotFrames) {
|
||||
const dx = frame.ownerX - args.ownerX;
|
||||
const dy = frame.ownerY - args.ownerY;
|
||||
const distanceSquared = dx * dx + dy * dy;
|
||||
if (!best || distanceSquared < best.distanceSquared) {
|
||||
best = { frame, distanceSquared };
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetOwnerId: best.frame.ownerId,
|
||||
previewOwnerX: best.frame.ownerX,
|
||||
previewOwnerY: best.frame.ownerY,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStrictSmallTeamNearestSlotAssignment(args: {
|
||||
ownerId: string;
|
||||
ownerX: number;
|
||||
|
|
@ -512,12 +541,10 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
|
|||
return null;
|
||||
}
|
||||
|
||||
let best:
|
||||
| {
|
||||
frame: SlotFrame;
|
||||
distanceSquared: number;
|
||||
}
|
||||
| null = null;
|
||||
let best: {
|
||||
frame: SlotFrame;
|
||||
distanceSquared: number;
|
||||
} | null = null;
|
||||
for (const frame of strictFrames) {
|
||||
const dx = frame.ownerX - args.ownerX;
|
||||
const dy = frame.ownerY - args.ownerY;
|
||||
|
|
@ -568,7 +595,9 @@ function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFr
|
|||
}
|
||||
|
||||
const actualAssignmentKeys = frames
|
||||
.map((frame) => buildAssignmentKey({ ringIndex: frame.ringIndex, sectorIndex: frame.sectorIndex }))
|
||||
.map((frame) =>
|
||||
buildAssignmentKey({ ringIndex: frame.ringIndex, sectorIndex: frame.sectorIndex })
|
||||
)
|
||||
.sort();
|
||||
const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort();
|
||||
|
||||
|
|
@ -600,12 +629,7 @@ export function validateStableSlotLayout(
|
|||
const seenOwnerIds = new Set<string>();
|
||||
const seenAssignments = new Set<string>();
|
||||
for (const frame of snapshot.memberSlotFrames) {
|
||||
const frameValidation = validateMemberSlotFrame(
|
||||
frame,
|
||||
snapshot,
|
||||
seenOwnerIds,
|
||||
seenAssignments
|
||||
);
|
||||
const frameValidation = validateMemberSlotFrame(frame, snapshot, seenOwnerIds, seenAssignments);
|
||||
if (frameValidation) {
|
||||
return frameValidation;
|
||||
}
|
||||
|
|
@ -673,8 +697,13 @@ function validateLeadSnapshotRects(
|
|||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
||||
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)) {
|
||||
return { valid: false, reason: 'lead processBandRect must fit inside leadCentralReservedBlock' };
|
||||
if (
|
||||
!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'lead processBandRect must fit inside leadCentralReservedBlock',
|
||||
};
|
||||
}
|
||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.kanbanBandRect)) {
|
||||
return { valid: false, reason: 'lead kanbanBandRect must fit inside leadCentralReservedBlock' };
|
||||
|
|
@ -692,7 +721,10 @@ function validateLeadSnapshotRects(
|
|||
};
|
||||
}
|
||||
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
|
||||
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock',
|
||||
};
|
||||
}
|
||||
const paddedCentralCollisionRects = padCentralCollisionRects(
|
||||
snapshot.centralCollisionRects,
|
||||
|
|
@ -855,13 +887,10 @@ function buildUnassignedTaskRect(
|
|||
leadCentralReservedBlock: StableRect
|
||||
): StableRect | null {
|
||||
const visibleOwnerIds = new Set(
|
||||
nodes
|
||||
.filter((node) => node.kind === 'lead' || node.kind === 'member')
|
||||
.map((node) => node.id)
|
||||
nodes.filter((node) => node.kind === 'lead' || node.kind === 'member').map((node) => node.id)
|
||||
);
|
||||
const unassignedTasks = nodes.filter(
|
||||
(node) =>
|
||||
node.kind === 'task' && (!node.ownerId || !visibleOwnerIds.has(node.ownerId))
|
||||
(node) => node.kind === 'task' && (!node.ownerId || !visibleOwnerIds.has(node.ownerId))
|
||||
);
|
||||
if (unassignedTasks.length === 0) {
|
||||
return null;
|
||||
|
|
@ -923,6 +952,52 @@ function planOwnerSlots(
|
|||
return placedFrames;
|
||||
}
|
||||
|
||||
function planGridUnderLeadOwnerSlots(
|
||||
ownerFootprints: readonly OwnerFootprint[],
|
||||
centralCollisionRects: readonly StableRect[]
|
||||
): SlotFrame[] {
|
||||
const frames: SlotFrame[] = [];
|
||||
const centralBlock = unionRects([...centralCollisionRects]);
|
||||
let rowTop = centralBlock.bottom + GRID_UNDER_LEAD_LEAD_GAP;
|
||||
|
||||
for (
|
||||
let rowStartIndex = 0;
|
||||
rowStartIndex < ownerFootprints.length;
|
||||
rowStartIndex += GRID_UNDER_LEAD_COLUMN_COUNT
|
||||
) {
|
||||
const rowFootprints = ownerFootprints.slice(
|
||||
rowStartIndex,
|
||||
rowStartIndex + GRID_UNDER_LEAD_COLUMN_COUNT
|
||||
);
|
||||
const rowWidth =
|
||||
rowFootprints.reduce((sum, footprint) => sum + footprint.slotWidth, 0) +
|
||||
Math.max(0, rowFootprints.length - 1) * SLOT_GEOMETRY.slotHorizontalGap;
|
||||
const rowHeight = Math.max(...rowFootprints.map((footprint) => footprint.slotHeight));
|
||||
const ownerY = rowTop + getOwnerAnchorTopOffset();
|
||||
let nextLeft = -rowWidth / 2;
|
||||
|
||||
rowFootprints.forEach((footprint, columnIndex) => {
|
||||
const ownerX = nextLeft + footprint.slotWidth / 2;
|
||||
frames.push(
|
||||
buildSlotFrameAtOwnerAnchor(
|
||||
footprint,
|
||||
{
|
||||
ringIndex: Math.floor(rowStartIndex / GRID_UNDER_LEAD_COLUMN_COUNT),
|
||||
sectorIndex: columnIndex,
|
||||
},
|
||||
ownerX,
|
||||
ownerY
|
||||
)
|
||||
);
|
||||
nextLeft += footprint.slotWidth + SLOT_GEOMETRY.slotHorizontalGap;
|
||||
});
|
||||
|
||||
rowTop += rowHeight + GRID_UNDER_LEAD_ROW_GAP;
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
function shouldUseStrictSmallTeamCardinalLayout(
|
||||
ownerFootprints: readonly OwnerFootprint[],
|
||||
layout?: GraphLayoutPort
|
||||
|
|
@ -1052,7 +1127,10 @@ function resolveStrictSmallTeamRadiusByAxis(
|
|||
return radiusByAxis;
|
||||
}
|
||||
|
||||
function resolveStrictSmallTeamVectorAxis(vector: { x: number; y: number }): 'horizontal' | 'vertical' {
|
||||
function resolveStrictSmallTeamVectorAxis(vector: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): 'horizontal' | 'vertical' {
|
||||
return Math.abs(vector.x) >= Math.abs(vector.y) ? 'horizontal' : 'vertical';
|
||||
}
|
||||
|
||||
|
|
@ -1171,7 +1249,8 @@ function buildSlotFrameAtRadius(
|
|||
assignment: GraphOwnerSlotAssignment,
|
||||
radius: number
|
||||
): SlotFrame {
|
||||
const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0];
|
||||
const vector =
|
||||
SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0];
|
||||
return buildSlotFrameAtRadiusWithVector(footprint, assignment, radius, vector);
|
||||
}
|
||||
|
||||
|
|
@ -1183,8 +1262,16 @@ function buildSlotFrameAtRadiusWithVector(
|
|||
): SlotFrame {
|
||||
const ownerX = vector.x * radius;
|
||||
const ownerY = vector.y * radius;
|
||||
const slotTop =
|
||||
ownerY - (SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2);
|
||||
return buildSlotFrameAtOwnerAnchor(footprint, assignment, ownerX, ownerY);
|
||||
}
|
||||
|
||||
function buildSlotFrameAtOwnerAnchor(
|
||||
footprint: OwnerFootprint,
|
||||
assignment: GraphOwnerSlotAssignment,
|
||||
ownerX: number,
|
||||
ownerY: number
|
||||
): SlotFrame {
|
||||
const slotTop = ownerY - getOwnerAnchorTopOffset();
|
||||
const bounds = createRect(
|
||||
ownerX - footprint.slotWidth / 2,
|
||||
slotTop,
|
||||
|
|
@ -1209,8 +1296,9 @@ function buildSlotFrameAtRadiusWithVector(
|
|||
footprint.activityColumnWidth,
|
||||
footprint.activityColumnHeight
|
||||
);
|
||||
const activityToKanbanGap = footprint.activityColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const kanbanBandRect = createRect(
|
||||
activityColumnRect.right + SLOT_GEOMETRY.boardColumnGap,
|
||||
activityColumnRect.right + activityToKanbanGap,
|
||||
boardBandRect.top,
|
||||
footprint.kanbanBandWidth,
|
||||
footprint.kanbanBandHeight
|
||||
|
|
@ -1232,6 +1320,10 @@ function buildSlotFrameAtRadiusWithVector(
|
|||
};
|
||||
}
|
||||
|
||||
function getOwnerAnchorTopOffset(): number {
|
||||
return SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2;
|
||||
}
|
||||
|
||||
function buildCandidateAssignments(maxRingExclusive: number): GraphOwnerSlotAssignment[] {
|
||||
const candidates: GraphOwnerSlotAssignment[] = [];
|
||||
for (let ringIndex = 0; ringIndex < maxRingExclusive; ringIndex += 1) {
|
||||
|
|
@ -1272,10 +1364,7 @@ function computePlannerRingLimit(
|
|||
(max, assignment) => Math.max(max, assignment.ringIndex),
|
||||
0
|
||||
);
|
||||
return Math.max(
|
||||
SLOT_GEOMETRY.maxGeneratedRings,
|
||||
maxAssignedRing + ownerFootprints.length + 2
|
||||
);
|
||||
return Math.max(SLOT_GEOMETRY.maxGeneratedRings, maxAssignedRing + ownerFootprints.length + 2);
|
||||
}
|
||||
|
||||
function ownerFootprintsSpillBudget(placedOwnerCount: number): number {
|
||||
|
|
@ -1362,7 +1451,9 @@ function rankNearestSlotAssignmentResult(args: {
|
|||
if (!displacedFrame) {
|
||||
return null;
|
||||
}
|
||||
const otherFrames = existingFrames.filter((existing) => existing.ownerId !== occupiedFrame.ownerId);
|
||||
const otherFrames = existingFrames.filter(
|
||||
(existing) => existing.ownerId !== occupiedFrame.ownerId
|
||||
);
|
||||
if (
|
||||
!isSlotFramePlacementValid(frame, otherFrames, centralCollisionRects) ||
|
||||
!isSlotFramePlacementValid(displacedFrame, otherFrames, centralCollisionRects) ||
|
||||
|
|
@ -1692,7 +1783,8 @@ function resolveMinimumDirectionalRadius(args: {
|
|||
runtimeCentralExclusion: StableRect;
|
||||
}): number {
|
||||
return resolveMinimumDirectionalRadiusForVector({
|
||||
vector: SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0],
|
||||
vector:
|
||||
SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0],
|
||||
footprint: args.footprint,
|
||||
centralCollisionRects: args.centralCollisionRects,
|
||||
runtimeCentralExclusion: args.runtimeCentralExclusion,
|
||||
|
|
@ -1750,12 +1842,12 @@ function computeLegacyMinimumRingRadius(
|
|||
footprint: OwnerFootprint,
|
||||
centralExclusion: StableRect
|
||||
): number {
|
||||
const horizontalExtent =
|
||||
vector.x >= 0 ? centralExclusion.right : Math.abs(centralExclusion.left);
|
||||
const horizontalExtent = vector.x >= 0 ? centralExclusion.right : Math.abs(centralExclusion.left);
|
||||
const verticalExtent = vector.y >= 0 ? centralExclusion.bottom : Math.abs(centralExclusion.top);
|
||||
const requiredX =
|
||||
Math.abs(vector.x) > 0.001
|
||||
? (horizontalExtent + footprint.slotWidth / 2 + SLOT_GEOMETRY.ringPadding) / Math.abs(vector.x)
|
||||
? (horizontalExtent + footprint.slotWidth / 2 + SLOT_GEOMETRY.ringPadding) /
|
||||
Math.abs(vector.x)
|
||||
: 0;
|
||||
const requiredY =
|
||||
Math.abs(vector.y) > 0.001
|
||||
|
|
@ -1791,12 +1883,7 @@ function rectsOverlap(a: StableRect, b: StableRect): boolean {
|
|||
}
|
||||
|
||||
function ownerSlotFramesOverlap(a: StableRect, b: StableRect): boolean {
|
||||
return rectsOverlapWithAxisGap(
|
||||
a,
|
||||
b,
|
||||
SLOT_GEOMETRY.slotHorizontalGap,
|
||||
SLOT_GEOMETRY.ringPadding
|
||||
);
|
||||
return rectsOverlapWithAxisGap(a, b, SLOT_GEOMETRY.slotHorizontalGap, SLOT_GEOMETRY.ringPadding);
|
||||
}
|
||||
|
||||
function rectContainsRect(outer: StableRect, inner: StableRect): boolean {
|
||||
|
|
@ -1850,10 +1937,7 @@ function isSameAssignment(
|
|||
left: GraphOwnerSlotAssignment | undefined,
|
||||
right: GraphOwnerSlotAssignment
|
||||
): boolean {
|
||||
return (
|
||||
left?.ringIndex === right.ringIndex &&
|
||||
left?.sectorIndex === right.sectorIndex
|
||||
);
|
||||
return left?.ringIndex === right.ringIndex && left?.sectorIndex === right.sectorIndex;
|
||||
}
|
||||
|
||||
function createRect(left: number, top: number, width: number, height: number): StableRect {
|
||||
|
|
@ -1867,12 +1951,22 @@ function createRect(left: number, top: number, width: number, height: number): S
|
|||
};
|
||||
}
|
||||
|
||||
function createCenteredRect(centerX: number, centerY: number, width: number, height: number): StableRect {
|
||||
function createCenteredRect(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
width: number,
|
||||
height: number
|
||||
): StableRect {
|
||||
return createRect(centerX - width / 2, centerY - height / 2, width, height);
|
||||
}
|
||||
|
||||
function padRect(rect: StableRect, padding: number): StableRect {
|
||||
return createRect(rect.left - padding, rect.top - padding, rect.width + padding * 2, rect.height + padding * 2);
|
||||
return createRect(
|
||||
rect.left - padding,
|
||||
rect.top - padding,
|
||||
rect.width + padding * 2,
|
||||
rect.height + padding * 2
|
||||
);
|
||||
}
|
||||
|
||||
function translateRect(rect: StableRect, dx: number, dy: number): StableRect {
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ export type {
|
|||
GraphDomainRef,
|
||||
GraphOwnerSlotAssignment,
|
||||
GraphLayoutPort,
|
||||
GraphLayoutMode,
|
||||
GraphLayoutVersion,
|
||||
} from './types';
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export interface GraphActivityItem {
|
|||
}
|
||||
|
||||
export type GraphLayoutVersion = 'stable-slots-v1';
|
||||
export type GraphLayoutMode = 'radial' | 'grid-under-lead';
|
||||
|
||||
export interface GraphOwnerSlotAssignment {
|
||||
ringIndex: number;
|
||||
|
|
@ -63,6 +64,8 @@ export interface GraphOwnerSlotAssignment {
|
|||
|
||||
export interface GraphLayoutPort {
|
||||
version: GraphLayoutVersion;
|
||||
mode?: GraphLayoutMode;
|
||||
showActivity?: boolean;
|
||||
ownerOrder: string[];
|
||||
slotAssignments: Record<string, GraphOwnerSlotAssignment>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from 'lucide-react';
|
||||
import type { GraphLayoutMode } from '../ports/types';
|
||||
|
||||
export interface GraphFilterState {
|
||||
showActivity: boolean;
|
||||
|
|
@ -50,6 +51,8 @@ export interface GraphControlsProps {
|
|||
teamName: string;
|
||||
teamColor?: string;
|
||||
isAlive?: boolean;
|
||||
layoutMode?: GraphLayoutMode;
|
||||
onLayoutModeChange?: (mode: GraphLayoutMode) => void;
|
||||
topToolbarContent?: React.ReactNode;
|
||||
interactionLocked?: boolean;
|
||||
}
|
||||
|
|
@ -71,6 +74,8 @@ export function GraphControls({
|
|||
onToggleSidebar,
|
||||
isSidebarVisible = true,
|
||||
teamColor,
|
||||
layoutMode = 'radial',
|
||||
onLayoutModeChange,
|
||||
topToolbarContent,
|
||||
interactionLocked = false,
|
||||
}: GraphControlsProps): React.JSX.Element {
|
||||
|
|
@ -80,8 +85,14 @@ export function GraphControls({
|
|||
(key: keyof GraphFilterState) => {
|
||||
onFiltersChange({ ...filters, [key]: !filters[key] });
|
||||
},
|
||||
[filters, onFiltersChange],
|
||||
[filters, onFiltersChange]
|
||||
);
|
||||
const toggleLayoutMode = useCallback(() => {
|
||||
if (!onLayoutModeChange) {
|
||||
return;
|
||||
}
|
||||
onLayoutModeChange(layoutMode === 'radial' ? 'grid-under-lead' : 'radial');
|
||||
}, [layoutMode, onLayoutModeChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingsOpen) return;
|
||||
|
|
@ -174,9 +185,7 @@ export function GraphControls({
|
|||
|
||||
<div className="absolute left-1/2 top-0 w-[min(360px,38vw)] -translate-x-1/2 px-2">
|
||||
{topToolbarContent ? (
|
||||
<div className={`${chromeInteractivityClass} min-w-0`}>
|
||||
{topToolbarContent}
|
||||
</div>
|
||||
<div className={`${chromeInteractivityClass} min-w-0`}>{topToolbarContent}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -190,12 +199,44 @@ export function GraphControls({
|
|||
>
|
||||
<ToolbarButton
|
||||
onClick={() => toggle('paused')}
|
||||
icon={filters.paused ? <Play size={TOPBAR_ICON_SIZE} /> : <Pause size={TOPBAR_ICON_SIZE} />}
|
||||
icon={
|
||||
filters.paused ? (
|
||||
<Play size={TOPBAR_ICON_SIZE} />
|
||||
) : (
|
||||
<Pause size={TOPBAR_ICON_SIZE} />
|
||||
)
|
||||
}
|
||||
toolbar
|
||||
title={filters.paused ? 'Resume animation' : 'Pause animation'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{onLayoutModeChange ? (
|
||||
<div
|
||||
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<ToolbarButton
|
||||
onClick={toggleLayoutMode}
|
||||
icon={
|
||||
layoutMode === 'radial' ? (
|
||||
<Columns3 size={TOPBAR_ICON_SIZE} />
|
||||
) : (
|
||||
<Users size={TOPBAR_ICON_SIZE} />
|
||||
)
|
||||
}
|
||||
active={layoutMode === 'grid-under-lead'}
|
||||
toolbar
|
||||
title={
|
||||
layoutMode === 'radial' ? 'Switch to rows layout' : 'Switch to radial layout'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={settingsRef} className={`relative ${chromeInteractivityClass}`}>
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
|
||||
|
|
@ -288,7 +329,7 @@ export function GraphControls({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-3 right-3 z-20 pointer-events-none">
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-0.5 py-[2px] backdrop-blur-sm"
|
||||
style={{
|
||||
|
|
@ -349,7 +390,7 @@ function ToolbarButton({
|
|||
}
|
||||
: undefined
|
||||
}
|
||||
className={`flex items-center rounded-md font-mono transition-colors cursor-pointer ${
|
||||
className={`flex cursor-pointer items-center rounded-md font-mono transition-colors ${
|
||||
toolbar
|
||||
? 'justify-center text-[0]'
|
||||
: mini
|
||||
|
|
@ -359,8 +400,8 @@ function ToolbarButton({
|
|||
: 'gap-1 px-2 py-1 text-[11px]'
|
||||
} ${
|
||||
active
|
||||
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.14)]'
|
||||
: 'text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)]'
|
||||
? 'bg-[rgba(100,200,255,0.14)] text-[#aaeeff]'
|
||||
: 'text-[#66ccff90] hover:bg-[rgba(100,200,255,0.1)] hover:text-[#aaeeff]'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
|
|
@ -379,7 +420,7 @@ function ToolbarButton({
|
|||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className="z-[100] rounded-md border border-[rgba(100,200,255,0.14)] bg-[rgba(8,12,24,0.96)] px-2 py-1 text-[11px] font-mono text-[#dff6ff] shadow-xl backdrop-blur-sm"
|
||||
className="z-[100] rounded-md border border-[rgba(100,200,255,0.14)] bg-[rgba(8,12,24,0.96)] px-2 py-1 font-mono text-[11px] text-[#dff6ff] shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
{title}
|
||||
<Tooltip.Arrow className="fill-[rgba(8,12,24,0.96)]" />
|
||||
|
|
@ -405,12 +446,12 @@ function ToolbarToggle({
|
|||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-mono transition-all cursor-pointer border ${
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 font-mono text-[11px] transition-all ${
|
||||
block ? 'w-full justify-start' : ''
|
||||
} ${
|
||||
active
|
||||
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.15)] border-[rgba(100,200,255,0.25)]'
|
||||
: 'text-[#66ccff50] bg-transparent border-transparent hover:text-[#66ccff90] hover:bg-[rgba(100,200,255,0.06)]'
|
||||
? 'border-[rgba(100,200,255,0.25)] bg-[rgba(100,200,255,0.15)] text-[#aaeeff]'
|
||||
: 'border-transparent bg-transparent text-[#66ccff50] hover:bg-[rgba(100,200,255,0.06)] hover:text-[#66ccff90]'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/d
|
|||
import type { GraphDataPort } from '../ports/GraphDataPort';
|
||||
import type { GraphEventPort } from '../ports/GraphEventPort';
|
||||
import type { GraphConfigPort } from '../ports/GraphConfigPort';
|
||||
import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
||||
import type {
|
||||
GraphEdge,
|
||||
GraphLayoutMode,
|
||||
GraphNode,
|
||||
GraphOwnerSlotAssignment,
|
||||
} from '../ports/types';
|
||||
import type { StableRect } from '../layout/stableSlots';
|
||||
import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas';
|
||||
import { GraphControls, type GraphFilterState } from './GraphControls';
|
||||
|
|
@ -50,12 +55,14 @@ export interface GraphViewProps {
|
|||
onToggleSidebar?: () => void;
|
||||
isSidebarVisible?: boolean;
|
||||
renderTopToolbarContent?: () => React.ReactNode;
|
||||
onLayoutModeChange?: (mode: GraphLayoutMode) => void;
|
||||
onOwnerSlotDrop?: (payload: {
|
||||
nodeId: string;
|
||||
assignment: GraphOwnerSlotAssignment;
|
||||
displacedNodeId?: string;
|
||||
displacedAssignment?: GraphOwnerSlotAssignment;
|
||||
}) => void;
|
||||
onOwnerGridOrderDrop?: (payload: { nodeId: string; targetNodeId: string }) => void;
|
||||
/** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */
|
||||
renderOverlay?: (props: {
|
||||
node: GraphNode;
|
||||
|
|
@ -72,7 +79,7 @@ export interface GraphViewProps {
|
|||
renderHud?: (props: {
|
||||
filters: GraphFilterState;
|
||||
getLaunchAnchorScreenPlacement: (
|
||||
leadNodeId: string,
|
||||
leadNodeId: string
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||
getTransientHandoffSnapshot: (options?: {
|
||||
|
|
@ -103,7 +110,9 @@ export function GraphView({
|
|||
onToggleSidebar,
|
||||
isSidebarVisible = true,
|
||||
renderTopToolbarContent,
|
||||
onLayoutModeChange,
|
||||
onOwnerSlotDrop,
|
||||
onOwnerGridOrderDrop,
|
||||
renderOverlay,
|
||||
renderEdgeOverlay,
|
||||
renderHud,
|
||||
|
|
@ -120,6 +129,18 @@ export function GraphView({
|
|||
paused: !(config?.animationEnabled ?? true),
|
||||
});
|
||||
const effectivePaused = filters.paused || suspendAnimation;
|
||||
const layoutMode = data.layout?.mode ?? 'radial';
|
||||
const canDragOwners = layoutMode === 'radial' || layoutMode === 'grid-under-lead';
|
||||
const simulationLayout = useMemo(
|
||||
() =>
|
||||
data.layout
|
||||
? {
|
||||
...data.layout,
|
||||
showActivity: filters.showActivity,
|
||||
}
|
||||
: data.layout,
|
||||
[data.layout, filters.showActivity]
|
||||
);
|
||||
|
||||
// Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change
|
||||
const selectedNodeIdRef = useRef<string | null>(null);
|
||||
|
|
@ -156,6 +177,12 @@ export function GraphView({
|
|||
simulation.setNodePosition(nodeId, x, y);
|
||||
},
|
||||
[simulation]
|
||||
),
|
||||
useMemo(
|
||||
() => ({
|
||||
canDragNode: (node: GraphNode) => canDragOwners && node.kind === 'member',
|
||||
}),
|
||||
[canDragOwners]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -166,9 +193,9 @@ export function GraphView({
|
|||
cameraRef.current = camera;
|
||||
const interactionRef = useRef(interaction);
|
||||
interactionRef.current = interaction;
|
||||
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
|
||||
null
|
||||
);
|
||||
const processActivePointerMoveRef = useRef<
|
||||
((clientX: number, clientY: number) => boolean) | null
|
||||
>(null);
|
||||
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
|
||||
null
|
||||
);
|
||||
|
|
@ -196,8 +223,8 @@ export function GraphView({
|
|||
|
||||
// ─── Sync data from adapter → simulation ────────────────────────────────
|
||||
useEffect(() => {
|
||||
simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, data.layout);
|
||||
}, [data, simulation]);
|
||||
simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, simulationLayout);
|
||||
}, [data.edges, data.nodes, data.particles, data.teamName, simulation, simulationLayout]);
|
||||
|
||||
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
|
||||
const focusState = useMemo(
|
||||
|
|
@ -240,26 +267,29 @@ export function GraphView({
|
|||
height: container?.clientHeight ?? 0,
|
||||
};
|
||||
}, []);
|
||||
const getLaunchAnchorScreenPlacement = useCallback((leadNodeId: string) => {
|
||||
const anchor = simulationRef.current.getLaunchAnchorWorldPosition(leadNodeId);
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
const viewport = getViewportSize();
|
||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
const transform = cameraRef.current.transformRef.current;
|
||||
return buildLaunchAnchorScreenPlacement({
|
||||
anchorX: anchor.x,
|
||||
anchorY: anchor.y,
|
||||
cameraX: transform.x,
|
||||
cameraY: transform.y,
|
||||
zoom: transform.zoom,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
});
|
||||
}, [getViewportSize]);
|
||||
const getLaunchAnchorScreenPlacement = useCallback(
|
||||
(leadNodeId: string) => {
|
||||
const anchor = simulationRef.current.getLaunchAnchorWorldPosition(leadNodeId);
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
const viewport = getViewportSize();
|
||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
const transform = cameraRef.current.transformRef.current;
|
||||
return buildLaunchAnchorScreenPlacement({
|
||||
anchorX: anchor.x,
|
||||
anchorY: anchor.y,
|
||||
cameraX: transform.x,
|
||||
cameraY: transform.y,
|
||||
zoom: transform.zoom,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
});
|
||||
},
|
||||
[getViewportSize]
|
||||
);
|
||||
const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []);
|
||||
const getActivityWorldRect = useCallback(
|
||||
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
||||
|
|
@ -277,7 +307,9 @@ export function GraphView({
|
|||
[]
|
||||
);
|
||||
const getNodeWorldPosition = useCallback((nodeId: string) => {
|
||||
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
||||
const node = simulationRef.current.stateRef.current.nodes.find(
|
||||
(candidate) => candidate.id === nodeId
|
||||
);
|
||||
if (node?.x == null || node?.y == null) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -548,11 +580,7 @@ export function GraphView({
|
|||
}
|
||||
|
||||
const edgeMouseDown = edgeMouseDownRef.current;
|
||||
if (
|
||||
edgeMouseDown &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current
|
||||
) {
|
||||
if (edgeMouseDown && !interaction.dragNodeId.current && !interaction.isDragging.current) {
|
||||
const dx = clientX - edgeMouseDown.clientX;
|
||||
const dy = clientY - edgeMouseDown.clientY;
|
||||
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
|
||||
|
|
@ -585,16 +613,25 @@ export function GraphView({
|
|||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||
interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes));
|
||||
interaction.handleMouseMove(
|
||||
world.x,
|
||||
world.y,
|
||||
getVisibleNodes(simulation.stateRef.current.nodes)
|
||||
);
|
||||
|
||||
const draggedNodeId = interaction.dragNodeId.current;
|
||||
if (interaction.isDragging.current && draggedNodeId) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
}
|
||||
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
||||
const draggedNode = simulation.stateRef.current.nodes.find(
|
||||
(node) => node.id === draggedNodeId
|
||||
);
|
||||
if (draggedNode?.kind === 'member') {
|
||||
const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
|
||||
const nearest =
|
||||
layoutMode === 'grid-under-lead'
|
||||
? simulation.resolveNearestOwnerGridTarget(draggedNodeId, world.x, world.y)
|
||||
: simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
|
||||
if (nearest) {
|
||||
dragPreviewRef.current = {
|
||||
nodeId: draggedNodeId,
|
||||
|
|
@ -610,7 +647,7 @@ export function GraphView({
|
|||
dragPreviewRef.current = null;
|
||||
return true;
|
||||
},
|
||||
[camera, getVisibleNodes, interaction, simulation]
|
||||
[camera, getVisibleNodes, interaction, layoutMode, simulation]
|
||||
);
|
||||
|
||||
const completePointerInteraction = useCallback(
|
||||
|
|
@ -633,8 +670,31 @@ export function GraphView({
|
|||
const clickedId = interaction.handleMouseUp();
|
||||
if (wasDragging && draggedNodeId) {
|
||||
setInteractionGuards(false);
|
||||
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
||||
const draggedNode = simulation.stateRef.current.nodes.find(
|
||||
(node) => node.id === draggedNodeId
|
||||
);
|
||||
if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) {
|
||||
if (layoutMode === 'grid-under-lead') {
|
||||
const nearest = simulation.resolveNearestOwnerGridTarget(
|
||||
draggedNodeId,
|
||||
draggedNode.x,
|
||||
draggedNode.y
|
||||
);
|
||||
if (nearest) {
|
||||
if (nearest.targetOwnerId !== draggedNodeId) {
|
||||
onOwnerGridOrderDrop?.({
|
||||
nodeId: draggedNodeId,
|
||||
targetNodeId: nearest.targetOwnerId,
|
||||
});
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
simulation.clearNodePosition(draggedNodeId);
|
||||
});
|
||||
dragPreviewRef.current = null;
|
||||
edgeMouseDownRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const nearest = simulation.resolveNearestOwnerSlot(
|
||||
draggedNodeId,
|
||||
draggedNode.x,
|
||||
|
|
@ -700,7 +760,16 @@ export function GraphView({
|
|||
}
|
||||
dragPreviewRef.current = null;
|
||||
},
|
||||
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
||||
[
|
||||
camera,
|
||||
events,
|
||||
interaction,
|
||||
layoutMode,
|
||||
onOwnerGridOrderDrop,
|
||||
onOwnerSlotDrop,
|
||||
setInteractionGuards,
|
||||
simulation,
|
||||
]
|
||||
);
|
||||
processActivePointerMoveRef.current = processActivePointerMove;
|
||||
completePointerInteractionRef.current = completePointerInteraction;
|
||||
|
|
@ -965,7 +1034,7 @@ export function GraphView({
|
|||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative h-full w-full overflow-hidden select-none ${className ?? ''}`}
|
||||
className={`relative h-full w-full select-none overflow-hidden ${className ?? ''}`}
|
||||
>
|
||||
<GraphCanvas
|
||||
ref={canvasHandle}
|
||||
|
|
@ -1011,6 +1080,8 @@ export function GraphView({
|
|||
teamName={data.teamName}
|
||||
teamColor={data.teamColor}
|
||||
isAlive={data.isAlive}
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={onLayoutModeChange}
|
||||
topToolbarContent={renderTopToolbarContent?.()}
|
||||
interactionLocked={interactionLocked}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
import type {
|
||||
GraphDataPort,
|
||||
GraphEdge,
|
||||
GraphLayoutMode,
|
||||
GraphLayoutPort,
|
||||
GraphNode,
|
||||
GraphNodeState,
|
||||
|
|
@ -109,7 +110,9 @@ export class TeamGraphAdapter {
|
|||
commentReadState?: Record<string, unknown>,
|
||||
provisioningProgress?: TeamProvisioningProgress | null,
|
||||
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
||||
layoutMode: GraphLayoutMode = 'radial',
|
||||
gridOwnerOrder?: readonly string[]
|
||||
): GraphDataPort {
|
||||
if (teamData?.teamName !== teamName) {
|
||||
return TeamGraphAdapter.#emptyResult(teamName);
|
||||
|
|
@ -225,7 +228,13 @@ export class TeamGraphAdapter {
|
|||
teamName,
|
||||
teamColor: teamData.config.color ?? undefined,
|
||||
isAlive: teamData.isAlive,
|
||||
layout: TeamGraphAdapter.#buildLayoutPort(teamData, teamName, slotAssignments),
|
||||
layout: TeamGraphAdapter.#buildLayoutPort(
|
||||
teamData,
|
||||
teamName,
|
||||
slotAssignments,
|
||||
layoutMode,
|
||||
gridOwnerOrder
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -258,7 +267,9 @@ export class TeamGraphAdapter {
|
|||
static #buildLayoutPort(
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
||||
mode: GraphLayoutMode = 'radial',
|
||||
gridOwnerOrder?: readonly string[]
|
||||
): GraphLayoutPort {
|
||||
const ownerOrder: string[] = [];
|
||||
const seenOwnerNodeIds = new Set<string>();
|
||||
|
|
@ -286,26 +297,44 @@ export class TeamGraphAdapter {
|
|||
ownerOrder.push(nodeId);
|
||||
};
|
||||
|
||||
for (const stableOwnerId of canonicalVisibleOwnerIds) {
|
||||
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
|
||||
if (!visibleMember) {
|
||||
continue;
|
||||
if (mode === 'grid-under-lead') {
|
||||
const seenStableOwnerIds = new Set<string>();
|
||||
for (const stableOwnerId of gridOwnerOrder ?? []) {
|
||||
if (seenStableOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
seenStableOwnerIds.add(stableOwnerId);
|
||||
pushMember(visibleMemberByStableOwnerId.get(stableOwnerId));
|
||||
}
|
||||
if (!assignedStableOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
pushMember(visibleMember);
|
||||
}
|
||||
|
||||
for (const stableOwnerId of canonicalVisibleOwnerIds) {
|
||||
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
|
||||
if (!visibleMember) {
|
||||
continue;
|
||||
for (const stableOwnerId of canonicalVisibleOwnerIds) {
|
||||
if (seenStableOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
pushMember(visibleMemberByStableOwnerId.get(stableOwnerId));
|
||||
}
|
||||
if (assignedStableOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
} else {
|
||||
for (const stableOwnerId of canonicalVisibleOwnerIds) {
|
||||
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
|
||||
if (!visibleMember) {
|
||||
continue;
|
||||
}
|
||||
if (!assignedStableOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
pushMember(visibleMember);
|
||||
}
|
||||
|
||||
for (const stableOwnerId of canonicalVisibleOwnerIds) {
|
||||
const visibleMember = visibleMemberByStableOwnerId.get(stableOwnerId);
|
||||
if (!visibleMember) {
|
||||
continue;
|
||||
}
|
||||
if (assignedStableOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
pushMember(visibleMember);
|
||||
}
|
||||
pushMember(visibleMember);
|
||||
}
|
||||
|
||||
const normalizedAssignments: Record<string, GraphOwnerSlotAssignment> = {};
|
||||
|
|
@ -320,6 +349,7 @@ export class TeamGraphAdapter {
|
|||
|
||||
return {
|
||||
version: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
mode,
|
||||
ownerOrder,
|
||||
slotAssignments: normalizedAssignments,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
toolHistory,
|
||||
provisioningProgress,
|
||||
memberSpawnSnapshot,
|
||||
graphLayoutMode,
|
||||
gridOwnerOrder,
|
||||
slotAssignments,
|
||||
graphLayoutSession,
|
||||
ensureTeamGraphSlotAssignments,
|
||||
|
|
@ -55,6 +57,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined,
|
||||
provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
|
||||
memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||
graphLayoutMode: teamName ? s.graphLayoutModeByTeam[teamName] : undefined,
|
||||
gridOwnerOrder: teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
|
||||
slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
||||
graphLayoutSession: teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
|
||||
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
||||
|
|
@ -144,7 +148,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
commentReadState,
|
||||
provisioningProgress,
|
||||
memberSpawnSnapshot,
|
||||
effectiveSlotAssignments
|
||||
effectiveSlotAssignments,
|
||||
graphLayoutMode ?? 'radial',
|
||||
gridOwnerOrder
|
||||
),
|
||||
[
|
||||
teamData,
|
||||
|
|
@ -160,6 +166,8 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
provisioningProgress,
|
||||
memberSpawnSnapshot,
|
||||
effectiveSlotAssignments,
|
||||
graphLayoutMode,
|
||||
gridOwnerOrder,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamS
|
|||
|
||||
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
|
||||
|
||||
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||
import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||
|
||||
export function useTeamGraphSurfaceActions(teamName: string): {
|
||||
openTeamPage: () => void;
|
||||
|
|
@ -16,6 +16,8 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
|||
displacedNodeId?: string;
|
||||
displacedAssignment?: GraphOwnerSlotAssignment;
|
||||
}) => void;
|
||||
commitOwnerGridOrderDrop: (payload: { nodeId: string; targetNodeId: string }) => void;
|
||||
setLayoutMode: (mode: GraphLayoutMode) => void;
|
||||
} {
|
||||
const openTeamPage = useCallback(() => {
|
||||
useStore.getState().openTeamTab(teamName);
|
||||
|
|
@ -43,6 +45,9 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
|||
? parseGraphMemberNodeId(payload.displacedNodeId, teamName)
|
||||
: null;
|
||||
const store = useStore.getState();
|
||||
if ((store.graphLayoutModeByTeam[teamName] ?? 'radial') !== 'radial') {
|
||||
return;
|
||||
}
|
||||
if (displacedStableOwnerId && payload.displacedAssignment) {
|
||||
store.commitTeamGraphOwnerSlotDrop(
|
||||
teamName,
|
||||
|
|
@ -58,9 +63,36 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
|||
[teamName]
|
||||
);
|
||||
|
||||
const commitOwnerGridOrderDrop = useCallback(
|
||||
(payload: { nodeId: string; targetNodeId: string }) => {
|
||||
const stableOwnerId = parseGraphMemberNodeId(payload.nodeId, teamName);
|
||||
const targetStableOwnerId = parseGraphMemberNodeId(payload.targetNodeId, teamName);
|
||||
if (!stableOwnerId || !targetStableOwnerId || stableOwnerId === targetStableOwnerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useStore.getState();
|
||||
if ((store.graphLayoutModeByTeam[teamName] ?? 'radial') !== 'grid-under-lead') {
|
||||
return;
|
||||
}
|
||||
|
||||
store.swapTeamGraphGridOwners(teamName, stableOwnerId, targetStableOwnerId);
|
||||
},
|
||||
[teamName]
|
||||
);
|
||||
|
||||
const setLayoutMode = useCallback(
|
||||
(mode: GraphLayoutMode) => {
|
||||
useStore.getState().setTeamGraphLayoutMode(teamName, mode);
|
||||
},
|
||||
[teamName]
|
||||
);
|
||||
|
||||
return {
|
||||
openTeamPage,
|
||||
resetOwnerSlotAssignmentsToDefaults,
|
||||
commitOwnerSlotDrop,
|
||||
commitOwnerGridOrderDrop,
|
||||
setLayoutMode,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,12 @@ export const TeamGraphOverlay = ({
|
|||
onOpenMemberProfile,
|
||||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||
const graphData = useTeamGraphAdapter(teamName);
|
||||
const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||
const {
|
||||
openTeamPage: openTeamTab,
|
||||
commitOwnerSlotDrop,
|
||||
commitOwnerGridOrderDrop,
|
||||
setLayoutMode,
|
||||
} = useTeamGraphSurfaceActions(teamName);
|
||||
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||
useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
|
|
@ -124,7 +129,9 @@ export const TeamGraphOverlay = ({
|
|||
onToggleSidebar={handleToggleSidebar}
|
||||
isSidebarVisible={effectiveSidebarVisible}
|
||||
renderTopToolbarContent={() => <GraphProvisioningHud teamName={teamName} />}
|
||||
onLayoutModeChange={setLayoutMode}
|
||||
onOwnerSlotDrop={commitOwnerSlotDrop}
|
||||
onOwnerGridOrderDrop={commitOwnerGridOrderDrop}
|
||||
className="team-graph-view min-w-0 flex-1"
|
||||
renderHud={(hudProps) => {
|
||||
const extraHudProps = hudProps as typeof hudProps & {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ export const TeamGraphTab = ({
|
|||
isPaneFocused = false,
|
||||
}: TeamGraphTabProps): React.JSX.Element => {
|
||||
const graphData = useTeamGraphAdapter(teamName);
|
||||
const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName);
|
||||
const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } =
|
||||
useTeamGraphSurfaceActions(teamName);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
|
|
@ -149,7 +150,9 @@ export const TeamGraphTab = ({
|
|||
renderTopToolbarContent={() => (
|
||||
<GraphProvisioningHud teamName={teamName} enabled={isActive} />
|
||||
)}
|
||||
onLayoutModeChange={setLayoutMode}
|
||||
onOwnerSlotDrop={commitOwnerSlotDrop}
|
||||
onOwnerGridOrderDrop={commitOwnerGridOrderDrop}
|
||||
renderHud={(hudProps) => {
|
||||
const extraHudProps = hudProps as typeof hudProps & {
|
||||
getViewportSize?: () => { width: number; height: number };
|
||||
|
|
|
|||
33
src/features/runtime-provider-management/contracts/api.ts
Normal file
33
src/features/runtime-provider-management/contracts/api.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type {
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementTestModelInput,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
} from './types';
|
||||
|
||||
export interface RuntimeProviderManagementApi {
|
||||
loadView(
|
||||
input: RuntimeProviderManagementLoadViewInput
|
||||
): Promise<RuntimeProviderManagementViewResponse>;
|
||||
connectWithApiKey(
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse>;
|
||||
forgetCredential(
|
||||
input: RuntimeProviderManagementForgetInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse>;
|
||||
loadModels(
|
||||
input: RuntimeProviderManagementLoadModelsInput
|
||||
): Promise<RuntimeProviderManagementModelsResponse>;
|
||||
testModel(
|
||||
input: RuntimeProviderManagementTestModelInput
|
||||
): Promise<RuntimeProviderManagementModelTestResponse>;
|
||||
setDefaultModel(
|
||||
input: RuntimeProviderManagementSetDefaultModelInput
|
||||
): Promise<RuntimeProviderManagementViewResponse>;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export const RUNTIME_PROVIDER_MANAGEMENT_VIEW = 'runtimeProviderManagement:view';
|
||||
export const RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY =
|
||||
'runtimeProviderManagement:connectApiKey';
|
||||
export const RUNTIME_PROVIDER_MANAGEMENT_FORGET = 'runtimeProviderManagement:forget';
|
||||
export const RUNTIME_PROVIDER_MANAGEMENT_MODELS = 'runtimeProviderManagement:models';
|
||||
export const RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL = 'runtimeProviderManagement:testModel';
|
||||
export const RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL =
|
||||
'runtimeProviderManagement:setDefaultModel';
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export type * from './api';
|
||||
export * from './channels';
|
||||
export type * from './types';
|
||||
184
src/features/runtime-provider-management/contracts/types.ts
Normal file
184
src/features/runtime-provider-management/contracts/types.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
export type RuntimeProviderManagementRuntimeId = 'opencode';
|
||||
|
||||
export type RuntimeProviderStateDto = 'ready' | 'needs-auth' | 'needs-setup' | 'degraded';
|
||||
|
||||
export type RuntimeProviderManagedProfileStateDto = 'active' | 'missing' | 'stale';
|
||||
|
||||
export type RuntimeProviderLocalAuthStateDto = 'synced' | 'missing' | 'stale' | 'disabled';
|
||||
|
||||
export type RuntimeProviderConnectionStateDto =
|
||||
| 'connected'
|
||||
| 'available'
|
||||
| 'not-connected'
|
||||
| 'ignored'
|
||||
| 'error';
|
||||
|
||||
export type RuntimeProviderOwnershipDto = 'managed' | 'local' | 'env' | 'project';
|
||||
|
||||
export type RuntimeProviderAuthMethodDto = 'api' | 'oauth' | 'wellknown';
|
||||
|
||||
export type RuntimeProviderActionIdDto =
|
||||
| 'connect'
|
||||
| 'use'
|
||||
| 'test'
|
||||
| 'set-default'
|
||||
| 'forget'
|
||||
| 'configure'
|
||||
| 'unignore';
|
||||
|
||||
export type RuntimeProviderActionOwnershipScopeDto = RuntimeProviderOwnershipDto | 'runtime';
|
||||
|
||||
export interface RuntimeProviderActionDescriptorDto {
|
||||
id: RuntimeProviderActionIdDto;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
disabledReason: string | null;
|
||||
requiresSecret: boolean;
|
||||
ownershipScope: RuntimeProviderActionOwnershipScopeDto;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementRuntimeDto {
|
||||
state: RuntimeProviderStateDto;
|
||||
cliPath: string | null;
|
||||
version: string | null;
|
||||
managedProfile: RuntimeProviderManagedProfileStateDto;
|
||||
localAuth: RuntimeProviderLocalAuthStateDto;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderConnectionDto {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
state: RuntimeProviderConnectionStateDto;
|
||||
ownership: readonly RuntimeProviderOwnershipDto[];
|
||||
recommended: boolean;
|
||||
modelCount: number;
|
||||
defaultModelId: string | null;
|
||||
authMethods: readonly RuntimeProviderAuthMethodDto[];
|
||||
actions: readonly RuntimeProviderActionDescriptorDto[];
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementViewDto {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
title: string;
|
||||
runtime: RuntimeProviderManagementRuntimeDto;
|
||||
providers: readonly RuntimeProviderConnectionDto[];
|
||||
defaultModel: string | null;
|
||||
fallbackModel: string | null;
|
||||
diagnostics: readonly string[];
|
||||
}
|
||||
|
||||
export type RuntimeProviderManagementErrorCodeDto =
|
||||
| 'unsupported-runtime'
|
||||
| 'unsupported-action'
|
||||
| 'runtime-missing'
|
||||
| 'runtime-unhealthy'
|
||||
| 'provider-missing'
|
||||
| 'auth-required'
|
||||
| 'auth-failed'
|
||||
| 'model-missing'
|
||||
| 'model-test-failed'
|
||||
| 'unsupported-auth-method';
|
||||
|
||||
export interface RuntimeProviderManagementErrorDto {
|
||||
code: RuntimeProviderManagementErrorCodeDto;
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementViewResponse {
|
||||
schemaVersion: 1;
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
view?: RuntimeProviderManagementViewDto;
|
||||
error?: RuntimeProviderManagementErrorDto;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementProviderResponse {
|
||||
schemaVersion: 1;
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
provider?: RuntimeProviderConnectionDto;
|
||||
error?: RuntimeProviderManagementErrorDto;
|
||||
}
|
||||
|
||||
export type RuntimeProviderModelAvailabilityDto =
|
||||
| 'available'
|
||||
| 'unavailable'
|
||||
| 'not-authenticated'
|
||||
| 'unknown'
|
||||
| 'untested';
|
||||
|
||||
export interface RuntimeProviderModelDto {
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
sourceLabel: string;
|
||||
free: boolean;
|
||||
default: boolean;
|
||||
availability: RuntimeProviderModelAvailabilityDto;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementModelsDto {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
providerId: string;
|
||||
models: readonly RuntimeProviderModelDto[];
|
||||
defaultModelId: string | null;
|
||||
diagnostics: readonly string[];
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementModelsResponse {
|
||||
schemaVersion: 1;
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
models?: RuntimeProviderManagementModelsDto;
|
||||
error?: RuntimeProviderManagementErrorDto;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderModelTestResultDto {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
ok: boolean;
|
||||
availability: RuntimeProviderModelAvailabilityDto;
|
||||
message: string;
|
||||
diagnostics: readonly string[];
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementModelTestResponse {
|
||||
schemaVersion: 1;
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
result?: RuntimeProviderModelTestResultDto;
|
||||
error?: RuntimeProviderManagementErrorDto;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementLoadViewInput {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementConnectApiKeyInput {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
providerId: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementForgetInput {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementLoadModelsInput {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
providerId: string;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementTestModelInput {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementSetDefaultModelInput {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
probe?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
||||
|
||||
export type RuntimeProviderManagementPort = RuntimeProviderManagementApi;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export type * from './RuntimeProviderManagementPort';
|
||||
export * from './runtimeProviderManagementUseCases';
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { RuntimeProviderManagementPort } from './RuntimeProviderManagementPort';
|
||||
import type {
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementTestModelInput,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
|
||||
export function loadRuntimeProviderManagementView(
|
||||
port: RuntimeProviderManagementPort,
|
||||
input: RuntimeProviderManagementLoadViewInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> {
|
||||
return port.loadView(input);
|
||||
}
|
||||
|
||||
export function connectRuntimeProviderWithApiKey(
|
||||
port: RuntimeProviderManagementPort,
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> {
|
||||
return port.connectWithApiKey(input);
|
||||
}
|
||||
|
||||
export function forgetRuntimeProviderCredential(
|
||||
port: RuntimeProviderManagementPort,
|
||||
input: RuntimeProviderManagementForgetInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> {
|
||||
return port.forgetCredential(input);
|
||||
}
|
||||
|
||||
export function loadRuntimeProviderModels(
|
||||
port: RuntimeProviderManagementPort,
|
||||
input: RuntimeProviderManagementLoadModelsInput
|
||||
): Promise<RuntimeProviderManagementModelsResponse> {
|
||||
return port.loadModels(input);
|
||||
}
|
||||
|
||||
export function testRuntimeProviderModel(
|
||||
port: RuntimeProviderManagementPort,
|
||||
input: RuntimeProviderManagementTestModelInput
|
||||
): Promise<RuntimeProviderManagementModelTestResponse> {
|
||||
return port.testModel(input);
|
||||
}
|
||||
|
||||
export function setRuntimeProviderDefaultModel(
|
||||
port: RuntimeProviderManagementPort,
|
||||
input: RuntimeProviderManagementSetDefaultModelInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> {
|
||||
return port.setDefaultModel(input);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './providerManagementView';
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import type {
|
||||
RuntimeProviderActionDescriptorDto,
|
||||
RuntimeProviderActionIdDto,
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderManagementRuntimeDto,
|
||||
RuntimeProviderManagementViewDto,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
|
||||
const ACTION_ORDER: RuntimeProviderActionIdDto[] = [
|
||||
'connect',
|
||||
'use',
|
||||
'test',
|
||||
'set-default',
|
||||
'forget',
|
||||
'configure',
|
||||
'unignore',
|
||||
];
|
||||
|
||||
export function getProviderAction(
|
||||
provider: RuntimeProviderConnectionDto,
|
||||
actionId: RuntimeProviderActionIdDto
|
||||
): RuntimeProviderActionDescriptorDto | null {
|
||||
return provider.actions.find((action) => action.id === actionId) ?? null;
|
||||
}
|
||||
|
||||
export function getPrimaryProviderAction(
|
||||
provider: RuntimeProviderConnectionDto
|
||||
): RuntimeProviderActionDescriptorDto | null {
|
||||
for (const actionId of ACTION_ORDER) {
|
||||
const action = getProviderAction(provider, actionId);
|
||||
if (action) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return provider.actions[0] ?? null;
|
||||
}
|
||||
|
||||
export function canConnectWithApiKey(provider: RuntimeProviderConnectionDto): boolean {
|
||||
const connect = getProviderAction(provider, 'connect');
|
||||
return Boolean(
|
||||
connect?.enabled && connect.requiresSecret && provider.authMethods.includes('api')
|
||||
);
|
||||
}
|
||||
|
||||
export function canForgetManagedCredential(provider: RuntimeProviderConnectionDto): boolean {
|
||||
const forget = getProviderAction(provider, 'forget');
|
||||
return Boolean(forget?.enabled);
|
||||
}
|
||||
|
||||
export function selectInitialProviderId(
|
||||
view: RuntimeProviderManagementViewDto | null
|
||||
): string | null {
|
||||
if (!view?.providers.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
view.providers.find((provider) => provider.recommended && provider.state !== 'connected')
|
||||
?.providerId ??
|
||||
view.providers.find((provider) => provider.state === 'connected')?.providerId ??
|
||||
view.providers[0]?.providerId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function formatRuntimeState(runtime: RuntimeProviderManagementRuntimeDto): string {
|
||||
switch (runtime.state) {
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
case 'needs-auth':
|
||||
return 'Needs auth';
|
||||
case 'needs-setup':
|
||||
return 'Needs setup';
|
||||
case 'degraded':
|
||||
return 'Degraded';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatProviderState(provider: RuntimeProviderConnectionDto): string {
|
||||
switch (provider.state) {
|
||||
case 'connected':
|
||||
return 'Connected';
|
||||
case 'available':
|
||||
return 'Available';
|
||||
case 'not-connected':
|
||||
return 'Not connected';
|
||||
case 'ignored':
|
||||
return 'Ignored';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderModelsLabel(provider: RuntimeProviderConnectionDto): string {
|
||||
if (provider.modelCount <= 0) {
|
||||
return 'Models not reported';
|
||||
}
|
||||
return `${provider.modelCount} model${provider.modelCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
1
src/features/runtime-provider-management/index.ts
Normal file
1
src/features/runtime-provider-management/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type * from './contracts';
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import {
|
||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { RuntimeProviderManagementFeatureFacade } from '../../composition/createRuntimeProviderManagementFeature';
|
||||
import type {
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementTestModelInput,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
import type { IpcMain } from 'electron';
|
||||
|
||||
const logger = createLogger('Feature:RuntimeProviderManagement:IPC');
|
||||
|
||||
export function registerRuntimeProviderManagementIpc(
|
||||
ipcMain: IpcMain,
|
||||
feature: RuntimeProviderManagementFeatureFacade
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
|
||||
async (
|
||||
_event,
|
||||
input: RuntimeProviderManagementLoadViewInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> => {
|
||||
try {
|
||||
return await feature.loadView(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load runtime provider management view', error);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'runtime-unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Failed to load providers',
|
||||
recoverable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
||||
async (
|
||||
_event,
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> => {
|
||||
try {
|
||||
return await feature.connectWithApiKey(input);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Failed to connect runtime provider',
|
||||
error instanceof Error ? error.name : error
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'auth-failed',
|
||||
message: 'Failed to connect provider',
|
||||
recoverable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
|
||||
async (
|
||||
_event,
|
||||
input: RuntimeProviderManagementForgetInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> => {
|
||||
try {
|
||||
return await feature.forgetCredential(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to forget runtime provider credential', error);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'unsupported-action',
|
||||
message: error instanceof Error ? error.message : 'Failed to forget provider',
|
||||
recoverable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
|
||||
async (
|
||||
_event,
|
||||
input: RuntimeProviderManagementLoadModelsInput
|
||||
): Promise<RuntimeProviderManagementModelsResponse> => {
|
||||
try {
|
||||
return await feature.loadModels(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load runtime provider models', error);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'runtime-unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Failed to load provider models',
|
||||
recoverable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL,
|
||||
async (
|
||||
_event,
|
||||
input: RuntimeProviderManagementTestModelInput
|
||||
): Promise<RuntimeProviderManagementModelTestResponse> => {
|
||||
try {
|
||||
return await feature.testModel(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to test runtime provider model', error);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'model-test-failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to test model',
|
||||
recoverable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
|
||||
async (
|
||||
_event,
|
||||
input: RuntimeProviderManagementSetDefaultModelInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> => {
|
||||
try {
|
||||
return await feature.setDefaultModel(input);
|
||||
} catch (error) {
|
||||
logger.error('Failed to set runtime provider default model', error);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'model-test-failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to set default model',
|
||||
recoverable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function removeRuntimeProviderManagementIpc(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_VIEW);
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY);
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_FORGET);
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_MODELS);
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL);
|
||||
ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { AgentTeamsRuntimeProviderManagementCliClient } from '../infrastructure/AgentTeamsRuntimeProviderManagementCliClient';
|
||||
|
||||
import type { RuntimeProviderManagementPort } from '../../core/application';
|
||||
import type {
|
||||
RuntimeProviderManagementApi,
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementTestModelInput,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
|
||||
export type RuntimeProviderManagementFeatureFacade = RuntimeProviderManagementApi;
|
||||
|
||||
export function createRuntimeProviderManagementFeature(
|
||||
deps: {
|
||||
port?: RuntimeProviderManagementPort;
|
||||
} = {}
|
||||
): RuntimeProviderManagementFeatureFacade {
|
||||
const port = deps.port ?? new AgentTeamsRuntimeProviderManagementCliClient();
|
||||
|
||||
return {
|
||||
loadView: (
|
||||
input: RuntimeProviderManagementLoadViewInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> => port.loadView(input),
|
||||
connectWithApiKey: (
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> => port.connectWithApiKey(input),
|
||||
forgetCredential: (
|
||||
input: RuntimeProviderManagementForgetInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> => port.forgetCredential(input),
|
||||
loadModels: (
|
||||
input: RuntimeProviderManagementLoadModelsInput
|
||||
): Promise<RuntimeProviderManagementModelsResponse> => port.loadModels(input),
|
||||
testModel: (
|
||||
input: RuntimeProviderManagementTestModelInput
|
||||
): Promise<RuntimeProviderManagementModelTestResponse> => port.testModel(input),
|
||||
setDefaultModel: (
|
||||
input: RuntimeProviderManagementSetDefaultModelInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> => port.setDefaultModel(input),
|
||||
};
|
||||
}
|
||||
8
src/features/runtime-provider-management/main/index.ts
Normal file
8
src/features/runtime-provider-management/main/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
registerRuntimeProviderManagementIpc,
|
||||
removeRuntimeProviderManagementIpc,
|
||||
} from './adapters/input/registerRuntimeProviderManagementIpc';
|
||||
export {
|
||||
createRuntimeProviderManagementFeature,
|
||||
type RuntimeProviderManagementFeatureFacade,
|
||||
} from './composition/createRuntimeProviderManagementFeature';
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv';
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
|
||||
import type {
|
||||
RuntimeProviderManagementApi,
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementErrorDto,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementTestModelInput,
|
||||
RuntimeProviderManagementRuntimeId,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
import type { ChildProcessWithoutNullStreams } from 'child_process';
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 45_000;
|
||||
const PROBE_COMMAND_TIMEOUT_MS = 90_000;
|
||||
|
||||
type RuntimeProviderManagementErrorResponse =
|
||||
| RuntimeProviderManagementViewResponse
|
||||
| RuntimeProviderManagementProviderResponse
|
||||
| RuntimeProviderManagementModelsResponse
|
||||
| RuntimeProviderManagementModelTestResponse;
|
||||
|
||||
function errorResponse<T extends RuntimeProviderManagementErrorResponse>(
|
||||
runtimeId: RuntimeProviderManagementRuntimeId,
|
||||
message: string,
|
||||
code: RuntimeProviderManagementErrorDto['code'] = 'runtime-unhealthy'
|
||||
): T {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runtimeId,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
recoverable: true,
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
function extractJsonObject<T>(raw: string): T {
|
||||
const start = raw.indexOf('{');
|
||||
const end = raw.lastIndexOf('}');
|
||||
if (start < 0 || end < start) {
|
||||
throw new Error('CLI did not return a JSON object');
|
||||
}
|
||||
return JSON.parse(raw.slice(start, end + 1)) as T;
|
||||
}
|
||||
|
||||
function normalizeCommandFailure(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Runtime provider management command failed';
|
||||
}
|
||||
|
||||
async function resolveCliEnv(): Promise<{
|
||||
binaryPath: string | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}> {
|
||||
const shellEnv = await resolveInteractiveShellEnv();
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
return {
|
||||
binaryPath: null,
|
||||
env: {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const providerAware = await buildProviderAwareCliEnv({
|
||||
binaryPath,
|
||||
providerId: 'opencode',
|
||||
shellEnv,
|
||||
connectionMode: 'augment',
|
||||
});
|
||||
return {
|
||||
binaryPath,
|
||||
env: providerAware.env,
|
||||
};
|
||||
}
|
||||
|
||||
function collectSpawnOutput(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
stdinValue: string
|
||||
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
killProcessTree(child, 'SIGKILL');
|
||||
reject(new Error('Runtime provider management command timed out'));
|
||||
}, COMMAND_TIMEOUT_MS);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
child.once('error', (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.once('close', (code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf8'),
|
||||
code,
|
||||
});
|
||||
});
|
||||
|
||||
child.stdin.write(stdinValue);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProviderManagementApi {
|
||||
async loadView(
|
||||
input: RuntimeProviderManagementLoadViewInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> {
|
||||
const { binaryPath, env } = await resolveCliEnv();
|
||||
if (!binaryPath) {
|
||||
return errorResponse<RuntimeProviderManagementViewResponse>(
|
||||
input.runtimeId,
|
||||
'Multimodel runtime binary was not found.',
|
||||
'runtime-missing'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
['runtime', 'providers', 'view', '--runtime', input.runtimeId, '--json', '--compact'],
|
||||
{ env, timeout: COMMAND_TIMEOUT_MS }
|
||||
);
|
||||
return extractJsonObject<RuntimeProviderManagementViewResponse>(stdout);
|
||||
} catch (error) {
|
||||
return errorResponse<RuntimeProviderManagementViewResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async connectWithApiKey(
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> {
|
||||
const { binaryPath, env } = await resolveCliEnv();
|
||||
if (!binaryPath) {
|
||||
return errorResponse<RuntimeProviderManagementProviderResponse>(
|
||||
input.runtimeId,
|
||||
'Multimodel runtime binary was not found.',
|
||||
'runtime-missing'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawnCli(
|
||||
binaryPath,
|
||||
[
|
||||
'runtime',
|
||||
'providers',
|
||||
'connect-api-key',
|
||||
'--runtime',
|
||||
input.runtimeId,
|
||||
'--provider',
|
||||
input.providerId,
|
||||
'--stdin-key',
|
||||
'--json',
|
||||
],
|
||||
{
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
}
|
||||
) as ChildProcessWithoutNullStreams;
|
||||
const result = await collectSpawnOutput(child, input.apiKey);
|
||||
if (result.code === 0) {
|
||||
return extractJsonObject<RuntimeProviderManagementProviderResponse>(result.stdout);
|
||||
}
|
||||
|
||||
try {
|
||||
return extractJsonObject<RuntimeProviderManagementProviderResponse>(result.stdout);
|
||||
} catch {
|
||||
return errorResponse<RuntimeProviderManagementProviderResponse>(
|
||||
input.runtimeId,
|
||||
`Runtime provider connect command failed with exit code ${String(result.code ?? 'unknown')}.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return errorResponse<RuntimeProviderManagementProviderResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async forgetCredential(
|
||||
input: RuntimeProviderManagementForgetInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> {
|
||||
const { binaryPath, env } = await resolveCliEnv();
|
||||
if (!binaryPath) {
|
||||
return errorResponse<RuntimeProviderManagementProviderResponse>(
|
||||
input.runtimeId,
|
||||
'Multimodel runtime binary was not found.',
|
||||
'runtime-missing'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
[
|
||||
'runtime',
|
||||
'providers',
|
||||
'forget',
|
||||
'--runtime',
|
||||
input.runtimeId,
|
||||
'--provider',
|
||||
input.providerId,
|
||||
'--json',
|
||||
],
|
||||
{ env, timeout: COMMAND_TIMEOUT_MS }
|
||||
);
|
||||
return extractJsonObject<RuntimeProviderManagementProviderResponse>(stdout);
|
||||
} catch (error) {
|
||||
return errorResponse<RuntimeProviderManagementProviderResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async loadModels(
|
||||
input: RuntimeProviderManagementLoadModelsInput
|
||||
): Promise<RuntimeProviderManagementModelsResponse> {
|
||||
const { binaryPath, env } = await resolveCliEnv();
|
||||
if (!binaryPath) {
|
||||
return errorResponse<RuntimeProviderManagementModelsResponse>(
|
||||
input.runtimeId,
|
||||
'Multimodel runtime binary was not found.',
|
||||
'runtime-missing'
|
||||
);
|
||||
}
|
||||
|
||||
const args = [
|
||||
'runtime',
|
||||
'providers',
|
||||
'models',
|
||||
'--runtime',
|
||||
input.runtimeId,
|
||||
'--provider',
|
||||
input.providerId,
|
||||
'--json',
|
||||
];
|
||||
if (input.query?.trim()) {
|
||||
args.push('--query', input.query.trim());
|
||||
}
|
||||
if (typeof input.limit === 'number' && Number.isFinite(input.limit) && input.limit > 0) {
|
||||
args.push('--limit', String(Math.floor(input.limit)));
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(binaryPath, args, {
|
||||
env,
|
||||
timeout: COMMAND_TIMEOUT_MS,
|
||||
});
|
||||
return extractJsonObject<RuntimeProviderManagementModelsResponse>(stdout);
|
||||
} catch (error) {
|
||||
return errorResponse<RuntimeProviderManagementModelsResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async testModel(
|
||||
input: RuntimeProviderManagementTestModelInput
|
||||
): Promise<RuntimeProviderManagementModelTestResponse> {
|
||||
const { binaryPath, env } = await resolveCliEnv();
|
||||
if (!binaryPath) {
|
||||
return errorResponse<RuntimeProviderManagementModelTestResponse>(
|
||||
input.runtimeId,
|
||||
'Multimodel runtime binary was not found.',
|
||||
'runtime-missing'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
[
|
||||
'runtime',
|
||||
'providers',
|
||||
'test-model',
|
||||
'--runtime',
|
||||
input.runtimeId,
|
||||
'--provider',
|
||||
input.providerId,
|
||||
'--model',
|
||||
input.modelId,
|
||||
'--json',
|
||||
],
|
||||
{ env, timeout: PROBE_COMMAND_TIMEOUT_MS }
|
||||
);
|
||||
return extractJsonObject<RuntimeProviderManagementModelTestResponse>(stdout);
|
||||
} catch (error) {
|
||||
return errorResponse<RuntimeProviderManagementModelTestResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error),
|
||||
'model-test-failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setDefaultModel(
|
||||
input: RuntimeProviderManagementSetDefaultModelInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> {
|
||||
const { binaryPath, env } = await resolveCliEnv();
|
||||
if (!binaryPath) {
|
||||
return errorResponse<RuntimeProviderManagementViewResponse>(
|
||||
input.runtimeId,
|
||||
'Multimodel runtime binary was not found.',
|
||||
'runtime-missing'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
[
|
||||
'runtime',
|
||||
'providers',
|
||||
'set-default',
|
||||
'--runtime',
|
||||
input.runtimeId,
|
||||
'--provider',
|
||||
input.providerId,
|
||||
'--model',
|
||||
input.modelId,
|
||||
'--probe',
|
||||
'--compact',
|
||||
'--json',
|
||||
],
|
||||
{ env, timeout: PROBE_COMMAND_TIMEOUT_MS }
|
||||
);
|
||||
return extractJsonObject<RuntimeProviderManagementViewResponse>(stdout);
|
||||
} catch (error) {
|
||||
return errorResponse<RuntimeProviderManagementViewResponse>(
|
||||
input.runtimeId,
|
||||
normalizeCommandFailure(error),
|
||||
'model-test-failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_FORGET,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
|
||||
type RuntimeProviderManagementApi,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
|
||||
import type {
|
||||
RuntimeProviderManagementConnectApiKeyInput,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementTestModelInput,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
import type { IpcRenderer } from 'electron';
|
||||
|
||||
export function createRuntimeProviderManagementBridge(
|
||||
ipcRenderer: IpcRenderer
|
||||
): RuntimeProviderManagementApi {
|
||||
return {
|
||||
loadView: (
|
||||
input: RuntimeProviderManagementLoadViewInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> =>
|
||||
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_VIEW, input),
|
||||
connectWithApiKey: (
|
||||
input: RuntimeProviderManagementConnectApiKeyInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> =>
|
||||
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, input),
|
||||
forgetCredential: (
|
||||
input: RuntimeProviderManagementForgetInput
|
||||
): Promise<RuntimeProviderManagementProviderResponse> =>
|
||||
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_FORGET, input),
|
||||
loadModels: (
|
||||
input: RuntimeProviderManagementLoadModelsInput
|
||||
): Promise<RuntimeProviderManagementModelsResponse> =>
|
||||
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_MODELS, input),
|
||||
testModel: (
|
||||
input: RuntimeProviderManagementTestModelInput
|
||||
): Promise<RuntimeProviderManagementModelTestResponse> =>
|
||||
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_TEST_MODEL, input),
|
||||
setDefaultModel: (
|
||||
input: RuntimeProviderManagementSetDefaultModelInput
|
||||
): Promise<RuntimeProviderManagementViewResponse> =>
|
||||
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL, input),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { createRuntimeProviderManagementBridge } from './createRuntimeProviderManagementBridge';
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { useRuntimeProviderManagement } from './hooks/useRuntimeProviderManagement';
|
||||
import { RuntimeProviderManagementPanelView } from './ui/RuntimeProviderManagementPanelView';
|
||||
|
||||
import type { RuntimeProviderManagementRuntimeId } from '@features/runtime-provider-management/contracts';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
interface RuntimeProviderManagementPanelProps {
|
||||
readonly runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
readonly open: boolean;
|
||||
readonly disabled?: boolean;
|
||||
readonly onProviderChanged?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function RuntimeProviderManagementPanel({
|
||||
runtimeId,
|
||||
open,
|
||||
disabled = false,
|
||||
onProviderChanged,
|
||||
}: RuntimeProviderManagementPanelProps): JSX.Element {
|
||||
const [state, actions] = useRuntimeProviderManagement({
|
||||
runtimeId,
|
||||
enabled: open,
|
||||
onProviderChanged,
|
||||
});
|
||||
|
||||
return <RuntimeProviderManagementPanelView state={state} actions={actions} disabled={disabled} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import {
|
||||
setStoredCreateTeamModel,
|
||||
setStoredCreateTeamProvider,
|
||||
} from '@renderer/services/createTeamPreferences';
|
||||
|
||||
export function saveOpenCodeModelForNewTeams(modelId: string): void {
|
||||
setStoredCreateTeamProvider('opencode');
|
||||
setStoredCreateTeamModel('opencode', modelId);
|
||||
}
|
||||
|
|
@ -0,0 +1,579 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import { selectInitialProviderId } from '../../core/domain';
|
||||
import { saveOpenCodeModelForNewTeams } from '../adapters/createTeamDefaultModelWriter';
|
||||
|
||||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderManagementRuntimeId,
|
||||
RuntimeProviderManagementViewDto,
|
||||
RuntimeProviderModelDto,
|
||||
RuntimeProviderModelTestResultDto,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
|
||||
interface UseRuntimeProviderManagementOptions {
|
||||
runtimeId: RuntimeProviderManagementRuntimeId;
|
||||
enabled: boolean;
|
||||
onProviderChanged?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export type RuntimeProviderModelPickerMode = 'use' | 'runtime-default';
|
||||
|
||||
export interface RuntimeProviderManagementState {
|
||||
view: RuntimeProviderManagementViewDto | null;
|
||||
providers: readonly RuntimeProviderConnectionDto[];
|
||||
selectedProviderId: string | null;
|
||||
activeFormProviderId: string | null;
|
||||
apiKeyValue: string;
|
||||
modelPickerProviderId: string | null;
|
||||
modelPickerMode: RuntimeProviderModelPickerMode | null;
|
||||
modelQuery: string;
|
||||
models: readonly RuntimeProviderModelDto[];
|
||||
modelsLoading: boolean;
|
||||
modelsError: string | null;
|
||||
selectedModelId: string | null;
|
||||
testingModelId: string | null;
|
||||
savingDefaultModelId: string | null;
|
||||
modelResults: Readonly<Record<string, RuntimeProviderModelTestResultDto>>;
|
||||
loading: boolean;
|
||||
savingProviderId: string | null;
|
||||
error: string | null;
|
||||
successMessage: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeProviderManagementActions {
|
||||
refresh: () => Promise<void>;
|
||||
selectProvider: (providerId: string) => void;
|
||||
startConnect: (providerId: string) => void;
|
||||
cancelConnect: () => void;
|
||||
setApiKeyValue: (value: string) => void;
|
||||
submitConnect: (providerId: string) => Promise<void>;
|
||||
forgetProvider: (providerId: string) => Promise<void>;
|
||||
openModelPicker: (providerId: string, mode: RuntimeProviderModelPickerMode) => void;
|
||||
closeModelPicker: () => void;
|
||||
setModelQuery: (value: string) => void;
|
||||
selectModel: (modelId: string) => void;
|
||||
useModelForNewTeams: (modelId: string) => void;
|
||||
testModel: (providerId: string, modelId: string) => Promise<void>;
|
||||
setDefaultModel: (providerId: string, modelId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function replaceProvider(
|
||||
view: RuntimeProviderManagementViewDto | null,
|
||||
provider: RuntimeProviderConnectionDto
|
||||
): RuntimeProviderManagementViewDto | null {
|
||||
if (!view) {
|
||||
return view;
|
||||
}
|
||||
return {
|
||||
...view,
|
||||
providers: view.providers.map((entry) =>
|
||||
entry.providerId === provider.providerId ? provider : entry
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function resetModelState(): {
|
||||
modelPickerProviderId: null;
|
||||
modelPickerMode: null;
|
||||
models: readonly RuntimeProviderModelDto[];
|
||||
modelsError: null;
|
||||
selectedModelId: null;
|
||||
modelResults: Record<string, RuntimeProviderModelTestResultDto>;
|
||||
} {
|
||||
return {
|
||||
modelPickerProviderId: null,
|
||||
modelPickerMode: null,
|
||||
models: [],
|
||||
modelsError: null,
|
||||
selectedModelId: null,
|
||||
modelResults: {},
|
||||
};
|
||||
}
|
||||
|
||||
function withUiTimeout<T>(promise: Promise<T>, message: string, timeoutMs = 70_000): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
reject(new Error(message));
|
||||
}, timeoutMs);
|
||||
promise.then(
|
||||
(value) => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
window.clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function useRuntimeProviderManagement(
|
||||
options: UseRuntimeProviderManagementOptions
|
||||
): [RuntimeProviderManagementState, RuntimeProviderManagementActions] {
|
||||
const [view, setView] = useState<RuntimeProviderManagementViewDto | null>(null);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
|
||||
const [activeFormProviderId, setActiveFormProviderId] = useState<string | null>(null);
|
||||
const [apiKeyValue, setApiKeyValue] = useState('');
|
||||
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
|
||||
const [modelPickerMode, setModelPickerMode] = useState<RuntimeProviderModelPickerMode | null>(
|
||||
null
|
||||
);
|
||||
const [modelQuery, setModelQuery] = useState('');
|
||||
const [models, setModels] = useState<readonly RuntimeProviderModelDto[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [testingModelId, setTestingModelId] = useState<string | null>(null);
|
||||
const [savingDefaultModelId, setSavingDefaultModelId] = useState<string | null>(null);
|
||||
const [modelResults, setModelResults] = useState<
|
||||
Record<string, RuntimeProviderModelTestResultDto>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
if (!options.enabled) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.runtimeProviderManagement.loadView({
|
||||
runtimeId: options.runtimeId,
|
||||
});
|
||||
if (response.error) {
|
||||
setView(null);
|
||||
setError(response.error.message);
|
||||
return;
|
||||
}
|
||||
const nextView = response.view ?? null;
|
||||
setView(nextView);
|
||||
setSelectedProviderId((current) => {
|
||||
if (current && nextView?.providers.some((provider) => provider.providerId === current)) {
|
||||
return current;
|
||||
}
|
||||
return selectInitialProviderId(nextView);
|
||||
});
|
||||
} catch (loadError) {
|
||||
setView(null);
|
||||
setError(loadError instanceof Error ? loadError.message : 'Failed to load providers');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [options.enabled, options.runtimeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
setApiKeyValue('');
|
||||
setActiveFormProviderId(null);
|
||||
const reset = resetModelState();
|
||||
setModelPickerProviderId(reset.modelPickerProviderId);
|
||||
setModelPickerMode(reset.modelPickerMode);
|
||||
setModels(reset.models);
|
||||
setModelsError(reset.modelsError);
|
||||
setSelectedModelId(reset.selectedModelId);
|
||||
setModelResults(reset.modelResults);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [options.enabled, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !modelPickerProviderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
void withUiTimeout(
|
||||
api.runtimeProviderManagement.loadModels({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId: modelPickerProviderId,
|
||||
query: modelQuery.trim() || null,
|
||||
limit: 250,
|
||||
}),
|
||||
'Provider models load timed out'
|
||||
)
|
||||
.then((response) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (response.error) {
|
||||
setModels([]);
|
||||
setModelsError(response.error.message);
|
||||
return;
|
||||
}
|
||||
const nextModels = response.models?.models ?? [];
|
||||
setModels(nextModels);
|
||||
setSelectedModelId((current) => {
|
||||
if (current && nextModels.some((model) => model.modelId === current)) {
|
||||
return current;
|
||||
}
|
||||
return (
|
||||
nextModels.find((model) => model.default)?.modelId ?? nextModels[0]?.modelId ?? null
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((modelsLoadError) => {
|
||||
if (!cancelled) {
|
||||
setModels([]);
|
||||
setModelsError(
|
||||
modelsLoadError instanceof Error
|
||||
? modelsLoadError.message
|
||||
: 'Failed to load provider models'
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [modelPickerProviderId, modelQuery, options.enabled, options.runtimeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled || activeFormProviderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedProvider = view?.providers.find(
|
||||
(provider) => provider.providerId === selectedProviderId
|
||||
);
|
||||
if (
|
||||
selectedProvider &&
|
||||
selectedProvider.state === 'connected' &&
|
||||
selectedProvider.modelCount > 0
|
||||
) {
|
||||
if (modelPickerProviderId !== selectedProvider.providerId) {
|
||||
setModelPickerProviderId(selectedProvider.providerId);
|
||||
setModelPickerMode('use');
|
||||
setModelQuery('');
|
||||
setModels([]);
|
||||
setModelsError(null);
|
||||
setSelectedModelId(null);
|
||||
setModelResults({});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelPickerProviderId) {
|
||||
setModelPickerProviderId(null);
|
||||
setModelPickerMode(null);
|
||||
setModels([]);
|
||||
setModelsError(null);
|
||||
setSelectedModelId(null);
|
||||
setModelResults({});
|
||||
}
|
||||
}, [activeFormProviderId, modelPickerProviderId, options.enabled, selectedProviderId, view]);
|
||||
|
||||
const startConnect = useCallback((providerId: string): void => {
|
||||
setSelectedProviderId(providerId);
|
||||
setActiveFormProviderId(providerId);
|
||||
setModelPickerProviderId(null);
|
||||
setModelPickerMode(null);
|
||||
setApiKeyValue('');
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
}, []);
|
||||
|
||||
const cancelConnect = useCallback((): void => {
|
||||
setActiveFormProviderId(null);
|
||||
setApiKeyValue('');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const submitConnect = useCallback(
|
||||
async (providerId: string): Promise<void> => {
|
||||
const apiKey = apiKeyValue.trim();
|
||||
if (!apiKey) {
|
||||
setError('API key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingProviderId(providerId);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.connectWithApiKey({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
apiKey,
|
||||
}),
|
||||
'Provider connect timed out'
|
||||
);
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
return;
|
||||
}
|
||||
if (response.provider) {
|
||||
setView((current) => replaceProvider(current, response.provider!));
|
||||
}
|
||||
setActiveFormProviderId(null);
|
||||
setSuccessMessage('Provider connected');
|
||||
setSavingProviderId(null);
|
||||
setApiKeyValue('');
|
||||
void Promise.resolve(options.onProviderChanged?.())
|
||||
.then(() => refresh())
|
||||
.catch((refreshError) => {
|
||||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
);
|
||||
});
|
||||
} catch (connectError) {
|
||||
setError(
|
||||
connectError instanceof Error ? connectError.message : 'Failed to connect provider'
|
||||
);
|
||||
} finally {
|
||||
setApiKeyValue('');
|
||||
setSavingProviderId(null);
|
||||
}
|
||||
},
|
||||
[apiKeyValue, options, refresh]
|
||||
);
|
||||
|
||||
const forgetProvider = useCallback(
|
||||
async (providerId: string): Promise<void> => {
|
||||
setSavingProviderId(providerId);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.forgetCredential({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
}),
|
||||
'Provider forget timed out'
|
||||
);
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
return;
|
||||
}
|
||||
if (response.provider) {
|
||||
setView((current) => replaceProvider(current, response.provider!));
|
||||
}
|
||||
setSuccessMessage('Credential removed');
|
||||
setSavingProviderId(null);
|
||||
void Promise.resolve(options.onProviderChanged?.())
|
||||
.then(() => refresh())
|
||||
.catch((refreshError) => {
|
||||
setError(
|
||||
refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers'
|
||||
);
|
||||
});
|
||||
} catch (forgetError) {
|
||||
setError(
|
||||
forgetError instanceof Error ? forgetError.message : 'Failed to forget credential'
|
||||
);
|
||||
} finally {
|
||||
setSavingProviderId(null);
|
||||
}
|
||||
},
|
||||
[options, refresh]
|
||||
);
|
||||
|
||||
const openModelPicker = useCallback(
|
||||
(providerId: string, mode: RuntimeProviderModelPickerMode): void => {
|
||||
setSelectedProviderId(providerId);
|
||||
setActiveFormProviderId(null);
|
||||
setModelPickerProviderId(providerId);
|
||||
setModelPickerMode(mode);
|
||||
setModelQuery('');
|
||||
setModels([]);
|
||||
setModelsError(null);
|
||||
setSelectedModelId(null);
|
||||
setModelResults({});
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const closeModelPicker = useCallback((): void => {
|
||||
setModelPickerProviderId(null);
|
||||
setModelPickerMode(null);
|
||||
setModelQuery('');
|
||||
setModels([]);
|
||||
setModelsError(null);
|
||||
setSelectedModelId(null);
|
||||
setModelResults({});
|
||||
}, []);
|
||||
|
||||
const useModelForNewTeams = useCallback((modelId: string): void => {
|
||||
saveOpenCodeModelForNewTeams(modelId);
|
||||
setSelectedModelId(modelId);
|
||||
setSuccessMessage('Model saved for new teams');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const testModel = useCallback(
|
||||
async (providerId: string, modelId: string): Promise<void> => {
|
||||
setTestingModelId(modelId);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.testModel({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
modelId,
|
||||
}),
|
||||
'Model test timed out',
|
||||
100_000
|
||||
);
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
return;
|
||||
}
|
||||
if (response.result) {
|
||||
setModelResults((current) => ({
|
||||
...current,
|
||||
[modelId]: response.result!,
|
||||
}));
|
||||
setSuccessMessage(response.result.ok ? 'Model probe passed' : response.result.message);
|
||||
}
|
||||
} catch (testError) {
|
||||
setError(testError instanceof Error ? testError.message : 'Failed to test model');
|
||||
} finally {
|
||||
setTestingModelId(null);
|
||||
}
|
||||
},
|
||||
[options.runtimeId]
|
||||
);
|
||||
|
||||
const setDefaultModel = useCallback(
|
||||
async (providerId: string, modelId: string): Promise<void> => {
|
||||
setSavingDefaultModelId(modelId);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
const response = await withUiTimeout(
|
||||
api.runtimeProviderManagement.setDefaultModel({
|
||||
runtimeId: options.runtimeId,
|
||||
providerId,
|
||||
modelId,
|
||||
probe: true,
|
||||
}),
|
||||
'Set default model timed out',
|
||||
100_000
|
||||
);
|
||||
if (response.error) {
|
||||
setError(response.error.message);
|
||||
return;
|
||||
}
|
||||
if (response.view) {
|
||||
setView(response.view);
|
||||
}
|
||||
setSelectedModelId(modelId);
|
||||
setModels((current) =>
|
||||
current.map((model) => ({
|
||||
...model,
|
||||
default: model.modelId === modelId,
|
||||
}))
|
||||
);
|
||||
setSuccessMessage(`OpenCode default set to ${modelId}`);
|
||||
await options.onProviderChanged?.();
|
||||
} catch (defaultError) {
|
||||
setError(
|
||||
defaultError instanceof Error ? defaultError.message : 'Failed to set OpenCode default'
|
||||
);
|
||||
} finally {
|
||||
setSavingDefaultModelId(null);
|
||||
}
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
const selectProvider = useCallback((providerId: string): void => {
|
||||
setSelectedProviderId(providerId);
|
||||
}, []);
|
||||
|
||||
const state = useMemo<RuntimeProviderManagementState>(
|
||||
() => ({
|
||||
view,
|
||||
providers: view?.providers ?? [],
|
||||
selectedProviderId,
|
||||
activeFormProviderId,
|
||||
apiKeyValue,
|
||||
modelPickerProviderId,
|
||||
modelPickerMode,
|
||||
modelQuery,
|
||||
models,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
selectedModelId,
|
||||
testingModelId,
|
||||
savingDefaultModelId,
|
||||
modelResults,
|
||||
loading,
|
||||
savingProviderId,
|
||||
error,
|
||||
successMessage,
|
||||
}),
|
||||
[
|
||||
activeFormProviderId,
|
||||
apiKeyValue,
|
||||
error,
|
||||
loading,
|
||||
modelPickerMode,
|
||||
modelPickerProviderId,
|
||||
modelQuery,
|
||||
modelResults,
|
||||
models,
|
||||
modelsError,
|
||||
modelsLoading,
|
||||
savingDefaultModelId,
|
||||
savingProviderId,
|
||||
selectedModelId,
|
||||
selectedProviderId,
|
||||
successMessage,
|
||||
testingModelId,
|
||||
view,
|
||||
]
|
||||
);
|
||||
|
||||
const actions = useMemo<RuntimeProviderManagementActions>(
|
||||
() => ({
|
||||
refresh,
|
||||
selectProvider,
|
||||
startConnect,
|
||||
cancelConnect,
|
||||
setApiKeyValue,
|
||||
submitConnect,
|
||||
forgetProvider,
|
||||
openModelPicker,
|
||||
closeModelPicker,
|
||||
setModelQuery,
|
||||
selectModel: setSelectedModelId,
|
||||
useModelForNewTeams,
|
||||
testModel,
|
||||
setDefaultModel,
|
||||
}),
|
||||
[
|
||||
cancelConnect,
|
||||
closeModelPicker,
|
||||
forgetProvider,
|
||||
openModelPicker,
|
||||
refresh,
|
||||
selectProvider,
|
||||
setDefaultModel,
|
||||
startConnect,
|
||||
submitConnect,
|
||||
testModel,
|
||||
useModelForNewTeams,
|
||||
]
|
||||
);
|
||||
|
||||
return [state, actions];
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { RuntimeProviderManagementPanel } from './RuntimeProviderManagementPanel';
|
||||
|
|
@ -0,0 +1,618 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
formatProviderState,
|
||||
formatRuntimeState,
|
||||
getProviderAction,
|
||||
getProviderModelsLabel,
|
||||
} from '../../core/domain';
|
||||
|
||||
import type {
|
||||
RuntimeProviderManagementActions,
|
||||
RuntimeProviderManagementState,
|
||||
} from '../hooks/useRuntimeProviderManagement';
|
||||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderModelDto,
|
||||
RuntimeProviderModelTestResultDto,
|
||||
} from '@features/runtime-provider-management/contracts';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
interface RuntimeProviderManagementPanelViewProps {
|
||||
readonly state: RuntimeProviderManagementState;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
readonly disabled: boolean;
|
||||
}
|
||||
|
||||
interface ProviderActionsProps {
|
||||
readonly provider: RuntimeProviderConnectionDto;
|
||||
readonly busy: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly onStartConnect: () => void;
|
||||
readonly onUse: () => void;
|
||||
readonly onSetDefault: () => void;
|
||||
readonly onForget: () => void;
|
||||
}
|
||||
|
||||
interface ProviderRowProps {
|
||||
readonly provider: RuntimeProviderConnectionDto;
|
||||
readonly state: RuntimeProviderManagementState;
|
||||
readonly active: boolean;
|
||||
readonly formOpen: boolean;
|
||||
readonly apiKeyValue: string;
|
||||
readonly busy: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
}
|
||||
|
||||
function stateClassName(provider: RuntimeProviderConnectionDto): string {
|
||||
switch (provider.state) {
|
||||
case 'connected':
|
||||
return 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200';
|
||||
case 'available':
|
||||
return 'border-sky-400/25 bg-sky-400/10 text-sky-200';
|
||||
case 'error':
|
||||
return 'border-red-400/25 bg-red-400/10 text-red-200';
|
||||
case 'ignored':
|
||||
return 'border-zinc-400/25 bg-zinc-400/10 text-zinc-300';
|
||||
case 'not-connected':
|
||||
return 'border-white/10 bg-white/[0.04] text-[var(--color-text-muted)]';
|
||||
}
|
||||
}
|
||||
|
||||
function RuntimeSummary({
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: Pick<RuntimeProviderManagementPanelViewProps, 'state' | 'disabled'> & {
|
||||
onRefresh: () => void;
|
||||
}): JSX.Element {
|
||||
const runtime = state.view?.runtime;
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.025)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
OpenCode runtime
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="border-white/10">
|
||||
{runtime ? formatRuntimeState(runtime) : state.loading ? 'Loading' : 'Unavailable'}
|
||||
</Badge>
|
||||
{runtime?.version ? (
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>v{runtime.version}</span>
|
||||
) : null}
|
||||
{state.view?.defaultModel ? (
|
||||
<span className="break-all" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Default: {state.view.defaultModel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{state.view?.diagnostics.length ? (
|
||||
<div
|
||||
className="mt-2 space-y-1 text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{state.view.diagnostics.slice(0, 3).map((diagnostic) => (
|
||||
<div key={diagnostic}>{diagnostic}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled || state.loading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{state.loading ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="mr-1 size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderActions({
|
||||
provider,
|
||||
busy,
|
||||
disabled,
|
||||
onStartConnect,
|
||||
onUse,
|
||||
onSetDefault,
|
||||
onForget,
|
||||
}: ProviderActionsProps): JSX.Element {
|
||||
const connect = getProviderAction(provider, 'connect');
|
||||
const use = getProviderAction(provider, 'use');
|
||||
const setDefault = getProviderAction(provider, 'set-default');
|
||||
const forget = getProviderAction(provider, 'forget');
|
||||
const configure = getProviderAction(provider, 'configure');
|
||||
|
||||
if (connect) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || busy || !connect.enabled}
|
||||
title={connect.disabledReason ?? undefined}
|
||||
onClick={onStartConnect}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<KeyRound className="mr-1 size-3.5" />
|
||||
)}
|
||||
{connect.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
{use ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || busy || !use.enabled}
|
||||
title={use.disabledReason ?? undefined}
|
||||
onClick={onUse}
|
||||
>
|
||||
<Play className="mr-1 size-3.5" />
|
||||
{use.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{setDefault ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled || busy || !setDefault.enabled}
|
||||
title={setDefault.disabledReason ?? undefined}
|
||||
onClick={onSetDefault}
|
||||
>
|
||||
<Star className="mr-1 size-3.5" />
|
||||
{setDefault.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{forget ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled || busy || !forget.enabled}
|
||||
title={forget.disabledReason ?? undefined}
|
||||
onClick={onForget}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
)}
|
||||
{forget.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{configure ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
title={configure.disabledReason ?? undefined}
|
||||
>
|
||||
{configure.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{!use && !setDefault && !forget && !configure ? (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">No actions</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
provider,
|
||||
state,
|
||||
active,
|
||||
formOpen,
|
||||
apiKeyValue,
|
||||
busy,
|
||||
disabled,
|
||||
actions,
|
||||
}: ProviderRowProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-3"
|
||||
style={{
|
||||
borderColor: active ? 'rgba(96, 165, 250, 0.4)' : 'var(--color-border-subtle)',
|
||||
backgroundColor: active ? 'rgba(96, 165, 250, 0.055)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="grid w-full grid-cols-[1fr_auto] items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
onClick={() => actions.selectProvider(provider.providerId)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{provider.displayName}
|
||||
</span>
|
||||
{provider.recommended ? <Badge variant="secondary">Recommended</Badge> : null}
|
||||
<span
|
||||
className={`rounded-md border px-2 py-0.5 text-[11px] ${stateClassName(provider)}`}
|
||||
>
|
||||
{formatProviderState(provider)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{getProviderModelsLabel(provider)}
|
||||
</span>
|
||||
{provider.defaultModelId ? (
|
||||
<span className="break-all" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Default: {provider.defaultModelId}
|
||||
</span>
|
||||
) : null}
|
||||
{provider.ownership.map((owner) => (
|
||||
<Badge
|
||||
key={owner}
|
||||
variant="outline"
|
||||
className="border-white/10 px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{owner}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{provider.detail ? (
|
||||
<div className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{provider.detail}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<ProviderActions
|
||||
provider={provider}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
onStartConnect={() => actions.startConnect(provider.providerId)}
|
||||
onUse={() => actions.openModelPicker(provider.providerId, 'use')}
|
||||
onSetDefault={() => actions.openModelPicker(provider.providerId, 'runtime-default')}
|
||||
onForget={() => void actions.forgetProvider(provider.providerId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formOpen ? (
|
||||
<div
|
||||
className="mt-3 rounded-md border p-3"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`runtime-provider-key-${provider.providerId}`} className="text-xs">
|
||||
{provider.displayName} API key
|
||||
</Label>
|
||||
<Input
|
||||
id={`runtime-provider-key-${provider.providerId}`}
|
||||
type="password"
|
||||
value={apiKeyValue}
|
||||
disabled={disabled || busy}
|
||||
onChange={(event) => actions.setApiKeyValue(event.target.value)}
|
||||
placeholder="Paste API key"
|
||||
className="h-9 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={actions.cancelConnect}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={disabled || busy || !apiKeyValue.trim()}
|
||||
onClick={() => void actions.submitConnect(provider.providerId)}
|
||||
>
|
||||
{busy ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
|
||||
Save key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{active && provider.state === 'connected' && provider.modelCount > 0 ? (
|
||||
<ProviderModelList
|
||||
state={state}
|
||||
actions={actions}
|
||||
provider={provider}
|
||||
disabled={disabled || busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelBadges({ model }: { readonly model: RuntimeProviderModelDto }): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="border-white/10 px-1.5 py-0 text-[10px]">
|
||||
{model.sourceLabel}
|
||||
</Badge>
|
||||
{model.free ? (
|
||||
<Badge className="bg-emerald-400/15 px-1.5 py-0 text-[10px] text-emerald-200">free</Badge>
|
||||
) : null}
|
||||
{model.default ? (
|
||||
<Badge className="bg-amber-400/15 px-1.5 py-0 text-[10px] text-amber-200">default</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelResult({
|
||||
result,
|
||||
}: {
|
||||
readonly result: RuntimeProviderModelTestResultDto | undefined;
|
||||
}): JSX.Element | null {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={`mt-2 text-xs ${result.ok ? 'text-emerald-200' : 'text-red-200'}`}>
|
||||
{result.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
provider,
|
||||
model,
|
||||
selected,
|
||||
disabled,
|
||||
testing,
|
||||
savingDefault,
|
||||
result,
|
||||
actions,
|
||||
mode,
|
||||
}: {
|
||||
readonly provider: RuntimeProviderConnectionDto;
|
||||
readonly model: RuntimeProviderModelDto;
|
||||
readonly selected: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly testing: boolean;
|
||||
readonly savingDefault: boolean;
|
||||
readonly result: RuntimeProviderModelTestResultDto | undefined;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
readonly mode: RuntimeProviderManagementState['modelPickerMode'];
|
||||
}): JSX.Element {
|
||||
const useButton = (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => actions.useModelForNewTeams(model.modelId)}
|
||||
>
|
||||
Use for new teams
|
||||
</Button>
|
||||
);
|
||||
const setDefaultButton = (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={model.default ? 'ghost' : 'outline'}
|
||||
disabled={disabled || savingDefault}
|
||||
onClick={() => void actions.setDefaultModel(provider.providerId, model.modelId)}
|
||||
>
|
||||
{savingDefault ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
|
||||
Set OpenCode default
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border p-3"
|
||||
style={{
|
||||
borderColor: selected ? 'rgba(96, 165, 250, 0.45)' : 'var(--color-border-subtle)',
|
||||
backgroundColor: selected ? 'rgba(96, 165, 250, 0.06)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
onClick={() => actions.selectModel(model.modelId)}
|
||||
>
|
||||
<div className="break-all text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{model.displayName}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{model.modelId}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ModelBadges model={model} />
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled || testing}
|
||||
onClick={() => void actions.testModel(provider.providerId, model.modelId)}
|
||||
>
|
||||
{testing ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
|
||||
Test
|
||||
</Button>
|
||||
{mode === 'runtime-default' ? setDefaultButton : useButton}
|
||||
{mode === 'runtime-default' ? useButton : setDefaultButton}
|
||||
</div>
|
||||
</div>
|
||||
<ModelResult result={result} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModelList({
|
||||
state,
|
||||
actions,
|
||||
provider,
|
||||
disabled,
|
||||
}: {
|
||||
readonly state: RuntimeProviderManagementState;
|
||||
readonly actions: RuntimeProviderManagementActions;
|
||||
readonly provider: RuntimeProviderConnectionDto;
|
||||
readonly disabled: boolean;
|
||||
}): JSX.Element {
|
||||
const pickerOpen = state.modelPickerProviderId === provider.providerId;
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3 border-t border-white/10 pt-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 size-4 text-[var(--color-text-muted)]" />
|
||||
<Input
|
||||
value={state.modelQuery}
|
||||
disabled={disabled || state.modelsLoading}
|
||||
onChange={(event) => actions.setModelQuery(event.target.value)}
|
||||
placeholder="Search models"
|
||||
className="h-9 pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.modelsError ? (
|
||||
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
|
||||
{state.modelsError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto pr-1">
|
||||
{!pickerOpen || state.modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading models
|
||||
</div>
|
||||
) : null}
|
||||
{pickerOpen && !state.modelsLoading && state.models.length === 0 && !state.modelsError ? (
|
||||
<div className="text-sm text-[var(--color-text-muted)]">No models found.</div>
|
||||
) : null}
|
||||
{pickerOpen
|
||||
? state.models.map((model) => (
|
||||
<ModelRow
|
||||
key={model.modelId}
|
||||
provider={provider}
|
||||
model={model}
|
||||
selected={state.selectedModelId === model.modelId}
|
||||
disabled={disabled}
|
||||
testing={state.testingModelId === model.modelId}
|
||||
savingDefault={state.savingDefaultModelId === model.modelId}
|
||||
result={state.modelResults[model.modelId]}
|
||||
actions={actions}
|
||||
mode={state.modelPickerMode}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeProviderManagementPanelView({
|
||||
state,
|
||||
actions,
|
||||
disabled,
|
||||
}: RuntimeProviderManagementPanelViewProps): JSX.Element {
|
||||
const selectedProviderId = state.selectedProviderId ?? state.providers[0]?.providerId ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<RuntimeSummary state={state} disabled={disabled} onRefresh={() => void actions.refresh()} />
|
||||
|
||||
{state.error ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(248, 113, 113, 0.25)',
|
||||
backgroundColor: 'rgba(248, 113, 113, 0.06)',
|
||||
color: '#fca5a5',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{state.error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.successMessage ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
borderColor: 'rgba(74, 222, 128, 0.25)',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.08)',
|
||||
color: '#86efac',
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{state.successMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[62vh] space-y-2 overflow-y-auto pr-1">
|
||||
{state.providers.map((provider) => (
|
||||
<ProviderRow
|
||||
key={provider.providerId}
|
||||
provider={provider}
|
||||
state={state}
|
||||
active={provider.providerId === selectedProviderId}
|
||||
formOpen={state.activeFormProviderId === provider.providerId}
|
||||
apiKeyValue={state.apiKeyValue}
|
||||
busy={state.savingProviderId === provider.providerId}
|
||||
disabled={disabled || state.loading}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!state.loading && state.providers.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg border p-3 text-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
No OpenCode providers reported by the managed runtime.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,6 +36,12 @@ import {
|
|||
registerRecentProjectsIpc,
|
||||
removeRecentProjectsIpc,
|
||||
} from '@features/recent-projects/main';
|
||||
import {
|
||||
createRuntimeProviderManagementFeature,
|
||||
registerRuntimeProviderManagementIpc,
|
||||
removeRuntimeProviderManagementIpc,
|
||||
type RuntimeProviderManagementFeatureFacade,
|
||||
} from '@features/runtime-provider-management/main';
|
||||
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
|
||||
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||
|
|
@ -550,6 +556,7 @@ let sshConnectionManager: SshConnectionManager;
|
|||
let codexAccountFeature: CodexAccountFeatureFacade | null = null;
|
||||
let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null;
|
||||
let recentProjectsFeature: RecentProjectsFeatureFacade;
|
||||
let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
|
||||
let teamDataService: TeamDataService;
|
||||
let teamProvisioningService: TeamProvisioningService;
|
||||
let cliInstallerService: CliInstallerService;
|
||||
|
|
@ -1166,6 +1173,18 @@ async function initializeServices(): Promise<void> {
|
|||
teamLogSourceTracker.onLogSourceChange((teamName) => {
|
||||
teammateToolTracker?.handleLogSourceChange(teamName);
|
||||
});
|
||||
void teamDataService
|
||||
.listTeams()
|
||||
.then(async (teams) => {
|
||||
await Promise.all(
|
||||
teams.map((team) =>
|
||||
teamProvisioningService.scanOpenCodePromptDeliveryWatchdog(team.teamName)
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
logger.warn(`[Init] OpenCode prompt delivery watchdog recovery failed: ${String(error)}`)
|
||||
);
|
||||
teamTaskStallMonitor.start();
|
||||
|
||||
// Allow SchedulerService to push schedule events to renderer
|
||||
|
|
@ -1187,6 +1206,7 @@ async function initializeServices(): Promise<void> {
|
|||
getLocalContext: () => contextRegistry.get('local'),
|
||||
logger: createLogger('Feature:RecentProjects'),
|
||||
});
|
||||
runtimeProviderManagementFeature = createRuntimeProviderManagementFeature();
|
||||
codexAccountFeature = createCodexAccountFeature({
|
||||
logger: createLogger('Feature:CodexAccount'),
|
||||
configManager,
|
||||
|
|
@ -1253,6 +1273,7 @@ async function initializeServices(): Promise<void> {
|
|||
);
|
||||
registerCodexAccountIpc(ipcMain, codexAccountFeature);
|
||||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
|
||||
|
||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||
sshConnectionManager.on('state-change', (status: unknown) => {
|
||||
|
|
@ -1436,6 +1457,7 @@ async function shutdownServices(): Promise<void> {
|
|||
removeIpcHandlers();
|
||||
removeCodexAccountIpc(ipcMain);
|
||||
removeRecentProjectsIpc(ipcMain);
|
||||
removeRuntimeProviderManagementIpc(ipcMain);
|
||||
});
|
||||
|
||||
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());
|
||||
|
|
|
|||
|
|
@ -2622,9 +2622,13 @@ async function handleSendMessage(
|
|||
delivered: 0,
|
||||
failed: 1,
|
||||
lastDelivery: {
|
||||
delivered: false,
|
||||
reason: 'opencode_runtime_delivery_timeout',
|
||||
diagnostics: ['opencode_runtime_delivery_timeout'],
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
acceptanceUnknown: true,
|
||||
responseState: 'not_observed',
|
||||
reason: 'opencode_runtime_delivery_ui_timeout_pending',
|
||||
diagnostics: ['opencode_runtime_delivery_ui_timeout_pending'],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
@ -2637,10 +2641,20 @@ async function handleSendMessage(
|
|||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: delivery.delivered,
|
||||
responsePending: delivery.responsePending,
|
||||
acceptanceUnknown: delivery.acceptanceUnknown,
|
||||
responseState: delivery.responseState,
|
||||
ledgerStatus: delivery.ledgerStatus,
|
||||
visibleReplyMessageId: delivery.visibleReplyMessageId,
|
||||
visibleReplyCorrelation: delivery.visibleReplyCorrelation,
|
||||
reason: delivery.reason,
|
||||
diagnostics: delivery.diagnostics,
|
||||
};
|
||||
if (!delivery.delivered && delivery.reason !== 'recipient_is_not_opencode') {
|
||||
if (
|
||||
!delivery.delivered &&
|
||||
delivery.reason !== 'recipient_is_not_opencode' &&
|
||||
delivery.reason !== 'opencode_runtime_delivery_ui_timeout_pending'
|
||||
) {
|
||||
logger.warn(
|
||||
`OpenCode runtime delivery after sendMessage failed for teammate "${memberName}": ${
|
||||
delivery.reason ?? 'unknown error'
|
||||
|
|
|
|||
|
|
@ -501,7 +501,7 @@ export class CliInstallerService {
|
|||
},
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
},
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
|
|
@ -768,18 +768,32 @@ export class CliInstallerService {
|
|||
return null;
|
||||
}
|
||||
|
||||
const providerStatus =
|
||||
providerId === 'opencode'
|
||||
? await this.multimodelBridgeService.verifyProviderStatus(binaryPath, providerId)
|
||||
: await this.multimodelBridgeService.getProviderStatus(binaryPath, providerId);
|
||||
const nextProviderStatus =
|
||||
providerId === 'opencode'
|
||||
? await this.multimodelBridgeService.verifyOpenCodeModels(binaryPath, providerStatus)
|
||||
: this.applyProviderModelAvailabilityToProvider(
|
||||
binaryPath,
|
||||
versionProbe.version,
|
||||
providerStatus
|
||||
);
|
||||
if (providerId === 'opencode') {
|
||||
const providerStatus = await this.multimodelBridgeService.verifyProviderStatus(
|
||||
binaryPath,
|
||||
providerId
|
||||
);
|
||||
const nextProviderStatus = {
|
||||
...providerStatus,
|
||||
modelVerificationState: 'idle' as const,
|
||||
modelAvailability: [],
|
||||
};
|
||||
this.updateLatestProviderStatus(nextProviderStatus);
|
||||
if (this.latestStatusSnapshot) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
return nextProviderStatus;
|
||||
}
|
||||
|
||||
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
|
||||
binaryPath,
|
||||
providerId
|
||||
);
|
||||
const nextProviderStatus = this.applyProviderModelAvailabilityToProvider(
|
||||
binaryPath,
|
||||
versionProbe.version,
|
||||
providerStatus
|
||||
);
|
||||
this.updateLatestProviderStatus(nextProviderStatus);
|
||||
if (this.latestStatusSnapshot) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
createDefaultCliExtensionCapabilities,
|
||||
createLegacyRuntimeFallbackCliExtensionCapabilities,
|
||||
} from '@shared/utils/providerExtensionCapabilities';
|
||||
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
|
||||
import { mkdtemp, readFile, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
|
|
@ -14,18 +13,12 @@ import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
|||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||
import { providerConnectionService } from './ProviderConnectionService';
|
||||
|
||||
import type {
|
||||
CliProviderId,
|
||||
CliProviderModelAvailability,
|
||||
CliProviderReasoningEffort,
|
||||
CliProviderStatus,
|
||||
} from '@shared/types';
|
||||
import type { CliProviderId, CliProviderReasoningEffort, CliProviderStatus } from '@shared/types';
|
||||
|
||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||
|
||||
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
|
||||
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
|
||||
const OPENCODE_MODEL_VERIFY_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface RuntimeExtensionCapabilityResponse {
|
||||
status?: 'supported' | 'read-only' | 'unsupported';
|
||||
|
|
@ -301,16 +294,6 @@ export interface OpenCodeRuntimeTranscriptLogMessage {
|
|||
level?: string;
|
||||
}
|
||||
|
||||
interface OpenCodeRuntimeVerifyModelResponse {
|
||||
schemaVersion?: number;
|
||||
providerId?: 'opencode';
|
||||
result?: {
|
||||
modelId?: string;
|
||||
outcome?: 'available' | 'unavailable' | 'unknown';
|
||||
reason?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'];
|
||||
|
||||
function getProviderDisplayName(providerId: CliProviderId): string {
|
||||
|
|
@ -322,7 +305,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode';
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -371,18 +354,28 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
|||
}
|
||||
|
||||
function mapRuntimeExtensionCapabilities(
|
||||
providerId: CliProviderId,
|
||||
capabilities?: RuntimeExtensionCapabilitiesResponse
|
||||
): CliProviderStatus['capabilities']['extensions'] {
|
||||
const defaults = capabilities
|
||||
? createDefaultCliExtensionCapabilities()
|
||||
: createLegacyRuntimeFallbackCliExtensionCapabilities();
|
||||
const pluginStatus =
|
||||
providerId === 'opencode'
|
||||
? 'unsupported'
|
||||
: (capabilities?.plugins?.status ?? defaults.plugins.status);
|
||||
const pluginReason =
|
||||
providerId === 'opencode'
|
||||
? (capabilities?.plugins?.reason ??
|
||||
'OpenCode does not support plugin management from Agent Teams.')
|
||||
: (capabilities?.plugins?.reason ?? defaults.plugins.reason);
|
||||
|
||||
return {
|
||||
plugins: {
|
||||
...defaults.plugins,
|
||||
status: capabilities?.plugins?.status ?? defaults.plugins.status,
|
||||
status: pluginStatus,
|
||||
ownership: capabilities?.plugins?.ownership ?? defaults.plugins.ownership,
|
||||
reason: capabilities?.plugins?.reason ?? defaults.plugins.reason,
|
||||
reason: pluginReason,
|
||||
},
|
||||
mcp: {
|
||||
...defaults.mcp,
|
||||
|
|
@ -578,7 +571,10 @@ export class ClaudeMultimodelBridgeService {
|
|||
capabilities: {
|
||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
|
||||
extensions: mapRuntimeExtensionCapabilities(
|
||||
providerId,
|
||||
runtimeStatus.capabilities?.extensions
|
||||
),
|
||||
},
|
||||
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
|
||||
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
|
||||
|
|
@ -868,72 +864,14 @@ export class ClaudeMultimodelBridgeService {
|
|||
}
|
||||
}
|
||||
|
||||
private async verifyOpenCodeModel(
|
||||
binaryPath: string,
|
||||
modelId: string
|
||||
): Promise<CliProviderModelAvailability> {
|
||||
const { env } = await this.buildCliEnv(binaryPath);
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
['runtime', 'verify-model', '--json', '--provider', 'opencode', '--model', modelId],
|
||||
{
|
||||
timeout: OPENCODE_MODEL_VERIFY_TIMEOUT_MS,
|
||||
env,
|
||||
}
|
||||
);
|
||||
const parsed = extractJsonObject<OpenCodeRuntimeVerifyModelResponse>(stdout);
|
||||
const outcome = parsed.providerId === 'opencode' ? parsed.result?.outcome : undefined;
|
||||
const reason = parsed.providerId === 'opencode' ? (parsed.result?.reason ?? null) : null;
|
||||
|
||||
return {
|
||||
modelId,
|
||||
status:
|
||||
outcome === 'available'
|
||||
? 'available'
|
||||
: outcome === 'unavailable'
|
||||
? 'unavailable'
|
||||
: 'unknown',
|
||||
reason,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
modelId,
|
||||
status: 'unknown',
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async verifyOpenCodeModels(
|
||||
binaryPath: string,
|
||||
_binaryPath: string,
|
||||
provider: CliProviderStatus
|
||||
): Promise<CliProviderStatus> {
|
||||
const visibleModels = filterVisibleProviderRuntimeModels(provider.providerId, provider.models);
|
||||
if (
|
||||
provider.providerId !== 'opencode' ||
|
||||
provider.supported !== true ||
|
||||
provider.authenticated !== true ||
|
||||
visibleModels.length === 0
|
||||
) {
|
||||
return {
|
||||
...provider,
|
||||
modelVerificationState: 'idle',
|
||||
modelAvailability: [],
|
||||
};
|
||||
}
|
||||
|
||||
const modelAvailability: CliProviderModelAvailability[] = [];
|
||||
for (const modelId of visibleModels) {
|
||||
modelAvailability.push(await this.verifyOpenCodeModel(binaryPath, modelId));
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
modelVerificationState: 'verified',
|
||||
modelAvailability,
|
||||
modelVerificationState: 'idle',
|
||||
modelAvailability: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1063,7 +1001,10 @@ export class ClaudeMultimodelBridgeService {
|
|||
capabilities: {
|
||||
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
|
||||
extensions: mapRuntimeExtensionCapabilities(
|
||||
providerId,
|
||||
runtimeStatus.capabilities?.extensions
|
||||
),
|
||||
},
|
||||
backend: runtimeStatus.backend?.kind
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ const TEAM_ROOT_FILES = [
|
|||
// Subdirs under ~/.claude/teams/{teamName}/
|
||||
const TEAM_SUBDIRS = ['inboxes', 'review-decisions'];
|
||||
const TEAM_RECURSIVE_SUBDIRS = ['.opencode-runtime'];
|
||||
const ATOMIC_WRITE_TEMP_FILE_PREFIX = '.tmp.';
|
||||
const QUARANTINED_OPENCODE_LANE_INDEX_RE = /^lanes\.invalid\.\d+\.json$/;
|
||||
// Subdirs under getAppDataPath() (our own storage, not in ~/.claude/)
|
||||
const APP_DATA_SUBDIRS = ['attachments'];
|
||||
const APP_DATA_DEEP_SUBDIRS = ['task-attachments'];
|
||||
|
|
@ -105,6 +107,18 @@ function isValidConfig(content: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function shouldCollectRecursiveBackupFile(relPath: string): boolean {
|
||||
const fileName = path.basename(relPath);
|
||||
if (fileName.startsWith(ATOMIC_WRITE_TEMP_FILE_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
// Runtime quarantine files are diagnostic snapshots of invalid JSON.
|
||||
if (QUARANTINED_OPENCODE_LANE_INDEX_RE.test(fileName)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function collectRecursiveFiles(
|
||||
rootDir: string,
|
||||
relPrefix: string
|
||||
|
|
@ -120,6 +134,9 @@ async function collectRecursiveFiles(
|
|||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
if (!shouldCollectRecursiveBackupFile(relPath)) {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
sourcePath,
|
||||
relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath,
|
||||
|
|
@ -144,6 +161,9 @@ function collectRecursiveFilesSync(rootDir: string, relPrefix: string): BackupFi
|
|||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
if (!shouldCollectRecursiveBackupFile(relPath)) {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
sourcePath,
|
||||
relPath: relPrefix ? `${relPrefix}/${relPath}` : relPath,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ export type OpenCodeBridgeCommandName =
|
|||
| 'opencode.reconcileTeam'
|
||||
| 'opencode.stopTeam'
|
||||
| 'opencode.sendMessage'
|
||||
| 'opencode.observeMessageDelivery'
|
||||
| 'opencode.answerPermission'
|
||||
| 'opencode.listRuntimePermissions'
|
||||
| 'opencode.getRuntimeTranscript'
|
||||
|
|
@ -156,15 +157,73 @@ export interface OpenCodeSendMessageCommandBody {
|
|||
memberName: string;
|
||||
text: string;
|
||||
messageId?: string;
|
||||
actionMode?: 'do' | 'ask' | 'delegate';
|
||||
taskRefs?: { taskId: string; displayId: string; teamName: string }[];
|
||||
agent?: string;
|
||||
noReply?: boolean;
|
||||
}
|
||||
|
||||
export type OpenCodeDeliveryResponseState =
|
||||
| 'not_observed'
|
||||
| 'pending'
|
||||
| 'prompt_not_indexed'
|
||||
| 'responded_tool_call'
|
||||
| 'responded_visible_message'
|
||||
| 'responded_non_visible_tool'
|
||||
| 'responded_plain_text'
|
||||
| 'permission_blocked'
|
||||
| 'tool_error'
|
||||
| 'empty_assistant_turn'
|
||||
| 'session_stale'
|
||||
| 'session_error'
|
||||
| 'reconcile_failed';
|
||||
|
||||
export type OpenCodeDeliveryVisibleReplyCorrelation =
|
||||
| 'relayOfMessageId'
|
||||
| 'direct_child_message_send'
|
||||
| 'plain_assistant_text';
|
||||
|
||||
export interface OpenCodeDeliveryResponseObservation {
|
||||
state: OpenCodeDeliveryResponseState;
|
||||
deliveredUserMessageId: string | null;
|
||||
assistantMessageId: string | null;
|
||||
toolCallNames: string[];
|
||||
visibleMessageToolCallId: string | null;
|
||||
visibleReplyMessageId: string | null;
|
||||
visibleReplyCorrelation: OpenCodeDeliveryVisibleReplyCorrelation | null;
|
||||
visibleReplyMissingRelayOfMessageId?: boolean;
|
||||
latestAssistantPreview: string | null;
|
||||
needsFullHistory?: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface OpenCodeSendMessageCommandData {
|
||||
accepted: boolean;
|
||||
sessionId?: string;
|
||||
memberName: string;
|
||||
runtimePid?: number;
|
||||
prePromptCursor?: string | null;
|
||||
responseObservation?: OpenCodeDeliveryResponseObservation;
|
||||
diagnostics: OpenCodeTeamBridgeDiagnostic[];
|
||||
}
|
||||
|
||||
export interface OpenCodeObserveMessageDeliveryCommandBody {
|
||||
runId?: string;
|
||||
laneId: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
projectPath: string;
|
||||
memberName: string;
|
||||
messageId: string;
|
||||
prePromptCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface OpenCodeObserveMessageDeliveryCommandData {
|
||||
observed: boolean;
|
||||
sessionId?: string;
|
||||
memberName: string;
|
||||
runtimePid?: number;
|
||||
responseObservation: OpenCodeDeliveryResponseObservation;
|
||||
diagnostics: OpenCodeTeamBridgeDiagnostic[];
|
||||
}
|
||||
|
||||
|
|
@ -310,6 +369,7 @@ const VALID_COMMANDS: ReadonlySet<OpenCodeBridgeCommandName> = new Set([
|
|||
'opencode.reconcileTeam',
|
||||
'opencode.stopTeam',
|
||||
'opencode.sendMessage',
|
||||
'opencode.observeMessageDelivery',
|
||||
'opencode.answerPermission',
|
||||
'opencode.listRuntimePermissions',
|
||||
'opencode.getRuntimeTranscript',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import type {
|
|||
OpenCodeCleanupHostsCommandData,
|
||||
OpenCodeLaunchTeamCommandBody,
|
||||
OpenCodeLaunchTeamCommandData,
|
||||
OpenCodeObserveMessageDeliveryCommandBody,
|
||||
OpenCodeObserveMessageDeliveryCommandData,
|
||||
OpenCodeReconcileTeamCommandBody,
|
||||
OpenCodeSendMessageCommandBody,
|
||||
OpenCodeSendMessageCommandData,
|
||||
|
|
@ -40,6 +42,7 @@ export interface OpenCodeReadinessBridgeOptions {
|
|||
launchTimeoutMs?: number;
|
||||
reconcileTimeoutMs?: number;
|
||||
sendTimeoutMs?: number;
|
||||
observeTimeoutMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
cleanupTimeoutMs?: number;
|
||||
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
||||
|
|
@ -55,6 +58,7 @@ const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
|||
const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_SEND_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_OBSERVE_TIMEOUT_MS = 8_000;
|
||||
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
|
||||
|
||||
|
|
@ -228,6 +232,48 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
};
|
||||
}
|
||||
|
||||
async observeOpenCodeTeamMessageDelivery(
|
||||
input: OpenCodeObserveMessageDeliveryCommandBody
|
||||
): Promise<OpenCodeObserveMessageDeliveryCommandData> {
|
||||
const result = await this.bridge.execute<
|
||||
OpenCodeObserveMessageDeliveryCommandBody,
|
||||
OpenCodeObserveMessageDeliveryCommandData
|
||||
>('opencode.observeMessageDelivery', input, {
|
||||
cwd: input.projectPath,
|
||||
timeoutMs: this.options.observeTimeoutMs ?? DEFAULT_OBSERVE_TIMEOUT_MS,
|
||||
});
|
||||
if (result.ok) {
|
||||
return result.data;
|
||||
}
|
||||
return {
|
||||
observed: false,
|
||||
memberName: input.memberName,
|
||||
responseObservation: {
|
||||
state: 'reconcile_failed',
|
||||
deliveredUserMessageId: null,
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: result.error.message,
|
||||
},
|
||||
diagnostics: [
|
||||
{
|
||||
code: result.error.kind,
|
||||
severity: 'error',
|
||||
message: `OpenCode message delivery observe bridge failed: ${result.error.message}`,
|
||||
},
|
||||
...result.diagnostics.map((event) => ({
|
||||
code: event.type,
|
||||
severity: event.severity,
|
||||
message: event.message,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async executeStateChangingCommand<TBody, TData>(
|
||||
command: OpenCodeStateChangingTeamCommandName,
|
||||
body: TBody,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,833 @@
|
|||
import { stableHash } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore';
|
||||
|
||||
import type { AgentActionMode, TaskRef } from '@shared/types/team';
|
||||
import type {
|
||||
OpenCodeDeliveryResponseObservation,
|
||||
OpenCodeDeliveryResponseState,
|
||||
OpenCodeDeliveryVisibleReplyCorrelation,
|
||||
} from '../bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1;
|
||||
export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const OPENCODE_PROMPT_DELIVERY_FAILED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type OpenCodePromptDeliveryStatus =
|
||||
| 'pending'
|
||||
| 'accepted'
|
||||
| 'responded'
|
||||
| 'unanswered'
|
||||
| 'retry_scheduled'
|
||||
| 'retried'
|
||||
| 'failed_retryable'
|
||||
| 'failed_terminal';
|
||||
|
||||
export interface OpenCodePromptDeliveryLedgerRecord {
|
||||
id: string;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId: string;
|
||||
runId: string | null;
|
||||
runtimeSessionId: string | null;
|
||||
inboxMessageId: string;
|
||||
inboxTimestamp: string;
|
||||
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog';
|
||||
replyRecipient: string;
|
||||
actionMode: AgentActionMode | null;
|
||||
taskRefs: TaskRef[];
|
||||
payloadHash: string;
|
||||
status: OpenCodePromptDeliveryStatus;
|
||||
responseState: OpenCodeDeliveryResponseState;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
acceptanceUnknown: boolean;
|
||||
nextAttemptAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
lastObservedAt: string | null;
|
||||
acceptedAt: string | null;
|
||||
respondedAt: string | null;
|
||||
failedAt: string | null;
|
||||
inboxReadCommittedAt: string | null;
|
||||
inboxReadCommitError: string | null;
|
||||
prePromptCursor: string | null;
|
||||
postPromptCursor: string | null;
|
||||
deliveredUserMessageId: string | null;
|
||||
observedAssistantMessageId: string | null;
|
||||
observedAssistantPreview: string | null;
|
||||
observedToolCallNames: string[];
|
||||
observedVisibleMessageId: string | null;
|
||||
visibleReplyMessageId: string | null;
|
||||
visibleReplyInbox: string | null;
|
||||
visibleReplyCorrelation: OpenCodeDeliveryVisibleReplyCorrelation | null;
|
||||
lastReason: string | null;
|
||||
diagnostics: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const OPENCODE_PROMPT_DELIVERY_STATUSES = new Set<OpenCodePromptDeliveryStatus>([
|
||||
'pending',
|
||||
'accepted',
|
||||
'responded',
|
||||
'unanswered',
|
||||
'retry_scheduled',
|
||||
'retried',
|
||||
'failed_retryable',
|
||||
'failed_terminal',
|
||||
]);
|
||||
|
||||
const OPENCODE_DELIVERY_RESPONSE_STATES = new Set<OpenCodeDeliveryResponseState>([
|
||||
'not_observed',
|
||||
'pending',
|
||||
'prompt_not_indexed',
|
||||
'responded_tool_call',
|
||||
'responded_visible_message',
|
||||
'responded_non_visible_tool',
|
||||
'responded_plain_text',
|
||||
'permission_blocked',
|
||||
'tool_error',
|
||||
'empty_assistant_turn',
|
||||
'session_stale',
|
||||
'session_error',
|
||||
'reconcile_failed',
|
||||
]);
|
||||
|
||||
const OPENCODE_PROMPT_DELIVERY_SOURCES = new Set<OpenCodePromptDeliveryLedgerRecord['source']>([
|
||||
'watcher',
|
||||
'ui-send',
|
||||
'manual',
|
||||
'watchdog',
|
||||
]);
|
||||
|
||||
const OPENCODE_DELIVERY_VISIBLE_REPLY_CORRELATIONS =
|
||||
new Set<OpenCodeDeliveryVisibleReplyCorrelation>([
|
||||
'relayOfMessageId',
|
||||
'direct_child_message_send',
|
||||
'plain_assistant_text',
|
||||
]);
|
||||
|
||||
const AGENT_ACTION_MODES = new Set<AgentActionMode>(['do', 'ask', 'delegate']);
|
||||
|
||||
export interface EnsureOpenCodePromptDeliveryInput {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId: string;
|
||||
runId?: string | null;
|
||||
inboxMessageId: string;
|
||||
inboxTimestamp: string;
|
||||
source: OpenCodePromptDeliveryLedgerRecord['source'];
|
||||
replyRecipient: string;
|
||||
actionMode?: AgentActionMode | null;
|
||||
taskRefs?: TaskRef[];
|
||||
payloadHash: string;
|
||||
maxAttempts?: number;
|
||||
now: string;
|
||||
}
|
||||
|
||||
export interface ApplyOpenCodePromptDeliveryResultInput {
|
||||
id: string;
|
||||
accepted: boolean;
|
||||
attempted?: boolean;
|
||||
responseObservation?: OpenCodeDeliveryResponseObservation;
|
||||
sessionId?: string | null;
|
||||
runtimePid?: number;
|
||||
prePromptCursor?: string | null;
|
||||
diagnostics?: string[];
|
||||
reason?: string | null;
|
||||
now: string;
|
||||
}
|
||||
|
||||
export interface ApplyOpenCodePromptDestinationProofInput {
|
||||
id: string;
|
||||
visibleReplyInbox: string;
|
||||
visibleReplyMessageId: string;
|
||||
visibleReplyCorrelation: 'relayOfMessageId';
|
||||
semanticallySufficient: boolean;
|
||||
diagnostics?: string[];
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export class OpenCodePromptDeliveryLedgerStore {
|
||||
constructor(private readonly store: VersionedJsonStore<OpenCodePromptDeliveryLedgerRecord[]>) {}
|
||||
|
||||
async ensurePending(
|
||||
input: EnsureOpenCodePromptDeliveryInput
|
||||
): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
const id = buildOpenCodePromptDeliveryRecordId(input);
|
||||
let result: OpenCodePromptDeliveryLedgerRecord | null = null;
|
||||
await this.store.updateLocked((records) => {
|
||||
const existing = records.find((record) => record.id === id);
|
||||
if (existing) {
|
||||
if (existing.payloadHash !== input.payloadHash) {
|
||||
const reason = 'opencode_prompt_delivery_payload_mismatch';
|
||||
const updated: OpenCodePromptDeliveryLedgerRecord = {
|
||||
...existing,
|
||||
status: 'failed_terminal',
|
||||
failedAt: input.now,
|
||||
nextAttemptAt: null,
|
||||
lastReason: reason,
|
||||
diagnostics: mergeDiagnostics(existing.diagnostics, [
|
||||
`${reason}: existing payload hash does not match current inbox row payload`,
|
||||
]),
|
||||
updatedAt: input.now,
|
||||
};
|
||||
result = updated;
|
||||
return records.map((record) => (record.id === existing.id ? updated : record));
|
||||
}
|
||||
result = existing;
|
||||
return records;
|
||||
}
|
||||
|
||||
const created: OpenCodePromptDeliveryLedgerRecord = {
|
||||
id,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
laneId: input.laneId,
|
||||
runId: input.runId ?? null,
|
||||
runtimeSessionId: null,
|
||||
inboxMessageId: input.inboxMessageId,
|
||||
inboxTimestamp: input.inboxTimestamp,
|
||||
source: input.source,
|
||||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode ?? null,
|
||||
taskRefs: input.taskRefs ?? [],
|
||||
payloadHash: input.payloadHash,
|
||||
status: 'pending',
|
||||
responseState: 'not_observed',
|
||||
attempts: 0,
|
||||
maxAttempts: input.maxAttempts ?? 3,
|
||||
acceptanceUnknown: false,
|
||||
nextAttemptAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastObservedAt: null,
|
||||
acceptedAt: null,
|
||||
respondedAt: null,
|
||||
failedAt: null,
|
||||
inboxReadCommittedAt: null,
|
||||
inboxReadCommitError: null,
|
||||
prePromptCursor: null,
|
||||
postPromptCursor: null,
|
||||
deliveredUserMessageId: null,
|
||||
observedAssistantMessageId: null,
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: [],
|
||||
observedVisibleMessageId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyInbox: null,
|
||||
visibleReplyCorrelation: null,
|
||||
lastReason: null,
|
||||
diagnostics: [],
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
};
|
||||
result = created;
|
||||
return [...records, created];
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error('OpenCode prompt delivery ensurePending failed');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getByInboxMessage(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId: string;
|
||||
inboxMessageId: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord | null> {
|
||||
const records = await this.readRequired();
|
||||
return (
|
||||
records.find(
|
||||
(record) =>
|
||||
record.teamName === input.teamName &&
|
||||
record.memberName.toLowerCase() === input.memberName.toLowerCase() &&
|
||||
record.laneId === input.laneId &&
|
||||
record.inboxMessageId === input.inboxMessageId
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async getActiveForMember(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord | null> {
|
||||
const records = await this.readRequired();
|
||||
return (
|
||||
records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.teamName === input.teamName &&
|
||||
record.memberName.toLowerCase() === input.memberName.toLowerCase() &&
|
||||
record.laneId === input.laneId &&
|
||||
!isTerminalForAutomaticSelection(record)
|
||||
)
|
||||
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async applyDeliveryResult(
|
||||
input: ApplyOpenCodePromptDeliveryResultInput
|
||||
): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => {
|
||||
const observation = input.responseObservation;
|
||||
const responseState =
|
||||
observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
|
||||
const responded = isOpenCodePromptResponseStateResponded(responseState);
|
||||
const unanswered = responseState === 'empty_assistant_turn';
|
||||
return {
|
||||
...record,
|
||||
status: input.accepted
|
||||
? responded
|
||||
? 'responded'
|
||||
: unanswered
|
||||
? 'unanswered'
|
||||
: 'accepted'
|
||||
: 'failed_retryable',
|
||||
responseState,
|
||||
attempts:
|
||||
input.accepted || input.attempted === true ? record.attempts + 1 : record.attempts,
|
||||
runtimeSessionId: input.sessionId ?? record.runtimeSessionId,
|
||||
acceptanceUnknown: input.accepted ? false : record.acceptanceUnknown,
|
||||
lastAttemptAt: input.now,
|
||||
lastObservedAt: observation ? input.now : record.lastObservedAt,
|
||||
acceptedAt: input.accepted ? (record.acceptedAt ?? input.now) : record.acceptedAt,
|
||||
respondedAt: responded ? (record.respondedAt ?? input.now) : record.respondedAt,
|
||||
prePromptCursor: input.prePromptCursor ?? record.prePromptCursor,
|
||||
deliveredUserMessageId:
|
||||
observation?.deliveredUserMessageId ?? record.deliveredUserMessageId,
|
||||
observedAssistantMessageId:
|
||||
observation?.assistantMessageId ?? record.observedAssistantMessageId,
|
||||
observedAssistantPreview:
|
||||
observation?.latestAssistantPreview ?? record.observedAssistantPreview,
|
||||
observedToolCallNames: observation?.toolCallNames ?? record.observedToolCallNames,
|
||||
observedVisibleMessageId:
|
||||
observation?.visibleMessageToolCallId ?? record.observedVisibleMessageId,
|
||||
visibleReplyMessageId: observation?.visibleReplyMessageId ?? record.visibleReplyMessageId,
|
||||
visibleReplyCorrelation:
|
||||
observation?.visibleReplyCorrelation ?? record.visibleReplyCorrelation,
|
||||
lastReason: input.reason ?? observation?.reason ?? record.lastReason,
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
|
||||
updatedAt: input.now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async applyObservation(input: {
|
||||
id: string;
|
||||
responseObservation: OpenCodeDeliveryResponseObservation;
|
||||
diagnostics?: string[];
|
||||
observedAt: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => {
|
||||
const responded = isOpenCodePromptResponseStateResponded(input.responseObservation.state);
|
||||
const unanswered = input.responseObservation.state === 'empty_assistant_turn';
|
||||
return {
|
||||
...record,
|
||||
status: responded
|
||||
? 'responded'
|
||||
: unanswered
|
||||
? 'unanswered'
|
||||
: record.status === 'pending'
|
||||
? 'accepted'
|
||||
: record.status,
|
||||
responseState: input.responseObservation.state,
|
||||
lastObservedAt: input.observedAt,
|
||||
respondedAt: responded ? (record.respondedAt ?? input.observedAt) : record.respondedAt,
|
||||
deliveredUserMessageId:
|
||||
input.responseObservation.deliveredUserMessageId ?? record.deliveredUserMessageId,
|
||||
observedAssistantMessageId:
|
||||
input.responseObservation.assistantMessageId ?? record.observedAssistantMessageId,
|
||||
observedAssistantPreview:
|
||||
input.responseObservation.latestAssistantPreview ?? record.observedAssistantPreview,
|
||||
observedToolCallNames: input.responseObservation.toolCallNames,
|
||||
observedVisibleMessageId:
|
||||
input.responseObservation.visibleMessageToolCallId ?? record.observedVisibleMessageId,
|
||||
visibleReplyMessageId:
|
||||
input.responseObservation.visibleReplyMessageId ?? record.visibleReplyMessageId,
|
||||
visibleReplyCorrelation:
|
||||
input.responseObservation.visibleReplyCorrelation ?? record.visibleReplyCorrelation,
|
||||
lastReason: input.responseObservation.reason ?? record.lastReason,
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
|
||||
updatedAt: input.observedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async applyDestinationProof(
|
||||
input: ApplyOpenCodePromptDestinationProofInput
|
||||
): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => ({
|
||||
...record,
|
||||
status: input.semanticallySufficient ? 'responded' : record.status,
|
||||
responseState: 'responded_visible_message',
|
||||
lastObservedAt: input.observedAt,
|
||||
respondedAt: input.semanticallySufficient
|
||||
? (record.respondedAt ?? input.observedAt)
|
||||
: record.respondedAt,
|
||||
visibleReplyInbox: input.visibleReplyInbox,
|
||||
visibleReplyMessageId: input.visibleReplyMessageId,
|
||||
visibleReplyCorrelation: input.visibleReplyCorrelation,
|
||||
lastReason: input.semanticallySufficient
|
||||
? record.lastReason
|
||||
: 'visible_reply_ack_only_still_requires_answer',
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
|
||||
updatedAt: input.observedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async markAcceptanceUnknown(input: {
|
||||
id: string;
|
||||
reason: string;
|
||||
nextAttemptAt: string;
|
||||
diagnostics?: string[];
|
||||
markedAt: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => ({
|
||||
...record,
|
||||
status: 'failed_retryable',
|
||||
responseState: 'not_observed',
|
||||
acceptanceUnknown: true,
|
||||
nextAttemptAt: input.nextAttemptAt,
|
||||
lastReason: input.reason,
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, [
|
||||
input.reason,
|
||||
...(input.diagnostics ?? []),
|
||||
]),
|
||||
updatedAt: input.markedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async markNextAttemptScheduled(input: {
|
||||
id: string;
|
||||
status: Extract<OpenCodePromptDeliveryStatus, 'accepted' | 'retry_scheduled'>;
|
||||
nextAttemptAt: string;
|
||||
reason: string;
|
||||
scheduledAt: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => ({
|
||||
...record,
|
||||
status: input.status,
|
||||
nextAttemptAt: input.nextAttemptAt,
|
||||
lastReason: input.reason,
|
||||
updatedAt: input.scheduledAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async markRetryAttempted(input: {
|
||||
id: string;
|
||||
attemptedAt: string;
|
||||
reason?: string | null;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => ({
|
||||
...record,
|
||||
status: 'retried',
|
||||
attempts: record.attempts + 1,
|
||||
lastAttemptAt: input.attemptedAt,
|
||||
nextAttemptAt: null,
|
||||
lastReason: input.reason ?? record.lastReason,
|
||||
updatedAt: input.attemptedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async markFailedTerminal(input: {
|
||||
id: string;
|
||||
reason: string;
|
||||
diagnostics?: string[];
|
||||
failedAt: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => ({
|
||||
...record,
|
||||
status: 'failed_terminal',
|
||||
failedAt: input.failedAt,
|
||||
nextAttemptAt: null,
|
||||
lastReason: input.reason,
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, [
|
||||
input.reason,
|
||||
...(input.diagnostics ?? []),
|
||||
]),
|
||||
updatedAt: input.failedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async markInboxReadCommitted(input: {
|
||||
id: string;
|
||||
committedAt: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => ({
|
||||
...record,
|
||||
inboxReadCommittedAt: input.committedAt,
|
||||
inboxReadCommitError: null,
|
||||
updatedAt: input.committedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async markInboxReadCommitFailed(input: {
|
||||
id: string;
|
||||
error: string;
|
||||
failedAt: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => ({
|
||||
...record,
|
||||
inboxReadCommitError: input.error,
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, [input.error]),
|
||||
updatedAt: input.failedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async list(): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
|
||||
return await this.readRequired();
|
||||
}
|
||||
|
||||
async listDue(input: {
|
||||
teamName?: string;
|
||||
now: Date;
|
||||
limit: number;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
|
||||
const nowMs = input.now.getTime();
|
||||
const limit = Math.max(0, input.limit);
|
||||
if (limit === 0) {
|
||||
return [];
|
||||
}
|
||||
const teamName = input.teamName?.trim().toLowerCase() ?? null;
|
||||
const records = await this.readRequired();
|
||||
return records
|
||||
.filter((record) => {
|
||||
if (teamName && record.teamName.trim().toLowerCase() !== teamName) {
|
||||
return false;
|
||||
}
|
||||
if (isTerminalForAutomaticSelection(record)) {
|
||||
return false;
|
||||
}
|
||||
return isOpenCodePromptDeliveryAttemptDue(record, nowMs);
|
||||
})
|
||||
.sort(compareOpenCodePromptDeliveryDueOrder)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async pruneTerminalRecords(input: {
|
||||
now: Date;
|
||||
respondedRetentionMs?: number;
|
||||
failedRetentionMs?: number;
|
||||
}): Promise<{ pruned: number; remaining: number }> {
|
||||
const nowMs = input.now.getTime();
|
||||
const respondedRetentionMs =
|
||||
input.respondedRetentionMs ?? OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS;
|
||||
const failedRetentionMs =
|
||||
input.failedRetentionMs ?? OPENCODE_PROMPT_DELIVERY_FAILED_RETENTION_MS;
|
||||
let pruned = 0;
|
||||
let remaining = 0;
|
||||
await this.store.updateLocked((records) => {
|
||||
const kept = records.filter((record) => {
|
||||
if (
|
||||
shouldPruneOpenCodePromptDeliveryRecord(
|
||||
record,
|
||||
nowMs,
|
||||
respondedRetentionMs,
|
||||
failedRetentionMs
|
||||
)
|
||||
) {
|
||||
pruned += 1;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
remaining = kept.length;
|
||||
return kept;
|
||||
});
|
||||
return { pruned, remaining };
|
||||
}
|
||||
|
||||
private async updateExisting(
|
||||
id: string,
|
||||
updater: (record: OpenCodePromptDeliveryLedgerRecord) => OpenCodePromptDeliveryLedgerRecord
|
||||
): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
let updated: OpenCodePromptDeliveryLedgerRecord | null = null;
|
||||
await this.store.updateLocked((records) =>
|
||||
records.map((record) => {
|
||||
if (record.id !== id) {
|
||||
return record;
|
||||
}
|
||||
updated = updater(record);
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
if (!updated) {
|
||||
throw new Error(`OpenCode prompt delivery record not found: ${id}`);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async readRequired(): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
|
||||
const result = await this.store.read();
|
||||
if (!result.ok) {
|
||||
throw new VersionedJsonStoreError(result.message, result.reason, result.quarantinePath);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOpenCodePromptDeliveryLedgerStore(options: {
|
||||
filePath: string;
|
||||
clock?: () => Date;
|
||||
}): OpenCodePromptDeliveryLedgerStore {
|
||||
const clock = options.clock ?? (() => new Date());
|
||||
return new OpenCodePromptDeliveryLedgerStore(
|
||||
new VersionedJsonStore<OpenCodePromptDeliveryLedgerRecord[]>({
|
||||
filePath: options.filePath,
|
||||
schemaVersion: OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION,
|
||||
defaultData: () => [],
|
||||
validate: validateOpenCodePromptDeliveryLedgerRecords,
|
||||
clock,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function buildOpenCodePromptDeliveryRecordId(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId: string;
|
||||
inboxMessageId: string;
|
||||
}): string {
|
||||
return `opencode-prompt:${stableHash({
|
||||
version: 1,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName.toLowerCase(),
|
||||
laneId: input.laneId,
|
||||
inboxMessageId: input.inboxMessageId,
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function hashOpenCodePromptDeliveryPayload(input: {
|
||||
text: string;
|
||||
replyRecipient: string;
|
||||
actionMode?: AgentActionMode | null;
|
||||
taskRefs?: TaskRef[];
|
||||
attachments?: Array<{ id?: string; filename?: string; mimeType?: string; size?: number }>;
|
||||
source?: string;
|
||||
}): string {
|
||||
return `sha256:${stableHash({
|
||||
text: input.text,
|
||||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode ?? null,
|
||||
taskRefs: input.taskRefs ?? [],
|
||||
attachments:
|
||||
input.attachments?.map((attachment) => ({
|
||||
id: attachment.id ?? null,
|
||||
filename: attachment.filename ?? null,
|
||||
mimeType: attachment.mimeType ?? null,
|
||||
size: attachment.size ?? null,
|
||||
})) ?? [],
|
||||
source: input.source ?? null,
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function isOpenCodePromptResponseStateResponded(
|
||||
state: OpenCodeDeliveryResponseState
|
||||
): boolean {
|
||||
return (
|
||||
state === 'responded_visible_message' ||
|
||||
state === 'responded_non_visible_tool' ||
|
||||
state === 'responded_tool_call' ||
|
||||
state === 'responded_plain_text'
|
||||
);
|
||||
}
|
||||
|
||||
export function isOpenCodePromptDeliveryAttemptDue(
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
nowMs: number = Date.now()
|
||||
): boolean {
|
||||
if (!record.nextAttemptAt) {
|
||||
return true;
|
||||
}
|
||||
const dueMs = Date.parse(record.nextAttemptAt);
|
||||
return !Number.isFinite(dueMs) || dueMs <= nowMs;
|
||||
}
|
||||
|
||||
export function validateOpenCodePromptDeliveryLedgerRecords(
|
||||
value: unknown
|
||||
): OpenCodePromptDeliveryLedgerRecord[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('OpenCode prompt delivery ledger must be an array');
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return value.map((record, index) => {
|
||||
if (!isOpenCodePromptDeliveryLedgerRecord(record)) {
|
||||
throw new Error(`Invalid OpenCode prompt delivery ledger record at index ${index}`);
|
||||
}
|
||||
if (seen.has(record.id)) {
|
||||
throw new Error(`Duplicate OpenCode prompt delivery ledger id: ${record.id}`);
|
||||
}
|
||||
seen.add(record.id);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
function isOpenCodePromptDeliveryLedgerRecord(
|
||||
value: unknown
|
||||
): value is OpenCodePromptDeliveryLedgerRecord {
|
||||
const record = value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||
return Boolean(
|
||||
record &&
|
||||
typeof record.id === 'string' &&
|
||||
typeof record.teamName === 'string' &&
|
||||
typeof record.memberName === 'string' &&
|
||||
typeof record.laneId === 'string' &&
|
||||
isOptionalNullableString(record.runId) &&
|
||||
isOptionalNullableString(record.runtimeSessionId) &&
|
||||
typeof record.inboxMessageId === 'string' &&
|
||||
typeof record.inboxTimestamp === 'string' &&
|
||||
isOpenCodePromptDeliverySource(record.source) &&
|
||||
typeof record.replyRecipient === 'string' &&
|
||||
isOptionalNullableActionMode(record.actionMode) &&
|
||||
isTaskRefArray(record.taskRefs) &&
|
||||
typeof record.payloadHash === 'string' &&
|
||||
isOpenCodePromptDeliveryStatus(record.status) &&
|
||||
isOpenCodeDeliveryResponseState(record.responseState) &&
|
||||
isNonNegativeInteger(record.attempts) &&
|
||||
isNonNegativeInteger(record.maxAttempts) &&
|
||||
typeof record.acceptanceUnknown === 'boolean' &&
|
||||
isOptionalNullableString(record.nextAttemptAt) &&
|
||||
isOptionalNullableString(record.lastAttemptAt) &&
|
||||
isOptionalNullableString(record.lastObservedAt) &&
|
||||
isOptionalNullableString(record.acceptedAt) &&
|
||||
isOptionalNullableString(record.respondedAt) &&
|
||||
isOptionalNullableString(record.failedAt) &&
|
||||
isOptionalNullableString(record.inboxReadCommittedAt) &&
|
||||
isOptionalNullableString(record.inboxReadCommitError) &&
|
||||
isOptionalNullableString(record.prePromptCursor) &&
|
||||
isOptionalNullableString(record.postPromptCursor) &&
|
||||
isOptionalNullableString(record.deliveredUserMessageId) &&
|
||||
isOptionalNullableString(record.observedAssistantMessageId) &&
|
||||
isOptionalNullableString(record.observedAssistantPreview) &&
|
||||
isStringArray(record.observedToolCallNames) &&
|
||||
isOptionalNullableString(record.observedVisibleMessageId) &&
|
||||
isOptionalNullableString(record.visibleReplyMessageId) &&
|
||||
isOptionalNullableString(record.visibleReplyInbox) &&
|
||||
isOptionalNullableVisibleReplyCorrelation(record.visibleReplyCorrelation) &&
|
||||
isOptionalNullableString(record.lastReason) &&
|
||||
isStringArray(record.diagnostics) &&
|
||||
typeof record.createdAt === 'string' &&
|
||||
typeof record.updatedAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenCodePromptDeliveryStatus(value: unknown): value is OpenCodePromptDeliveryStatus {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
OPENCODE_PROMPT_DELIVERY_STATUSES.has(value as OpenCodePromptDeliveryStatus)
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenCodeDeliveryResponseState(value: unknown): value is OpenCodeDeliveryResponseState {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
OPENCODE_DELIVERY_RESPONSE_STATES.has(value as OpenCodeDeliveryResponseState)
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenCodePromptDeliverySource(
|
||||
value: unknown
|
||||
): value is OpenCodePromptDeliveryLedgerRecord['source'] {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
OPENCODE_PROMPT_DELIVERY_SOURCES.has(value as OpenCodePromptDeliveryLedgerRecord['source'])
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalNullableVisibleReplyCorrelation(
|
||||
value: unknown
|
||||
): value is OpenCodeDeliveryVisibleReplyCorrelation | null | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' &&
|
||||
OPENCODE_DELIVERY_VISIBLE_REPLY_CORRELATIONS.has(
|
||||
value as OpenCodeDeliveryVisibleReplyCorrelation
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalNullableActionMode(value: unknown): value is AgentActionMode | null | undefined {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' && AGENT_ACTION_MODES.has(value as AgentActionMode))
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalNullableString(value: unknown): value is string | null | undefined {
|
||||
return value === undefined || value === null || typeof value === 'string';
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function isTaskRefArray(value: unknown): value is TaskRef[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return false;
|
||||
}
|
||||
const taskRef = item as Record<string, unknown>;
|
||||
return (
|
||||
typeof taskRef.taskId === 'string' &&
|
||||
typeof taskRef.displayId === 'string' &&
|
||||
typeof taskRef.teamName === 'string'
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isTerminalForAutomaticSelection(record: OpenCodePromptDeliveryLedgerRecord): boolean {
|
||||
return (
|
||||
record.status === 'failed_terminal' ||
|
||||
(record.status === 'responded' && record.inboxReadCommittedAt != null)
|
||||
);
|
||||
}
|
||||
|
||||
function compareOpenCodePromptDeliveryDueOrder(
|
||||
left: OpenCodePromptDeliveryLedgerRecord,
|
||||
right: OpenCodePromptDeliveryLedgerRecord
|
||||
): number {
|
||||
const leftDue = left.nextAttemptAt ? Date.parse(left.nextAttemptAt) : Date.parse(left.createdAt);
|
||||
const rightDue = right.nextAttemptAt
|
||||
? Date.parse(right.nextAttemptAt)
|
||||
: Date.parse(right.createdAt);
|
||||
const dueDelta = safeSortableTime(leftDue) - safeSortableTime(rightDue);
|
||||
if (dueDelta !== 0) {
|
||||
return dueDelta;
|
||||
}
|
||||
return Date.parse(left.createdAt) - Date.parse(right.createdAt);
|
||||
}
|
||||
|
||||
function safeSortableTime(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function shouldPruneOpenCodePromptDeliveryRecord(
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
nowMs: number,
|
||||
respondedRetentionMs: number,
|
||||
failedRetentionMs: number
|
||||
): boolean {
|
||||
if (record.status === 'responded' && record.inboxReadCommittedAt) {
|
||||
const committedMs = Date.parse(record.inboxReadCommittedAt);
|
||||
return Number.isFinite(committedMs) && nowMs - committedMs >= respondedRetentionMs;
|
||||
}
|
||||
if (record.status === 'failed_terminal') {
|
||||
const failedMs = Date.parse(record.failedAt ?? record.updatedAt);
|
||||
return Number.isFinite(failedMs) && nowMs - failedMs >= failedRetentionMs;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mergeDiagnostics(existing: string[], next: string[]): string[] {
|
||||
return [...new Set([...existing, ...next].filter((item) => item.trim()))];
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import type { AgentActionMode, InboxMessage, TaskRef } from '@shared/types/team';
|
||||
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
export const OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS = 3_000;
|
||||
export const OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS = 15_000;
|
||||
export const OPENCODE_PROMPT_WATCHDOG_GLOBAL_CONCURRENCY = 2;
|
||||
export const OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY = 1;
|
||||
|
||||
const ACK_ONLY_PHRASES = new Set([
|
||||
'понял',
|
||||
'поняла',
|
||||
'ок',
|
||||
'окей',
|
||||
'принял',
|
||||
'приняла',
|
||||
'сделаю',
|
||||
'разберусь',
|
||||
'understood',
|
||||
'got it',
|
||||
'ok',
|
||||
'okay',
|
||||
'will do',
|
||||
]);
|
||||
|
||||
const ACK_ONLY_PREFIXES = [
|
||||
"i'll check",
|
||||
'i will check',
|
||||
"i'll take a look",
|
||||
'i will take a look',
|
||||
"i'll do it",
|
||||
'i will do it',
|
||||
'я проверю',
|
||||
'я посмотрю',
|
||||
];
|
||||
|
||||
export interface OpenCodeVisibleReplyProof {
|
||||
inboxName: string;
|
||||
message: InboxMessage & { messageId: string };
|
||||
missingRuntimeDeliverySource?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenCodeVisibleReplySemanticResult {
|
||||
sufficient: boolean;
|
||||
reason?: 'ack_only' | 'concrete_reply';
|
||||
}
|
||||
|
||||
export function isOpenCodeVisibleReplySemanticallySufficient(input: {
|
||||
actionMode?: AgentActionMode | null;
|
||||
taskRefs?: TaskRef[];
|
||||
text: string;
|
||||
summary?: string | null;
|
||||
}): OpenCodeVisibleReplySemanticResult {
|
||||
const combined = [input.summary, input.text]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (!combined) {
|
||||
return { sufficient: false, reason: 'ack_only' };
|
||||
}
|
||||
if (!looksLikeNarrowAckOnly(combined)) {
|
||||
return { sufficient: true, reason: 'concrete_reply' };
|
||||
}
|
||||
|
||||
return { sufficient: false, reason: 'ack_only' };
|
||||
}
|
||||
|
||||
export function isOpenCodeVisibleReplyReadCommitAllowed(input: {
|
||||
actionMode?: AgentActionMode | null;
|
||||
taskRefs?: TaskRef[];
|
||||
visibleReply?: OpenCodeVisibleReplyProof | null;
|
||||
transcriptOnlyVisibleReply?: boolean;
|
||||
}): boolean {
|
||||
if (input.visibleReply) {
|
||||
return isOpenCodeVisibleReplySemanticallySufficient({
|
||||
actionMode: input.actionMode,
|
||||
taskRefs: input.taskRefs,
|
||||
text: input.visibleReply.message.text,
|
||||
summary: input.visibleReply.message.summary,
|
||||
}).sufficient;
|
||||
}
|
||||
|
||||
// Transcript-only message_send proves OpenCode attempted a visible reply, but not
|
||||
// whether the destination store committed it yet. Keep it pending for the watchdog.
|
||||
return input.transcriptOnlyVisibleReply !== true;
|
||||
}
|
||||
|
||||
export function isOpenCodePromptDeliveryRetryableResponseState(
|
||||
state: OpenCodeDeliveryResponseState | undefined
|
||||
): boolean {
|
||||
return (
|
||||
state === 'empty_assistant_turn' ||
|
||||
state === 'tool_error' ||
|
||||
state === 'reconcile_failed' ||
|
||||
state === 'not_observed'
|
||||
);
|
||||
}
|
||||
|
||||
export function isOpenCodePromptDeliveryObserveLaterResponseState(
|
||||
state: OpenCodeDeliveryResponseState | undefined
|
||||
): boolean {
|
||||
return (
|
||||
state === 'pending' ||
|
||||
state === 'prompt_not_indexed' ||
|
||||
state === 'permission_blocked' ||
|
||||
state === 'session_stale'
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeNarrowAckOnly(text: string): boolean {
|
||||
const normalized = text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[.!?,;:()[\]{}"'`«»]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!normalized || normalized.length > 120) {
|
||||
return false;
|
||||
}
|
||||
if (/[#/@\\]|\d|```|`/.test(text)) {
|
||||
return false;
|
||||
}
|
||||
if (/[??]/.test(text)) {
|
||||
return false;
|
||||
}
|
||||
const sentenceLikeParts = text
|
||||
.split(/[.!?。!?]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (sentenceLikeParts.length > 1) {
|
||||
return false;
|
||||
}
|
||||
if (ACK_ONLY_PHRASES.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return ACK_ONLY_PREFIXES.some(
|
||||
(prefix) => normalized === prefix || normalized.startsWith(`${prefix} `)
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export type RuntimeStoreSchemaName =
|
|||
| 'opencode.sessionStore'
|
||||
| 'opencode.launchTransaction'
|
||||
| 'opencode.deliveryJournal'
|
||||
| 'opencode.promptDeliveryLedger'
|
||||
| 'opencode.permissionRequests'
|
||||
| 'opencode.hostLeases'
|
||||
| 'opencode.compatibilitySnapshot'
|
||||
|
|
@ -205,6 +206,14 @@ export const OPENCODE_RUNTIME_STORE_DESCRIPTORS: RuntimeStoreDescriptor[] = [
|
|||
owner: 'delivery',
|
||||
rebuildStrategy: 'verify_canonical_destinations',
|
||||
},
|
||||
{
|
||||
schemaName: 'opencode.promptDeliveryLedger',
|
||||
schemaVersion: 1,
|
||||
relativePath: 'opencode-prompt-delivery-ledger.json',
|
||||
criticality: 'rebuildable_from_canonical_destination',
|
||||
owner: 'delivery',
|
||||
rebuildStrategy: 'verify_canonical_destinations',
|
||||
},
|
||||
{
|
||||
schemaName: 'opencode.permissionRequests',
|
||||
schemaVersion: 1,
|
||||
|
|
@ -1087,6 +1096,7 @@ function isRuntimeStoreSchemaName(value: unknown): value is RuntimeStoreSchemaNa
|
|||
value === 'opencode.sessionStore' ||
|
||||
value === 'opencode.launchTransaction' ||
|
||||
value === 'opencode.deliveryJournal' ||
|
||||
value === 'opencode.promptDeliveryLedger' ||
|
||||
value === 'opencode.permissionRequests' ||
|
||||
value === 'opencode.hostLeases' ||
|
||||
value === 'opencode.compatibilitySnapshot' ||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type {
|
|||
OpenCodeBridgeRuntimeSnapshot,
|
||||
OpenCodeLaunchTeamCommandBody,
|
||||
OpenCodeLaunchTeamCommandData,
|
||||
OpenCodeObserveMessageDeliveryCommandBody,
|
||||
OpenCodeObserveMessageDeliveryCommandData,
|
||||
OpenCodeReconcileTeamCommandBody,
|
||||
OpenCodeSendMessageCommandBody,
|
||||
OpenCodeSendMessageCommandData,
|
||||
|
|
@ -41,6 +43,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
|||
sendOpenCodeTeamMessage?(
|
||||
input: OpenCodeSendMessageCommandBody
|
||||
): Promise<OpenCodeSendMessageCommandData>;
|
||||
observeOpenCodeTeamMessageDelivery?(
|
||||
input: OpenCodeObserveMessageDeliveryCommandBody
|
||||
): Promise<OpenCodeObserveMessageDeliveryCommandData>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamRuntimeMessageInput {
|
||||
|
|
@ -62,6 +67,8 @@ export interface OpenCodeTeamRuntimeMessageResult {
|
|||
memberName: string;
|
||||
sessionId?: string;
|
||||
runtimePid?: number;
|
||||
prePromptCursor?: string | null;
|
||||
responseObservation?: OpenCodeSendMessageCommandData['responseObservation'];
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
|
|
@ -285,6 +292,8 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
memberName: input.memberName,
|
||||
text: buildOpenCodeRuntimeMessageText(input),
|
||||
messageId: input.messageId,
|
||||
actionMode: input.actionMode,
|
||||
taskRefs: input.taskRefs,
|
||||
agent: 'teammate',
|
||||
});
|
||||
|
||||
|
|
@ -294,6 +303,50 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
memberName: input.memberName,
|
||||
sessionId: data.sessionId,
|
||||
runtimePid: data.runtimePid,
|
||||
prePromptCursor: data.prePromptCursor,
|
||||
responseObservation: data.responseObservation,
|
||||
diagnostics: data.diagnostics.map((diagnostic) => diagnostic.message),
|
||||
};
|
||||
}
|
||||
|
||||
async observeMessageDelivery(
|
||||
input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null }
|
||||
): Promise<OpenCodeTeamRuntimeMessageResult> {
|
||||
if (!this.bridge.observeOpenCodeTeamMessageDelivery) {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: this.providerId,
|
||||
memberName: input.memberName,
|
||||
diagnostics: ['OpenCode message delivery observe bridge is not registered.'],
|
||||
};
|
||||
}
|
||||
if (!input.messageId?.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: this.providerId,
|
||||
memberName: input.memberName,
|
||||
diagnostics: ['OpenCode message delivery observe requires messageId.'],
|
||||
};
|
||||
}
|
||||
|
||||
const data = await this.bridge.observeOpenCodeTeamMessageDelivery({
|
||||
runId: input.runId,
|
||||
laneId: input.laneId,
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
projectPath: input.cwd,
|
||||
memberName: input.memberName,
|
||||
messageId: input.messageId,
|
||||
prePromptCursor: input.prePromptCursor ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: data.observed,
|
||||
providerId: this.providerId,
|
||||
memberName: input.memberName,
|
||||
sessionId: data.sessionId,
|
||||
runtimePid: data.runtimePid,
|
||||
responseObservation: data.responseObservation,
|
||||
diagnostics: data.diagnostics.map((diagnostic) => diagnostic.message),
|
||||
};
|
||||
}
|
||||
|
|
@ -564,6 +617,10 @@ function buildMemberBootstrapPrompt(
|
|||
'After runtime identity check-in, if you have not already done so, call MCP tool agent-teams_member_briefing (or mcp__agent-teams__member_briefing if that is the exposed name) with:',
|
||||
`{ "teamName": "${input.teamName}", "memberName": "${member.name}", "runtimeProvider": "opencode" }`,
|
||||
'If that tool is not available, stay idle and wait for app-delivered instructions. Do not improvise a replacement workflow.',
|
||||
'Launch bootstrap is a silent attach, not a user/team conversation turn.',
|
||||
'After runtime_bootstrap_checkin and member_briefing both succeed, stop this turn immediately and wait for app-delivered messages or actionable task assignments.',
|
||||
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
|
||||
'If the briefing says there are no actionable tasks, stay idle silently.',
|
||||
'',
|
||||
'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.',
|
||||
`Always set from="${member.name}" when sending a team message from this OpenCode teammate.`,
|
||||
|
|
@ -582,14 +639,17 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
'You are running in OpenCode, not Claude Code or Codex native.',
|
||||
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).',
|
||||
`Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,
|
||||
'Include source="runtime_delivery" in that message_send call.',
|
||||
input.messageId
|
||||
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
|
||||
: null,
|
||||
'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.',
|
||||
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
|
||||
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',
|
||||
'Do not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.',
|
||||
input.actionMode ? `Action mode for this message: ${input.actionMode}.` : null,
|
||||
taskRefs ? `If your reply is about these tasks, include taskRefs exactly: ${taskRefs}` : null,
|
||||
input.messageId
|
||||
? `The inbound app messageId is "${input.messageId}"; keep it only as context unless a tool explicitly asks for provenance.`
|
||||
: null,
|
||||
input.messageId ? `The inbound app messageId is "${input.messageId}".` : null,
|
||||
'</opencode_app_message_delivery>',
|
||||
'',
|
||||
input.text,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createCodexAccountBridge } from '@features/codex-account/preload';
|
||||
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
|
||||
import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload';
|
||||
import { createTmuxInstallerBridge } from '@features/tmux-installer/preload';
|
||||
import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
|
|
@ -465,6 +466,7 @@ const electronAPI: ElectronAPI = {
|
|||
ipcRenderer,
|
||||
}),
|
||||
...createRecentProjectsBridge(),
|
||||
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
getProjects: () => ipcRenderer.invoke('get-projects'),
|
||||
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
|
||||
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
||||
import type {
|
||||
AppConfig,
|
||||
AttachmentFileData,
|
||||
|
|
@ -1187,6 +1188,63 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
},
|
||||
};
|
||||
|
||||
runtimeProviderManagement: RuntimeProviderManagementApi = {
|
||||
loadView: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'runtime-unhealthy',
|
||||
message: 'Runtime provider management is not available in browser mode.',
|
||||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
connectWithApiKey: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'unsupported-action',
|
||||
message: 'Runtime provider management is not available in browser mode.',
|
||||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
forgetCredential: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'unsupported-action',
|
||||
message: 'Runtime provider management is not available in browser mode.',
|
||||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
loadModels: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'unsupported-action',
|
||||
message: 'Runtime provider management is not available in browser mode.',
|
||||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
testModel: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'unsupported-action',
|
||||
message: 'Runtime provider management is not available in browser mode.',
|
||||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
setDefaultModel: async (input) => ({
|
||||
schemaVersion: 1,
|
||||
runtimeId: input.runtimeId,
|
||||
error: {
|
||||
code: 'unsupported-action',
|
||||
message: 'Runtime provider management is not available in browser mode.',
|
||||
recoverable: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
tmux: TmuxAPI = {
|
||||
getStatus: async (): Promise<TmuxStatus> => ({
|
||||
platform: 'unknown',
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ function getProviderLabel(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode';
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -758,7 +758,9 @@ const InstalledBanner = ({
|
|||
className="text-xs font-medium"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{provider.displayName}
|
||||
{provider.providerId === 'opencode'
|
||||
? getProviderLabel(provider.providerId)
|
||||
: provider.displayName}
|
||||
</span>
|
||||
{provider.providerId === 'opencode' ? <OpenCodeBetaBadge /> : null}
|
||||
</span>
|
||||
|
|
@ -802,6 +804,7 @@ const InstalledBanner = ({
|
|||
models={provider.models}
|
||||
modelAvailability={provider.modelAvailability}
|
||||
providerStatus={provider}
|
||||
collapseAfter={15}
|
||||
/>
|
||||
{codexDashboardRateLimits!.map((item) => (
|
||||
<div
|
||||
|
|
@ -960,6 +963,7 @@ const InstalledBanner = ({
|
|||
models={provider.models}
|
||||
modelAvailability={provider.modelAvailability}
|
||||
providerStatus={provider}
|
||||
collapseAfter={15}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1177,9 +1181,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
const handleProviderRefresh = useCallback(
|
||||
(providerId: CliProviderId) => {
|
||||
void fetchCliProviderStatus(providerId, {
|
||||
verifyModels: providerId === 'opencode',
|
||||
});
|
||||
void fetchCliProviderStatus(providerId);
|
||||
},
|
||||
[fetchCliProviderStatus]
|
||||
);
|
||||
|
|
@ -1265,11 +1267,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
providerStatusLoading={cliProviderStatusLoading}
|
||||
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
|
||||
onSelectBackend={handleProviderBackendChange}
|
||||
onRefreshProvider={(providerId) =>
|
||||
fetchCliProviderStatus(providerId, {
|
||||
verifyModels: providerId === 'opencode',
|
||||
})
|
||||
}
|
||||
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
|
||||
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
|
||||
/>
|
||||
{providerTerminal && renderCliStatus.binaryPath && (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import {
|
||||
getTeamModelBadgeLabel,
|
||||
getVisibleTeamProviderModels,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
CliProviderId,
|
||||
|
|
@ -48,50 +51,92 @@ export const ProviderModelBadges = ({
|
|||
models,
|
||||
modelAvailability,
|
||||
providerStatus,
|
||||
collapseAfter,
|
||||
expandedMaxHeightPx = 200,
|
||||
}: {
|
||||
readonly providerId: CliProviderId;
|
||||
readonly models: string[];
|
||||
readonly modelAvailability?: CliProviderModelAvailability[];
|
||||
readonly providerStatus?: Pick<CliProviderStatus, 'providerId' | 'authMethod' | 'backend'> | null;
|
||||
readonly collapseAfter?: number;
|
||||
readonly expandedMaxHeightPx?: number;
|
||||
}): React.JSX.Element => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleModels = getVisibleTeamProviderModels(providerId, models, providerStatus);
|
||||
const displayModelAvailability = providerId === 'opencode' ? undefined : modelAvailability;
|
||||
const shouldCollapse =
|
||||
typeof collapseAfter === 'number' && collapseAfter > 0 && visibleModels.length > collapseAfter;
|
||||
const displayedModels =
|
||||
shouldCollapse && !expanded ? visibleModels.slice(0, collapseAfter) : visibleModels;
|
||||
const hiddenCount = shouldCollapse ? visibleModels.length - collapseAfter : 0;
|
||||
|
||||
const badgeClassName =
|
||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[10px] leading-4';
|
||||
const badgeStyle = {
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
};
|
||||
const buttonClassName =
|
||||
'inline-flex items-center gap-1 rounded-full border border-[rgba(59,130,246,0.35)] bg-[rgba(59,130,246,0.12)] px-2 py-px text-[10px] font-medium leading-4 text-[rgb(147,197,253)] transition-colors hover:border-[rgba(59,130,246,0.55)] hover:bg-[rgba(59,130,246,0.18)] hover:text-[rgb(191,219,254)]';
|
||||
const listClassName = cn('flex flex-wrap gap-1.5', expanded && shouldCollapse ? 'pr-1' : null);
|
||||
const listStyle =
|
||||
expanded && shouldCollapse
|
||||
? ({ maxHeight: expandedMaxHeightPx, overflowY: 'auto' } as const)
|
||||
: undefined;
|
||||
|
||||
const renderModelBadge = (model: string, index: number): React.JSX.Element => {
|
||||
const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability);
|
||||
const availabilityReason = getAvailabilityReason(model, displayModelAvailability);
|
||||
const availabilityChip = getAvailabilityChip(availabilityStatus);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`${model}-${index}`}
|
||||
className={badgeClassName}
|
||||
style={badgeStyle}
|
||||
title={availabilityReason ?? availabilityChip ?? undefined}
|
||||
>
|
||||
<span>{formatModelBadgeLabel(providerId, model)}</span>
|
||||
{availabilityChip ? (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em]',
|
||||
availabilityStatus === 'checking'
|
||||
? 'bg-[rgba(59,130,246,0.12)] text-[var(--color-text-secondary)]'
|
||||
: availabilityStatus === 'unavailable'
|
||||
? 'bg-[rgba(239,68,68,0.12)] text-[rgb(248,113,113)]'
|
||||
: 'bg-[rgba(245,158,11,0.12)] text-[rgb(251,191,36)]'
|
||||
)}
|
||||
>
|
||||
{availabilityChip}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (!shouldCollapse) {
|
||||
return <div className="flex flex-wrap gap-1.5">{displayedModels.map(renderModelBadge)}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{visibleModels.map((model) => {
|
||||
const availabilityStatus = getAvailabilityStatus(model, modelAvailability);
|
||||
const availabilityReason = getAvailabilityReason(model, modelAvailability);
|
||||
const availabilityChip = getAvailabilityChip(availabilityStatus);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
title={availabilityReason ?? availabilityChip ?? undefined}
|
||||
>
|
||||
<span>{formatModelBadgeLabel(providerId, model)}</span>
|
||||
{availabilityChip ? (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1 py-0 text-[9px] font-medium uppercase tracking-[0.06em]',
|
||||
availabilityStatus === 'checking'
|
||||
? 'bg-[rgba(59,130,246,0.12)] text-[var(--color-text-secondary)]'
|
||||
: availabilityStatus === 'unavailable'
|
||||
? 'bg-[rgba(239,68,68,0.12)] text-[rgb(248,113,113)]'
|
||||
: 'bg-[rgba(245,158,11,0.12)] text-[rgb(251,191,36)]'
|
||||
)}
|
||||
>
|
||||
{availabilityChip}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<div className={listClassName} style={listStyle}>
|
||||
{displayedModels.map(renderModelBadge)}
|
||||
{shouldCollapse && !expanded ? (
|
||||
<button type="button" className={buttonClassName} onClick={() => setExpanded(true)}>
|
||||
<ChevronDown className="size-3" />
|
||||
<span>+{hiddenCount} more</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{shouldCollapse && expanded ? (
|
||||
<button type="button" className={buttonClassName} onClick={() => setExpanded(false)}>
|
||||
<ChevronUp className="size-3" />
|
||||
<span>Hide</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -122,7 +122,7 @@ function getProviderLabel(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode';
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -649,11 +649,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
providerStatusLoading={cliProviderStatusLoading}
|
||||
disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading}
|
||||
onSelectBackend={handleRuntimeBackendChange}
|
||||
onRefreshProvider={(providerId) =>
|
||||
fetchCliProviderStatus(providerId, {
|
||||
verifyModels: providerId === 'opencode',
|
||||
})
|
||||
}
|
||||
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
|
||||
onRequestLogin={(providerId) =>
|
||||
setProviderTerminal({ providerId, action: 'login' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1231,6 +1231,7 @@ export const TeamDetailView = ({
|
|||
sendingMessage,
|
||||
sendMessageError,
|
||||
sendMessageWarning,
|
||||
sendMessageDebugDetails,
|
||||
lastSendMessageResult,
|
||||
reviewActionError,
|
||||
addMember,
|
||||
|
|
@ -1282,6 +1283,7 @@ export const TeamDetailView = ({
|
|||
sendingMessage: s.sendingMessage,
|
||||
sendMessageError: s.sendMessageError,
|
||||
sendMessageWarning: s.sendMessageWarning,
|
||||
sendMessageDebugDetails: s.sendMessageDebugDetails,
|
||||
lastSendMessageResult: s.lastSendMessageResult,
|
||||
reviewActionError: s.reviewActionError,
|
||||
addMember: s.addMember,
|
||||
|
|
@ -2947,6 +2949,7 @@ export const TeamDetailView = ({
|
|||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
sendWarning={sendMessageWarning}
|
||||
sendDebugDetails={sendMessageDebugDetails}
|
||||
lastResult={lastSendMessageResult}
|
||||
onSend={async (member, text, summary, attachments, actionMode, taskRefs) => {
|
||||
const sentAtMs = Date.now();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
|||
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
|
||||
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
||||
import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector';
|
||||
import { OpenCodeDeliveryWarning } from '@renderer/components/team/messages/OpenCodeDeliveryWarning';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -26,6 +27,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
|||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
import {
|
||||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
|
|
@ -65,6 +67,7 @@ interface SendMessageDialogProps {
|
|||
sending: boolean;
|
||||
sendError: string | null;
|
||||
sendWarning?: string | null;
|
||||
sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||
lastResult: SendMessageResult | null;
|
||||
onSend: (
|
||||
member: string,
|
||||
|
|
@ -93,6 +96,7 @@ export const SendMessageDialog = ({
|
|||
sending,
|
||||
sendError,
|
||||
sendWarning,
|
||||
sendDebugDetails,
|
||||
lastResult,
|
||||
onSend,
|
||||
onClose,
|
||||
|
|
@ -275,7 +279,13 @@ export const SendMessageDialog = ({
|
|||
taskRefs
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
.then((result) => {
|
||||
if (
|
||||
result?.runtimeDelivery?.attempted === true &&
|
||||
result.runtimeDelivery.delivered === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
textDraft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
clearAttachments();
|
||||
|
|
@ -542,10 +552,10 @@ export const SendMessageDialog = ({
|
|||
{sendError}
|
||||
</span>
|
||||
) : sendWarning ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
{sendWarning}
|
||||
</span>
|
||||
<OpenCodeDeliveryWarning
|
||||
warning={sendWarning}
|
||||
debugDetails={sendDebugDetails}
|
||||
/>
|
||||
) : null}
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { AttachmentPreviewList } from '@renderer/components/team/attachments/Att
|
|||
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector';
|
||||
import { OpenCodeDeliveryWarning } from '@renderer/components/team/messages/OpenCodeDeliveryWarning';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -18,6 +19,7 @@ import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
|||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
|
||||
import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
|
||||
|
|
@ -52,6 +54,7 @@ interface MessageComposerProps {
|
|||
sending: boolean;
|
||||
sendError: string | null;
|
||||
sendWarning?: string | null;
|
||||
sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||
lastResult?: SendMessageResult | null;
|
||||
/** Ref to the underlying textarea element for external focus management. */
|
||||
textareaRef?: React.Ref<HTMLTextAreaElement>;
|
||||
|
|
@ -80,6 +83,7 @@ export const MessageComposer = ({
|
|||
sending,
|
||||
sendError,
|
||||
sendWarning,
|
||||
sendDebugDetails,
|
||||
lastResult,
|
||||
textareaRef: externalTextareaRef,
|
||||
onSend,
|
||||
|
|
@ -382,11 +386,11 @@ export const MessageComposer = ({
|
|||
useEffect(() => {
|
||||
if (!sending && pendingSendRef.current) {
|
||||
pendingSendRef.current = false;
|
||||
if (!sendError) {
|
||||
if (!sendError && sendDebugDetails?.delivered !== false) {
|
||||
draft.clearDraft();
|
||||
}
|
||||
}
|
||||
}, [sending, sendError, draft]);
|
||||
}, [sending, sendError, sendDebugDetails, draft]);
|
||||
|
||||
const { addFiles: draftAddFiles } = draft;
|
||||
const handleFileInputChange = useCallback(
|
||||
|
|
@ -485,10 +489,7 @@ export const MessageComposer = ({
|
|||
{sendError}
|
||||
</span>
|
||||
) : sendWarning ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
{sendWarning}
|
||||
</span>
|
||||
<OpenCodeDeliveryWarning warning={sendWarning} debugDetails={sendDebugDetails} />
|
||||
) : lastResult?.deduplicated ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||
<Check size={10} className="shrink-0" />
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
sendingMessage,
|
||||
sendMessageError,
|
||||
sendMessageWarning,
|
||||
sendMessageDebugDetails,
|
||||
lastSendMessageResult,
|
||||
teams,
|
||||
openTeamTab,
|
||||
|
|
@ -205,6 +206,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
sendingMessage: s.sendingMessage,
|
||||
sendMessageError: s.sendMessageError,
|
||||
sendMessageWarning: s.sendMessageWarning,
|
||||
sendMessageDebugDetails: s.sendMessageDebugDetails,
|
||||
lastSendMessageResult: s.lastSendMessageResult,
|
||||
teams: s.teams,
|
||||
openTeamTab: s.openTeamTab,
|
||||
|
|
@ -687,6 +689,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
sendWarning={sendMessageWarning}
|
||||
sendDebugDetails={sendMessageDebugDetails}
|
||||
lastResult={lastSendMessageResult}
|
||||
textareaRef={composerTextareaRef}
|
||||
onSend={handleSend}
|
||||
|
|
@ -873,6 +876,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
sendWarning={sendMessageWarning}
|
||||
sendDebugDetails={sendMessageDebugDetails}
|
||||
lastResult={lastSendMessageResult}
|
||||
textareaRef={composerTextareaRef}
|
||||
onSend={handleSend}
|
||||
|
|
@ -1158,6 +1162,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
sendWarning={sendMessageWarning}
|
||||
sendDebugDetails={sendMessageDebugDetails}
|
||||
lastResult={lastSendMessageResult}
|
||||
textareaRef={composerTextareaRef}
|
||||
onSend={handleSend}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatOpenCodeRuntimeDeliveryDebugDetails,
|
||||
type OpenCodeRuntimeDeliveryDebugDetails,
|
||||
} from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
import type { JSX } from 'react';
|
||||
|
||||
interface OpenCodeDeliveryWarningProps {
|
||||
warning: string | null;
|
||||
debugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||
}
|
||||
|
||||
export function OpenCodeDeliveryWarning({
|
||||
warning,
|
||||
debugDetails,
|
||||
}: OpenCodeDeliveryWarningProps): JSX.Element | null {
|
||||
const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}`;
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const copiedResetTimerRef = useRef<number | null>(null);
|
||||
const expanded = expandedKey === detailsKey;
|
||||
const copied = copiedKey === detailsKey;
|
||||
const copyText = useMemo(
|
||||
() => (debugDetails ? formatOpenCodeRuntimeDeliveryDebugDetails(debugDetails) : ''),
|
||||
[debugDetails]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (copiedResetTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedResetTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!warning) return null;
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
if (!copyText || !navigator.clipboard?.writeText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyText);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
setCopiedKey(detailsKey);
|
||||
if (copiedResetTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedResetTimerRef.current);
|
||||
}
|
||||
copiedResetTimerRef.current = window.setTimeout(() => {
|
||||
copiedResetTimerRef.current = null;
|
||||
if (mountedRef.current) {
|
||||
setCopiedKey(null);
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex flex-col items-start gap-1">
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
<span>{warning}</span>
|
||||
{debugDetails ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 rounded px-1 text-[10px] font-medium text-amber-200 underline decoration-amber-300/50 underline-offset-2 hover:text-amber-100"
|
||||
aria-expanded={expanded}
|
||||
onClick={() =>
|
||||
setExpandedKey((currentKey) => (currentKey === detailsKey ? null : detailsKey))
|
||||
}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{expanded && debugDetails ? (
|
||||
<span className="z-10 block max-w-[min(34rem,calc(100vw-3rem))] rounded border border-amber-500/20 bg-[var(--color-bg-primary)] p-2 text-left text-[10px] text-[var(--color-text-secondary)] shadow-xl">
|
||||
<span className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
|
||||
<span className="text-[var(--color-text-muted)]">messageId</span>
|
||||
<span className="break-all">{debugDetails.messageId}</span>
|
||||
<span className="text-[var(--color-text-muted)]">providerId</span>
|
||||
<span>{debugDetails.providerId}</span>
|
||||
<span className="text-[var(--color-text-muted)]">delivered</span>
|
||||
<span>{String(debugDetails.delivered)}</span>
|
||||
<span className="text-[var(--color-text-muted)]">responsePending</span>
|
||||
<span>{String(debugDetails.responsePending)}</span>
|
||||
<span className="text-[var(--color-text-muted)]">responseState</span>
|
||||
<span>{debugDetails.responseState ?? 'null'}</span>
|
||||
<span className="text-[var(--color-text-muted)]">ledgerStatus</span>
|
||||
<span>{debugDetails.ledgerStatus ?? 'null'}</span>
|
||||
<span className="text-[var(--color-text-muted)]">acceptanceUnknown</span>
|
||||
<span>{String(debugDetails.acceptanceUnknown)}</span>
|
||||
<span className="text-[var(--color-text-muted)]">reason</span>
|
||||
<span>{debugDetails.reason ?? 'null'}</span>
|
||||
<span className="text-[var(--color-text-muted)]">diagnostics</span>
|
||||
<span>
|
||||
{debugDetails.diagnostics.length ? debugDetails.diagnostics.join('; ') : '[]'}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 rounded border border-amber-500/20 px-2 py-1 text-[10px] text-amber-200 hover:border-amber-400/40 hover:text-amber-100"
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy debug details'}
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
|||
{ providerId: 'anthropic', displayName: 'Anthropic' },
|
||||
{ providerId: 'codex', displayName: 'Codex' },
|
||||
{ providerId: 'gemini', displayName: 'Gemini' },
|
||||
{ providerId: 'opencode', displayName: 'OpenCode' },
|
||||
{ providerId: 'opencode', displayName: 'OpenCode (75+ LLM providers)' },
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
...provider,
|
||||
|
|
@ -212,7 +212,7 @@ function getProviderDisplayName(providerId: CliProviderId): string {
|
|||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode';
|
||||
return 'OpenCode (75+ LLM providers)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -480,7 +480,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
if (get().cliStatus && !get().cliStatus?.installed) {
|
||||
return;
|
||||
}
|
||||
const verifyModels = options?.verifyModels === true;
|
||||
const verifyModels = options?.verifyModels === true && providerId !== 'opencode';
|
||||
const requestKey = `${providerId}:${verifyModels ? 'verify' : 'status'}`;
|
||||
const inFlight = cliProviderStatusInFlight.get(requestKey);
|
||||
if (inFlight) return inFlight;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
import { buildOpenCodeRuntimeDeliveryDiagnostics } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
|
@ -22,8 +23,9 @@ import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
|||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||
import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||
import type { AppConfig } from '@renderer/types/data';
|
||||
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||||
import type {
|
||||
ActiveToolCall,
|
||||
|
|
@ -1848,6 +1850,33 @@ function pruneTeamGraphSlotAssignmentsForVisibleOwners(
|
|||
return Object.keys(normalizedAssignments).length > 0 ? normalizedAssignments : undefined;
|
||||
}
|
||||
|
||||
function normalizeTeamGraphGridOwnerOrder(
|
||||
order: readonly string[] | undefined,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): string[] {
|
||||
const visibleOwnerIdSet = new Set(visibleOwnerIds);
|
||||
const normalizedOrder: string[] = [];
|
||||
const seenOwnerIds = new Set<string>();
|
||||
|
||||
for (const stableOwnerId of order ?? []) {
|
||||
if (!visibleOwnerIdSet.has(stableOwnerId) || seenOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
normalizedOrder.push(stableOwnerId);
|
||||
seenOwnerIds.add(stableOwnerId);
|
||||
}
|
||||
|
||||
for (const stableOwnerId of visibleOwnerIds) {
|
||||
if (seenOwnerIds.has(stableOwnerId)) {
|
||||
continue;
|
||||
}
|
||||
normalizedOrder.push(stableOwnerId);
|
||||
seenOwnerIds.add(stableOwnerId);
|
||||
}
|
||||
|
||||
return normalizedOrder;
|
||||
}
|
||||
|
||||
export function getDefaultTeamGraphSlotAssignmentsForMembers(
|
||||
members: readonly TeamGraphMemberSeedInput[],
|
||||
configMembers: readonly TeamGraphConfigMemberSeedInput[] = []
|
||||
|
|
@ -1921,6 +1950,8 @@ export interface TeamSlice {
|
|||
/** Team-scoped detailed cache used by multi-pane views like agent graph. */
|
||||
teamDataCacheByName: Record<string, TeamViewSnapshot>;
|
||||
slotLayoutVersion: string;
|
||||
graphLayoutModeByTeam: Record<string, GraphLayoutMode>;
|
||||
gridOwnerOrderByTeam: Record<string, string[]>;
|
||||
slotAssignmentsByTeam: Record<string, TeamGraphSlotAssignments>;
|
||||
teamMessagesByName: Record<string, TeamMessagesCacheEntry>;
|
||||
memberActivityMetaByTeam: Record<string, TeamMemberActivityMeta>;
|
||||
|
|
@ -1931,6 +1962,7 @@ export interface TeamSlice {
|
|||
sendingMessage: boolean;
|
||||
sendMessageError: string | null;
|
||||
sendMessageWarning: string | null;
|
||||
sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||
lastSendMessageResult: SendMessageResult | null;
|
||||
reviewActionError: string | null;
|
||||
provisioningRuns: Record<string, TeamProvisioningProgress>;
|
||||
|
|
@ -1987,6 +2019,12 @@ export interface TeamSlice {
|
|||
displacedStableOwnerId?: string,
|
||||
displacedAssignment?: GraphOwnerSlotAssignment
|
||||
) => void;
|
||||
setTeamGraphLayoutMode: (teamName: string, mode: GraphLayoutMode) => void;
|
||||
swapTeamGraphGridOwners: (
|
||||
teamName: string,
|
||||
stableOwnerId: string,
|
||||
targetStableOwnerId: string
|
||||
) => void;
|
||||
swapTeamGraphOwnerSlots: (
|
||||
teamName: string,
|
||||
stableOwnerId: string,
|
||||
|
|
@ -2256,6 +2294,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
selectedTeamData: null,
|
||||
teamDataCacheByName: {},
|
||||
slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
graphLayoutModeByTeam: {},
|
||||
gridOwnerOrderByTeam: {},
|
||||
slotAssignmentsByTeam: {},
|
||||
teamMessagesByName: {},
|
||||
memberActivityMetaByTeam: {},
|
||||
|
|
@ -2266,6 +2306,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
sendingMessage: false,
|
||||
sendMessageError: null,
|
||||
sendMessageWarning: null,
|
||||
sendMessageDebugDetails: null,
|
||||
lastSendMessageResult: null,
|
||||
crossTeamTargets: [],
|
||||
crossTeamTargetsLoading: false,
|
||||
|
|
@ -2912,6 +2953,62 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
});
|
||||
},
|
||||
|
||||
setTeamGraphLayoutMode: (teamName, mode) => {
|
||||
set((state) => {
|
||||
if ((state.graphLayoutModeByTeam[teamName] ?? 'radial') === mode) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
graphLayoutModeByTeam: {
|
||||
...state.graphLayoutModeByTeam,
|
||||
[teamName]: mode,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
swapTeamGraphGridOwners: (teamName, stableOwnerId, targetStableOwnerId) => {
|
||||
if (stableOwnerId === targetStableOwnerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const teamData = selectTeamDataForName(state, teamName);
|
||||
const fallbackVisibleOwnerIds = [...(state.gridOwnerOrderByTeam[teamName] ?? [])];
|
||||
for (const ownerId of [stableOwnerId, targetStableOwnerId]) {
|
||||
if (!fallbackVisibleOwnerIds.includes(ownerId)) {
|
||||
fallbackVisibleOwnerIds.push(ownerId);
|
||||
}
|
||||
}
|
||||
const visibleOwnerIds = teamData
|
||||
? buildTeamGraphDefaultLayoutSeed(teamData.members, teamData.config.members ?? [])
|
||||
.orderedVisibleOwnerIds
|
||||
: fallbackVisibleOwnerIds;
|
||||
const normalizedOrder = normalizeTeamGraphGridOwnerOrder(
|
||||
state.gridOwnerOrderByTeam[teamName],
|
||||
visibleOwnerIds
|
||||
);
|
||||
const stableOwnerIndex = normalizedOrder.indexOf(stableOwnerId);
|
||||
const targetOwnerIndex = normalizedOrder.indexOf(targetStableOwnerId);
|
||||
|
||||
if (stableOwnerIndex < 0 || targetOwnerIndex < 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const nextOrder = [...normalizedOrder];
|
||||
nextOrder[stableOwnerIndex] = targetStableOwnerId;
|
||||
nextOrder[targetOwnerIndex] = stableOwnerId;
|
||||
|
||||
return {
|
||||
gridOwnerOrderByTeam: {
|
||||
...state.gridOwnerOrderByTeam,
|
||||
[teamName]: nextOrder,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
swapTeamGraphOwnerSlots: (teamName, stableOwnerId, otherStableOwnerId) => {
|
||||
if (stableOwnerId === otherStableOwnerId) {
|
||||
return;
|
||||
|
|
@ -3865,6 +3962,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
sendingMessage: true,
|
||||
sendMessageError: null,
|
||||
sendMessageWarning: null,
|
||||
sendMessageDebugDetails: null,
|
||||
lastSendMessageResult: null,
|
||||
});
|
||||
try {
|
||||
|
|
@ -3873,13 +3971,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
);
|
||||
const runtimeDeliveryFailed =
|
||||
result.runtimeDelivery?.attempted === true && result.runtimeDelivery.delivered === false;
|
||||
const runtimeDeliveryWarning = runtimeDeliveryFailed
|
||||
? `OpenCode runtime delivery failed: ${
|
||||
result.runtimeDelivery?.reason ??
|
||||
result.runtimeDelivery?.diagnostics?.[0] ??
|
||||
'message was saved to inbox but not delivered live'
|
||||
}`
|
||||
: null;
|
||||
const runtimeDeliveryDiagnostics = buildOpenCodeRuntimeDeliveryDiagnostics(result);
|
||||
const optimisticMessage: InboxMessage = {
|
||||
from: request.from ?? 'user',
|
||||
to: request.to ?? request.member,
|
||||
|
|
@ -3906,7 +3998,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
set((state) => ({
|
||||
sendingMessage: false,
|
||||
sendMessageError: null,
|
||||
sendMessageWarning: runtimeDeliveryWarning,
|
||||
sendMessageWarning: runtimeDeliveryDiagnostics.warning,
|
||||
sendMessageDebugDetails: runtimeDeliveryDiagnostics.debugDetails,
|
||||
lastSendMessageResult: runtimeDeliveryFailed ? null : result,
|
||||
teamMessagesByName: {
|
||||
...state.teamMessagesByName,
|
||||
|
|
@ -3923,6 +4016,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
sendingMessage: false,
|
||||
lastSendMessageResult: null,
|
||||
sendMessageWarning: null,
|
||||
sendMessageDebugDetails: null,
|
||||
sendMessageError: mapSendMessageError(error),
|
||||
});
|
||||
throw error;
|
||||
|
|
@ -3945,6 +4039,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
sendingMessage: true,
|
||||
sendMessageError: null,
|
||||
sendMessageWarning: null,
|
||||
sendMessageDebugDetails: null,
|
||||
lastSendMessageResult: null,
|
||||
});
|
||||
try {
|
||||
|
|
@ -3953,6 +4048,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
sendingMessage: false,
|
||||
sendMessageError: null,
|
||||
sendMessageWarning: null,
|
||||
sendMessageDebugDetails: null,
|
||||
lastSendMessageResult: {
|
||||
messageId: result.messageId,
|
||||
deliveredToInbox: result.deliveredToInbox,
|
||||
|
|
@ -3965,6 +4061,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
sendingMessage: false,
|
||||
lastSendMessageResult: null,
|
||||
sendMessageWarning: null,
|
||||
sendMessageDebugDetails: null,
|
||||
sendMessageError: mapSendMessageError(error),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
79
src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts
Normal file
79
src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { SendMessageResult } from '@shared/types';
|
||||
|
||||
export interface OpenCodeRuntimeDeliveryDebugDetails {
|
||||
messageId: string;
|
||||
providerId: string;
|
||||
delivered: boolean | null;
|
||||
responsePending: boolean | null;
|
||||
responseState: string | null;
|
||||
ledgerStatus: string | null;
|
||||
acceptanceUnknown: boolean | null;
|
||||
reason: string | null;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
interface OpenCodeRuntimeDeliveryDiagnostics {
|
||||
warning: string | null;
|
||||
debugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||
}
|
||||
|
||||
const PENDING_WARNING =
|
||||
'OpenCode runtime delivery is still being checked. Message was saved and will be retried if needed.';
|
||||
const FAILED_WARNING =
|
||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
||||
|
||||
export function buildOpenCodeRuntimeDeliveryDiagnostics(
|
||||
result: SendMessageResult
|
||||
): OpenCodeRuntimeDeliveryDiagnostics {
|
||||
const runtimeDelivery = result.runtimeDelivery;
|
||||
if (!runtimeDelivery || runtimeDelivery.attempted !== true) {
|
||||
return { warning: null, debugDetails: null };
|
||||
}
|
||||
|
||||
const isFailed = runtimeDelivery.delivered === false;
|
||||
const isPending = runtimeDelivery.responsePending === true;
|
||||
if (!isFailed && !isPending) {
|
||||
return { warning: null, debugDetails: null };
|
||||
}
|
||||
|
||||
return {
|
||||
warning: isFailed ? FAILED_WARNING : PENDING_WARNING,
|
||||
debugDetails: {
|
||||
messageId: result.messageId,
|
||||
providerId: runtimeDelivery.providerId,
|
||||
delivered: typeof runtimeDelivery.delivered === 'boolean' ? runtimeDelivery.delivered : null,
|
||||
responsePending:
|
||||
typeof runtimeDelivery.responsePending === 'boolean'
|
||||
? runtimeDelivery.responsePending
|
||||
: null,
|
||||
responseState: runtimeDelivery.responseState ?? null,
|
||||
ledgerStatus: runtimeDelivery.ledgerStatus ?? null,
|
||||
acceptanceUnknown:
|
||||
typeof runtimeDelivery.acceptanceUnknown === 'boolean'
|
||||
? runtimeDelivery.acceptanceUnknown
|
||||
: null,
|
||||
reason: runtimeDelivery.reason ?? null,
|
||||
diagnostics: runtimeDelivery.diagnostics ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatOpenCodeRuntimeDeliveryDebugDetails(
|
||||
details: OpenCodeRuntimeDeliveryDebugDetails
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
messageId: details.messageId,
|
||||
providerId: details.providerId,
|
||||
delivered: details.delivered,
|
||||
responsePending: details.responsePending,
|
||||
responseState: details.responseState,
|
||||
ledgerStatus: details.ledgerStatus,
|
||||
acceptanceUnknown: details.acceptanceUnknown,
|
||||
reason: details.reason,
|
||||
diagnostics: details.diagnostics,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
|
@ -95,6 +95,7 @@ import type { TmuxAPI } from './tmux';
|
|||
import type { WaterfallData } from './visualization';
|
||||
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
|
||||
import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts';
|
||||
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
||||
import type {
|
||||
ConversationGroup,
|
||||
FileChangeEvent,
|
||||
|
|
@ -868,6 +869,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
|
|||
// CLI Installer API
|
||||
cliInstaller: CliInstallerAPI;
|
||||
|
||||
// Runtime nested provider management API
|
||||
runtimeProviderManagement: RuntimeProviderManagementApi;
|
||||
|
||||
// tmux runtime diagnostics API
|
||||
tmux: TmuxAPI;
|
||||
|
||||
|
|
|
|||
|
|
@ -608,6 +608,7 @@ export interface InboxMessage {
|
|||
| 'inbox'
|
||||
| 'lead_session'
|
||||
| 'lead_process'
|
||||
| 'runtime_delivery'
|
||||
| 'user_sent'
|
||||
| 'system_notification'
|
||||
| 'cross_team'
|
||||
|
|
@ -682,6 +683,37 @@ export interface SendMessageResult {
|
|||
providerId: 'opencode';
|
||||
attempted: boolean;
|
||||
delivered: boolean;
|
||||
responsePending?: boolean;
|
||||
responseState?:
|
||||
| 'not_observed'
|
||||
| 'pending'
|
||||
| 'prompt_not_indexed'
|
||||
| 'responded_tool_call'
|
||||
| 'responded_visible_message'
|
||||
| 'responded_non_visible_tool'
|
||||
| 'responded_plain_text'
|
||||
| 'permission_blocked'
|
||||
| 'tool_error'
|
||||
| 'empty_assistant_turn'
|
||||
| 'session_stale'
|
||||
| 'session_error'
|
||||
| 'reconcile_failed';
|
||||
ledgerStatus?:
|
||||
| 'pending'
|
||||
| 'accepted'
|
||||
| 'responded'
|
||||
| 'unanswered'
|
||||
| 'retry_scheduled'
|
||||
| 'retried'
|
||||
| 'failed_retryable'
|
||||
| 'failed_terminal';
|
||||
visibleReplyMessageId?: string;
|
||||
visibleReplyCorrelation?:
|
||||
| 'relayOfMessageId'
|
||||
| 'direct_child_message_send'
|
||||
| 'plain_assistant_text';
|
||||
acceptanceUnknown?: boolean;
|
||||
queuedBehindMessageId?: string;
|
||||
reason?: string;
|
||||
diagnostics?: string[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerRuntimeProviderManagementIpc } from '../../../../src/features/runtime-provider-management/main';
|
||||
import {
|
||||
RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_MODELS,
|
||||
RUNTIME_PROVIDER_MANAGEMENT_VIEW,
|
||||
} from '../../../../src/features/runtime-provider-management/contracts';
|
||||
|
||||
import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main';
|
||||
import type {
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementViewResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
} from '../../../../src/features/runtime-provider-management/contracts';
|
||||
import type { IpcMain } from 'electron';
|
||||
|
||||
describe('registerRuntimeProviderManagementIpc', () => {
|
||||
it('passes API keys through input only and returns provider DTOs without the raw secret', async () => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
|
||||
const ipcMain = {
|
||||
handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
removeHandler: vi.fn(),
|
||||
} as unknown as IpcMain;
|
||||
const viewResponse: RuntimeProviderManagementViewResponse = {
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
view: {
|
||||
runtimeId: 'opencode',
|
||||
title: 'OpenCode',
|
||||
runtime: {
|
||||
state: 'ready',
|
||||
cliPath: null,
|
||||
version: null,
|
||||
managedProfile: 'active',
|
||||
localAuth: 'synced',
|
||||
},
|
||||
providers: [],
|
||||
defaultModel: null,
|
||||
fallbackModel: null,
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
const connectedResponse: RuntimeProviderManagementProviderResponse = {
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
provider: {
|
||||
providerId: 'openrouter',
|
||||
displayName: 'OpenRouter',
|
||||
state: 'connected',
|
||||
ownership: ['managed'],
|
||||
recommended: true,
|
||||
modelCount: 4,
|
||||
defaultModelId: null,
|
||||
authMethods: ['api'],
|
||||
actions: [],
|
||||
detail: null,
|
||||
},
|
||||
};
|
||||
const forgottenResponse: RuntimeProviderManagementProviderResponse = {
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
provider: {
|
||||
providerId: 'openrouter',
|
||||
displayName: 'OpenRouter',
|
||||
state: 'available',
|
||||
ownership: [],
|
||||
recommended: true,
|
||||
modelCount: 4,
|
||||
defaultModelId: null,
|
||||
authMethods: ['api'],
|
||||
actions: [],
|
||||
detail: null,
|
||||
},
|
||||
};
|
||||
const modelsResponse: RuntimeProviderManagementModelsResponse = {
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
models: {
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openrouter',
|
||||
models: [],
|
||||
defaultModelId: null,
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
const testResponse: RuntimeProviderManagementModelTestResponse = {
|
||||
schemaVersion: 1,
|
||||
runtimeId: 'opencode',
|
||||
result: {
|
||||
providerId: 'openrouter',
|
||||
modelId: 'openrouter/openai/gpt-oss-20b:free',
|
||||
ok: true,
|
||||
availability: 'available',
|
||||
message: 'Model probe passed',
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
const feature: RuntimeProviderManagementFeatureFacade = {
|
||||
loadView: vi.fn(() => Promise.resolve(viewResponse)),
|
||||
connectWithApiKey: vi.fn(() => Promise.resolve(connectedResponse)),
|
||||
forgetCredential: vi.fn(() => Promise.resolve(forgottenResponse)),
|
||||
loadModels: vi.fn(() => Promise.resolve(modelsResponse)),
|
||||
testModel: vi.fn(() => Promise.resolve(testResponse)),
|
||||
setDefaultModel: vi.fn(() => Promise.resolve(viewResponse)),
|
||||
};
|
||||
|
||||
registerRuntimeProviderManagementIpc(ipcMain, feature);
|
||||
|
||||
await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_VIEW)?.({}, { runtimeId: 'opencode' });
|
||||
const response = await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY)?.(
|
||||
{},
|
||||
{
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openrouter',
|
||||
apiKey: 'sk-secret-value',
|
||||
}
|
||||
);
|
||||
|
||||
expect(feature.connectWithApiKey).toHaveBeenCalledWith({
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openrouter',
|
||||
apiKey: 'sk-secret-value',
|
||||
});
|
||||
expect(JSON.stringify(response)).not.toContain('sk-secret-value');
|
||||
|
||||
await handlers.get(RUNTIME_PROVIDER_MANAGEMENT_MODELS)?.(
|
||||
{},
|
||||
{ runtimeId: 'opencode', providerId: 'openrouter', query: 'free', limit: 10 }
|
||||
);
|
||||
expect(feature.loadModels).toHaveBeenCalledWith({
|
||||
runtimeId: 'opencode',
|
||||
providerId: 'openrouter',
|
||||
query: 'free',
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -238,7 +238,16 @@ describe('ipc teams handlers', () => {
|
|||
delivered: 0,
|
||||
failed: 0,
|
||||
lastDelivery: undefined as
|
||||
| { delivered: boolean; reason?: string; diagnostics?: string[] }
|
||||
| {
|
||||
delivered: boolean;
|
||||
accepted?: boolean;
|
||||
responsePending?: boolean;
|
||||
acceptanceUnknown?: boolean;
|
||||
responseState?: NonNullable<SendMessageResult['runtimeDelivery']>['responseState'];
|
||||
ledgerStatus?: NonNullable<SendMessageResult['runtimeDelivery']>['ledgerStatus'];
|
||||
reason?: string;
|
||||
diagnostics?: string[];
|
||||
}
|
||||
| undefined,
|
||||
})),
|
||||
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
|
||||
|
|
@ -640,6 +649,77 @@ describe('ipc teams handlers', () => {
|
|||
vi.mocked(console.warn).mockClear();
|
||||
});
|
||||
|
||||
it('returns runtimeDelivery acceptanceUnknown for OpenCode observe-pending timeout sends', async () => {
|
||||
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
|
||||
provisioningService.relayOpenCodeMemberInboxMessages.mockResolvedValueOnce({
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
lastDelivery: {
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
acceptanceUnknown: true,
|
||||
responseState: 'not_observed',
|
||||
ledgerStatus: 'failed_retryable',
|
||||
reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout',
|
||||
diagnostics: ['opencode_prompt_acceptance_unknown_after_bridge_timeout'],
|
||||
},
|
||||
});
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
const result = (await sendHandler!({} as never, 'my-team', {
|
||||
member: 'bob',
|
||||
text: 'Ping bob',
|
||||
})) as { success: boolean; data?: SendMessageResult };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.runtimeDelivery).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
acceptanceUnknown: true,
|
||||
ledgerStatus: 'failed_retryable',
|
||||
reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps OpenCode UI relay timeout to pending acceptance-unknown delivery', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
provisioningService.isOpenCodeRuntimeRecipient.mockResolvedValueOnce(true);
|
||||
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
|
||||
new Promise(() => undefined)
|
||||
);
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
const resultPromise = sendHandler!({} as never, 'my-team', {
|
||||
member: 'bob',
|
||||
text: 'Ping bob',
|
||||
}) as Promise<{ success: boolean; data?: SendMessageResult }>;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(12_000);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.runtimeDelivery).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
acceptanceUnknown: true,
|
||||
responseState: 'not_observed',
|
||||
reason: 'opencode_runtime_delivery_ui_timeout_pending',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('passes hidden ask-mode instructions to a live lead without exposing them in stored text', async () => {
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -140,7 +140,9 @@ describe('CliInstallerService', () => {
|
|||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue(null);
|
||||
|
||||
const status = await service.getStatus();
|
||||
const openCodeStatus = status.providers.find((provider) => provider.providerId === 'opencode');
|
||||
const openCodeStatus = status.providers.find(
|
||||
(provider) => provider.providerId === 'opencode'
|
||||
);
|
||||
|
||||
expect(status.providers.map((provider) => provider.providerId)).toEqual([
|
||||
'anthropic',
|
||||
|
|
@ -149,7 +151,7 @@ describe('CliInstallerService', () => {
|
|||
'opencode',
|
||||
]);
|
||||
expect(openCodeStatus).toMatchObject({
|
||||
displayName: 'OpenCode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
supported: false,
|
||||
statusMessage: 'Runtime not found.',
|
||||
canLoginFromUi: false,
|
||||
|
|
@ -362,7 +364,9 @@ describe('CliInstallerService', () => {
|
|||
service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);
|
||||
|
||||
const status = await service.getStatus();
|
||||
expect(status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability).toEqual([]);
|
||||
expect(
|
||||
status.providers.find((provider) => provider.providerId === 'codex')?.modelAvailability
|
||||
).toEqual([]);
|
||||
|
||||
const verifiedProvider = await service.verifyProviderModels('codex');
|
||||
expect(verifiedProvider?.modelAvailability).toEqual(
|
||||
|
|
@ -411,10 +415,11 @@ describe('CliInstallerService', () => {
|
|||
'modelAvailability' in provider &&
|
||||
(provider as { providerId?: string }).providerId === 'codex' &&
|
||||
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
|
||||
(provider as { modelAvailability: Array<{ modelId?: string; status?: string }> })
|
||||
.modelAvailability.some(
|
||||
(item) => item.modelId === 'gpt-5.4' && item.status === 'available'
|
||||
)
|
||||
(
|
||||
provider as { modelAvailability: Array<{ modelId?: string; status?: string }> }
|
||||
).modelAvailability.some(
|
||||
(item) => item.modelId === 'gpt-5.4' && item.status === 'available'
|
||||
)
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
|
|
@ -428,15 +433,15 @@ describe('CliInstallerService', () => {
|
|||
'modelAvailability' in provider &&
|
||||
(provider as { providerId?: string }).providerId === 'codex' &&
|
||||
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
|
||||
(provider as { modelAvailability: Array<{ modelId?: string }> }).modelAvailability.some(
|
||||
(item) => item.modelId === 'gpt-5.2-codex'
|
||||
)
|
||||
(
|
||||
provider as { modelAvailability: Array<{ modelId?: string }> }
|
||||
).modelAvailability.some((item) => item.modelId === 'gpt-5.2-codex')
|
||||
)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses execution-grade OpenCode model verification for explicit verify requests', async () => {
|
||||
it('keeps OpenCode provider verification catalog-only for explicit verify requests', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||
|
|
@ -600,22 +605,15 @@ describe('CliInstallerService', () => {
|
|||
});
|
||||
|
||||
const status = await service.getStatus();
|
||||
expect(status.providers.find((provider) => provider.providerId === 'opencode')?.modelAvailability).toEqual([]);
|
||||
expect(
|
||||
status.providers.find((provider) => provider.providerId === 'opencode')?.modelAvailability
|
||||
).toEqual([]);
|
||||
|
||||
const verifiedProvider = await service.verifyProviderModels('opencode');
|
||||
|
||||
expect(verifyOpenCodeModelsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(verifiedProvider?.modelVerificationState).toBe('verified');
|
||||
expect(verifiedProvider?.modelAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
status: 'unavailable',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
modelId: 'opencode/big-pickle',
|
||||
status: 'available',
|
||||
}),
|
||||
]);
|
||||
expect(verifyOpenCodeModelsSpy).not.toHaveBeenCalled();
|
||||
expect(verifiedProvider?.modelVerificationState).toBe('idle');
|
||||
expect(verifiedProvider?.modelAvailability).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -63,11 +63,11 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
buildProviderAwareCliEnvMock.mockImplementation(
|
||||
({ providerId }: { providerId?: string } = {}) =>
|
||||
Promise.resolve({
|
||||
env: {
|
||||
HOME: '/Users/tester',
|
||||
...(providerId ? { CLAUDE_CODE_ENTRY_PROVIDER: providerId } : {}),
|
||||
},
|
||||
connectionIssues: {},
|
||||
env: {
|
||||
HOME: '/Users/tester',
|
||||
...(providerId ? { CLAUDE_CODE_ENTRY_PROVIDER: providerId } : {}),
|
||||
},
|
||||
connectionIssues: {},
|
||||
})
|
||||
);
|
||||
readFileMock.mockImplementation((filePath) => {
|
||||
|
|
@ -221,7 +221,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
});
|
||||
expect(providers[3]).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
displayName: 'OpenCode (75+ LLM providers)',
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
models: [],
|
||||
|
|
@ -237,8 +237,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { HOME: '/Users/tester' },
|
||||
connectionIssues: {
|
||||
anthropic:
|
||||
'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.',
|
||||
anthropic: 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.',
|
||||
},
|
||||
});
|
||||
execCliMock.mockResolvedValue({
|
||||
|
|
@ -511,7 +510,8 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
plugins: {
|
||||
status: 'unsupported',
|
||||
ownership: 'shared',
|
||||
reason: 'Plugins are not currently guaranteed for codex-native sessions in the multimodel runtime.',
|
||||
reason:
|
||||
'Plugins are not currently guaranteed for codex-native sessions in the multimodel runtime.',
|
||||
},
|
||||
mcp: {
|
||||
status: 'unsupported',
|
||||
|
|
@ -636,14 +636,16 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
|
||||
const codex = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex');
|
||||
|
||||
expect(codex.availableBackends?.find((backend) => backend.id === 'codex-native')).toMatchObject({
|
||||
id: 'codex-native',
|
||||
selectable: true,
|
||||
available: true,
|
||||
state: 'ready',
|
||||
audience: 'general',
|
||||
statusMessage: 'Ready',
|
||||
});
|
||||
expect(codex.availableBackends?.find((backend) => backend.id === 'codex-native')).toMatchObject(
|
||||
{
|
||||
id: 'codex-native',
|
||||
selectable: true,
|
||||
available: true,
|
||||
state: 'ready',
|
||||
audience: 'general',
|
||||
statusMessage: 'Ready',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves codex-native runtime-missing rollout states from runtime status payloads', async () => {
|
||||
|
|
@ -657,7 +659,8 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
verificationState: 'unknown',
|
||||
canLoginFromUi: false,
|
||||
statusMessage: 'Codex native runtime unavailable',
|
||||
detailMessage: 'Codex native runtime requires the codex CLI binary to be installed and discoverable.',
|
||||
detailMessage:
|
||||
'Codex native runtime requires the codex CLI binary to be installed and discoverable.',
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [
|
||||
|
|
@ -670,7 +673,8 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
state: 'runtime-missing',
|
||||
audience: 'general',
|
||||
statusMessage: 'Codex CLI not found',
|
||||
detailMessage: 'Codex native runtime requires the codex CLI binary to be installed and discoverable.',
|
||||
detailMessage:
|
||||
'Codex native runtime requires the codex CLI binary to be installed and discoverable.',
|
||||
},
|
||||
],
|
||||
capabilities: {
|
||||
|
|
@ -697,14 +701,16 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
|
||||
const codex = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex');
|
||||
|
||||
expect(codex.availableBackends?.find((backend) => backend.id === 'codex-native')).toMatchObject({
|
||||
id: 'codex-native',
|
||||
selectable: false,
|
||||
available: false,
|
||||
state: 'runtime-missing',
|
||||
audience: 'general',
|
||||
statusMessage: 'Codex CLI not found',
|
||||
});
|
||||
expect(codex.availableBackends?.find((backend) => backend.id === 'codex-native')).toMatchObject(
|
||||
{
|
||||
id: 'codex-native',
|
||||
selectable: false,
|
||||
available: false,
|
||||
state: 'runtime-missing',
|
||||
audience: 'general',
|
||||
statusMessage: 'Codex CLI not found',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('uses live OpenCode verification on explicit provider verify', async () => {
|
||||
|
|
@ -783,12 +789,25 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
|
||||
const provider = await service.verifyProviderStatus('/mock/agent_teams_orchestrator', 'opencode');
|
||||
const provider = await service.verifyProviderStatus(
|
||||
'/mock/agent_teams_orchestrator',
|
||||
'opencode'
|
||||
);
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
verificationState: 'verified',
|
||||
detailMessage: expect.stringContaining('live resolved-fin'),
|
||||
capabilities: {
|
||||
extensions: {
|
||||
plugins: {
|
||||
status: 'unsupported',
|
||||
},
|
||||
mcp: {
|
||||
status: 'read-only',
|
||||
},
|
||||
},
|
||||
},
|
||||
backend: {
|
||||
kind: 'opencode-cli',
|
||||
authMethodDetail: 'managed teammate agent',
|
||||
|
|
@ -971,67 +990,9 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
expect(toolNames).not.toContain('SendMessage');
|
||||
});
|
||||
|
||||
it('verifies OpenCode models through execution-grade runtime probes', async () => {
|
||||
it('keeps OpenCode model verification catalog-only in the bridge', async () => {
|
||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||
|
||||
if (
|
||||
normalizedArgs
|
||||
=== 'runtime verify-model --json --provider opencode --model openai/gpt-5.4-mini'
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
result: {
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
outcome: 'unavailable',
|
||||
reason: 'Token refresh failed: 401',
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedArgs
|
||||
=== 'runtime verify-model --json --provider opencode --model opencode/big-pickle'
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
result: {
|
||||
modelId: 'opencode/big-pickle',
|
||||
outcome: 'available',
|
||||
reason: null,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedArgs
|
||||
=== 'runtime verify-model --json --provider opencode --model openrouter/moonshotai/kimi-k2'
|
||||
) {
|
||||
return Promise.resolve({
|
||||
stdout: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
result: {
|
||||
modelId: 'openrouter/moonshotai/kimi-k2',
|
||||
outcome: 'available',
|
||||
reason: null,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||
});
|
||||
|
||||
|
|
@ -1070,23 +1031,8 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
connection: null,
|
||||
});
|
||||
|
||||
expect(provider.modelVerificationState).toBe('verified');
|
||||
expect(provider.modelAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
modelId: 'openai/gpt-5.4-mini',
|
||||
status: 'unavailable',
|
||||
reason: 'Token refresh failed: 401',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
modelId: 'openrouter/moonshotai/kimi-k2',
|
||||
status: 'available',
|
||||
reason: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
modelId: 'opencode/big-pickle',
|
||||
status: 'available',
|
||||
reason: null,
|
||||
}),
|
||||
]);
|
||||
expect(execCliMock).not.toHaveBeenCalled();
|
||||
expect(provider.modelVerificationState).toBe('idle');
|
||||
expect(provider.modelAvailability).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
444
test/main/services/team/OpenCodePromptDeliveryLedger.test.ts
Normal file
444
test/main/services/team/OpenCodePromptDeliveryLedger.test.ts
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createOpenCodePromptDeliveryLedgerStore,
|
||||
hashOpenCodePromptDeliveryPayload,
|
||||
isOpenCodePromptDeliveryAttemptDue,
|
||||
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||
|
||||
describe('OpenCodePromptDeliveryLedger', () => {
|
||||
let tempDir = '';
|
||||
const corruptionCases: Array<[string, (record: Record<string, unknown>) => void]> = [
|
||||
[
|
||||
'unknown delivery status',
|
||||
(record) => {
|
||||
record.status = 'quietly_broken';
|
||||
},
|
||||
],
|
||||
[
|
||||
'unknown response state',
|
||||
(record) => {
|
||||
record.responseState = 'assistant_maybe_replied';
|
||||
},
|
||||
],
|
||||
[
|
||||
'invalid task reference shape',
|
||||
(record) => {
|
||||
record.taskRefs = [{ taskId: 'task-1', displayId: '#1' }];
|
||||
},
|
||||
],
|
||||
[
|
||||
'invalid diagnostic array',
|
||||
(record) => {
|
||||
record.diagnostics = ['ok', 42];
|
||||
},
|
||||
],
|
||||
[
|
||||
'invalid visible reply correlation',
|
||||
(record) => {
|
||||
record.visibleReplyCorrelation = 'guessed_from_text';
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-prompt-ledger-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createStore() {
|
||||
return createOpenCodePromptDeliveryLedgerStore({
|
||||
filePath: path.join(tempDir, 'opencode-prompt-delivery-ledger.json'),
|
||||
clock: () => new Date('2026-04-25T10:00:00.000Z'),
|
||||
});
|
||||
}
|
||||
|
||||
function ledgerPath() {
|
||||
return path.join(tempDir, 'opencode-prompt-delivery-ledger.json');
|
||||
}
|
||||
|
||||
async function writeCorruptedLedgerRecord(
|
||||
mutate: (record: Record<string, unknown>) => void
|
||||
): Promise<ReturnType<typeof createStore>> {
|
||||
const store = createStore();
|
||||
await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-corrupt',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'ask',
|
||||
taskRefs: [],
|
||||
payloadHash: 'sha256:corrupt',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const envelope = JSON.parse(await fs.readFile(ledgerPath(), 'utf8')) as {
|
||||
data: Record<string, unknown>[];
|
||||
};
|
||||
mutate(envelope.data[0]);
|
||||
await fs.writeFile(ledgerPath(), `${JSON.stringify(envelope, null, 2)}\n`, 'utf8');
|
||||
return store;
|
||||
}
|
||||
|
||||
it('is idempotent for the same inbox message and payload hash', async () => {
|
||||
const store = createStore();
|
||||
const payloadHash = hashOpenCodePromptDeliveryPayload({
|
||||
text: 'Please answer',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'ask',
|
||||
source: 'watcher',
|
||||
});
|
||||
|
||||
const first = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-1',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'ask',
|
||||
taskRefs: [],
|
||||
payloadHash,
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
const second = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-1',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'ask',
|
||||
taskRefs: [],
|
||||
payloadHash,
|
||||
now: '2026-04-25T10:00:30.000Z',
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.attempts).toBe(0);
|
||||
await expect(store.list()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(corruptionCases)('rejects corrupted persisted records with %s', async (_name, mutate) => {
|
||||
const store = await writeCorruptedLedgerRecord(mutate);
|
||||
|
||||
await expect(store.list()).rejects.toMatchObject({
|
||||
reason: 'invalid_data',
|
||||
});
|
||||
await expect(fs.readdir(tempDir)).resolves.toContain(
|
||||
'opencode-prompt-delivery-ledger.json'
|
||||
);
|
||||
expect((await fs.readdir(tempDir)).some((name) => name.includes('.invalid_data.'))).toBe(true);
|
||||
});
|
||||
|
||||
it('marks same logical delivery with a different payload hash terminal', async () => {
|
||||
const store = createStore();
|
||||
const original = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-1',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:first',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const mismatch = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-1',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:second',
|
||||
now: '2026-04-25T10:00:30.000Z',
|
||||
});
|
||||
|
||||
expect(mismatch.id).toBe(original.id);
|
||||
expect(mismatch.status).toBe('failed_terminal');
|
||||
expect(mismatch.lastReason).toBe('opencode_prompt_delivery_payload_mismatch');
|
||||
expect(mismatch.diagnostics.join('\n')).toContain('payload hash does not match');
|
||||
await expect(store.list()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps ack-only destination proof nonterminal and due retry checks deterministic', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-1',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:first',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const ackOnly = await store.applyDestinationProof({
|
||||
id: record.id,
|
||||
visibleReplyInbox: 'user',
|
||||
visibleReplyMessageId: 'reply-1',
|
||||
visibleReplyCorrelation: 'relayOfMessageId',
|
||||
semanticallySufficient: false,
|
||||
observedAt: '2026-04-25T10:00:01.000Z',
|
||||
});
|
||||
expect(ackOnly.status).toBe('pending');
|
||||
expect(ackOnly.responseState).toBe('responded_visible_message');
|
||||
expect(ackOnly.lastReason).toBe('visible_reply_ack_only_still_requires_answer');
|
||||
|
||||
const scheduled = await store.markNextAttemptScheduled({
|
||||
id: record.id,
|
||||
status: 'retry_scheduled',
|
||||
nextAttemptAt: '2026-04-25T10:00:30.000Z',
|
||||
reason: 'visible_reply_ack_only_still_requires_answer',
|
||||
scheduledAt: '2026-04-25T10:00:02.000Z',
|
||||
});
|
||||
expect(isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:29.000Z'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:30.000Z'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('records empty assistant delivery results as unanswered and stores plain text previews', async () => {
|
||||
const store = createStore();
|
||||
const unanswered = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-empty',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:empty',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const emptyResult = await store.applyDeliveryResult({
|
||||
id: unanswered.id,
|
||||
accepted: true,
|
||||
attempted: true,
|
||||
responseObservation: {
|
||||
state: 'empty_assistant_turn',
|
||||
deliveredUserMessageId: 'oc-user-1',
|
||||
assistantMessageId: 'oc-assistant-1',
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'empty_assistant_turn',
|
||||
},
|
||||
now: '2026-04-25T10:00:05.000Z',
|
||||
});
|
||||
|
||||
expect(emptyResult.status).toBe('unanswered');
|
||||
expect(emptyResult.responseState).toBe('empty_assistant_turn');
|
||||
expect(emptyResult.attempts).toBe(1);
|
||||
|
||||
const plain = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-plain',
|
||||
inboxTimestamp: '2026-04-25T09:59:10.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:plain',
|
||||
now: '2026-04-25T10:00:10.000Z',
|
||||
});
|
||||
const observed = await store.applyObservation({
|
||||
id: plain.id,
|
||||
responseObservation: {
|
||||
state: 'responded_plain_text',
|
||||
deliveredUserMessageId: 'oc-user-2',
|
||||
assistantMessageId: 'oc-assistant-2',
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: 'Понял',
|
||||
reason: null,
|
||||
},
|
||||
observedAt: '2026-04-25T10:00:15.000Z',
|
||||
});
|
||||
|
||||
expect(observed.status).toBe('responded');
|
||||
expect(observed.observedAssistantPreview).toBe('Понял');
|
||||
});
|
||||
|
||||
it('lists due nonterminal records in deterministic due order', async () => {
|
||||
const store = createStore();
|
||||
const first = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-1',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:first',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
const second = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-2',
|
||||
inboxTimestamp: '2026-04-25T09:59:10.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:second',
|
||||
now: '2026-04-25T10:00:01.000Z',
|
||||
});
|
||||
await store.markNextAttemptScheduled({
|
||||
id: first.id,
|
||||
status: 'retry_scheduled',
|
||||
nextAttemptAt: '2026-04-25T10:00:20.000Z',
|
||||
reason: 'empty_assistant_turn',
|
||||
scheduledAt: '2026-04-25T10:00:02.000Z',
|
||||
});
|
||||
await store.markNextAttemptScheduled({
|
||||
id: second.id,
|
||||
status: 'retry_scheduled',
|
||||
nextAttemptAt: '2026-04-25T10:00:10.000Z',
|
||||
reason: 'empty_assistant_turn',
|
||||
scheduledAt: '2026-04-25T10:00:02.000Z',
|
||||
});
|
||||
|
||||
const dueBefore = await store.listDue({
|
||||
teamName: 'team-a',
|
||||
now: new Date('2026-04-25T10:00:15.000Z'),
|
||||
limit: 10,
|
||||
});
|
||||
expect(dueBefore.map((record) => record.inboxMessageId)).toEqual(['msg-2']);
|
||||
|
||||
const dueAfter = await store.listDue({
|
||||
teamName: 'team-a',
|
||||
now: new Date('2026-04-25T10:00:21.000Z'),
|
||||
limit: 10,
|
||||
});
|
||||
expect(dueAfter.map((record) => record.inboxMessageId)).toEqual(['msg-2', 'msg-1']);
|
||||
});
|
||||
|
||||
it('rebuilds missing ledger rows as acceptance-unknown retryable records', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-1',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watchdog',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:first',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const rebuilt = await store.markAcceptanceUnknown({
|
||||
id: record.id,
|
||||
reason: 'opencode_prompt_delivery_ledger_rebuilt_from_unread_inbox',
|
||||
nextAttemptAt: '2026-04-25T10:00:00.000Z',
|
||||
markedAt: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(rebuilt.status).toBe('failed_retryable');
|
||||
expect(rebuilt.acceptanceUnknown).toBe(true);
|
||||
expect(rebuilt.responseState).toBe('not_observed');
|
||||
expect(rebuilt.lastReason).toBe('opencode_prompt_delivery_ledger_rebuilt_from_unread_inbox');
|
||||
});
|
||||
|
||||
it('prunes only terminal records after their retention windows', async () => {
|
||||
const store = createStore();
|
||||
const responded = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'responded',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:responded',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
await store.applyDestinationProof({
|
||||
id: responded.id,
|
||||
visibleReplyInbox: 'user',
|
||||
visibleReplyMessageId: 'reply-1',
|
||||
visibleReplyCorrelation: 'relayOfMessageId',
|
||||
semanticallySufficient: true,
|
||||
observedAt: '2026-04-25T10:00:01.000Z',
|
||||
});
|
||||
await store.markInboxReadCommitted({
|
||||
id: responded.id,
|
||||
committedAt: '2026-04-25T10:00:02.000Z',
|
||||
});
|
||||
|
||||
const failed = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'failed',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:failed',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
await store.markFailedTerminal({
|
||||
id: failed.id,
|
||||
reason: 'opencode_runtime_not_active',
|
||||
failedAt: '2026-04-25T10:00:03.000Z',
|
||||
});
|
||||
|
||||
const active = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'active',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:active',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
await expect(store.pruneTerminalRecords({
|
||||
now: new Date('2026-04-25T10:00:20.000Z'),
|
||||
respondedRetentionMs: 10_000,
|
||||
failedRetentionMs: 30_000,
|
||||
})).resolves.toEqual({ pruned: 1, remaining: 2 });
|
||||
expect((await store.list()).map((record) => record.inboxMessageId).sort()).toEqual([
|
||||
active.inboxMessageId,
|
||||
failed.inboxMessageId,
|
||||
]);
|
||||
|
||||
await expect(store.pruneTerminalRecords({
|
||||
now: new Date('2026-04-25T10:00:40.000Z'),
|
||||
respondedRetentionMs: 10_000,
|
||||
failedRetentionMs: 30_000,
|
||||
})).resolves.toEqual({ pruned: 1, remaining: 1 });
|
||||
expect((await store.list()).map((record) => record.inboxMessageId)).toEqual([
|
||||
active.inboxMessageId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,8 +2,10 @@ import { constants as fsConstants, promises as fs } from 'fs';
|
|||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import Fastify from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { registerTeamRoutes } from '../../../../src/main/http/teams';
|
||||
import { OpenCodeBridgeCommandClient } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||
import {
|
||||
createOpenCodeBridgeCommandLeaseStore,
|
||||
|
|
@ -27,6 +29,7 @@ import {
|
|||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import type { HttpServices } from '../../../../src/main/http';
|
||||
import type { OpenCodeBridgeCommandExecutor } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import type { RuntimeStoreManifestEvidence } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import type { RuntimeStoreManifestReader } from '../../../../src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
|
|
@ -68,7 +71,7 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
it(
|
||||
'delivers a desktop message to an OpenCode member and records the reply through agent-teams_message_send',
|
||||
async () => {
|
||||
const { bridgeClient, selectedModel, svc } = await createOpenCodeLiveHarness(tempDir);
|
||||
const { bridgeClient, selectedModel, svc, dispose } = await createOpenCodeLiveHarness(tempDir);
|
||||
|
||||
const teamName = `opencode-semantic-message-${Date.now()}`;
|
||||
const memberName = 'bob';
|
||||
|
|
@ -164,7 +167,8 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
});
|
||||
expect(reply.text).toContain(expectedReply);
|
||||
} finally {
|
||||
svc.stopTeam(teamName);
|
||||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
await dispose();
|
||||
await waitUntil(async () => {
|
||||
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName);
|
||||
return Object.keys(laneIndex.lanes).length === 0;
|
||||
|
|
@ -177,7 +181,7 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
it(
|
||||
'relays an OpenCode teammate message into another OpenCode member runtime and records the reply',
|
||||
async () => {
|
||||
const { bridgeClient, selectedModel, svc } = await createOpenCodeLiveHarness(tempDir);
|
||||
const { bridgeClient, selectedModel, svc, dispose } = await createOpenCodeLiveHarness(tempDir);
|
||||
|
||||
const teamName = `opencode-peer-message-${Date.now()}`;
|
||||
const senderName = 'bob';
|
||||
|
|
@ -186,7 +190,8 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
const replyToken = `opencode-peer-reply-e2e-${Date.now()}`;
|
||||
const peerInstructionText = [
|
||||
`Peer relay token: ${peerToken}.`,
|
||||
`${recipientName}, call agent-teams_message_send with teamName="${teamName}", to="user", from="${recipientName}", text exactly "${replyToken}", and summary "peer reply".`,
|
||||
`Please reply to the app user with exactly ${replyToken}.`,
|
||||
`Use agent-teams_message_send with teamName="${teamName}", to="user", from="${recipientName}", text exactly "${replyToken}", and summary "peer reply".`,
|
||||
].join(' ');
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
|
||||
|
|
@ -274,7 +279,7 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
teamName,
|
||||
recipientName,
|
||||
senderName,
|
||||
[peerToken, replyToken],
|
||||
replyToken,
|
||||
90_000
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
@ -321,7 +326,8 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
});
|
||||
expect(reply.text).toContain(replyToken);
|
||||
} finally {
|
||||
svc.stopTeam(teamName);
|
||||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
await dispose();
|
||||
await waitUntil(async () => {
|
||||
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName);
|
||||
return Object.keys(laneIndex.lanes).length === 0;
|
||||
|
|
@ -435,18 +441,24 @@ async function createOpenCodeLiveHarness(tempDir: string): Promise<{
|
|||
bridgeClient: OpenCodeBridgeCommandClient;
|
||||
selectedModel: string;
|
||||
svc: TeamProvisioningService;
|
||||
dispose: () => Promise<void>;
|
||||
}> {
|
||||
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
|
||||
const orchestratorCli =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
await assertExecutable(orchestratorCli);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
const controlApi = await startLiveTeamControlApi(svc);
|
||||
svc.setControlApiBaseUrlResolver(async () => controlApi.baseUrl);
|
||||
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
const bridgeEnv = {
|
||||
...createStableBridgeEnv(),
|
||||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
XDG_DATA_HOME: path.join(tempDir, 'xdg-data'),
|
||||
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
|
||||
CLAUDE_TEAM_CONTROL_URL: controlApi.baseUrl,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
};
|
||||
|
|
@ -467,9 +479,39 @@ async function createOpenCodeLiveHarness(tempDir: string): Promise<{
|
|||
stopTimeoutMs: 90_000,
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
return { bridgeClient, selectedModel, svc };
|
||||
return {
|
||||
bridgeClient,
|
||||
selectedModel,
|
||||
svc,
|
||||
dispose: async () => {
|
||||
svc.setControlApiBaseUrlResolver(null);
|
||||
await controlApi.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startLiveTeamControlApi(svc: TeamProvisioningService): Promise<{
|
||||
baseUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const app = Fastify({ logger: false });
|
||||
registerTeamRoutes(app, {
|
||||
teamProvisioningService: svc,
|
||||
} as HttpServices);
|
||||
await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const address = app.server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
await app.close();
|
||||
throw new Error('Failed to start live team control API');
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: async () => {
|
||||
await app.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getRuntimeTranscript(
|
||||
|
|
|
|||
|
|
@ -205,6 +205,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
);
|
||||
const launchArg = launchOpenCodeTeam.mock.calls[0]?.[0];
|
||||
expect(launchArg?.members[0]?.prompt).toContain('Do NOT create local team files');
|
||||
expect(launchArg?.members[0]?.prompt).toContain('Launch bootstrap is a silent attach');
|
||||
expect(launchArg?.members[0]?.prompt).toContain('stay idle silently');
|
||||
expect(launchArg?.members[0]?.prompt).not.toContain('Join team "team-a"');
|
||||
});
|
||||
|
||||
|
|
@ -349,11 +351,15 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
memberName: 'bob',
|
||||
text: expect.stringContaining('agent-teams_message_send'),
|
||||
messageId: 'msg-1',
|
||||
actionMode: 'delegate',
|
||||
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
|
||||
agent: 'teammate',
|
||||
});
|
||||
const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? '';
|
||||
expect(sentText).toContain('hello bob');
|
||||
expect(sentText).toContain('Use teamName="team-a", to="alice", from="bob", text, and summary.');
|
||||
expect(sentText).toContain('Include source="runtime_delivery"');
|
||||
expect(sentText).toContain('Include relayOfMessageId="msg-1"');
|
||||
expect(sentText).toContain('Action mode for this message: delegate.');
|
||||
expect(sentText).toContain(
|
||||
'If your reply is about these tasks, include taskRefs exactly: [{"taskId":"task-1","displayId":"abcd1234","teamName":"team-a"}]'
|
||||
|
|
|
|||
|
|
@ -230,4 +230,89 @@ describe('TeamBackupService', () => {
|
|||
expect(restoredRuntimeLaneIndex.lanes['secondary:opencode:tom'].state).toBe('active');
|
||||
expect(restoredRuntimeManifest.activeRunId).toBe('lane-run-1');
|
||||
});
|
||||
|
||||
it('skips quarantined and temporary OpenCode runtime files during backup', async () => {
|
||||
const service = new TeamBackupService();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const teamName = 'runtime-quarantine-team';
|
||||
const teamDir = path.join(hoisted.teamsBase, teamName);
|
||||
const runtimeDir = path.join(teamDir, '.opencode-runtime');
|
||||
const runtimeLaneIndex = {
|
||||
version: 1,
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
lanes: {
|
||||
'secondary:opencode:tom': {
|
||||
laneId: 'secondary:opencode:tom',
|
||||
state: 'active',
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.mkdir(runtimeDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({ name: 'Runtime Quarantine Team' }),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(runtimeDir, 'lanes.json'),
|
||||
JSON.stringify(runtimeLaneIndex),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(runtimeDir, 'lanes.invalid.123.json'),
|
||||
'{"version":1}\n}',
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(path.join(runtimeDir, '.tmp.deadbeef'), '{"partial":', 'utf8');
|
||||
|
||||
await service.initialize();
|
||||
await service.backupTeam(teamName);
|
||||
|
||||
const backupRuntimeDir = path.join(
|
||||
hoisted.backupsBase,
|
||||
'teams',
|
||||
teamName,
|
||||
'.opencode-runtime'
|
||||
);
|
||||
await expect(fs.readFile(path.join(backupRuntimeDir, 'lanes.json'), 'utf8')).resolves.toBe(
|
||||
JSON.stringify(runtimeLaneIndex)
|
||||
);
|
||||
await expect(
|
||||
fs.stat(path.join(backupRuntimeDir, 'lanes.invalid.123.json'))
|
||||
).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
await expect(fs.stat(path.join(backupRuntimeDir, '.tmp.deadbeef'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(hoisted.backupsBase, 'teams', teamName, 'manifest.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as { fileStats: Record<string, unknown> };
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
manifest.fileStats,
|
||||
'.opencode-runtime/lanes.invalid.123.json'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
manifest.fileStats,
|
||||
'.opencode-runtime/.tmp.deadbeef'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
warnSpy.mock.calls.some((args) =>
|
||||
args.some((arg) => String(arg).includes('Skipping invalid JSON'))
|
||||
)
|
||||
).toBe(false);
|
||||
} finally {
|
||||
service.dispose();
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -280,6 +280,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
expect(prompt).toContain(
|
||||
'The REVIEW column is for the same task #X moving through review. It is NOT a signal to create another task for review.'
|
||||
);
|
||||
expect(prompt).toContain('Task reference formatting (CRITICAL)');
|
||||
expect(prompt).toContain('Do NOT manually write [#abcd1234](task://...) in visible text');
|
||||
expect(prompt).toContain('task_create_from_message');
|
||||
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
|
||||
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
||||
|
|
@ -592,6 +594,27 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
|||
);
|
||||
});
|
||||
|
||||
it('teammate spawn prompts forbid manual task markdown links in visible messages', () => {
|
||||
const addPrompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
});
|
||||
const restartPrompt = buildRestartMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
});
|
||||
|
||||
for (const prompt of [addPrompt, restartPrompt]) {
|
||||
expect(prompt).toContain('Task reference formatting (CRITICAL)');
|
||||
expect(prompt).toContain('write task refs as plain #<short-id> text');
|
||||
expect(prompt).toContain(
|
||||
'Never wrap task refs or Markdown task links in backticks/code spans'
|
||||
);
|
||||
expect(prompt).toContain('Do NOT manually write [#abcd1234](task://...) in visible text');
|
||||
expect(prompt).toContain('include structured taskRefs metadata');
|
||||
}
|
||||
});
|
||||
|
||||
it('add-member spawn prompt explicitly forbids no-task bootstrap chatter', () => {
|
||||
const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
|
||||
name: 'alice',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { promises as fsPromises } from 'fs';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
|
|
@ -36,11 +38,13 @@ const hoisted = vi.hoisted(() => {
|
|||
}
|
||||
files.set(norm(filePath), data);
|
||||
});
|
||||
const mkdir = vi.fn(async () => undefined);
|
||||
|
||||
return {
|
||||
files,
|
||||
stat,
|
||||
readFile,
|
||||
mkdir,
|
||||
atomicWrite,
|
||||
appendSentMessage: vi.fn((teamName: string, message: Record<string, unknown>) => {
|
||||
const sentMessagesPath = `/mock/teams/${teamName}/sentMessages.json`;
|
||||
|
|
@ -80,10 +84,21 @@ vi.mock('fs', async (importOriginal) => {
|
|||
...actual.promises,
|
||||
stat: hoisted.stat,
|
||||
readFile: hoisted.readFile,
|
||||
mkdir: hoisted.mkdir,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
stat: hoisted.stat,
|
||||
readFile: hoisted.readFile,
|
||||
mkdir: hoisted.mkdir,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
|
||||
atomicWriteAsync: hoisted.atomicWrite,
|
||||
}));
|
||||
|
|
@ -236,11 +251,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
beforeEach(() => {
|
||||
hoisted.files.clear();
|
||||
hoisted.readFile.mockClear();
|
||||
hoisted.mkdir.mockClear();
|
||||
hoisted.atomicWrite.mockClear();
|
||||
hoisted.setAtomicWriteShouldFail(false);
|
||||
hoisted.appendSentMessage.mockClear();
|
||||
hoisted.sendInboxMessage.mockClear();
|
||||
hoisted.setAtomicWriteShouldFail(false);
|
||||
vi.spyOn(fsPromises, 'mkdir').mockImplementation(hoisted.mkdir as never);
|
||||
});
|
||||
|
||||
it('relays unread lead inbox messages into stdin', async () => {
|
||||
|
|
@ -1671,6 +1688,299 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(rows[0].read).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps OpenCode member inbox rows unread while runtime response is pending', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Please answer this.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-response-pending-1',
|
||||
actionMode: 'ask',
|
||||
},
|
||||
]);
|
||||
vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
diagnostics: ['opencode_delivery_response_pending'],
|
||||
});
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||
|
||||
expect(relay).toMatchObject({
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
lastDelivery: { delivered: true, responsePending: true },
|
||||
});
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
expect(rows[0].read).toBe(false);
|
||||
});
|
||||
|
||||
it('skips failed-terminal OpenCode rows without blocking newer unread rows', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack');
|
||||
expect(identity.ok).toBe(true);
|
||||
const failedRecord = {
|
||||
id: 'ledger-terminal-old',
|
||||
status: 'failed_terminal',
|
||||
inboxMessageId: 'opencode-terminal-old',
|
||||
lastReason: 'opencode_attachments_not_supported_for_secondary_runtime',
|
||||
diagnostics: ['opencode_attachments_not_supported_for_secondary_runtime'],
|
||||
};
|
||||
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||
getByInboxMessage: vi.fn(async (input: { inboxMessageId: string }) =>
|
||||
input.inboxMessageId === 'opencode-terminal-old' ? failedRecord : null
|
||||
),
|
||||
});
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Old terminal row.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-terminal-old',
|
||||
},
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'New deliverable row.',
|
||||
timestamp: '2026-02-23T17:00:02.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-terminal-new',
|
||||
},
|
||||
]);
|
||||
const deliverSpy = vi
|
||||
.spyOn(service, 'deliverOpenCodeMemberMessage')
|
||||
.mockResolvedValue({ delivered: true, diagnostics: [] });
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||
|
||||
expect(relay).toMatchObject({ relayed: 1, attempted: 1, delivered: 1, failed: 0 });
|
||||
expect(relay.diagnostics?.join('\n')).toContain(
|
||||
'opencode_attachments_not_supported_for_secondary_runtime'
|
||||
);
|
||||
expect(deliverSpy).toHaveBeenCalledTimes(1);
|
||||
expect(deliverSpy).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
expect.objectContaining({ messageId: 'opencode-terminal-new' })
|
||||
);
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('fails OpenCode secondary rows with attachments terminally without text-only delivery', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack');
|
||||
expect(identity.ok).toBe(true);
|
||||
const records: any[] = [];
|
||||
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||
getByInboxMessage: vi.fn(async () => null),
|
||||
ensurePending: vi.fn(async (input: Record<string, unknown>) => {
|
||||
const record = {
|
||||
id: 'ledger-attachment-1',
|
||||
status: 'pending',
|
||||
responseState: 'not_observed',
|
||||
diagnostics: [] as string[],
|
||||
...input,
|
||||
};
|
||||
records.push(record);
|
||||
return record;
|
||||
}),
|
||||
markFailedTerminal: vi.fn(async (input: { id: string; reason: string; failedAt: string }) => {
|
||||
const record = records.find((candidate) => candidate.id === input.id);
|
||||
Object.assign(record, {
|
||||
status: 'failed_terminal',
|
||||
failedAt: input.failedAt,
|
||||
lastReason: input.reason,
|
||||
diagnostics: [input.reason],
|
||||
});
|
||||
return record;
|
||||
}),
|
||||
list: vi.fn(async () => records),
|
||||
});
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Please inspect the attachment.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-attachment-1',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-1',
|
||||
filename: 'trace.log',
|
||||
mimeType: 'text/plain',
|
||||
size: 128,
|
||||
addedAt: '2026-02-23T17:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const deliverSpy = vi.spyOn(service, 'deliverOpenCodeMemberMessage');
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||
|
||||
expect(relay).toMatchObject({
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 1,
|
||||
lastDelivery: {
|
||||
delivered: false,
|
||||
reason: 'opencode_attachments_not_supported_for_secondary_runtime',
|
||||
},
|
||||
});
|
||||
expect(deliverSpy).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||
'opencode_attachments_not_supported_for_secondary_runtime'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
const rows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
|
||||
);
|
||||
expect(rows[0].read).toBe(false);
|
||||
expect(records[0]).toMatchObject({
|
||||
inboxMessageId: 'opencode-attachment-1',
|
||||
status: 'failed_terminal',
|
||||
lastReason: 'opencode_attachments_not_supported_for_secondary_runtime',
|
||||
});
|
||||
});
|
||||
|
||||
it('rebuilds missing OpenCode prompt ledger rows from unread inbox on startup scan', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack');
|
||||
expect(identity.ok).toBe(true);
|
||||
const laneId = identity.laneId;
|
||||
const records: any[] = [];
|
||||
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||
pruneTerminalRecords: vi.fn(async () => ({ pruned: 0, remaining: records.length })),
|
||||
list: vi.fn(async () => records),
|
||||
getByInboxMessage: vi.fn(async () => null),
|
||||
ensurePending: vi.fn(async (input: Record<string, unknown>) => {
|
||||
const record = {
|
||||
id: 'ledger-rebuild-1',
|
||||
status: 'pending',
|
||||
responseState: 'not_observed',
|
||||
acceptanceUnknown: false,
|
||||
diagnostics: [] as string[],
|
||||
...input,
|
||||
};
|
||||
records.push(record);
|
||||
return record;
|
||||
}),
|
||||
markAcceptanceUnknown: vi.fn(
|
||||
async (input: { id: string; reason: string; nextAttemptAt: string; markedAt: string }) => {
|
||||
const record = records.find((candidate) => candidate.id === input.id);
|
||||
Object.assign(record, {
|
||||
status: 'failed_retryable',
|
||||
acceptanceUnknown: true,
|
||||
nextAttemptAt: input.nextAttemptAt,
|
||||
lastReason: input.reason,
|
||||
updatedAt: input.markedAt,
|
||||
});
|
||||
return record;
|
||||
}
|
||||
),
|
||||
markFailedTerminal: vi.fn(async (input: { id: string; reason: string }) => {
|
||||
const record = records.find((candidate) => candidate.id === input.id);
|
||||
Object.assign(record, {
|
||||
status: 'failed_terminal',
|
||||
lastReason: input.reason,
|
||||
diagnostics: [input.reason],
|
||||
});
|
||||
return record;
|
||||
}),
|
||||
});
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'jack',
|
||||
text: 'Recover this delivery.',
|
||||
timestamp: '2026-02-23T17:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-rebuild-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const scheduled = await (service as any).scanOpenCodePromptDeliveryWatchdogForActiveLanes(
|
||||
teamName,
|
||||
[laneId]
|
||||
);
|
||||
|
||||
expect(scheduled).toBe(1);
|
||||
expect(records[0]).toMatchObject({
|
||||
inboxMessageId: 'opencode-rebuild-1',
|
||||
status: 'failed_retryable',
|
||||
acceptanceUnknown: true,
|
||||
lastReason: 'opencode_prompt_delivery_ledger_rebuilt_from_unread_inbox',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let an older in-flight OpenCode relay mask a specific UI-send message', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ vi.mock('@renderer/store', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
useShallow: <T,>(selector: T) => selector,
|
||||
useShallow: <T>(selector: T) => selector,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
|
|
@ -144,16 +144,19 @@ vi.mock('@renderer/components/ui/button', () => ({
|
|||
vi.mock('@renderer/components/ui/tabs', () => ({
|
||||
Tabs: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
TabsList: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
TabsContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
TabsContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('div', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||
TooltipProvider: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
|
||||
Tooltip: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
|
||||
TooltipContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/extensions/ExtensionsSubTabTrigger', () => ({
|
||||
|
|
@ -409,6 +412,61 @@ describe('ExtensionStoreView provider loading placeholders', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows OpenCode plugins as unsupported in multimodel capability cards', async () => {
|
||||
storeState.cliStatusLoading = false;
|
||||
storeState.cliProviderStatusLoading = {};
|
||||
const baseProvider = createLoadingMultimodelStatus().providers[0];
|
||||
storeState.cliStatus = {
|
||||
...createLoadingMultimodelStatus(),
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: false,
|
||||
providers: [
|
||||
{
|
||||
...baseProvider,
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'OpenCode CLI',
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
extensions: {
|
||||
plugins: { status: 'unsupported', ownership: 'provider-scoped', reason: null },
|
||||
mcp: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
skills: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
apiKeys: { status: 'read-only', ownership: 'provider-scoped', reason: null },
|
||||
},
|
||||
},
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ExtensionStoreView));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode');
|
||||
expect(host.textContent).toContain('Plugins: unsupported');
|
||||
expect(host.textContent).toContain('MCP: read-only');
|
||||
expect(host.textContent).not.toContain('Plugins: read-only');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the live Codex account snapshot to replace stale extension-card status', async () => {
|
||||
storeState.cliStatusLoading = false;
|
||||
storeState.cliProviderStatusLoading = {};
|
||||
|
|
@ -443,9 +501,7 @@ describe('ExtensionStoreView provider loading placeholders', () => {
|
|||
...createLoadingMultimodelStatus(),
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: false,
|
||||
providers: [
|
||||
createLoadingMultimodelStatus().providers[1],
|
||||
],
|
||||
providers: [createLoadingMultimodelStatus().providers[1]],
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
|
|
@ -659,9 +715,9 @@ describe('ExtensionStoreView provider loading placeholders', () => {
|
|||
expect(pluginsPanelProps.cliStatus?.providers[0]?.supported).toBe(true);
|
||||
expect(pluginsPanelProps.cliStatus?.providers[0]?.statusMessage).toBe('ChatGPT account ready');
|
||||
expect(mcpPanelProps.cliStatus?.providers[0]?.resolvedBackendId).toBe('codex-native');
|
||||
expect(customDialogProps.cliStatus?.providers[0]?.connection?.codex?.managedAccount?.email).toBe(
|
||||
'user@example.com'
|
||||
);
|
||||
expect(
|
||||
customDialogProps.cliStatus?.providers[0]?.connection?.codex?.managedAccount?.email
|
||||
).toBe('user@example.com');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
105
test/renderer/components/runtime/ProviderModelBadges.test.tsx
Normal file
105
test/renderer/components/runtime/ProviderModelBadges.test.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
|
||||
function render(element: React.ReactElement): HTMLDivElement {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(element);
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
describe('ProviderModelBadges', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('does not render stale availability chips for OpenCode models', () => {
|
||||
const host = render(
|
||||
<ProviderModelBadges
|
||||
providerId="opencode"
|
||||
models={['openrouter/openai/gpt-oss-20b:free']}
|
||||
modelAvailability={[
|
||||
{
|
||||
modelId: 'openrouter/openai/gpt-oss-20b:free',
|
||||
status: 'unknown',
|
||||
reason: 'old bulk check failed',
|
||||
checkedAt: '2026-04-25T00:00:00.000Z',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain('gpt-oss');
|
||||
expect(host.textContent).not.toContain('Check failed');
|
||||
});
|
||||
|
||||
it('keeps availability chips for providers that still support explicit badge checks', () => {
|
||||
const host = render(
|
||||
<ProviderModelBadges
|
||||
providerId="codex"
|
||||
models={['gpt-5-codex']}
|
||||
modelAvailability={[
|
||||
{
|
||||
modelId: 'gpt-5-codex',
|
||||
status: 'unknown',
|
||||
reason: 'probe timeout',
|
||||
checkedAt: '2026-04-25T00:00:00.000Z',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain('Check failed');
|
||||
});
|
||||
|
||||
it('collapses long model lists and expands them into a bounded scroll area', () => {
|
||||
const models = Array.from(
|
||||
{ length: 18 },
|
||||
(_, index) => `model-${String(index + 1).padStart(2, '0')}`
|
||||
);
|
||||
const host = render(
|
||||
<ProviderModelBadges providerId="codex" models={models} collapseAfter={15} />
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain('model-15');
|
||||
expect(host.textContent).not.toContain('model-16');
|
||||
expect(host.textContent).toContain('+3 more');
|
||||
|
||||
const moreButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('+3 more')
|
||||
);
|
||||
expect(moreButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
moreButton?.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('model-18');
|
||||
expect(host.textContent).toContain('Hide');
|
||||
const list = host.firstElementChild?.firstElementChild as HTMLElement | null;
|
||||
expect(list?.style.maxHeight).toBe('200px');
|
||||
expect(list?.style.overflowY).toBe('auto');
|
||||
|
||||
const hideButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Hide')
|
||||
);
|
||||
expect(hideButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
hideButton?.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('model-16');
|
||||
expect(host.textContent).toContain('+3 more');
|
||||
});
|
||||
});
|
||||
|
|
@ -62,6 +62,28 @@ vi.mock('@features/codex-account/renderer', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('@features/runtime-provider-management/renderer', () => ({
|
||||
RuntimeProviderManagementPanel: ({
|
||||
runtimeId,
|
||||
open,
|
||||
disabled,
|
||||
}: {
|
||||
runtimeId: string;
|
||||
open: boolean;
|
||||
disabled?: boolean;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'section',
|
||||
{
|
||||
'data-testid': 'runtime-provider-management-panel',
|
||||
'data-runtime-id': runtimeId,
|
||||
'data-open': String(open),
|
||||
'data-disabled': String(Boolean(disabled)),
|
||||
},
|
||||
`Runtime provider management: ${runtimeId}`
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
|
|
@ -89,7 +111,8 @@ vi.mock('@renderer/components/ui/dialog', () => ({
|
|||
open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null,
|
||||
DialogContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('div', { 'data-testid': 'dialog-content' }, children),
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
DialogHeader: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
|
||||
DialogDescription: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('p', null, children),
|
||||
|
|
@ -109,7 +132,8 @@ vi.mock('@renderer/components/ui/select', () => ({
|
|||
SelectTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('button', { type: 'button' }, children),
|
||||
SelectValue: () => React.createElement('span', null, 'select-value'),
|
||||
SelectContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
SelectContent: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement('div', null, children),
|
||||
SelectItem: ({ children }: React.PropsWithChildren<{ value: string }>) =>
|
||||
React.createElement('button', { type: 'button' }, children),
|
||||
}));
|
||||
|
|
@ -120,7 +144,11 @@ vi.mock('@renderer/components/ui/tabs', () => ({
|
|||
value,
|
||||
onValueChange,
|
||||
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
|
||||
React.createElement('div', { 'data-value': value, 'data-on-change': Boolean(onValueChange) }, children),
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-value': value, 'data-on-change': Boolean(onValueChange) },
|
||||
children
|
||||
),
|
||||
TabsList: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
||||
TabsTrigger: ({
|
||||
children,
|
||||
|
|
@ -198,21 +226,19 @@ function createCodexProvider(
|
|||
},
|
||||
selectedBackendId: overrides?.selectedBackendId ?? 'codex-native',
|
||||
resolvedBackendId: overrides?.resolvedBackendId ?? 'codex-native',
|
||||
availableBackends:
|
||||
overrides?.availableBackends ??
|
||||
[
|
||||
{
|
||||
id: 'codex-native',
|
||||
label: 'Codex native',
|
||||
description: 'Use the local codex exec JSON seam.',
|
||||
selectable: true,
|
||||
recommended: true,
|
||||
available: true,
|
||||
state: 'ready',
|
||||
audience: 'general',
|
||||
statusMessage: 'Codex native ready',
|
||||
},
|
||||
],
|
||||
availableBackends: overrides?.availableBackends ?? [
|
||||
{
|
||||
id: 'codex-native',
|
||||
label: 'Codex native',
|
||||
description: 'Use the local codex exec JSON seam.',
|
||||
selectable: true,
|
||||
recommended: true,
|
||||
available: true,
|
||||
state: 'ready',
|
||||
audience: 'general',
|
||||
statusMessage: 'Codex native ready',
|
||||
},
|
||||
],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: {
|
||||
kind: 'codex-native',
|
||||
|
|
@ -239,7 +265,8 @@ function createCodexProvider(
|
|||
startedAt: null,
|
||||
},
|
||||
rateLimits: null,
|
||||
launchAllowed: Boolean(overrides?.authenticated ?? true) || Boolean(overrides?.apiKeyConfigured),
|
||||
launchAllowed:
|
||||
Boolean(overrides?.authenticated ?? true) || Boolean(overrides?.apiKeyConfigured),
|
||||
launchIssueMessage: null,
|
||||
launchReadinessState:
|
||||
Boolean(overrides?.authenticated ?? true) || Boolean(overrides?.apiKeyConfigured)
|
||||
|
|
@ -442,7 +469,9 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
storeState.deleteApiKey = vi.fn(() => Promise.resolve(undefined));
|
||||
storeState.updateConfig = vi.fn((section: string, data: Record<string, unknown>) => {
|
||||
if (section === 'providerConnections') {
|
||||
const nextProviderConnections = data as Partial<StoreState['appConfig']['providerConnections']>;
|
||||
const nextProviderConnections = data as Partial<
|
||||
StoreState['appConfig']['providerConnections']
|
||||
>;
|
||||
storeState.appConfig = {
|
||||
...storeState.appConfig,
|
||||
providerConnections: {
|
||||
|
|
@ -567,9 +596,7 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
);
|
||||
expect(host.textContent).toContain('Connection method');
|
||||
expect(host.textContent).toContain('ChatGPT account');
|
||||
expect(host.textContent).toContain(
|
||||
'Use an OpenAI API key as a secondary Codex auth path.'
|
||||
);
|
||||
expect(host.textContent).toContain('Use an OpenAI API key as a secondary Codex auth path.');
|
||||
expect(host.textContent).toContain('Set API key');
|
||||
expect(host.textContent).toContain('Connect ChatGPT');
|
||||
});
|
||||
|
|
@ -800,7 +827,8 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
},
|
||||
rateLimits: null,
|
||||
launchAllowed: false,
|
||||
launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
|
||||
launchIssueMessage:
|
||||
'Reconnect ChatGPT to refresh the current Codex subscription session.',
|
||||
launchReadinessState: 'missing_auth',
|
||||
},
|
||||
}),
|
||||
|
|
@ -1220,22 +1248,16 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
expect(host.textContent).toContain('77%');
|
||||
expect(host.textContent).toContain('23% left');
|
||||
expect(host.textContent).toContain('Primary reset (5h)');
|
||||
expect(host.textContent).toContain(
|
||||
new Date(1_776_678_034_000).toLocaleString()
|
||||
);
|
||||
expect(host.textContent).toContain(new Date(1_776_678_034_000).toLocaleString());
|
||||
expect(host.textContent).toContain('Weekly used (1w)');
|
||||
expect(host.textContent).toContain('45%');
|
||||
expect(host.textContent).toContain('55% left');
|
||||
expect(host.textContent).toContain('Weekly reset (1w)');
|
||||
expect(host.textContent).toContain(
|
||||
new Date(1_776_999_999_000).toLocaleString()
|
||||
);
|
||||
expect(host.textContent).toContain(new Date(1_776_999_999_000).toLocaleString());
|
||||
expect(host.textContent).toContain('Credits');
|
||||
expect(host.textContent).toContain('42');
|
||||
expect(host.textContent).toContain('These percentages show used quota, not remaining quota.');
|
||||
expect(host.textContent).toContain(
|
||||
'77% used - about 23% left in the current 5-hour window.'
|
||||
);
|
||||
expect(host.textContent).toContain('77% used - about 23% left in the current 5-hour window.');
|
||||
});
|
||||
|
||||
it('shows truthful Codex rate-limit fallbacks instead of misleading zero values', async () => {
|
||||
|
|
@ -1306,7 +1328,9 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
expect(host.textContent).toContain('Credits');
|
||||
expect(host.textContent).toContain('Not available');
|
||||
expect(host.textContent).not.toContain('0%');
|
||||
expect(host.textContent).toContain('Shows used quota in the current 5-hour window, not remaining quota.');
|
||||
expect(host.textContent).toContain(
|
||||
'Shows used quota in the current 5-hour window, not remaining quota.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the API key icon container square', async () => {
|
||||
|
|
@ -1417,7 +1441,7 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
expect(host.textContent).toContain('Runtime updated, but failed to refresh provider status.');
|
||||
});
|
||||
|
||||
it('shows OpenCode live runtime detail and bounded diagnostics in the provider summary', async () => {
|
||||
it('renders the OpenCode runtime provider management feature panel', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
@ -1436,15 +1460,11 @@ describe('ProviderRuntimeSettingsDialog', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode');
|
||||
expect(host.textContent).toContain('version 1.4.0 - live resolved-fin - managed teammate agent');
|
||||
expect(host.textContent).toContain('OpenCode live host: Healthy - resolved resolved-fin');
|
||||
expect(host.textContent).toContain(
|
||||
'OpenCode managed runtime: Managed runtime verified - managed teammate agent'
|
||||
);
|
||||
expect(host.textContent).toContain(
|
||||
'OpenCode behavior: Behavior fingerprint stable - behavior abc123'
|
||||
);
|
||||
expect(host.textContent).not.toContain('Should be hidden');
|
||||
const panel = host.querySelector('[data-testid="runtime-provider-management-panel"]');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.getAttribute('data-runtime-id')).toBe('opencode');
|
||||
expect(panel?.getAttribute('data-open')).toBe('true');
|
||||
expect(host.textContent).toContain('Runtime provider management: opencode');
|
||||
expect(host.textContent).not.toContain('Desktop currently exposes status only.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
368
test/renderer/components/team/dialogs/SendMessageDialog.test.tsx
Normal file
368
test/renderer/components/team/dialogs/SendMessageDialog.test.tsx
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||
|
||||
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
|
||||
MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', null, content),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/attachments/AttachmentPreviewList', () => ({
|
||||
AttachmentPreviewList: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/attachments/DropZoneOverlay', () => ({
|
||||
DropZoneOverlay: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/messages/ActionModeSelector', () => ({
|
||||
ActionModeSelector: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'select',
|
||||
{
|
||||
'aria-label': 'Action mode',
|
||||
value,
|
||||
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onChange(event.target.value),
|
||||
},
|
||||
React.createElement('option', { value: 'do' }, 'Do'),
|
||||
React.createElement('option', { value: 'ask' }, 'Ask'),
|
||||
React.createElement('option', { value: 'delegate' }, 'Delegate')
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? React.createElement('div', null, children) : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', { role: 'dialog' }, children),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('p', null, children),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('h2', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/label', () => ({
|
||||
Label: ({
|
||||
children,
|
||||
htmlFor,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
htmlFor?: string;
|
||||
}) => React.createElement('label', { htmlFor }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/MemberSelect', () => ({
|
||||
MemberSelect: ({
|
||||
members,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
members: ResolvedTeamMember[];
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'select',
|
||||
{
|
||||
'aria-label': 'Recipient',
|
||||
value: value ?? '',
|
||||
onChange: (event: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChange(event.target.value || null),
|
||||
},
|
||||
React.createElement('option', { value: '' }, 'Select member...'),
|
||||
...members.map((member) =>
|
||||
React.createElement('option', { key: member.name, value: member.name }, member.name)
|
||||
)
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/MentionableTextarea', () => ({
|
||||
MentionableTextarea: ({
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
cornerAction,
|
||||
footerRight,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
cornerAction?: React.ReactNode;
|
||||
footerRight?: React.ReactNode;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
null,
|
||||
React.createElement('textarea', {
|
||||
'aria-label': 'Message',
|
||||
placeholder,
|
||||
value,
|
||||
disabled,
|
||||
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onValueChange(event.target.value),
|
||||
}),
|
||||
React.createElement('div', null, cornerAction),
|
||||
React.createElement('div', null, footerRight)
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/hooks/useAttachments', () => ({
|
||||
useAttachments: () => ({
|
||||
attachments: [],
|
||||
error: null,
|
||||
canAddMore: true,
|
||||
addFiles: vi.fn().mockResolvedValue(undefined),
|
||||
removeAttachment: vi.fn(),
|
||||
clearAttachments: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
handlePaste: vi.fn(),
|
||||
handleDrop: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/hooks/useTaskSuggestions', () => ({
|
||||
useTaskSuggestions: () => ({ suggestions: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
|
||||
useTeamSuggestions: () => ({ suggestions: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: { selectedTeamData: null }) => unknown) =>
|
||||
selector({ selectedTeamData: null }),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/MemberBadge', () => ({
|
||||
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
|
||||
}));
|
||||
|
||||
import { SendMessageDialog } from '@renderer/components/team/dialogs/SendMessageDialog';
|
||||
|
||||
const members: ResolvedTeamMember[] = [
|
||||
{
|
||||
name: 'team-lead',
|
||||
status: 'idle',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentType: 'team-lead',
|
||||
role: 'Team Lead',
|
||||
},
|
||||
{
|
||||
name: 'jack',
|
||||
status: 'idle',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentType: 'developer',
|
||||
role: 'Developer',
|
||||
},
|
||||
];
|
||||
|
||||
function renderDialog(props: Partial<React.ComponentProps<typeof SendMessageDialog>> = {}) {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onClose = vi.fn();
|
||||
const onSend = vi.fn<React.ComponentProps<typeof SendMessageDialog>['onSend']>();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(SendMessageDialog, {
|
||||
open: true,
|
||||
teamName: 'team-a',
|
||||
members,
|
||||
defaultRecipient: 'jack',
|
||||
isTeamAlive: true,
|
||||
sending: false,
|
||||
sendError: null,
|
||||
sendWarning: null,
|
||||
sendDebugDetails: null,
|
||||
lastResult: null,
|
||||
onClose,
|
||||
onSend,
|
||||
...props,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return { host, root, onClose, onSend };
|
||||
}
|
||||
|
||||
function getSendButton(host: HTMLElement): HTMLButtonElement {
|
||||
const button = Array.from(host.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === 'Send'
|
||||
);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error('Send button not found');
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (!setter) {
|
||||
throw new Error('HTMLTextAreaElement value setter not found');
|
||||
}
|
||||
setter.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('SendMessageDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
localStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('preserves draft text when async send fails', async () => {
|
||||
let rejectSend: (error: Error) => void = () => undefined;
|
||||
const failedSend = new Promise<SendMessageResult | void>((_resolve, reject) => {
|
||||
rejectSend = reject;
|
||||
});
|
||||
const onSend = vi.fn(() => failedSend);
|
||||
const { host, root } = renderDialog({ onSend, teamName: 'team-runtime-failed' });
|
||||
|
||||
const textarea = host.querySelector('textarea[aria-label="Message"]') as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setTextareaValue(textarea, 'Please verify the OpenCode delivery path');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getSendButton(host).disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
getSendButton(host).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onSend).toHaveBeenCalledWith(
|
||||
'jack',
|
||||
'Please verify the OpenCode delivery path',
|
||||
'Please verify the OpenCode delivery path',
|
||||
undefined,
|
||||
'do',
|
||||
[]
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rejectSend(new Error('runtime delivery failed'));
|
||||
await failedSend.catch(() => undefined);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(textarea.value).toBe('Please verify the OpenCode delivery path');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves draft text when OpenCode runtime delivery fails after persistence', async () => {
|
||||
const onSend = vi.fn<React.ComponentProps<typeof SendMessageDialog>['onSend']>(() =>
|
||||
Promise.resolve({
|
||||
deliveredToInbox: true,
|
||||
messageId: 'm-opencode-failed',
|
||||
runtimeDelivery: {
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: false,
|
||||
reason: 'runtime_delivery_failed',
|
||||
},
|
||||
})
|
||||
);
|
||||
const { host, root } = renderDialog({ onSend });
|
||||
|
||||
const textarea = host.querySelector('textarea[aria-label="Message"]') as HTMLTextAreaElement;
|
||||
|
||||
await act(async () => {
|
||||
setTextareaValue(textarea, 'Keep this text if live delivery fails');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getSendButton(host).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(textarea.value).toBe('Keep this text if live delivery fails');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows live delivery warning without closing the dialog', async () => {
|
||||
const warning =
|
||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
||||
const { host, root, onClose } = renderDialog({
|
||||
sendWarning: warning,
|
||||
sendDebugDetails: {
|
||||
messageId: 'm-opencode-1',
|
||||
providerId: 'opencode',
|
||||
delivered: false,
|
||||
responsePending: false,
|
||||
responseState: 'failed',
|
||||
ledgerStatus: 'failed',
|
||||
acceptanceUnknown: false,
|
||||
reason: 'runtime_delivery_failed',
|
||||
diagnostics: ['runtime_delivery_failed'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain(warning);
|
||||
expect(host.textContent).not.toContain('ledgerStatus');
|
||||
expect(host.textContent).not.toContain('runtime_delivery_failed');
|
||||
|
||||
const detailsButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Details')
|
||||
);
|
||||
expect(detailsButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
detailsButton?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('ledgerStatus');
|
||||
expect(host.textContent).toContain('responseState');
|
||||
expect(host.textContent).toContain('runtime_delivery_failed');
|
||||
expect(host.textContent).toContain('Send Message');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ const storeState = {
|
|||
sendingMessage: false,
|
||||
sendMessageError: null,
|
||||
sendMessageWarning: null,
|
||||
sendMessageDebugDetails: null,
|
||||
lastSendMessageResult: null,
|
||||
teams: [],
|
||||
openTeamTab: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OpenCodeDeliveryWarning } from '../../../../../src/renderer/components/team/messages/OpenCodeDeliveryWarning';
|
||||
|
||||
import type { OpenCodeRuntimeDeliveryDebugDetails } from '../../../../../src/renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
|
||||
const warning =
|
||||
'OpenCode runtime delivery is still being checked. Message was saved and will be retried if needed.';
|
||||
|
||||
const debugDetails: OpenCodeRuntimeDeliveryDebugDetails = {
|
||||
messageId: 'm-opencode-1',
|
||||
providerId: 'opencode',
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
ledgerStatus: 'accepted',
|
||||
acceptanceUnknown: false,
|
||||
reason: 'assistant_response_pending',
|
||||
diagnostics: ['assistant_response_pending'],
|
||||
};
|
||||
|
||||
function renderWarning(props: Partial<React.ComponentProps<typeof OpenCodeDeliveryWarning>> = {}) {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<OpenCodeDeliveryWarning warning={warning} debugDetails={debugDetails} {...props} />
|
||||
);
|
||||
});
|
||||
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
function findButton(host: HTMLElement, text: string): HTMLButtonElement {
|
||||
const button = Array.from(host.querySelectorAll('button')).find((candidate) =>
|
||||
candidate.textContent?.includes(text)
|
||||
);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Button not found: ${text}`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OpenCodeDeliveryWarning', () => {
|
||||
it('renders short warning first and hides raw diagnostics until details are opened', async () => {
|
||||
const { host, root } = renderWarning();
|
||||
|
||||
expect(host.textContent).toContain(warning);
|
||||
expect(host.textContent).toContain('Details');
|
||||
expect(host.textContent).not.toContain('ledgerStatus');
|
||||
expect(host.textContent).not.toContain('assistant_response_pending');
|
||||
|
||||
await act(async () => {
|
||||
findButton(host, 'Details').click();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('ledgerStatus');
|
||||
expect(host.textContent).toContain('accepted');
|
||||
expect(host.textContent).toContain('responseState');
|
||||
expect(host.textContent).toContain('pending');
|
||||
expect(host.textContent).toContain('reason');
|
||||
expect(host.textContent).toContain('assistant_response_pending');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('copies stable debug details text', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const { host, root } = renderWarning();
|
||||
|
||||
await act(async () => {
|
||||
findButton(host, 'Details').click();
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(host, 'Copy debug details').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('"ledgerStatus": "accepted"'));
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"responseState": "pending"')
|
||||
);
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"reason": "assistant_response_pending"')
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show details control without debug details', async () => {
|
||||
const { host, root } = renderWarning({ debugDetails: null });
|
||||
|
||||
expect(host.textContent).toContain(warning);
|
||||
expect(host.textContent).not.toContain('Details');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides details again when a different runtime delivery payload arrives', async () => {
|
||||
const { host, root } = renderWarning();
|
||||
|
||||
await act(async () => {
|
||||
findButton(host, 'Details').click();
|
||||
});
|
||||
expect(host.textContent).toContain('ledgerStatus');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<OpenCodeDeliveryWarning
|
||||
warning={warning}
|
||||
debugDetails={{
|
||||
...debugDetails,
|
||||
messageId: 'm-opencode-2',
|
||||
ledgerStatus: 'retry_scheduled',
|
||||
reason: 'retry_scheduled',
|
||||
diagnostics: ['retry_scheduled'],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain(warning);
|
||||
expect(host.textContent).toContain('Details');
|
||||
expect(host.textContent).not.toContain('ledgerStatus');
|
||||
expect(host.textContent).not.toContain('retry_scheduled');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -172,4 +172,48 @@ describe('GraphControls', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('switches layout mode from the top toolbar', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const onLayoutModeChange = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphControls, {
|
||||
filters: {
|
||||
showActivity: true,
|
||||
showTasks: true,
|
||||
showProcesses: true,
|
||||
showEdges: true,
|
||||
paused: false,
|
||||
},
|
||||
onFiltersChange: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomToFit: vi.fn(),
|
||||
layoutMode: 'radial',
|
||||
onLayoutModeChange,
|
||||
teamName: 'demo-team',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const rowsButton = host.querySelector('button[aria-label="Switch to rows layout"]');
|
||||
expect(rowsButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
rowsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onLayoutModeChange).toHaveBeenCalledWith('grid-under-lead');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,7 +30,33 @@ const hoisted = vi.hoisted(() => ({
|
|||
effects: [],
|
||||
time: 0,
|
||||
},
|
||||
setNodePosition: vi.fn(),
|
||||
clearNodePosition: vi.fn(),
|
||||
clearTransientOwnerPositions: vi.fn(),
|
||||
resolveNearestOwnerSlot: vi.fn<
|
||||
(
|
||||
nodeId: string,
|
||||
x: number,
|
||||
y: number
|
||||
) => {
|
||||
assignment: { ringIndex: number; sectorIndex: number };
|
||||
displacedOwnerId?: string;
|
||||
displacedAssignment?: { ringIndex: number; sectorIndex: number };
|
||||
previewOwnerX: number;
|
||||
previewOwnerY: number;
|
||||
} | null
|
||||
>(() => null),
|
||||
resolveNearestOwnerGridTarget: vi.fn<
|
||||
(
|
||||
nodeId: string,
|
||||
x: number,
|
||||
y: number
|
||||
) => {
|
||||
targetOwnerId: string;
|
||||
previewOwnerX: number;
|
||||
previewOwnerY: number;
|
||||
} | null
|
||||
>(() => null),
|
||||
graphControlsProps: null as null | Record<string, unknown>,
|
||||
}));
|
||||
|
||||
|
|
@ -62,10 +88,11 @@ vi.mock('../../../../packages/agent-graph/src/hooks/useGraphSimulation', () => (
|
|||
getExtraWorldBounds: vi.fn(() => []),
|
||||
getLaunchAnchorWorldPosition: vi.fn(() => null),
|
||||
getActivityWorldRect: vi.fn(() => null),
|
||||
resolveNearestOwnerSlot: vi.fn(() => null),
|
||||
clearNodePosition: vi.fn(),
|
||||
resolveNearestOwnerSlot: hoisted.resolveNearestOwnerSlot,
|
||||
resolveNearestOwnerGridTarget: hoisted.resolveNearestOwnerGridTarget,
|
||||
clearNodePosition: hoisted.clearNodePosition,
|
||||
clearTransientOwnerPositions: hoisted.clearTransientOwnerPositions,
|
||||
setNodePosition: vi.fn(),
|
||||
setNodePosition: hoisted.setNodePosition,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -99,6 +126,12 @@ describe('GraphView pan interactions', () => {
|
|||
hoisted.interaction.isDragging.current = false;
|
||||
hoisted.simulationState.nodes = [];
|
||||
hoisted.simulationState.edges = [];
|
||||
hoisted.interaction.handleMouseDown.mockImplementation(() => undefined);
|
||||
hoisted.interaction.handleMouseMove.mockImplementation(() => undefined);
|
||||
hoisted.interaction.handleMouseUp.mockImplementation(() => null);
|
||||
hoisted.interaction.handleDoubleClick.mockImplementation(() => null);
|
||||
hoisted.resolveNearestOwnerSlot.mockImplementation(() => null);
|
||||
hoisted.resolveNearestOwnerGridTarget.mockImplementation(() => null);
|
||||
hoisted.graphControlsProps = null;
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
|
|
@ -107,7 +140,10 @@ describe('GraphView pan interactions', () => {
|
|||
disconnect(): void {}
|
||||
}
|
||||
);
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 1)
|
||||
);
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
|
@ -155,10 +191,13 @@ describe('GraphView pan interactions', () => {
|
|||
hoisted.simulationState.nodes = [source, target];
|
||||
hoisted.simulationState.edges = [edge];
|
||||
|
||||
const midpoint = getEdgeMidpoint(edge, new Map([
|
||||
[source.id, source],
|
||||
[target.id, target],
|
||||
]));
|
||||
const midpoint = getEdgeMidpoint(
|
||||
edge,
|
||||
new Map([
|
||||
[source.id, source],
|
||||
[target.id, target],
|
||||
])
|
||||
);
|
||||
expect(midpoint).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -403,6 +442,77 @@ describe('GraphView pan interactions', () => {
|
|||
expect(hoisted.clearTransientOwnerPositions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('commits grid owner order drops without using radial slot drops', async () => {
|
||||
const source: GraphNode = {
|
||||
id: 'member:demo-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'idle',
|
||||
x: 80,
|
||||
y: 80,
|
||||
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
|
||||
};
|
||||
const target: GraphNode = {
|
||||
id: 'member:demo-team:bob',
|
||||
kind: 'member',
|
||||
label: 'bob',
|
||||
state: 'idle',
|
||||
x: 160,
|
||||
y: 80,
|
||||
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'bob' },
|
||||
};
|
||||
const onOwnerSlotDrop = vi.fn();
|
||||
const onOwnerGridOrderDrop = vi.fn();
|
||||
hoisted.simulationState.nodes = [source, target];
|
||||
hoisted.simulationState.edges = [];
|
||||
hoisted.interaction.dragNodeId.current = source.id;
|
||||
hoisted.interaction.isDragging.current = true;
|
||||
hoisted.resolveNearestOwnerGridTarget.mockReturnValue({
|
||||
targetOwnerId: target.id,
|
||||
previewOwnerX: target.x!,
|
||||
previewOwnerY: target.y!,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphView, {
|
||||
data: {
|
||||
teamName: 'demo-team',
|
||||
nodes: [source, target],
|
||||
edges: [],
|
||||
particles: [],
|
||||
layout: {
|
||||
version: 'stable-slots-v1',
|
||||
mode: 'grid-under-lead',
|
||||
ownerOrder: [source.id, target.id],
|
||||
slotAssignments: {},
|
||||
},
|
||||
},
|
||||
config: { animationEnabled: false },
|
||||
onOwnerSlotDrop,
|
||||
onOwnerGridOrderDrop,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mouseup', {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: 160,
|
||||
clientY: 80,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(onOwnerGridOrderDrop).toHaveBeenCalledWith({
|
||||
nodeId: source.id,
|
||||
targetNodeId: target.id,
|
||||
});
|
||||
expect(onOwnerSlotDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes activity filter state to renderHud and updates it through graph controls', async () => {
|
||||
const renderHud = vi.fn(() => null);
|
||||
|
||||
|
|
@ -433,24 +543,22 @@ describe('GraphView pan interactions', () => {
|
|||
})
|
||||
);
|
||||
|
||||
const controlsProps = hoisted.graphControlsProps as
|
||||
| {
|
||||
filters: {
|
||||
showActivity: boolean;
|
||||
showTasks: boolean;
|
||||
showProcesses: boolean;
|
||||
showEdges: boolean;
|
||||
paused: boolean;
|
||||
};
|
||||
onFiltersChange: (filters: {
|
||||
showActivity: boolean;
|
||||
showTasks: boolean;
|
||||
showProcesses: boolean;
|
||||
showEdges: boolean;
|
||||
paused: boolean;
|
||||
}) => void;
|
||||
}
|
||||
| null;
|
||||
const controlsProps = hoisted.graphControlsProps as {
|
||||
filters: {
|
||||
showActivity: boolean;
|
||||
showTasks: boolean;
|
||||
showProcesses: boolean;
|
||||
showEdges: boolean;
|
||||
paused: boolean;
|
||||
};
|
||||
onFiltersChange: (filters: {
|
||||
showActivity: boolean;
|
||||
showTasks: boolean;
|
||||
showProcesses: boolean;
|
||||
showEdges: boolean;
|
||||
paused: boolean;
|
||||
}) => void;
|
||||
} | null;
|
||||
expect(controlsProps).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -131,6 +131,121 @@ describe('TeamGraphAdapter particles', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('includes the requested graph layout mode in the layout port', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const graph = adapter.adapt(
|
||||
createBaseTeamData(),
|
||||
'my-team',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'grid-under-lead'
|
||||
);
|
||||
|
||||
expect(graph.layout?.mode).toBe('grid-under-lead');
|
||||
});
|
||||
|
||||
it('applies saved grid owner order only in grid-under-lead mode', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const teamData = createBaseTeamData({
|
||||
config: {
|
||||
name: 'My Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentId: 'lead-agent' },
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
],
|
||||
projectPath: '/repo',
|
||||
},
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentType: 'team-lead',
|
||||
agentId: 'lead-agent',
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 1,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentId: 'agent-alice',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 1,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentId: 'agent-bob',
|
||||
},
|
||||
],
|
||||
});
|
||||
const slotAssignments = {
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||
};
|
||||
const gridOwnerOrder = ['agent-bob', 'agent-alice'];
|
||||
|
||||
const gridGraph = adapter.adapt(
|
||||
teamData,
|
||||
'my-team',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
slotAssignments,
|
||||
'grid-under-lead',
|
||||
gridOwnerOrder
|
||||
);
|
||||
const radialGraph = adapter.adapt(
|
||||
teamData,
|
||||
'my-team',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
slotAssignments,
|
||||
'radial',
|
||||
gridOwnerOrder
|
||||
);
|
||||
|
||||
expect(gridGraph.layout?.ownerOrder).toEqual([
|
||||
'member:my-team:agent-bob',
|
||||
'member:my-team:agent-alice',
|
||||
]);
|
||||
expect(radialGraph.layout?.ownerOrder).toEqual([
|
||||
'member:my-team:agent-alice',
|
||||
'member:my-team:agent-bob',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a message particle for a new incoming message from the newest message set', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const baseline = createBaseTeamData();
|
||||
|
|
@ -711,10 +826,9 @@ describe('TeamGraphAdapter particles', () => {
|
|||
const graph = adapter.adapt(next, 'my-team');
|
||||
|
||||
expect(graph.particles).toHaveLength(2);
|
||||
expect(graph.particles.map((particle) => particle.kind).toSorted((a, b) => a.localeCompare(b))).toEqual([
|
||||
'inbox_message',
|
||||
'task_comment',
|
||||
]);
|
||||
expect(
|
||||
graph.particles.map((particle) => particle.kind).toSorted((a, b) => a.localeCompare(b))
|
||||
).toEqual(['inbox_message', 'task_comment']);
|
||||
});
|
||||
|
||||
it('maps lead-owned tasks onto the lead board without routing unknown owners to lead', () => {
|
||||
|
|
@ -924,8 +1038,7 @@ describe('TeamGraphAdapter particles', () => {
|
|||
expect(
|
||||
graph.edges.some(
|
||||
(edge) =>
|
||||
edge.id ===
|
||||
'edge:own:member:my-team:agent-alice:task:my-team:task-owned-by-stable-id'
|
||||
edge.id === 'edge:own:member:my-team:agent-alice:task:my-team:task-owned-by-stable-id'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
|
@ -1535,7 +1648,8 @@ describe('TeamGraphAdapter particles', () => {
|
|||
);
|
||||
|
||||
const overflowNode = graph.nodes.find(
|
||||
(node) => node.kind === 'task' && node.isOverflowStack && node.ownerId === 'member:my-team:alice'
|
||||
(node) =>
|
||||
node.kind === 'task' && node.isOverflowStack && node.ownerId === 'member:my-team:alice'
|
||||
);
|
||||
const blockingEdges = graph.edges.filter((edge) => edge.type === 'blocking');
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
buildStableSlotLayoutSnapshot,
|
||||
computeOwnerFootprints,
|
||||
computeProcessBandWidth,
|
||||
resolveNearestGridOwnerTarget,
|
||||
resolveNearestSlotAssignment,
|
||||
snapshotToWorldBounds,
|
||||
translateSlotFrame,
|
||||
|
|
@ -356,6 +357,38 @@ describe('stable slot layout planner', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('removes the reserved activity column when activity is hidden', () => {
|
||||
const teamName = 'team-hidden-activity-slot';
|
||||
const lead = createLead(teamName);
|
||||
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
showActivity: false,
|
||||
ownerOrder: [alice.id],
|
||||
slotAssignments: {
|
||||
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const [footprint] = computeOwnerFootprints([lead, alice], layout);
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes: [lead, alice],
|
||||
layout,
|
||||
});
|
||||
const frame = snapshot?.memberSlotFrames[0];
|
||||
|
||||
expect(footprint).toBeDefined();
|
||||
expect(footprint?.activityColumnWidth).toBe(0);
|
||||
expect(footprint?.activityColumnHeight).toBe(0);
|
||||
expect(footprint?.boardBandWidth).toBe(footprint?.kanbanBandWidth);
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
expect(frame?.activityColumnRect.width).toBe(0);
|
||||
expect(frame?.activityColumnRect.height).toBe(0);
|
||||
expect(frame?.kanbanBandRect.left).toBe(frame?.boardBandRect.left);
|
||||
});
|
||||
|
||||
it('keeps diagonal ring-zero sectors closer than the legacy coarse central box radius', () => {
|
||||
const teamName = 'team-directional-radius';
|
||||
const lead = createLead(teamName);
|
||||
|
|
@ -393,9 +426,9 @@ describe('stable slot layout planner', () => {
|
|||
const actualRadius = Math.abs(frame!.ownerX / sectorVector.x);
|
||||
|
||||
expect(actualRadius).toBeLessThan(legacyMinRadius);
|
||||
expect(
|
||||
snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect))
|
||||
).toBe(false);
|
||||
expect(snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('grows process band width when an owner has multiple visible process nodes', () => {
|
||||
|
|
@ -740,12 +773,8 @@ describe('stable slot layout planner', () => {
|
|||
layout,
|
||||
});
|
||||
const footprints = computeOwnerFootprints([lead, first, second], layout);
|
||||
const firstRingFrame = snapshot?.memberSlotFrames.find(
|
||||
(frame) => frame.ownerId === first.id
|
||||
);
|
||||
const secondRingFrame = snapshot?.memberSlotFrames.find(
|
||||
(frame) => frame.ownerId === second.id
|
||||
);
|
||||
const firstRingFrame = snapshot?.memberSlotFrames.find((frame) => frame.ownerId === first.id);
|
||||
const secondRingFrame = snapshot?.memberSlotFrames.find((frame) => frame.ownerId === second.id);
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(firstRingFrame).toBeDefined();
|
||||
|
|
@ -756,8 +785,9 @@ describe('stable slot layout planner', () => {
|
|||
throw new Error('expected first footprint for ring-depth test');
|
||||
}
|
||||
|
||||
const ringDelta = Math.hypot(secondRingFrame!.ownerX, secondRingFrame!.ownerY)
|
||||
- Math.hypot(firstRingFrame!.ownerX, firstRingFrame!.ownerY);
|
||||
const ringDelta =
|
||||
Math.hypot(secondRingFrame!.ownerX, secondRingFrame!.ownerY) -
|
||||
Math.hypot(firstRingFrame!.ownerX, firstRingFrame!.ownerY);
|
||||
const sectorVector = { x: 0.82, y: -0.57 };
|
||||
const ownerLocalY =
|
||||
STABLE_SLOT_GEOMETRY.memberSlotInnerPadding + STABLE_SLOT_GEOMETRY.ownerBandHeight / 2;
|
||||
|
|
@ -836,6 +866,141 @@ describe('stable slot layout planner', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('places grid-under-lead members in centered rows of two', () => {
|
||||
const teamName = 'team-grid-layout';
|
||||
const lead = createLead(teamName);
|
||||
const members = [
|
||||
createMember(teamName, 'agent-alice', 'alice'),
|
||||
createMember(teamName, 'agent-bob', 'bob'),
|
||||
createMember(teamName, 'agent-tom', 'tom'),
|
||||
createMember(teamName, 'agent-jack', 'jack'),
|
||||
createMember(teamName, 'agent-eve', 'eve'),
|
||||
];
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
mode: 'grid-under-lead',
|
||||
ownerOrder: members.map((member) => member.id),
|
||||
slotAssignments: {},
|
||||
};
|
||||
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes: [lead, ...members],
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
|
||||
const frames = snapshot!.memberSlotFrames;
|
||||
expect(frames).toHaveLength(5);
|
||||
expect(frames[0].ownerY).toBe(frames[1].ownerY);
|
||||
expect(frames[2].ownerY).toBe(frames[3].ownerY);
|
||||
expect(frames[2].ownerY).toBeGreaterThan(frames[0].ownerY);
|
||||
expect(frames[4].ownerY).toBeGreaterThan(frames[2].ownerY);
|
||||
expect(frames[0].ownerX).toBeLessThan(0);
|
||||
expect(frames[1].ownerX).toBeGreaterThan(0);
|
||||
expect(frames[4].ownerX).toBeCloseTo(0, 3);
|
||||
expect(frames[0].processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight);
|
||||
});
|
||||
|
||||
it('keeps wide grid-under-lead rows from overlapping horizontally', () => {
|
||||
const teamName = 'team-grid-wide';
|
||||
const lead = createLead(teamName);
|
||||
const members = [
|
||||
createMember(teamName, 'agent-alice', 'alice'),
|
||||
createMember(teamName, 'agent-bob', 'bob'),
|
||||
createMember(teamName, 'agent-tom', 'tom'),
|
||||
createMember(teamName, 'agent-jack', 'jack'),
|
||||
];
|
||||
const tasks = [
|
||||
createTask(teamName, 'alice-todo', members[0].id, { taskStatus: 'pending' }),
|
||||
createTask(teamName, 'alice-wip', members[0].id, { taskStatus: 'in_progress' }),
|
||||
createTask(teamName, 'alice-done', members[0].id, { taskStatus: 'completed' }),
|
||||
createTask(teamName, 'alice-review', members[0].id, { reviewState: 'review' }),
|
||||
createTask(teamName, 'bob-todo', members[1].id, { taskStatus: 'pending' }),
|
||||
createTask(teamName, 'bob-wip', members[1].id, { taskStatus: 'in_progress' }),
|
||||
createTask(teamName, 'bob-done', members[1].id, { taskStatus: 'completed' }),
|
||||
createTask(teamName, 'bob-review', members[1].id, { reviewState: 'review' }),
|
||||
];
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
mode: 'grid-under-lead',
|
||||
ownerOrder: members.map((member) => member.id),
|
||||
slotAssignments: {
|
||||
[members[0].id]: { ringIndex: 3, sectorIndex: 7 },
|
||||
[members[1].id]: { ringIndex: 3, sectorIndex: 7 },
|
||||
},
|
||||
};
|
||||
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes: [lead, ...members, ...tasks],
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
expect(
|
||||
horizontalGapBetween(
|
||||
snapshot!.memberSlotFrames[0].bounds,
|
||||
snapshot!.memberSlotFrames[1].bounds
|
||||
)
|
||||
).toBeGreaterThanOrEqual(STABLE_SLOT_GEOMETRY.slotHorizontalGap);
|
||||
expect(snapshot!.memberSlotFrames[0].ringIndex).toBe(0);
|
||||
expect(snapshot!.memberSlotFrames[0].sectorIndex).toBe(0);
|
||||
expect(snapshot!.memberSlotFrames[1].ringIndex).toBe(0);
|
||||
expect(snapshot!.memberSlotFrames[1].sectorIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('uses a separate nearest owner target for grid-under-lead drag-drop', () => {
|
||||
const teamName = 'team-grid-drag-target';
|
||||
const lead = createLead(teamName);
|
||||
const members = [
|
||||
createMember(teamName, 'agent-alice', 'alice'),
|
||||
createMember(teamName, 'agent-bob', 'bob'),
|
||||
];
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
mode: 'grid-under-lead',
|
||||
ownerOrder: members.map((member) => member.id),
|
||||
slotAssignments: {},
|
||||
};
|
||||
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes: [lead, ...members],
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
const targetFrame = snapshot!.memberSlotFrames[1]!;
|
||||
|
||||
expect(
|
||||
resolveNearestSlotAssignment({
|
||||
ownerId: members[0].id,
|
||||
ownerX: targetFrame.ownerX,
|
||||
ownerY: targetFrame.ownerY,
|
||||
nodes: [lead, ...members],
|
||||
snapshot: snapshot!,
|
||||
layout,
|
||||
})
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
resolveNearestGridOwnerTarget({
|
||||
ownerId: members[0].id,
|
||||
ownerX: targetFrame.ownerX,
|
||||
ownerY: targetFrame.ownerY,
|
||||
snapshot: snapshot!,
|
||||
})
|
||||
).toEqual({
|
||||
targetOwnerId: members[1].id,
|
||||
previewOwnerX: targetFrame.ownerX,
|
||||
previewOwnerY: targetFrame.ownerY,
|
||||
});
|
||||
});
|
||||
|
||||
it('positions lead-owned tasks inside the lead kanban band instead of unassigned', () => {
|
||||
const teamName = 'team-lead-owned-tasks';
|
||||
const lead = createLead(teamName);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RuntimeProviderManagementPanelView } from '../../../../src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView';
|
||||
|
||||
import type {
|
||||
RuntimeProviderManagementActions,
|
||||
RuntimeProviderManagementState,
|
||||
} from '../../../../src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement';
|
||||
|
||||
function createState(
|
||||
overrides: Partial<RuntimeProviderManagementState> = {}
|
||||
): RuntimeProviderManagementState {
|
||||
return {
|
||||
view: {
|
||||
runtimeId: 'opencode',
|
||||
title: 'OpenCode',
|
||||
runtime: {
|
||||
state: 'ready',
|
||||
cliPath: '/usr/local/bin/opencode',
|
||||
version: '1.14.24',
|
||||
managedProfile: 'active',
|
||||
localAuth: 'synced',
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
providerId: 'openrouter',
|
||||
displayName: 'OpenRouter',
|
||||
state: 'available',
|
||||
ownership: [],
|
||||
recommended: true,
|
||||
modelCount: 4,
|
||||
defaultModelId: null,
|
||||
authMethods: ['api'],
|
||||
actions: [
|
||||
{
|
||||
id: 'connect',
|
||||
label: 'Connect',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
requiresSecret: true,
|
||||
ownershipScope: 'managed',
|
||||
},
|
||||
],
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
defaultModel: null,
|
||||
fallbackModel: null,
|
||||
diagnostics: [],
|
||||
},
|
||||
providers: [],
|
||||
selectedProviderId: 'openrouter',
|
||||
activeFormProviderId: null,
|
||||
apiKeyValue: '',
|
||||
modelPickerProviderId: null,
|
||||
modelPickerMode: null,
|
||||
modelQuery: '',
|
||||
models: [],
|
||||
modelsLoading: false,
|
||||
modelsError: null,
|
||||
selectedModelId: null,
|
||||
testingModelId: null,
|
||||
savingDefaultModelId: null,
|
||||
modelResults: {},
|
||||
loading: false,
|
||||
savingProviderId: null,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createActions(): RuntimeProviderManagementActions {
|
||||
return {
|
||||
refresh: vi.fn(() => Promise.resolve()),
|
||||
selectProvider: vi.fn(),
|
||||
startConnect: vi.fn(),
|
||||
cancelConnect: vi.fn(),
|
||||
setApiKeyValue: vi.fn(),
|
||||
submitConnect: vi.fn(() => Promise.resolve()),
|
||||
forgetProvider: vi.fn(() => Promise.resolve()),
|
||||
openModelPicker: vi.fn(),
|
||||
closeModelPicker: vi.fn(),
|
||||
setModelQuery: vi.fn(),
|
||||
selectModel: vi.fn(),
|
||||
useModelForNewTeams: vi.fn(),
|
||||
testModel: vi.fn(() => Promise.resolve()),
|
||||
setDefaultModel: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
}
|
||||
|
||||
describe('RuntimeProviderManagementPanelView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders provider actions and opens API-key form state without exposing a raw secret', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const actions = createActions();
|
||||
const state = createState();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: { ...state, providers: state.view?.providers ?? [] },
|
||||
actions,
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenRouter');
|
||||
expect(host.textContent).toContain('4 models');
|
||||
|
||||
await act(async () => {
|
||||
const connect = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Connect')
|
||||
);
|
||||
connect?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(actions.startConnect).toHaveBeenCalledWith('openrouter');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state: {
|
||||
...state,
|
||||
providers: state.view?.providers ?? [],
|
||||
activeFormProviderId: 'openrouter',
|
||||
apiKeyValue: 'sk-secret-value',
|
||||
},
|
||||
actions,
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('input[type="password"]')).not.toBeNull();
|
||||
expect(host.textContent).not.toContain('sk-secret-value');
|
||||
});
|
||||
|
||||
it('renders connected provider model picker actions', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const actions = createActions();
|
||||
const connectedProvider = {
|
||||
providerId: 'openrouter',
|
||||
displayName: 'OpenRouter',
|
||||
state: 'connected' as const,
|
||||
ownership: ['managed'] as const,
|
||||
recommended: true,
|
||||
modelCount: 174,
|
||||
defaultModelId: null,
|
||||
authMethods: ['api'] as const,
|
||||
actions: [
|
||||
{
|
||||
id: 'use' as const,
|
||||
label: 'Use',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
requiresSecret: false,
|
||||
ownershipScope: 'runtime' as const,
|
||||
},
|
||||
{
|
||||
id: 'set-default' as const,
|
||||
label: 'Set default',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
requiresSecret: false,
|
||||
ownershipScope: 'runtime' as const,
|
||||
},
|
||||
],
|
||||
detail: null,
|
||||
};
|
||||
const state = createState({
|
||||
view: {
|
||||
...createState().view!,
|
||||
providers: [connectedProvider],
|
||||
},
|
||||
providers: [connectedProvider],
|
||||
modelPickerProviderId: 'openrouter',
|
||||
modelPickerMode: 'use',
|
||||
models: [
|
||||
{
|
||||
providerId: 'openrouter',
|
||||
modelId: 'openrouter/openai/gpt-oss-20b:free',
|
||||
displayName: 'openai/gpt-oss-20b:free',
|
||||
sourceLabel: 'OpenRouter',
|
||||
free: true,
|
||||
default: false,
|
||||
availability: 'untested',
|
||||
},
|
||||
],
|
||||
selectedModelId: 'openrouter/openai/gpt-oss-20b:free',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(RuntimeProviderManagementPanelView, {
|
||||
state,
|
||||
actions,
|
||||
disabled: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('openrouter/openai/gpt-oss-20b:free');
|
||||
expect(host.textContent).toContain('Use for new teams');
|
||||
expect(host.textContent).toContain('Set OpenCode default');
|
||||
|
||||
await act(async () => {
|
||||
const useButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Use for new teams')
|
||||
);
|
||||
useButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(actions.useModelForNewTeams).toHaveBeenCalledWith(
|
||||
'openrouter/openai/gpt-oss-20b:free'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canConnectWithApiKey,
|
||||
canForgetManagedCredential,
|
||||
selectInitialProviderId,
|
||||
} from '../../../../src/features/runtime-provider-management/core/domain';
|
||||
|
||||
import type {
|
||||
RuntimeProviderConnectionDto,
|
||||
RuntimeProviderManagementViewDto,
|
||||
} from '../../../../src/features/runtime-provider-management/contracts';
|
||||
|
||||
function provider(overrides: Partial<RuntimeProviderConnectionDto>): RuntimeProviderConnectionDto {
|
||||
return {
|
||||
providerId: 'custom',
|
||||
displayName: 'Custom',
|
||||
state: 'not-connected',
|
||||
ownership: [],
|
||||
recommended: false,
|
||||
modelCount: 0,
|
||||
defaultModelId: null,
|
||||
authMethods: [],
|
||||
actions: [],
|
||||
detail: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function view(providers: RuntimeProviderConnectionDto[]): RuntimeProviderManagementViewDto {
|
||||
return {
|
||||
runtimeId: 'opencode',
|
||||
title: 'OpenCode',
|
||||
runtime: {
|
||||
state: 'ready',
|
||||
cliPath: '/usr/local/bin/opencode',
|
||||
version: '1.14.24',
|
||||
managedProfile: 'active',
|
||||
localAuth: 'synced',
|
||||
},
|
||||
providers,
|
||||
defaultModel: null,
|
||||
fallbackModel: null,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('runtime provider management domain', () => {
|
||||
it('selects a recommended not-connected provider before already connected providers', () => {
|
||||
expect(
|
||||
selectInitialProviderId(
|
||||
view([
|
||||
provider({ providerId: 'openai', state: 'connected' }),
|
||||
provider({ providerId: 'openrouter', recommended: true, state: 'available' }),
|
||||
])
|
||||
)
|
||||
).toBe('openrouter');
|
||||
});
|
||||
|
||||
it('requires explicit API auth and enabled connect action for API-key connect', () => {
|
||||
expect(
|
||||
canConnectWithApiKey(
|
||||
provider({
|
||||
authMethods: ['api'],
|
||||
actions: [
|
||||
{
|
||||
id: 'connect',
|
||||
label: 'Connect',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
requiresSecret: true,
|
||||
ownershipScope: 'managed',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
canConnectWithApiKey(
|
||||
provider({
|
||||
authMethods: [],
|
||||
actions: [
|
||||
{
|
||||
id: 'configure',
|
||||
label: 'Configure manually',
|
||||
enabled: false,
|
||||
disabledReason: 'Manual config is required.',
|
||||
requiresSecret: false,
|
||||
ownershipScope: 'runtime',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes forget only when the backend sends an enabled forget action', () => {
|
||||
expect(
|
||||
canForgetManagedCredential(
|
||||
provider({
|
||||
actions: [
|
||||
{
|
||||
id: 'forget',
|
||||
label: 'Forget',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
requiresSecret: false,
|
||||
ownershipScope: 'managed',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -189,12 +189,14 @@ describe('cliInstallerSlice', () => {
|
|||
|
||||
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||
|
||||
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject({
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
});
|
||||
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
|
||||
{
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies model-only OpenCode fallback as incomplete for progress events', () => {
|
||||
|
|
@ -283,12 +285,14 @@ describe('cliInstallerSlice', () => {
|
|||
|
||||
const merged = mergeCliStatusPreservingHydratedProviders(current, incoming);
|
||||
|
||||
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject({
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Runtime not found.',
|
||||
});
|
||||
expect(merged.providers.find((provider) => provider.providerId === 'opencode')).toMatchObject(
|
||||
{
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Runtime not found.',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -487,12 +491,12 @@ describe('cliInstallerSlice', () => {
|
|||
});
|
||||
|
||||
it('drops global loading once metadata is ready and keeps only unresolved providers loading', async () => {
|
||||
let resolveCodexStatus!: (
|
||||
value: CliInstallationStatus['providers'][number]
|
||||
) => void;
|
||||
const pendingCodexStatus = new Promise<CliInstallationStatus['providers'][number]>((resolve) => {
|
||||
resolveCodexStatus = resolve;
|
||||
});
|
||||
let resolveCodexStatus!: (value: CliInstallationStatus['providers'][number]) => void;
|
||||
const pendingCodexStatus = new Promise<CliInstallationStatus['providers'][number]>(
|
||||
(resolve) => {
|
||||
resolveCodexStatus = resolve;
|
||||
}
|
||||
);
|
||||
const mockStatus: CliInstallationStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
|
|
@ -583,11 +587,12 @@ describe('cliInstallerSlice', () => {
|
|||
gemini: false,
|
||||
opencode: false,
|
||||
});
|
||||
expect(useStore.getState().cliStatus?.providers.find((provider) => provider.providerId === 'codex'))
|
||||
.toMatchObject({
|
||||
authenticated: true,
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
});
|
||||
expect(
|
||||
useStore.getState().cliStatus?.providers.find((provider) => provider.providerId === 'codex')
|
||||
).toMatchObject({
|
||||
authenticated: true,
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes OpenCode when bootstrap metadata only has fallback models', async () => {
|
||||
|
|
@ -670,13 +675,16 @@ describe('cliInstallerSlice', () => {
|
|||
gemini: false,
|
||||
opencode: false,
|
||||
});
|
||||
expect(useStore.getState().cliStatus?.providers.find((provider) => provider.providerId === 'opencode'))
|
||||
.toMatchObject({
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
});
|
||||
expect(
|
||||
useStore
|
||||
.getState()
|
||||
.cliStatus?.providers.find((provider) => provider.providerId === 'opencode')
|
||||
).toMatchObject({
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -738,7 +746,9 @@ describe('cliInstallerSlice', () => {
|
|||
});
|
||||
expect(useStore.getState().cliStatusError).toBe('Failed to refresh anthropic status');
|
||||
expect(
|
||||
useStore.getState().cliStatus?.providers.find((provider) => provider.providerId === 'anthropic')
|
||||
useStore
|
||||
.getState()
|
||||
.cliStatus?.providers.find((provider) => provider.providerId === 'anthropic')
|
||||
).toMatchObject({
|
||||
displayName: 'Anthropic',
|
||||
authenticated: false,
|
||||
|
|
@ -751,9 +761,11 @@ describe('cliInstallerSlice', () => {
|
|||
|
||||
it('marks authStatusChecking true while a multimodel provider refresh is in flight and clears it on success', async () => {
|
||||
let resolveProviderStatus!: (value: CliInstallationStatus['providers'][number]) => void;
|
||||
const pendingProviderStatus = new Promise<CliInstallationStatus['providers'][number]>((resolve) => {
|
||||
resolveProviderStatus = resolve;
|
||||
});
|
||||
const pendingProviderStatus = new Promise<CliInstallationStatus['providers'][number]>(
|
||||
(resolve) => {
|
||||
resolveProviderStatus = resolve;
|
||||
}
|
||||
);
|
||||
|
||||
useStore.setState({
|
||||
cliStatus: createMultimodelStatus([
|
||||
|
|
@ -800,6 +812,53 @@ describe('cliInstallerSlice', () => {
|
|||
});
|
||||
expect(useStore.getState().cliStatus?.authStatusChecking).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps OpenCode refresh status-only even when model verification is requested', async () => {
|
||||
const nextProvider = createMultimodelProvider({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
canLoginFromUi: false,
|
||||
models: ['openrouter/openai/gpt-oss-20b:free'],
|
||||
modelAvailability: [],
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
});
|
||||
|
||||
useStore.setState({
|
||||
cliStatus: createMultimodelStatus([
|
||||
createMultimodelProvider({
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
canLoginFromUi: false,
|
||||
models: ['openrouter/openai/gpt-oss-20b:free'],
|
||||
modelAvailability: [
|
||||
{
|
||||
modelId: 'openrouter/openai/gpt-oss-20b:free',
|
||||
status: 'unknown',
|
||||
reason: 'old bulk check failed',
|
||||
checkedAt: '2026-04-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
}),
|
||||
]),
|
||||
});
|
||||
vi.mocked(api.cliInstaller.getProviderStatus).mockResolvedValue(nextProvider);
|
||||
|
||||
await useStore.getState().fetchCliProviderStatus('opencode', { verifyModels: true });
|
||||
|
||||
expect(api.cliInstaller.verifyProviderModels).not.toHaveBeenCalled();
|
||||
expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledWith('opencode');
|
||||
expect(
|
||||
useStore
|
||||
.getState()
|
||||
.cliStatus?.providers.find((provider) => provider.providerId === 'opencode')
|
||||
?.modelAvailability
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress event handling', () => {
|
||||
|
|
|
|||
|
|
@ -272,7 +272,95 @@ describe('teamSlice actions', () => {
|
|||
expect(result.messageId).toBe('m-opencode-1');
|
||||
expect(store.getState().lastSendMessageResult).toBeNull();
|
||||
expect(store.getState().sendMessageError).toBeNull();
|
||||
expect(store.getState().sendMessageWarning).toContain('OpenCode runtime delivery failed');
|
||||
expect(store.getState().sendMessageWarning).toBe(
|
||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.'
|
||||
);
|
||||
expect(store.getState().sendMessageDebugDetails).toMatchObject({
|
||||
messageId: 'm-opencode-1',
|
||||
providerId: 'opencode',
|
||||
delivered: false,
|
||||
responsePending: null,
|
||||
responseState: null,
|
||||
ledgerStatus: null,
|
||||
acceptanceUnknown: null,
|
||||
reason: 'opencode_runtime_not_active',
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('stores hidden OpenCode runtime diagnostics while live response is pending', async () => {
|
||||
const store = createSliceStore();
|
||||
hoisted.sendMessage.mockResolvedValue({
|
||||
deliveredToInbox: true,
|
||||
messageId: 'm-opencode-pending',
|
||||
runtimeDelivery: {
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
ledgerStatus: 'accepted',
|
||||
acceptanceUnknown: false,
|
||||
reason: 'assistant_response_pending',
|
||||
diagnostics: ['assistant_response_pending'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await store.getState().sendTeamMessage('my-team', {
|
||||
member: 'bob',
|
||||
text: 'hello',
|
||||
});
|
||||
|
||||
expect(store.getState().lastSendMessageResult).toBe(result);
|
||||
expect(store.getState().sendMessageWarning).toBe(
|
||||
'OpenCode runtime delivery is still being checked. Message was saved and will be retried if needed.'
|
||||
);
|
||||
expect(store.getState().sendMessageDebugDetails).toMatchObject({
|
||||
messageId: 'm-opencode-pending',
|
||||
providerId: 'opencode',
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
ledgerStatus: 'accepted',
|
||||
acceptanceUnknown: false,
|
||||
reason: 'assistant_response_pending',
|
||||
diagnostics: ['assistant_response_pending'],
|
||||
});
|
||||
});
|
||||
|
||||
it('clears OpenCode runtime diagnostics after normal success or send failure', async () => {
|
||||
const store = createSliceStore();
|
||||
hoisted.sendMessage
|
||||
.mockResolvedValueOnce({
|
||||
deliveredToInbox: true,
|
||||
messageId: 'm-opencode-failed',
|
||||
runtimeDelivery: {
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: false,
|
||||
reason: 'runtime_unavailable',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
deliveredToInbox: true,
|
||||
messageId: 'm-ok',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await store.getState().sendTeamMessage('my-team', { member: 'bob', text: 'first' });
|
||||
expect(store.getState().sendMessageDebugDetails?.messageId).toBe('m-opencode-failed');
|
||||
|
||||
await store.getState().sendTeamMessage('my-team', { member: 'alice', text: 'second' });
|
||||
expect(store.getState().sendMessageWarning).toBeNull();
|
||||
expect(store.getState().sendMessageDebugDetails).toBeNull();
|
||||
expect(store.getState().lastSendMessageResult?.messageId).toBe('m-ok');
|
||||
|
||||
await expect(
|
||||
store.getState().sendTeamMessage('my-team', { member: 'alice', text: 'third' })
|
||||
).rejects.toThrow('boom');
|
||||
expect(store.getState().sendMessageWarning).toBeNull();
|
||||
expect(store.getState().sendMessageDebugDetails).toBeNull();
|
||||
expect(store.getState().sendMessageError).toBe('boom');
|
||||
});
|
||||
|
||||
it('maps task status verify failure in updateKanban and rethrows', async () => {
|
||||
|
|
@ -349,6 +437,83 @@ describe('teamSlice actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('stores graph layout mode without mutating radial slot assignments', () => {
|
||||
const store = createSliceStore();
|
||||
store
|
||||
.getState()
|
||||
.commitTeamGraphOwnerSlotDrop('my-team', 'agent-alice', { ringIndex: 0, sectorIndex: 2 });
|
||||
|
||||
store.getState().setTeamGraphLayoutMode('my-team', 'grid-under-lead');
|
||||
|
||||
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('grid-under-lead');
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||
});
|
||||
|
||||
store.getState().setTeamGraphLayoutMode('my-team', 'radial');
|
||||
|
||||
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('radial');
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('swaps grid owners from canonical visible order without mutating radial slots', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
teamDataCacheByName: {
|
||||
'my-team': createTeamSnapshot({
|
||||
config: {
|
||||
name: 'My Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentId: 'lead-agent' },
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
{ name: 'tom', agentId: 'agent-tom' },
|
||||
],
|
||||
},
|
||||
members: [
|
||||
{ name: 'team-lead', agentId: 'lead-agent', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
{ name: 'tom', agentId: 'agent-tom' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
slotAssignmentsByTeam: {
|
||||
'my-team': {
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.getState().swapTeamGraphGridOwners('my-team', 'agent-alice', 'agent-tom');
|
||||
|
||||
expect(store.getState().gridOwnerOrderByTeam['my-team']).toEqual([
|
||||
'agent-tom',
|
||||
'agent-bob',
|
||||
'agent-alice',
|
||||
]);
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps grid owner order unchanged when radial slots are committed', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
gridOwnerOrderByTeam: {
|
||||
'my-team': ['agent-bob', 'agent-alice'],
|
||||
},
|
||||
});
|
||||
|
||||
store
|
||||
.getState()
|
||||
.commitTeamGraphOwnerSlotDrop('my-team', 'agent-alice', { ringIndex: 0, sectorIndex: 2 });
|
||||
|
||||
expect(store.getState().gridOwnerOrderByTeam['my-team']).toEqual(['agent-bob', 'agent-alice']);
|
||||
});
|
||||
|
||||
it('replaces persisted slot assignments with defaults while persistence is disabled', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
|
|
|
|||
Loading…
Reference in a new issue