From 19b6937446afa92388f0e0f72db9cbdfa41416ea Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 25 Apr 2026 14:30:10 +0300 Subject: [PATCH] feat(opencode): harden delivery and provider UI --- .../src/internal/messages.js | 127 + agent-teams-controller/src/internal/tasks.js | 36 +- .../test/controller.test.js | 72 + .../opencode-delivery-watchdog-plan.md | 2257 +++++++++++++++++ mcp-server/src/tools/messageTools.ts | 5 +- mcp-server/test/tools.test.ts | 4 + .../src/hooks/useGraphInteraction.ts | 75 +- .../src/hooks/useGraphSimulation.ts | 91 +- packages/agent-graph/src/index.ts | 1 + .../agent-graph/src/layout/stableSlots.ts | 284 ++- packages/agent-graph/src/ports/index.ts | 1 + packages/agent-graph/src/ports/types.ts | 3 + packages/agent-graph/src/ui/GraphControls.tsx | 67 +- packages/agent-graph/src/ui/GraphView.tsx | 151 +- .../renderer/adapters/TeamGraphAdapter.ts | 68 +- .../renderer/hooks/useTeamGraphAdapter.ts | 10 +- .../hooks/useTeamGraphSurfaceActions.ts | 34 +- .../renderer/ui/TeamGraphOverlay.tsx | 9 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 5 +- .../contracts/api.ts | 33 + .../contracts/channels.ts | 8 + .../contracts/index.ts | 3 + .../contracts/types.ts | 184 ++ .../RuntimeProviderManagementPort.ts | 3 + .../core/application/index.ts | 2 + .../runtimeProviderManagementUseCases.ts | 55 + .../core/domain/index.ts | 1 + .../core/domain/providerManagementView.ts | 98 + .../runtime-provider-management/index.ts | 1 + .../registerRuntimeProviderManagementIpc.ts | 181 ++ .../createRuntimeProviderManagementFeature.ts | 47 + .../runtime-provider-management/main/index.ts | 8 + ...TeamsRuntimeProviderManagementCliClient.ts | 376 +++ .../createRuntimeProviderManagementBridge.ts | 54 + .../preload/index.ts | 1 + .../RuntimeProviderManagementPanel.tsx | 27 + .../adapters/createTeamDefaultModelWriter.ts | 9 + .../hooks/useRuntimeProviderManagement.ts | 579 +++++ .../renderer/index.ts | 1 + .../ui/RuntimeProviderManagementPanelView.tsx | 618 +++++ src/main/index.ts | 22 + src/main/ipc/teams.ts | 22 +- .../infrastructure/CliInstallerService.ts | 40 +- .../runtime/ClaudeMultimodelBridgeService.ts | 109 +- src/main/services/team/TeamBackupService.ts | 20 + .../services/team/TeamProvisioningService.ts | 1417 ++++++++++- .../bridge/OpenCodeBridgeCommandContract.ts | 60 + .../bridge/OpenCodeReadinessBridge.ts | 46 + .../delivery/OpenCodePromptDeliveryLedger.ts | 833 ++++++ .../OpenCodePromptDeliveryWatchdog.ts | 138 + .../opencode/store/RuntimeStoreManifest.ts | 10 + .../runtime/OpenCodeTeamRuntimeAdapter.ts | 66 +- src/preload/index.ts | 2 + src/renderer/api/httpClient.ts | 58 + .../components/dashboard/CliStatusBanner.tsx | 18 +- .../runtime/ProviderModelBadges.tsx | 115 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 1265 ++++----- .../settings/sections/CliStatusSection.tsx | 8 +- .../components/team/TeamDetailView.tsx | 3 + .../team/dialogs/SendMessageDialog.tsx | 20 +- .../team/messages/MessageComposer.tsx | 13 +- .../team/messages/MessagesPanel.tsx | 5 + .../team/messages/OpenCodeDeliveryWarning.tsx | 117 + .../store/slices/cliInstallerSlice.ts | 6 +- src/renderer/store/slices/teamSlice.ts | 115 +- .../openCodeRuntimeDeliveryDiagnostics.ts | 79 + src/shared/types/api.ts | 4 + src/shared/types/team.ts | 32 + ...gisterRuntimeProviderManagementIpc.test.ts | 141 + test/main/ipc/teams.test.ts | 82 +- .../CliInstallerService.test.ts | 46 +- .../ClaudeMultimodelBridgeService.test.ts | 156 +- .../team/OpenCodePromptDeliveryLedger.test.ts | 444 ++++ .../OpenCodeSemanticMessaging.live.test.ts | 58 +- .../team/OpenCodeTeamRuntimeAdapter.test.ts | 6 + .../services/team/TeamBackupService.test.ts | 85 + .../team/TeamProvisioningService.test.ts | 997 ++++++++ .../TeamProvisioningServicePrompts.test.ts | 23 + .../team/TeamProvisioningServiceRelay.test.ts | 310 +++ .../extensions/ExtensionStoreView.test.ts | 76 +- .../runtime/ProviderModelBadges.test.tsx | 105 + .../ProviderRuntimeSettingsDialog.test.ts | 110 +- .../team/dialogs/SendMessageDialog.test.tsx | 368 +++ .../team/messages/MessagesPanel.test.ts | 1 + .../messages/OpenCodeDeliveryWarning.test.tsx | 150 ++ .../agent-graph/GraphControls.test.ts | 44 + .../features/agent-graph/GraphView.test.ts | 160 +- .../agent-graph/TeamGraphAdapter.test.ts | 128 +- .../agent-graph/useGraphSimulation.test.ts | 187 +- ...RuntimeProviderManagementPanelView.test.ts | 238 ++ .../providerManagementView.test.ts | 116 + test/renderer/store/cliInstallerSlice.test.ts | 127 +- test/renderer/store/teamSlice.test.ts | 167 +- 93 files changed, 13243 insertions(+), 1314 deletions(-) create mode 100644 docs/team-management/opencode-delivery-watchdog-plan.md create mode 100644 src/features/runtime-provider-management/contracts/api.ts create mode 100644 src/features/runtime-provider-management/contracts/channels.ts create mode 100644 src/features/runtime-provider-management/contracts/index.ts create mode 100644 src/features/runtime-provider-management/contracts/types.ts create mode 100644 src/features/runtime-provider-management/core/application/RuntimeProviderManagementPort.ts create mode 100644 src/features/runtime-provider-management/core/application/index.ts create mode 100644 src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts create mode 100644 src/features/runtime-provider-management/core/domain/index.ts create mode 100644 src/features/runtime-provider-management/core/domain/providerManagementView.ts create mode 100644 src/features/runtime-provider-management/index.ts create mode 100644 src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts create mode 100644 src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts create mode 100644 src/features/runtime-provider-management/main/index.ts create mode 100644 src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts create mode 100644 src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts create mode 100644 src/features/runtime-provider-management/preload/index.ts create mode 100644 src/features/runtime-provider-management/renderer/RuntimeProviderManagementPanel.tsx create mode 100644 src/features/runtime-provider-management/renderer/adapters/createTeamDefaultModelWriter.ts create mode 100644 src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts create mode 100644 src/features/runtime-provider-management/renderer/index.ts create mode 100644 src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx create mode 100644 src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts create mode 100644 src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts create mode 100644 src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx create mode 100644 src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts create mode 100644 test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts create mode 100644 test/main/services/team/OpenCodePromptDeliveryLedger.test.ts create mode 100644 test/renderer/components/runtime/ProviderModelBadges.test.tsx create mode 100644 test/renderer/components/team/dialogs/SendMessageDialog.test.tsx create mode 100644 test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx create mode 100644 test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts create mode 100644 test/renderer/features/runtime-provider-management/providerManagementView.test.ts diff --git a/agent-teams-controller/src/internal/messages.js b/agent-teams-controller/src/internal/messages.js index a35e10fe..357feb7f 100644 --- a/agent-teams-controller/src/internal/messages.js +++ b/agent-teams-controller/src/internal/messages.js @@ -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); } diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 2ed89b1a..5e05992f 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -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.', ] : []), diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 81bb58a1..e95b3655 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -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 }); diff --git a/docs/team-management/opencode-delivery-watchdog-plan.md b/docs/team-management/opencode-delivery-watchdog-plan.md new file mode 100644 index 00000000..321ff406 --- /dev/null +++ b/docs/team-management/opencode-delivery-watchdog-plan.md @@ -0,0 +1,2257 @@ +# OpenCode Delivery Ledger And Bounded Retry Plan + +## Summary + +Recommended implementation: add an OpenCode prompt-delivery ledger with explicit `accepted`, `responded`, `unanswered`, `retried`, and `failed` states, plus a bounded retry watchdog. + +Goal: when the app delivers an inbox row into a live OpenCode teammate session, we stop treating `promptAsync()` acceptance as success. Acceptance only means the bridge accepted the prompt. A row is fully delivered only after OpenCode shows evidence that the teammate acted on it. + +Expected implementation size: + +- `claude_team`: 🎯 8 🛡️ 9 🧠 7 - roughly `950-1450` changed lines. +- `agent_teams_orchestrator`: 🎯 8 🛡️ 9 🧠 6 - roughly `500-850` changed lines. +- Tests and fixtures: 🎯 9 🛡️ 9 🧠 6 - roughly `750-1250` changed lines. + +The plan is intentionally OpenCode-only. Native Codex, Claude, and Gemini teammate delivery paths stay unchanged. + +## Why This Is Needed + +Current OpenCode relay semantics are too optimistic: + +1. `claude_team` writes an inbox row to `inboxes/.json`. +2. `relayOpenCodeMemberInboxMessages()` sees the unread row. +3. It calls `deliverOpenCodeMemberMessage()`. +4. `OpenCodeTeamRuntimeAdapter.sendMessageToMember()` calls the orchestrator bridge. +5. `OpenCodeBridgeCommandHandler.runSendMessage()` calls `promptAsync()`. +6. The bridge returns `accepted: true`. +7. `claude_team` marks the inbox row `read: true`. + +The problem: `accepted: true` only proves that OpenCode accepted a prompt into a session. It does not prove the model used tools, sent a visible message, touched a task, or even produced meaningful assistant text. + +Observed failure mode from `beacon-desk-92121221`: + +- Jack and Tom had task assignment rows marked read. +- The OpenCode sessions had received the app-delivery prompts. +- The transcript showed empty assistant turns after those prompts. +- The assigned tasks stayed `in_progress`. +- No comments, task events, or visible messages were produced. + +That is a durable message-loss bug because the inbox row is already `read`, so normal retry logic has nothing left to pick up. + +## Current Code Paths + +### App-side OpenCode delivery + +Primary files: + +- `src/main/services/team/TeamProvisioningService.ts` +- `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` +- `src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts` +- `src/main/ipc/teams.ts` +- `src/shared/types/team.ts` + +Important current behavior: + +```ts +// TeamProvisioningService.ts +const delivery = await this.deliverOpenCodeMemberMessage(teamName, { + memberName, + text: message.text, + messageId: message.messageId, + replyRecipient, + actionMode, + taskRefs, +}); + +if (delivery.delivered) { + await this.markInboxMessagesRead(teamName, memberName, [message]); +} +``` + +`delivery.delivered` currently means bridge prompt acceptance. It should become "bridge accepted plus response proof exists" for OpenCode inbox relay completion. + +### Deeper research findings + +The highest-risk parts after code review are: + +1. `promptAsync()` returns `Promise`. + It does not return the OpenCode user message id created by `/session/:id/prompt_async`, so v1 cannot rely on a prompt return value as proof. The response observer must use transcript reconcile. + +2. `sendSessionMessage()` does return an `OpenCodeMessage`, but it is the synchronous `/session/:id/message` path with a long request timeout. It is a bad default for live app delivery because it can block UI/runtime relay behind model execution. It is useful as a future fallback, not the v1 delivery primitive. + +3. `markInboxMessagesRead()` writes a separate JSON file from any new ledger file. There is no atomic cross-file commit. The ledger must represent "response observed, inbox read commit still pending" so crashes or mark-read failures do not cause duplicate prompts. + +4. Controller inbox writes always create a `messageId` when one is missing. Task assignment notifications use `messageStore.buildMessage()`, so they have stable ids. That means the OpenCode prompt ledger can require `inboxMessageId` for every eligible OpenCode relay row. + +5. OpenCode runtime stores are lane-scoped for secondary lanes. The prompt delivery ledger should be lane-scoped too, not only team-root scoped, because run id, session id, and manifest recovery are lane facts. + +6. Existing `relayedMemberInboxMessageIds` is a native relay dedupe cache. It is not durable, it is not response-aware, and it should not be reused as the OpenCode delivery truth. + +7. `opencode.sendMessage` in `OpenCodeReadinessBridge` is already a direct bridge command, not a state-changing command service call. The new observe command should follow this pattern. Do not route observe through `OpenCodeStateChangingBridgeCommandService`, because observe must not acquire state-changing command leases, write command-ledger entries, or commit runtime-store manifests. + +8. State-changing bridge service is currently only for: + +```ts +'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' +``` + +`opencode.observeMessageDelivery` must not be added to that union. It should be added only to the general bridge command contract and orchestrator supported-command dispatch. + +9. `reconcileSession(record, { limit })` can miss old prompt history when a long OpenCode session has many later events. If the observer cannot find the inbound `messageId` or the `prePromptCursor` inside the limited transcript, it must do one wider/full-history reconcile before returning `prompt_not_indexed` or `empty_assistant_turn`. + +10. Real OpenCode fixtures use canonical MCP tool ids like `agent-teams_message_send`, but tool names can also appear as `mcp__agent-teams__message_send` or, in lower-level proof paths, plain names such as `message_send`. The observer must normalize tool names before classifying response proof. + +11. Real OpenCode fixtures also use normal tool names such as `bash` and `read` in lowercase. Execution-tool response proof must not only match Codex-style display names like `Bash`. + +12. If the bridge command itself times out after the prompt may have been submitted, the app cannot know whether OpenCode accepted the prompt. Treat that as `failed_retryable` with `acceptanceUnknown: true`, and make the watchdog observe the transcript before sending any retry. Do not immediately send a duplicate retry after an acceptance-unknown timeout. + +13. `messageStore` and renderer/feed services already understand `relayOfMessageId`, but the OpenCode MCP `message_send` schema does not expose it yet. Exposing the existing field is lower risk than inventing a new reply-id contract, and it gives the watchdog a non-heuristic visible-reply correlation signal. + +### Orchestrator-side OpenCode bridge + +Primary files: + +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeSessionBridge.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeEventTranslator.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeTranscriptProjector.ts` + +Important current behavior: + +```ts +await openCodeSessionBridge.promptAsync(record, { + text: identityReminder ? `${identityReminder}\n\n${text}` : text, + agent: asString(body.agent) ?? 'teammate', + noReply: body.noReply === true, +}); + +const reconciled = await withTimeout( + openCodeSessionBridge.reconcileSession(record, { limit: 50 }), + OPENCODE_SEND_RECONCILE_TIMEOUT_MS, +); + +return { + accepted: true, + sessionId: record.opencodeSessionId, + memberName, + runtimePid, + diagnostics: reconcileDiagnostics, +}; +``` + +This already does a post-accept reconcile, but it does not turn the reconcile summary into a response-proof contract. + +### Transcript capabilities + +`OpenCodeTranscriptProjector` already gives enough normalized fields: + +```ts +export type OpenCodeCanonicalMessage = { + id: string | null; + parentId: string | null; + role: 'user' | 'assistant' | 'system' | 'unknown'; + completedAt: number | null; + text: string; + reasoningText: string; + previewText: string | null; + partTypes: string[]; + toolCalls: OpenCodeCanonicalToolCall[]; + finishReason: string | null; + hasError: boolean; +}; +``` + +This is enough to classify a prompt outcome. + +## Key Design Decision + +### Recommended read semantics + +Do not mark OpenCode member inbox rows `read: true` at `accepted`. + +Mark them read only when the delivery ledger reaches a terminal success state: + +- `responded` with a tool call that is sufficient for the message intent. +- `responded` with visible `agent-teams_message_send`. +- `responded` with a useful plain assistant answer, with diagnostic warning because visible UI capture may not happen. + +Do not auto-mark `failed_terminal` rows read in v1. Keep them unread and surface diagnostics. A later product action can add explicit "ack failed delivery" behavior, but the delivery watchdog should not hide failed OpenCode prompts. + +This is the safest model because `inboxes/.json` remains the durable queue of uncommitted work. + +### Options Considered + +1. Recommended: keep row unread until response proof, ledger suppresses duplicate relays. + 🎯 9 🛡️ 9 🧠 6 - roughly `650-1050` lines total. + This preserves durable queue truth and fixes the actual loss bug. The complexity is in preventing the file watcher from retrying immediately. + +2. Mark row read at prompt acceptance, but use a separate retry ledger. + 🎯 7 🛡️ 7 🧠 5 - roughly `400-750` lines total. + Easier to bolt on, but it keeps inbox truth dishonest. Recovery after app restart is harder because the original row no longer looks pending. + +3. Make UI send block until OpenCode response proof. + 🎯 6 🛡️ 8 🧠 4 - roughly `250-450` lines total. + Simpler conceptually, but bad UX and still incomplete for watcher-driven task assignment rows. + +Use option 1. + +## Ledger Model + +Add a new OpenCode-specific prompt delivery ledger. Do not reuse `RuntimeDeliveryJournal`; that journal is for the opposite direction, where a runtime writes into canonical app destinations through `runtime_deliver_message`. + +Recommended file for secondary OpenCode lanes: + +```txt +/.opencode-runtime/lanes//opencode-prompt-delivery-ledger.json +``` + +Recommended schema name: + +```ts +'opencode.promptDeliveryLedger' +``` + +Add it to the runtime store manifest descriptors, schema-name validator, cross-store invariant input, and lane-scoped manifest recovery checks: + +```ts +{ + schemaName: 'opencode.promptDeliveryLedger', + schemaVersion: 1, + relativePath: 'opencode-prompt-delivery-ledger.json', + criticality: 'rebuildable_from_canonical_destination', + owner: 'delivery', + rebuildStrategy: 'verify_canonical_destinations', +} +``` + +Do not store secondary-lane prompt delivery truth in the team root `.opencode-runtime` directory. Use `getOpenCodeLaneScopedRuntimeFilePath()` and the lane manifest path from `getOpenCodeRuntimeManifestPath(teamsBasePath, teamName, laneId)`. + +Reason: `activeRunId`, runtime session id, stale runtime detection, and durable launch evidence are lane scoped. A team-root ledger would make mixed launches harder to recover correctly after app restart or lane replacement. + +Readiness impact: + +- Prompt ledger corruption should not block OpenCode provider/model readiness. +- It should surface degraded diagnostics and force conservative observe-first reconstruction. +- Launch should not fail only because the prompt ledger is missing; unread inbox rows remain the canonical destination. + +### Missing Or Quarantined Ledger Recovery + +Because the ledger is `rebuildable_from_canonical_destination`, a missing ledger can be reconstructed from unread inbox rows. But reconstruction must be conservative. + +If the ledger is missing/quarantined while OpenCode lane/session evidence exists: + +1. Read unread OpenCode member inbox rows. +2. Create reconstructed records with `status: 'failed_retryable'`, `responseState: 'not_observed'`, and `acceptanceUnknown: true`. +3. Run `opencode.observeMessageDelivery` before sending any new prompt. +4. Only prompt if observation cannot find a delivered user prompt or response proof after the normal grace window. + +Reason: app crash or file corruption may have happened after `promptAsync()` accepted but before the ledger persisted `accepted`. Rebuilding directly as `pending` and immediately prompting can duplicate messages. + +If no active lane/session can be resolved, keep the reconstructed record `failed_retryable` with `opencode_runtime_not_active`; do not prompt until the runtime is live again. + +### Record Shape + +```ts +export type OpenCodePromptDeliveryStatus = + | 'pending' + | 'accepted' + | 'responded' + | 'unanswered' + | 'retry_scheduled' + | 'retried' + | 'failed_retryable' + | 'failed_terminal'; + +export type OpenCodePromptResponseState = + | '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 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: 'do' | 'ask' | 'delegate' | null; + taskRefs: TaskRef[]; + payloadHash: string; + status: OpenCodePromptDeliveryStatus; + responseState: OpenCodePromptResponseState; + 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; + observedToolCallNames: string[]; + observedVisibleMessageId: string | null; + visibleReplyMessageId: string | null; + visibleReplyInbox: string | null; + visibleReplyCorrelation: + | 'relayOfMessageId' + | 'direct_child_message_send' + | 'plain_assistant_text' + | null; + lastReason: string | null; + diagnostics: string[]; + createdAt: string; + updatedAt: string; +} +``` + +### Stable Record ID + +Use a stable deterministic ID: + +```ts +sha256([ + 'opencode-prompt-delivery-v1', + teamName, + memberName.toLowerCase(), + laneId, + inboxMessageId, +].join('\0')) +``` + +Do not use message text as the primary key. Two different messages can have identical text and both must be delivered. + +Use `payloadHash` only as a safety check. If the same record ID appears with a different payload hash, mark `failed_terminal` and log a diagnostic because the inbox row identity is corrupt or reused. + +Payload hash input should include: + +- message text, +- summary, +- actionMode, +- taskRefs, +- replyRecipient, +- attachment metadata ids/filenames/mime types/sizes, +- source/from/to, +- conversation ids if present. + +Do not hash attachment file bytes in v1. The watchdog needs stable delivery identity, not expensive file integrity checks. + +### Runtime Identity Binding + +The first delivery attempt must resolve and persist: + +- `laneId`, +- `runId` if known, +- `runtimeSessionId` when available, +- `memberName` canonical casing, +- `providerId: 'opencode'`. + +Retries and observations must use the ledger `laneId`, not recompute a new lane from current member config unless the ledger is being created for the first time. + +If the member's current provider/model changes while a record is non-terminal: + +- do not redirect the old OpenCode delivery to a new provider or lane, +- mark the record `failed_terminal` with `opencode_recipient_runtime_identity_changed`, +- leave the inbox row unread with diagnostics, +- let any new user message use the new provider path normally. + +Reason: a retry belongs to the original runtime session that accepted or may have accepted the prompt. Re-resolving from mutable team config can deliver stale messages to the wrong runtime. + +### Response Versus Inbox Commit + +`responded` means OpenCode acted. It does not necessarily mean the inbox read flag was committed. + +Track this separately: + +```ts +status: 'responded', +inboxReadCommittedAt: null, +inboxReadCommitError: 'write failed' +``` + +Relay behavior for this state: + +- Do not re-prompt. +- Retry only the `markInboxMessagesRead()` commit. +- Once read commit succeeds, set `inboxReadCommittedAt`. + +This closes the crash window between observing a response and flipping `read: true`. + +### Why Not Use `sendSessionMessage()` For v1 + +There is a tempting alternative: replace `promptAsync()` with synchronous `sendSessionMessage()` because it returns an `OpenCodeMessage`. + +Options: + +1. Recommended: keep `promptAsync()` plus observe-only reconcile. + 🎯 9 🛡️ 9 🧠 6 - roughly `550-950` lines total. + It preserves non-blocking relay and gives durable response proof through the watchdog. + +2. Use `sendSessionMessage()` for all OpenCode deliveries. + 🎯 5 🛡️ 6 🧠 4 - roughly `250-500` lines total. + It may block up to the model execution timeout, couples UI send to model latency, and still needs transcript projection for tool side effects. + +3. Hybrid: use `sendSessionMessage()` only for manual UI sends, keep `promptAsync()` for watcher deliveries. + 🎯 6 🛡️ 6 🧠 7 - roughly `450-800` lines total. + It creates two delivery semantics and makes failures harder to reason about. + +Use option 1. + +## State Machine + +```mermaid +stateDiagram-v2 + [*] --> pending + pending --> accepted: promptAsync accepted + pending --> failed_retryable: bridge transient failure + pending --> failed_terminal: bad recipient/runtime stopped + + accepted --> responded: response proof observed + accepted --> unanswered: idle + no meaningful response + accepted --> failed_retryable: reconcile failed or session stale + accepted --> failed_terminal: permanent contract error + + unanswered --> retry_scheduled: attempts < maxAttempts + retry_scheduled --> retried: due time reached + retried --> accepted: promptAsync accepted again + retried --> failed_retryable: transient retry failure + + failed_retryable --> retry_scheduled: attempts < maxAttempts + failed_retryable --> failed_terminal: attempts exhausted + responded --> [*] + failed_terminal --> [*] +``` + +Important semantics: + +- `pending`: ledger row exists but no bridge attempt has completed. +- `accepted`: OpenCode accepted a prompt, but no meaningful response proof exists yet. +- `responded`: OpenCode acted. This is the only normal success state that commits the inbox row as read. +- `unanswered`: OpenCode became idle and the transcript has no meaningful response after the delivered user prompt. +- `retry_scheduled`: watchdog will retry after `nextAttemptAt`. +- `retried`: diagnostic state for audit; implementation can immediately move from `retry_scheduled` to `accepted` after the next attempt. +- `failed_retryable`: transient bridge/runtime issue. +- `failed_terminal`: no more retries or permanent error. + +## Response Proof Contract + +Add a pure response observer in the orchestrator: + +```txt +/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeDeliveryResponseObserver.ts +``` + +Suggested API: + +```ts +export interface OpenCodeDeliveryResponseObservation { + state: + | 'pending' + | 'responded_tool_call' + | 'responded_visible_message' + | 'responded_plain_text' + | 'responded_non_visible_tool' + | 'permission_blocked' + | 'tool_error' + | 'empty_assistant_turn' + | 'not_observed' + | 'prompt_not_indexed' + | 'session_stale' + | 'session_error' + | 'reconcile_failed'; + deliveredUserMessageId: string | null; + assistantMessageId: string | null; + toolCallNames: string[]; + visibleMessageToolCallId: string | null; + visibleReplyMessageId: string | null; + visibleReplyCorrelation: + | 'relayOfMessageId' + | 'direct_child_message_send' + | 'plain_assistant_text' + | null; + visibleReplyMissingRelayOfMessageId?: boolean; + latestAssistantPreview: string | null; + needsFullHistory?: boolean; + reason: string | null; +} +``` + +### How To Identify The Delivered Prompt + +The app-delivery prompt already embeds: + +```txt +The inbound app messageId is "" +``` + +Observer should: + +1. Collect all `user` canonical messages whose text contains the exact inbound `messageId`. +2. Also pass `prePromptCursor`, usually the session record's `lastCanonicalCursor` before `promptAsync()`. +3. If exact `messageId` matches exist, treat them as attempts for the same logical delivery. A sufficient response to any matching attempt proves the delivery. +4. Use the newest exact `messageId` match only for pending/unanswered diagnostics when no matching attempt has sufficient response proof. +5. Fallback to the first `user` message after `prePromptCursor` only if exactly one candidate exists and the inbound `messageId` is missing from the indexed text. +6. If neither the exact `messageId` nor the `prePromptCursor` appears in the limited reconcile result, run one wider reconcile before classifying the prompt as missing. +7. Return `prompt_not_indexed` if the prompt is not yet visible in transcript and raw status is `busy` or `retry`. +8. Never use "latest user message" without either exact message-id match or a cursor-bounded unique candidate, because OpenCode sessions can contain bootstrap, briefing, assignment, comment, and retry prompts. + +`prompt_not_indexed` is not a failure. It means OpenCode accepted the async prompt but `/session/:id/message` history has not caught up yet. + +### Visible Reply Correlation Contract + +The response observer should not rely on text/time heuristics to decide whether a visible OpenCode reply belongs to an app-delivered inbox row. + +The app already has a durable message id for every relayable inbox row. The controller message store already persists `relayOfMessageId`. The missing piece is exposing it through the OpenCode-visible MCP `message_send` schema and teaching the runtime prompt to use it. + +Recommended v1 contract: + +- Extend `agent-teams_message_send` / `message_send` parameters with `relayOfMessageId?: string`. +- Preserve `relayOfMessageId` through the MCP server, controller `messages.sendMessage()`, and `messageStore.sendInboxMessage()`. +- In `buildOpenCodeRuntimeMessageText()`, instruct OpenCode replies to include: + +```txt +source="runtime_delivery" +relayOfMessageId="" +``` + +- If the message has `taskRefs`, still include those `taskRefs` exactly. +- Do not introduce a second field named `replyToMessageId` in v1. Use existing `relayOfMessageId` so renderer, feed services, and `TeamDataService` can share one correlation primitive. + +Suggested prompt wording: + +```txt +The inbound app messageId is "". +When you reply with agent-teams_message_send, include source="runtime_delivery" and relayOfMessageId="". +``` + +Strongest visible response proof is a canonical destination row where: + +```ts +message.from === memberName +message.relayOfMessageId === inboxMessageId +message.source === 'runtime_delivery' +``` + +For the normal user-directed case, this row is in `inboxes/user.json`. If the reply target is a same-team teammate or lead, check that recipient's inbox file instead of assuming user inbox. + +The transcript observer still matters because OpenCode can act with task tools or execution tools without creating a visible inbox message. But for visible replies, destination-store proof should outrank transcript-only proof because it proves the app actually received the reply, not only that OpenCode attempted a tool call. + +Recommended proof priority for visible replies: + +1. Destination row with matching `relayOfMessageId`. +2. Successful direct-child `message_send` tool call whose arguments include matching `relayOfMessageId`. +3. Successful direct-child `message_send` tool call without `relayOfMessageId`, accepted only as a fallback with diagnostic `visible_reply_missing_relayOfMessageId`. +4. Plain assistant text, accepted only as `responded_plain_text` with diagnostic because it may not appear in Messages UI. + +Do not use `TeamDataService.linkPassiveUserReplySummaries()` or summary/time matching as OpenCode delivery proof. That method is useful for rendering passive duplicate summaries, but it is intentionally heuristic and should not decide inbox read commits. + +### Visible Reply Semantic Sufficiency + +Correlation proves that a reply belongs to a delivered message. It does not prove that the reply is useful. + +For visible `message_send` replies, evaluate semantic sufficiency before read commit: + +```ts +function isVisibleReplySemanticallySufficient(input: { + actionMode: 'do' | 'ask' | 'delegate' | null; + taskRefs: TaskRef[]; + text: string; + summary?: string | null; +}): boolean +``` + +Rules: + +- For `actionMode: 'ask'`, ack-only text such as `Понял`, `Ок`, `Understood`, `Got it`, `I'll check`, or `I'll do it` is not sufficient. The reply must contain an answer, a blocking question, or a concrete status/result. +- For no action mode and no task refs, use the same rule as `ask`. +- For `taskRefs.length > 0`, ack-only visible text alone is not sufficient unless there is also a successful task tool or execution tool. The goal is not just acknowledgement; the prompt delivery should produce task-side action or a concrete status. +- For `actionMode: 'do'`, ack-only visible text alone is not sufficient unless there is also successful task/execution proof. +- For `actionMode: 'delegate'`, a visible message is sufficient only if it names the delegated action, target, or status. Pure acknowledgement is not enough. + +Ack-only detection must be intentionally narrow: + +- Normalize whitespace and lowercase. +- Only treat as ack-only when the combined text/summary is short, for example under 120 characters. +- Match exact phrases or simple phrase prefixes from a small allow-reviewed list: `понял`, `ок`, `принял`, `сделаю`, `разберусь`, `understood`, `got it`, `ok`, `will do`, `I'll check`, `I'll take a look`. +- If the text contains a concrete blocker, question, result, file path, task id, error, number, code span, or more than one sentence of substance, treat it as sufficient. +- If uncertain, prefer sufficient and log `visible_reply_semantic_uncertain`. False retries are usually worse than accepting a borderline concrete status. + +If a visible reply is correlated but semantically insufficient: + +- keep the ledger `accepted`, +- set `responseState = 'responded_visible_message'`, +- set `lastReason = 'visible_reply_ack_only_still_requires_answer'`, +- do not mark the original OpenCode inbox row read, +- retry after grace with copy asking for a concrete answer/status, not just acknowledgement. + +This is deliberately stricter than the controller-level idle/ack-only guard. The controller guard decides whether to persist a message. The watchdog read-commit policy decides whether that persisted message satisfies the delivered prompt. + +### OpenCode Ack-Only Guard Tightening + +Current controller logic treats any non-empty `source` as explicit delivery context for the idle/ack-only guard. That is too broad for OpenCode because a model can emit `source="runtime_delivery"` on a generic "Понял" / "Understood" message that is not tied to an app-delivered row. + +For OpenCode senders, `hasExplicitDeliveryContext()` should treat a message as explicit delivery context only when at least one of these is true: + +- `relayOfMessageId` is present and non-empty. +- `taskRefs` is present and non-empty. +- `attachments` is present and non-empty. +- `leadSessionId` is present for a real lead-session reply. +- Another future typed delivery context field is present. + +`source="runtime_delivery"` alone is metadata, not proof. It should not bypass ack-only filtering unless it is paired with `relayOfMessageId` or task/action context. + +This keeps the guard from dropping useful non-ack answers: a substantive answer with text still passes because the idle/ack-only detector only rejects ack/no-task/no-work phrases. The stricter context rule only affects OpenCode ack-only noise. + +### Reconcile Limit Fallback + +The first post-send reconcile should stay bounded for latency: + +```ts +await reconcileSession(record, { limit: 80 }); +``` + +But bounded history is not enough for durable watchdog observation. If `observeOpenCodeDeliveryResponse()` cannot find either: + +- the exact inbound `messageId`, or +- the `prePromptCursor` that was captured before `promptAsync()`, + +then `runObserveMessageDelivery()` must retry with a wider/full transcript once: + +```ts +const limited = await reconcileSession(record, { limit: 80 }); +const first = observeOpenCodeDeliveryResponse({ ...limited, prePromptCursor }); + +if (first.state === 'prompt_not_indexed' && first.needsFullHistory === true) { + const full = await reconcileSession(record); + return observeOpenCodeDeliveryResponse({ ...full, prePromptCursor }); +} +``` + +Do not add this full-history fallback to every `sendMessage` call. Use it in watchdog/observe paths and only when the anchor is missing. UI send should stay latency bounded. + +### What Counts As Responded + +Use a direct child assistant message where: + +```ts +assistant.parentId === deliveredUserMessage.id +``` + +There can be multiple assistant children for one delivered user prompt. Classify all children and choose the strongest successful response proof, not just the first or last child. + +Classify as success if any of these are true: + +1. Tool call exists: + +```ts +assistant.toolCalls.length > 0 +``` + +This is the strongest signal, but only after filtering tool calls. + +Meaningful tool calls: + +- `agent-teams_message_send` +- `mcp__agent-teams__message_send` +- `message_send` +- `agent-teams_task_*` +- `mcp__agent-teams__task_*` +- `task_*` +- normal execution tools when the prompt action mode is `do`, such as shell/edit/read tools, if exposed by OpenCode in canonical transcript + +Non-meaningful tool calls for delivery proof: + +- `runtime_bootstrap_checkin` +- `agent-teams_runtime_bootstrap_checkin` +- `mcp__agent-teams__runtime_bootstrap_checkin` +- `member_briefing` +- `agent-teams_member_briefing` +- `mcp__agent-teams__member_briefing` +- `runtime_heartbeat` +- `process_register` +- `process_list` + +Reason: identity/bootstrap tools can be emitted because of the prepended reminder, not because the agent responded to the delivered message. + +Tool-name normalization should happen before classification: + +```ts +function normalizeToolName(name: string): string { + return name + .replace(/^mcp__agent-teams__/, '') + .replace(/^agent-teams_/, '') + .toLowerCase(); +} +``` + +Then classify by normalized names: + +- visible message tool: `message_send` +- task tools: `task_get`, `task_start`, `task_add_comment`, `task_complete`, and other `task_` names +- bootstrap tools: `runtime_bootstrap_checkin`, `member_briefing`, `runtime_heartbeat`, `process_register`, `process_list` +- execution tools for `actionMode: 'do'`: `bash`, `read`, `edit`, `write`, `grep`, `glob`, and OpenCode equivalents observed in fixtures + +Real fixture coverage should include at least these observed names: + +- `agent-teams_runtime_bootstrap_checkin` +- `agent-teams_member_briefing` +- `agent-teams_message_send` +- `agent-teams_task_get` +- `agent-teams_task_start` +- `agent-teams_task_add_comment` +- `agent-teams_task_complete` +- `bash` +- `read` + +2. Visible team message tool call exists: + +```ts +assistant.toolCalls.some((tool) => + normalizeToolName(tool.toolName) === 'message_send' +) +``` + +This should classify as `responded_visible_message`. + +If the tool call arguments include `relayOfMessageId` matching the inbound inbox `messageId`, set `visibleReplyCorrelation = 'relayOfMessageId'`. + +If the tool call is a direct child of the delivered prompt but lacks `relayOfMessageId`, still classify it as `responded_visible_message` in v1, but emit diagnostic `visible_reply_missing_relayOfMessageId`. This avoids losing a real answer during rollout while making the missing correlation visible in logs/tests. + +3. Meaningful non-visible tool call exists: + +This classifies as `responded_non_visible_tool`. + +For task assignment prompts, this is enough to mark the message responded if the tool call is a task tool or execution tool. The delivery watchdog is proving that the prompt was noticed, not that the whole task is complete. + +For direct user questions, non-visible tool activity is not always enough. A `read` or `bash` call can prove the model noticed the prompt, but it does not prove the user got an answer. The read-commit policy must use `actionMode` and `taskRefs`, not only `responseState`. + +4. Non-empty assistant text exists: + +```ts +assistant.text.trim().length > 0 || assistant.previewText?.trim() +``` + +This classifies as `responded_plain_text`, not ideal success. It prevents prompt spam when the model actually answered in plain text, but diagnostics should warn that the app may not show it as a normal team message. + +Reasoning-only output is not a user-visible answer: + +```ts +assistant.reasoningText.trim().length > 0 && assistant.text.trim().length === 0 +``` + +Do not classify this as `responded_plain_text`. Treat it like no visible response unless a meaningful successful tool call exists. + +5. Tool call exists but all meaningful calls are errored: + +```ts +assistant.toolCalls.some((tool) => tool.isError) +``` + +This classifies as `tool_error`, not responded. Do not mark the inbox row read unless there is also a successful meaningful tool call or non-empty plain text. + +6. Assistant error exists: + +```ts +assistant.hasError === true +``` + +This is not success. It should map to `session_error` and `failed_retryable` or `failed_terminal` depending on error type. + +7. Pending permission exists: + +```ts +summary.pendingPermissionRequestIds.length > 0 +``` + +This is `permission_blocked`. Do not retry automatically while blocked. + +### What Counts As Unanswered + +Classify as `empty_assistant_turn` if a direct assistant child exists but all are true: + +```ts +assistant.toolCalls.length === 0 +assistant.text.trim().length === 0 +assistant.reasoningText.trim().length === 0 +assistant.completedAt == null +assistant.finishReason == null +assistant.hasError === false +``` + +Classify as `pending` if: + +- raw status is `busy` or `retry`, and no meaningful child exists yet. + +Classify as `empty_assistant_turn` or `unanswered` if: + +- raw status is `idle`. +- no meaningful child exists. +- no permission block exists. + +Do not rely on `replyPendingSinceMessageId` from `OpenCodeEventTranslator` alone. It currently treats any child message as a reply, including the empty assistant-turn failure. + +If the direct child contains only reasoning/step metadata and no visible text or meaningful successful tool call, treat it as unanswered for delivery-read purposes. + +### Response Proof Priority + +Use this classification order: + +1. `session_stale` if reconcile outcome is stale. +2. `permission_blocked` if pending permissions exist. +3. `prompt_not_indexed` if the delivered prompt is not indexed yet and raw status is `busy` or `retry`. +4. `pending` if direct child has running tool calls or session status is `busy`/`retry`. +5. `responded_visible_message` if a successful visible message tool call exists. +6. `responded_non_visible_tool` if a successful meaningful non-visible tool call exists. +7. `responded_tool_call` only as a backward-compatible generic response if the implementation cannot distinguish visible vs non-visible tool classes. +8. `responded_plain_text` if non-empty assistant text exists. +9. `tool_error` if errored meaningful tool calls exist and no success signal exists. +10. `empty_assistant_turn` if direct assistant child is empty or session is idle with no child. + +### Response Sufficiency Policy + +`responseState` is observation. Inbox read commit is policy. + +Use this policy: + +| Message intent | Sufficient to commit read | Not sufficient by itself | +| --- | --- | --- | +| `taskRefs.length > 0` | semantically sufficient visible message, plain text with concrete status, successful task tool, successful execution tool | bootstrap/identity tools, errored tools, ack-only visible message with no task/execution proof | +| `actionMode: 'do'` | semantically sufficient visible message, plain text with concrete status, successful meaningful execution or task tool | bootstrap/identity tools, errored tools, ack-only visible message with no tool proof | +| `actionMode: 'delegate'` | visible message that names target/action/status, plain text with concrete status, successful task tool or `message_send` delegation/status | unrelated execution-only tool with no task/message effect, ack-only text | +| `actionMode: 'ask'` | visible message or plain text that contains an answer, blocking question, or concrete status | non-visible execution/task lookup only, ack-only text | +| no action mode and no task refs | visible message or plain text that contains an answer, blocking question, or concrete status | non-visible tool activity only, ack-only text | + +This avoids the most dangerous false success: a user asks an OpenCode teammate a question, the model performs a `read`, then goes idle without sending any answer. That should stay pending or become retryable; it should not mark the inbox row read. + +Before committing read for `responded_visible_message`, the app should prefer destination-store proof: + +```ts +const visibleReply = await findVisibleReplyByRelayOfMessageId({ + teamName, + replyRecipient, + from: memberName, + relayOfMessageId: inboxMessageId, +}); +``` + +If destination proof exists, commit read even if the bounded transcript observation is still delayed. The MCP tool call already wrote the canonical app row, which is stronger than waiting for OpenCode transcript indexing. + +If destination proof does not exist but transcript proof shows a successful direct-child `message_send`, keep the normal transcript-based policy and log `visible_reply_destination_not_found_yet`. Do not immediately retry; re-observe/re-scan once because inbox writes and transcript reconciliation can race in either direction. + +If `responded_non_visible_tool` is not sufficient for the message intent: + +- keep the ledger non-terminal as `accepted`, +- store `responseState: 'responded_non_visible_tool'`, +- set `lastReason: 'visible_reply_still_required'`, +- re-observe after a short delay, +- after grace, retry with copy asking for a visible `agent-teams_message_send` or plain answer. + +## Bridge Contract Extension + +Extend both sides of the OpenCode bridge: + +```ts +export interface OpenCodeSendMessageCommandData { + accepted: boolean; + sessionId?: string; + memberName: string; + runtimePid?: number; + prePromptCursor?: string | null; + responseObservation?: OpenCodeDeliveryResponseObservation; + diagnostics: OpenCodeTeamBridgeDiagnostic[]; +} +``` + +Orchestrator `runSendMessage()` should still return `accepted: true` after `promptAsync()` succeeds, even if response observation is pending or unanswered. + +Reason: acceptance and response are different facts. The bridge should not turn post-accept reconcile weakness into a prompt delivery failure. + +### Add Observe-Only Bridge Command + +The watchdog must be able to inspect a previous accepted prompt without sending a new prompt. + +Add a new command: + +```ts +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[]; +} +``` + +Recommended command name: + +```txt +opencode.observeMessageDelivery +``` + +Why this is required: + +- Calling `opencode.sendMessage` for "check before retry" would create a duplicate prompt. +- `claude_team` does not have direct OpenCode transcript access. +- The orchestrator already owns session records, stale-session detection, host access, and transcript projection. + +`observeMessageDelivery` should run the same runtime/session validation as `sendMessage` except it must not call `promptAsync()`. +This is intentionally not an app-side state-changing bridge command. + +It is acceptable to reuse the orchestrator-side runtime precondition validator even if the current helper name includes "StateChanging". The hard boundary is app-side command execution: observe must not use the state-changing command service or its command ledger. + +Concrete bridge wiring: + +- Add `opencode.observeMessageDelivery` to `OpenCodeBridgeCommandName` and `VALID_COMMANDS` in `OpenCodeBridgeCommandContract.ts`. +- Add it to orchestrator `SUPPORTED_COMMANDS` and the dispatch switch in `OpenCodeBridgeCommandHandler.ts`. +- Add `observeOpenCodeTeamMessageDelivery()` to `OpenCodeReadinessBridge`. +- Add `observeMessageDelivery()` to `OpenCodeTeamRuntimeAdapter` and the bridge port type. +- Use direct `this.bridge.execute('opencode.observeMessageDelivery', ...)`, matching current `sendOpenCodeTeamMessage()`. +- Do not add it to `OpenCodeStateChangingTeamCommandName`. +- Do not route it through `OpenCodeStateChangingBridgeCommandService`. +- Do not create command-ledger entries or runtime-store manifest commits for observe. +- Use a short observe timeout separate from send timeout, for example `OPENCODE_OBSERVE_MESSAGE_TIMEOUT_MS = 8_000`. + +Reason: observe is read-only from the app perspective. It reconciles an existing OpenCode session and returns response evidence. Treating it as a state-changing command would add lease/idempotency behavior intended for launch/reconcile/stop and would create failure modes unrelated to delivery observation. + +Clarification: `reconcileSession()` may still update the orchestrator's own session record, for example stale flags or last cursor. That is acceptable. The constraint is that the app bridge must not treat observe as a launch/reconcile/stop command and must not commit app runtime-store manifests for it. + +Implementation sketch: + +```ts +async function runObserveMessageDelivery(envelope: BridgeEnvelope) { + const record = await openCodeSessionStore.get(teamId, laneId, memberName); + const limited = await openCodeSessionBridge.reconcileSession(record, { limit: 80 }); + let responseObservation = observeOpenCodeDeliveryResponse({ + inboundMessageId: messageId, + prePromptCursor, + summary: limited.summary, + }); + if (responseObservation.needsFullHistory === true) { + const full = await openCodeSessionBridge.reconcileSession(record); + responseObservation = observeOpenCodeDeliveryResponse({ + inboundMessageId: messageId, + prePromptCursor, + summary: full.summary, + }); + } + return { snapshot, data: { observed: true, responseObservation, ... } }; +} +``` + +Watchdog flow must be: + +1. Observe existing prompt. +2. If response is now proven, commit read. +3. If pending or blocked, wait. +4. If unanswered and retry due, send a retry prompt. + +Never retry first and observe second. + +## Retry Policy + +Recommended defaults: + +```ts +const OPENCODE_PROMPT_DELIVERY_MAX_ATTEMPTS = 3; +const OPENCODE_PROMPT_DELIVERY_RETRY_DELAYS_MS = [30_000, 90_000, 180_000]; +const OPENCODE_PROMPT_RESPONSE_GRACE_MS = 20_000; +const OPENCODE_PROMPT_RESPONSE_GRACE_FOR_TASK_MS = 45_000; +const OPENCODE_PROMPT_WATCHDOG_SCAN_MS = 15_000; +``` + +Rules: + +1. UI send gets one immediate bridge attempt. +2. Watcher delivery gets one immediate bridge attempt. +3. Watchdog always calls `opencode.observeMessageDelivery` before deciding to retry. +4. If OpenCode is `busy` or `retry`, keep the record `accepted` or `pending` and scan later. +5. If OpenCode is `idle` with empty/no response after grace period, mark `unanswered`. +6. Schedule retry if attempts remain. +7. Retry prompt should include a short retry header: + +```txt + +Previous app message delivery was accepted by OpenCode but no action was observed. +Retry attempt 2/3 for inbound app messageId "". +If you already acted on this message, do not duplicate work; send a concrete status via agent-teams_message_send with relayOfMessageId="" or update the related task. Do not reply only with acknowledgement. + +``` + +For direct `ask` messages where OpenCode already used non-visible tools but did not answer visibly, use a different header: + +```txt + +Previous app message delivery was noticed, but no visible answer was observed. +Retry attempt 2/3 for inbound app messageId "". +Please reply with agent-teams_message_send to "" and include relayOfMessageId="". If that tool is unavailable, provide a concise plain-text answer. Do not repeat tool work unless needed and do not reply only with acknowledgement. + +``` + +8. After attempts are exhausted, mark `failed_terminal`. + +### Acceptance-Unknown Bridge Failures + +Some failures happen at the command boundary after the prompt may already have reached OpenCode. Example: the app bridge times out while the orchestrator is still inside or just after `promptAsync()`. + +For these cases: + +- Create or update the ledger record as `failed_retryable`. +- Set `acceptanceUnknown: true`. +- Store `responseState: 'not_observed'` and the bridge timeout diagnostic. +- The next watchdog action must be `opencode.observeMessageDelivery`. +- Only retry the prompt if observe finds no matching delivered user prompt or meaningful response after the normal grace window. + +Do not immediately retry acceptance-unknown failures. That would turn a transport timeout into duplicate user prompts. + +### UI Timeout Is Not Delivery Failure + +The current IPC send path wraps OpenCode live relay in a short UI timeout. That timeout protects the renderer, but it does not necessarily cancel the underlying relay promise. The relay may still accept the prompt and update the ledger after the UI receives a timeout-shaped result. + +Therefore: + +- `OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS` should produce `runtimeDelivery.responsePending = true`, not a terminal failed delivery. +- The ledger record should be left `pending` or `failed_retryable` with `acceptanceUnknown: true`. +- The watchdog must observe before any retry. +- The UI copy should say that live delivery is still being checked, not that the message definitively failed. +- If the underlying relay later records response proof, normal read commit should complete. + +Suggested reason code: + +```ts +'opencode_runtime_delivery_ui_timeout_pending' +``` + +Do not reuse the old hard-failure copy for this state. + +### Retry Eligibility Matrix + +| Observation state | Retry? | Commit inbox read? | Notes | +| --- | --- | --- | --- | +| `responded_tool_call` | Depends | Depends | Generic responded state, prefer more specific visible/non-visible states when possible. Apply response sufficiency policy. | +| `responded_visible_message` | Depends | Depends | Commit only when correlated and semantically sufficient for the message intent. Ack-only visible replies can require retry. | +| `responded_non_visible_tool` | Depends | Depends | Commit for task/do/delegate when sufficient. For ask/direct questions, require visible/plain reply. | +| `responded_plain_text` | No | Yes | Warn because app Messages may not show it as normal teammate reply. | +| `permission_blocked` | No | No | Wait for permission resolution. | +| `prompt_not_indexed` | No | No | Re-observe after short delay. | +| `pending` | No | No | Re-observe after short delay. | +| `empty_assistant_turn` | Yes, if due | No | Core retry path. | +| `tool_error` | Yes, if retryable | No | Retry only after grace and bounded attempts. | +| `session_stale` | Not immediately | No | Require lane/session recovery first. | +| `reconcile_failed` | Yes, if retryable | No | Prefer observe retry before prompt retry. | +| `not_observed` with `acceptanceUnknown` | Observe first | No | Never retry prompt before transcript observation. | + +## Single Outstanding Delivery Per OpenCode Member + +This is important. + +Current relay processes up to 10 unread rows per member in one loop. For OpenCode, that can hammer a model session with multiple prompts before it has acted on the first one. That increases the chance of empty turns, ignored tasks, or responses to the wrong prompt. + +Recommended v1 behavior: + +- For OpenCode members, process only the oldest eligible unread row if there is no active non-terminal ledger record for that member. +- If a record is `accepted`, `pending`, `unanswered` with future `nextAttemptAt`, or `retry_scheduled`, do not relay newer rows for that member yet. +- Once the active record becomes `responded` or `failed_terminal`, the next scan can pick the next inbox row. +- Rows with `failed_terminal` ledger records stay unread for visibility, but automatic relay selection must skip them unless the user explicitly retries that failed message. + +This is not required for native teammates because native runtimes already consume inbox files directly and have their own event loops. + +### Required Serialization + +Use one per-member OpenCode delivery mutex for all entry points: + +- inbox file watcher relay, +- UI send relay, +- manual relay, +- watchdog observe/retry, +- startup recovery. + +The existing `openCodeMemberInboxRelayInFlight` pattern is the right shape, but v1 must ensure the watchdog also goes through the same per-member gate. Do not create a second independent watchdog path that can race the relay path. + +The mutex should cover: + +1. reading inbox rows, +2. checking active ledger state, +3. creating or resuming a ledger record, +4. deciding whether a bridge call is allowed, +5. applying the bridge result or observation. + +Do not hold an inbox file lock across a bridge call. The durable guard is the ledger record, written before the bridge call. The in-memory per-member mutex prevents duplicate bridge calls inside one app process. + +Lock ordering: + +1. Acquire per-member in-memory delivery gate. +2. Mutate ledger with `VersionedJsonStore.updateLocked()`, then release ledger file lock. +3. Call bridge if needed, with no inbox or ledger file lock held. +4. Mutate ledger with result/observation, then release ledger file lock. +5. If read commit is allowed, call `markInboxMessagesRead()` with inbox lock. +6. Mutate ledger to record `inboxReadCommittedAt` or `inboxReadCommitError`. + +Never hold ledger and inbox file locks at the same time. This keeps the watchdog compatible with existing inbox writer/reader paths and avoids lock inversion. + +If another call arrives with `onlyMessageId` while a different message is active: + +- wait for the existing per-member work to finish, +- re-read the ledger and inbox, +- return queued/pending for the new message if it is still unread, +- do not report the new message as delivered just because the older in-flight relay finished. + +### Active Record Definition + +A record blocks newer OpenCode inbox rows for the same member if: + +```ts +record.status !== 'responded' && +record.status !== 'failed_terminal' +``` + +or if: + +```ts +record.status === 'responded' && record.inboxReadCommittedAt == null +``` + +The second case blocks newer rows only until the read commit is retried. It should not send another prompt. + +### Failed Terminal Rows + +`failed_terminal` is terminal for automatic delivery attempts, not for user visibility. + +Rules: + +- Do not mark the inbox row read automatically. +- Do not select that row again in normal oldest-unread relay scans. +- Do not let that row block newer unread rows for the same member. +- Show diagnostics on the member/card/message surface. +- Allow a future explicit manual retry action to reset the ledger record or create a new attempt. + +This avoids both bad outcomes: hiding a failed message by marking it read, or permanently blocking the member queue behind a failed unread row. + +### UI Send Exception + +If the user sends a direct UI message to a member while an older OpenCode delivery is active: + +Recommended v1 behavior: + +- Persist the new inbox row as usual. +- Return `runtimeDelivery.responsePending = true`. +- Do not bypass the active record and send the new prompt immediately. +- Show copy like `Message saved. OpenCode is still processing an earlier delivery.` + +Reason: letting UI sends bypass the ordering rule can reintroduce the same prompt collision that caused lost task assignments. + +## App-Side Relay Changes + +### New Service + +Add: + +```txt +src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +``` + +Responsibilities: + +- Create or read records by `teamName/memberName/laneId/inboxMessageId`. +- Enforce payload hash consistency. +- Transition records with validation. +- List due retry records. +- List active member records. +- Prune terminal records older than a retention window. +- Use `VersionedJsonStore.updateLocked()` for every mutation. + +Suggested retention: + +```ts +const RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const FAILED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +``` + +### Ledger API Surface + +Recommended methods: + +```ts +interface OpenCodePromptDeliveryLedgerStore { + ensurePending(input: EnsurePromptDeliveryInput): Promise; + getByInboxMessage(input: DeliveryRecordKeyInput): Promise; + getActiveForMember(input: { teamName: string; laneId: string; memberName: string }): Promise; + listDue(input: { teamName?: string; now: Date; limit: number }): Promise; + markAccepted(input: MarkAcceptedInput): Promise; + applyObservation(input: ApplyObservationInput): Promise; + applyDestinationProof(input: ApplyDestinationProofInput): Promise; + markRetryScheduled(input: MarkRetryScheduledInput): Promise; + markRetryAttempted(input: MarkRetryAttemptedInput): Promise; + markInboxReadCommitted(input: { id: string; committedAt: string }): Promise; + markInboxReadCommitFailed(input: { id: string; error: string }): Promise; + markFailed(input: MarkFailedInput): Promise; +} +``` + +Suggested destination proof input: + +```ts +interface ApplyDestinationProofInput { + id: string; + visibleReplyInbox: string; + visibleReplyMessageId: string; + visibleReplyCorrelation: 'relayOfMessageId'; + visibleReplyText: string; + visibleReplySummary?: string | null; + semanticallySufficient: boolean; + observedAt: string; +} +``` + +`applyObservation()` must be idempotent. Re-observing a previously responded record must not change attempts or schedule another prompt. + +`applyDestinationProof()` must also be idempotent. It should set `responseState = 'responded_visible_message'` and visible-reply fields without incrementing attempts. It should set `status = 'responded'` and `respondedAt` only when `semanticallySufficient === true`; otherwise keep the delivery non-terminal and set `lastReason = 'visible_reply_ack_only_still_requires_answer'`. + +Implementation constraints: + +- Use the existing `RuntimeDeliveryJournalStore` style as a template, but do not copy its `begin()` attempt semantics blindly. +- `ensurePending()` must not increment `attempts` when a duplicate watcher event sees the same pending or accepted record. +- Increment `attempts` only when a new prompt attempt is actually about to be sent. +- `markRetryAttempted()` should be the only normal transition that increments attempts after the initial attempt. +- Validate duplicate ids on read, like `RuntimeDeliveryJournalStore` does. +- A payload hash conflict is terminal for that record and must not send a prompt. +- Ledger mutation methods should return the updated record so relay/watchdog code never reasons from stale in-memory copies. + +### TeamProvisioningService Relay Flow + +Current simplified flow: + +```ts +for (const message of unread.slice(0, 10)) { + const delivery = await deliverOpenCodeMemberMessage(...); + if (delivery.delivered) { + await markInboxMessagesRead(...); + } +} +``` + +New flow: + +```ts +const active = await promptDeliveryLedger.getActiveForMember(teamName, memberName); +if (active && !isDueForRetry(active, now)) { + return { + relayed: 0, + attempted: 0, + delivered: 0, + failed: 0, + lastDelivery: { + delivered: true, + responsePending: true, + reason: 'opencode_delivery_response_pending', + }, + }; +} + +const message = selectOldestEligibleUnreadMessage(...); +let record = await promptDeliveryLedger.ensurePending(...); + +const delivery = await deliverOpenCodeMemberMessage(...); +record = await promptDeliveryLedger.applyDeliveryResult(record.id, delivery); + +const visibleReply = await findOpenCodeVisibleReplyByRelayOfMessageId({ + teamName, + replyRecipient: record.replyRecipient, + from: memberName, + relayOfMessageId: record.inboxMessageId, +}); + +if (visibleReply) { + record = await promptDeliveryLedger.applyDestinationProof({ + id: record.id, + visibleReplyInbox: visibleReply.inboxName, + visibleReplyMessageId: visibleReply.messageId, + visibleReplyText: visibleReply.text, + visibleReplySummary: visibleReply.summary, + visibleReplyCorrelation: 'relayOfMessageId', + semanticallySufficient: isVisibleReplySemanticallySufficient({ + actionMode: record.actionMode, + taskRefs: record.taskRefs, + text: visibleReply.text, + summary: visibleReply.summary, + }), + observedAt: new Date().toISOString(), + }); +} + +if (isOpenCodeDeliveryReadCommitAllowed(delivery, record)) { + await markInboxMessagesRead(teamName, memberName, [message]); + await promptDeliveryLedger.markInboxReadCommitted(...); +} +``` + +`selectOldestEligibleUnreadMessage()` must skip unread rows whose ledger record is `failed_terminal`, unless `options.onlyMessageId` explicitly targets that message for a manual retry path. + +`isOpenCodeDeliveryReadCommitAllowed()` should return true for: + +- semantically sufficient visible response: `responded_visible_message`, +- plain assistant text: `responded_plain_text`, +- generic `responded_tool_call` only when it can be classified as sufficient for the message intent, +- non-visible tool activity only when `actionMode`/`taskRefs` make that sufficient. + +It should return false for: + +- `responded_non_visible_tool` on direct `ask` messages without task refs, +- `tool_error`, +- bootstrap/identity-only tools, +- `pending`, +- `prompt_not_indexed`, +- `empty_assistant_turn`, +- `reconcile_failed`, +- `session_stale`. + +Destination proof helper: + +```ts +async function findOpenCodeVisibleReplyByRelayOfMessageId(input: { + teamName: string; + replyRecipient: string; + from: string; + relayOfMessageId: string; +}): Promise<{ + inboxName: string; + messageId: string; + timestamp: string; + text: string; + summary: string | null; +} | null> +``` + +Rules: + +- For `replyRecipient === 'user'`, read `inboxes/user.json`. +- For lead aliases, resolve to the configured lead inbox name. +- For same-team teammate recipients, resolve to that teammate inbox name. +- Require exact `from` match after member-name canonicalization. +- Require exact `relayOfMessageId` match. +- Prefer a row with `source === 'runtime_delivery'`, but allow missing source only as fallback with diagnostic `visible_reply_missing_runtime_delivery_source`. +- Do not match by text, summary, timestamp, or task display id. + +If destination proof appears after the first bridge response observation, the next watchdog scan should mark the delivery `responded`, commit the original OpenCode recipient row read, and not send another prompt. + +`applyDeliveryResult()` must persist: + +- `acceptedAt` when bridge accepted. +- `prePromptCursor` returned by the orchestrator. +- `runtimeSessionId` and `runtimePid` if present. +- `acceptanceUnknown` when a command-boundary timeout means prompt acceptance cannot be proven. +- `responseState`, `deliveredUserMessageId`, `observedAssistantMessageId`, and `observedToolCallNames` when observation is available. +- `visibleReplyMessageId`, `visibleReplyInbox`, and `visibleReplyCorrelation` when destination-store proof exists. + +If `delivery.responseState` is already `responded_*` but `markInboxMessagesRead()` fails, the relay must not treat that as prompt failure. It should store `inboxReadCommitError` and retry the read commit on the next scan. + +If an unread row already has a ledger record with `status: 'responded'` and `inboxReadCommittedAt: null`, relay should only retry `markInboxMessagesRead()`. + +### Delivery Result Shape + +Extend internal delivery result: + +```ts +interface OpenCodeMemberInboxDelivery { + delivered: boolean; // bridge accepted or full response? See below. + accepted?: boolean; + responsePending?: boolean; + responseState?: OpenCodePromptResponseState; + ledgerStatus?: OpenCodePromptDeliveryStatus; + acceptanceUnknown?: boolean; + visibleReplyMessageId?: string; + visibleReplyCorrelation?: 'relayOfMessageId' | 'direct_child_message_send' | 'plain_assistant_text'; + queuedBehindMessageId?: string; + reason?: string; + diagnostics?: string[]; +} +``` + +For internal relay completion: + +- `accepted: true`, `responsePending: true`, `delivered: true` is acceptable for UI feedback, but must not mark row read. +- `responseState: responded_*`, `delivered: true`, `responsePending: false` commits read. +- `delivered: false` means prompt acceptance failed. + +For `SendMessageResult.runtimeDelivery`, keep backward compatibility: + +```ts +runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: accepted, + responsePending: observation is not responded, + responseState, + ledgerStatus, + acceptanceUnknown, + visibleReplyMessageId, + visibleReplyCorrelation, + queuedBehindMessageId, + reason, + diagnostics, +} +``` + +This avoids making UI send look like a hard failure when OpenCode accepted the message but has not responded yet. + +## Watchdog Scheduling + +Add a lightweight app-side scheduler: + +```txt +src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts +``` + +Responsibilities: + +- Wake after inbox relay creates `accepted`, `unanswered`, or `retry_scheduled`. +- On app startup or team activation, scan due records. +- For each due record, first call the observe-only bridge path. +- If observe shows response proof, commit the inbox row read. +- If observe shows pending/blocked, reschedule without prompt retry. +- If observe shows unanswered and retry is due, call back into `TeamProvisioningService.relayOpenCodeMemberInboxMessages(teamName, memberName, { onlyMessageId, source: 'watchdog' })`. +- Cap concurrency globally and per member. +- Use the same per-member OpenCode delivery gate as relay, not a separate parallel executor for the same member. + +Recommended caps: + +```ts +const OPENCODE_PROMPT_WATCHDOG_GLOBAL_CONCURRENCY = 2; +const OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY = 1; +``` + +Do not use a tight interval. Use: + +- a delayed timer for the nearest `nextAttemptAt`, +- plus opportunistic scans on inbox watcher events, +- plus app/team startup recovery. + +### Startup Recovery Algorithm + +On app startup or when a team detail view becomes active: + +1. Read OpenCode lane index. +2. For each active lane, read lane-scoped prompt delivery ledger. +3. For each `responded` record with `inboxReadCommittedAt === null`, retry only `markInboxMessagesRead()`. +4. For each `accepted` or `retry_scheduled` record, call `opencode.observeMessageDelivery`. +5. For each due `unanswered` record, observe first, then retry only if still unanswered. +6. For missing/quarantined prompt ledger files, reconstruct unread inbox rows as `acceptanceUnknown` and observe before prompt. +7. Ignore records whose lane is no longer active unless the row is still unread and the team is running. In that case classify as `failed_retryable` with `opencode_runtime_not_active`. + +Recovery must group work by `(teamName, laneId, memberName)` and run each group through the per-member gate. Do not let startup recovery observe/retry a member while the inbox watcher is also relaying that member. + +This avoids retrying stale prompts immediately after app restart. + +## Orchestrator Response Observer + +### New Pure Helper + +Add: + +```txt +/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeDeliveryResponseObserver.ts +``` + +Example: + +```ts +export function observeOpenCodeDeliveryResponse(input: { + inboundMessageId: string | null; + prePromptCursor: string | null; + summary: OpenCodeSessionReconcileSummary; +}): OpenCodeDeliveryResponseObservation { + if (input.summary.reconcileOutcome === 'stale') { + return { state: 'session_stale', ... }; + } + + if (input.summary.pendingPermissionRequestIds.length > 0) { + return { state: 'permission_blocked', ... }; + } + + const deliveryAttempts = findDeliveredUserMessageAttempts( + input.summary.messages, + input.inboundMessageId, + input.prePromptCursor, + ); + if (deliveryAttempts.length === 0) { + return input.summary.rawStatus === 'busy' || input.summary.rawStatus === 'retry' + ? { state: 'pending', ... } + : { state: 'empty_assistant_turn', reason: 'delivered_user_message_not_found', ... }; + } + + const attemptChildren = deliveryAttempts.map((attempt) => ({ + deliveredUser: attempt, + assistantChildren: input.summary.messages.filter( + (message) => message.role === 'assistant' && message.parentId === attempt.id + ), + })); + + return classifyDeliveryAttempts(attemptChildren, input.summary.rawStatus); +} +``` + +Keep this helper pure so fixture tests are cheap and deterministic. + +### Bridge Integration + +Update `runSendMessage()`: + +```ts +const inboundMessageId = asString(body.messageId); +const prePromptCursor = record.lastCanonicalCursor ?? null; + +await openCodeSessionBridge.promptAsync(...); + +let responseObservation: OpenCodeDeliveryResponseObservation = { + state: 'pending', + ... +}; + +try { + const reconciled = await withTimeout(...); + responseObservation = observeOpenCodeDeliveryResponse({ + inboundMessageId, + prePromptCursor, + summary: reconciled.summary, + }); +} catch (error) { + responseObservation = { + state: 'reconcile_failed', + reason: stringifyError(error), + ... + }; +} + +return { + accepted: true, + prePromptCursor, + responseObservation, + ... +}; +``` + +The app ledger must persist the returned `prePromptCursor` with the delivery record. If the bridge cannot return it because the command failed before loading a session record, store `null` and rely only on exact `messageId` matching. + +## UI Semantics + +UI should not synthesize replies. + +For user sends to OpenCode: + +- If inbox persistence succeeds and bridge acceptance fails: show warning as today. +- If bridge acceptance succeeds but response is pending: show a non-blocking warning/info: + +```txt +Message delivered to OpenCode. Waiting for teammate response... +``` + +- If watchdog later marks `failed_terminal`: surface diagnostic in team warning area or member card, not as a fake message from the teammate. + +Add fields to `SendMessageResult.runtimeDelivery`: + +```ts +responsePending?: boolean; +responseState?: OpenCodePromptResponseState; +ledgerStatus?: OpenCodePromptDeliveryStatus; +acceptanceUnknown?: boolean; +visibleReplyMessageId?: string; +visibleReplyCorrelation?: 'relayOfMessageId' | 'direct_child_message_send' | 'plain_assistant_text'; +queuedBehindMessageId?: string; +``` + +Renderer store should continue to return the `SendMessageResult` and only rethrow real send failures. + +Important current UI detail: + +- The renderer inserts an optimistic copy of the user's sent message with `read: true` in the sender-side message feed. +- That is separate from the recipient's inbox row and should not be changed by the OpenCode prompt ledger. +- The ledger controls only `inboxes/.json` read commit. + +Warning copy should distinguish three cases: + +1. Bridge failed: + +```txt +OpenCode runtime delivery failed: . Message was saved to inbox. +``` + +2. UI timeout while delivery is still being checked: + +```txt +OpenCode delivery is still being checked. Message was saved and will be observed before retry. +``` + +3. Bridge accepted but response pending: + +```txt +Message delivered to OpenCode. Waiting for teammate response... +``` + +4. Message queued behind older OpenCode delivery: + +```txt +Message saved. OpenCode is still processing an earlier delivery. +``` + +5. Previous OpenCode delivery exhausted retries: + +```txt +OpenCode did not respond after retries. Message is still unread and will not be retried automatically. +``` + +Renderer pending-reply behavior: + +- Keep the recipient in a pending state for `responsePending: true`. +- Clear pending when a real message from that member appears in `inboxes/user.json`. +- Clear pending when runtime delivery fails terminally. +- Do not clear pending merely because `delivered: true`; for OpenCode that can mean prompt accepted, not teammate answered. + +## Task Assignment Integration + +Task assignment notifications are the highest-risk OpenCode path because they are often watcher-driven, not UI-driven. + +Required behavior: + +- Assignment inbox rows must include `taskRefs`. +- OpenCode delivery prompt already includes taskRefs when present. +- Watchdog response proof for task rows accepts task MCP tool calls as success. +- Direct user `ask` rows should not be closed by a non-visible tool call alone; they need visible message or plain assistant text. +- If OpenCode sends a visible message saying blocked or busy with concrete blocker/status, classify as responded and mark read. +- If OpenCode does nothing, retry bounded. + +Already fixed in controller during research: + +- `agent-teams-controller/src/internal/tasks.js` now includes the assigned task as a structured `taskRefs` item in owner assignment notifications. +- Test added: `includes the assigned task ref in owner assignment notifications`. + +This should be part of the final implementation baseline. + +### Delivery Watchdog Is Not A Task Stall Monitor + +The delivery ledger should answer only one question: + +```txt +Did OpenCode notice and act on this delivered inbox row? +``` + +It should not try to prove: + +- the task is complete, +- the implementation is correct, +- the teammate is making enough progress after the first action. + +If an agent responds to an assignment by calling `task_get` or `task_start`, the delivery should be considered responded. If the task later stalls, that belongs to the task stall monitor or a separate OpenCode task-progress watchdog. + +This boundary avoids turning message delivery into a broad autonomous task supervisor. + +Do not enable or rewrite the global task-stall monitor as part of this v1. It is a separate product behavior with different thresholds, recipients, and false-positive risks. The only v1 integration is that task-related MCP tool calls can prove prompt delivery. + +## Edge Cases + +### Duplicate inbox watcher events + +Problem: leaving rows unread until `responded` means file watchers can repeatedly call relay. + +Mitigation: + +- Ledger lookup happens before any bridge call. +- If active record is not due, relay returns `responsePending: true` without sending another prompt. + +### App restart after accepted prompt + +Problem: app exits after prompt acceptance but before response proof. + +Mitigation: + +- Ledger persists `accepted`. +- Startup recovery scans accepted records. +- If transcript now shows response proof, mark `responded` and commit inbox row read. +- If still unanswered and due, retry. + +### OpenCode session stale after behavior/config change + +Problem: `reconcileSession()` can return stale because behavior fingerprint changed. + +Mitigation: + +- Observer returns `session_stale`. +- Ledger moves to `failed_retryable` with diagnostic. +- If active lane recovery later resolves session identity, retry can continue. +- If no active runtime can be resolved, move to `failed_terminal` after max attempts or team stop. + +### Permission request blocks the session + +Problem: OpenCode may need tool permission approval. + +Mitigation: + +- Observer returns `permission_blocked`. +- Do not retry while blocked. +- Keep row unread. +- Existing permission UI should handle the request. +- After permission answer, watchdog scan should re-check response state. + +### Plain assistant text + +Problem: OpenCode may answer as plain text instead of `agent-teams_message_send`. + +Mitigation: + +- Classify as `responded_plain_text`. +- Mark row read to avoid prompt spam. +- Emit diagnostic warning because app Messages may not display that as a normal teammate message unless transcript projection surfaces it elsewhere. + +Do not retry plain text. Retrying would likely produce duplicate or contradictory answers. + +### Multiple unread messages + +Problem: old code can deliver up to 10 unread OpenCode prompts in one loop. + +Mitigation: + +- One outstanding non-terminal ledger record per OpenCode member. +- Newer rows wait in inbox until the active one is answered or terminal. + +### Same text, multiple messages + +Problem: duplicate text should not dedupe distinct messages. + +Mitigation: + +- Primary key is `inboxMessageId`. +- Payload hash only detects corruption. + +### Message text changed under same messageId + +Problem: inbox row edited after ledger creation. + +Mitigation: + +- If payload hash differs for same record ID, do not retry silently. +- Mark `failed_terminal` with `opencode_prompt_delivery_payload_mismatch`. + +### Team stopped while retries are scheduled + +Problem: retries after stop would resurrect stale runtime assumptions. + +Mitigation: + +- Watchdog checks current team/run/lane before retry. +- If runtime stopped, mark `failed_terminal` or `failed_retryable` depending on whether team is still running. + +### Lead is OpenCode + +Pure OpenCode lead remains unsupported in v1. + +If recipient is OpenCode lead and no stored lead session exists: + +- Do not fake delivery. +- Leave inbox unread. +- Emit explicit diagnostic. + +This plan is for OpenCode secondary teammate delivery. + +### Attachments + +Current renderer UX only supports file attachments for live team-lead sends. OpenCode secondary teammate live delivery does not have an attachment transport in v1. + +Rules: + +- Do not add OpenCode attachment transport in this watchdog work. +- If an OpenCode secondary inbox row has attachments anyway, include attachment metadata in `payloadHash`. +- Do not embed file contents into the OpenCode prompt. +- Return `failed_terminal` with `opencode_attachments_not_supported_for_secondary_runtime` unless a later implementation adds a real attachment delivery contract. +- Keep the row unread and surface the diagnostic. + +Reason: silently delivering only text for an attachment-bearing message would be another form of message loss. + +## Implementation Phases + +### Phase 0b - MCP Visible Reply Correlation + +Files: + +- `mcp-server/src/tools/messageTools.ts` +- `agent-teams-controller/src/internal/messages.js` +- `agent-teams-controller/src/internal/messageStore.js` +- `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` +- `src/main/services/team/TeamDataService.ts` +- `src/main/services/team/TeamMessageFeedService.ts` +- `src/renderer/store/slices/teamSlice.ts` +- `test/controller.test.js` + +Definition of done: + +- `message_send` accepts optional `relayOfMessageId`. +- Controller passes `relayOfMessageId` through to the message store without inventing it. +- Message store persists `relayOfMessageId` on the visible reply row. +- OpenCode runtime prompt asks for `relayOfMessageId=""` on visible runtime replies. +- OpenCode ack-only guard does not treat `source` alone as explicit delivery context. +- Existing renderer/feed correlation by `relayOfMessageId` continues to work for native and OpenCode messages. +- No existing `message_send` caller is required to provide `relayOfMessageId`; the field is only mandatory for OpenCode runtime-delivery prompt wording. + +This phase is small but important. It gives the watchdog a hard correlation signal and prevents future implementation from falling back to summary/time heuristics. + +### Phase 1 - Orchestrator Response Observer + +Files: + +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeDeliveryResponseObserver.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/types.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.test.ts` + +Definition of done: + +- `runSendMessage()` still returns `accepted: true` after `promptAsync()` succeeds. +- Response observation is included when reconcile succeeds. +- Reconcile failure after prompt acceptance returns `accepted: true` with `responseObservation.state = 'reconcile_failed'`. +- Empty assistant child is classified as unanswered, not responded. +- Tool call child is classified as responded. +- Plain assistant text is classified as `responded_plain_text`. +- `prompt_not_indexed` is returned when async prompt acceptance has not appeared in history yet. + +### Phase 1b - Observe-Only Bridge Command + +Files: + +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.ts` +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.test.ts` +- `src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts` +- `src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts` +- `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` + +Definition of done: + +- `opencode.observeMessageDelivery` validates runtime/session preconditions. +- It does not call `promptAsync()`. +- It is executed as a direct bridge command, like current `opencode.sendMessage`. +- It is not part of `OpenCodeStateChangingTeamCommandName`. +- It does not use `OpenCodeStateChangingBridgeCommandService`. +- It returns the same response observation shape as `opencode.sendMessage`. +- App watchdog uses this before every retry. + +### Phase 2 - Bridge Contract Propagation In claude_team + +Files: + +- `src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts` +- `src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts` +- `src/main/services/team/TeamProvisioningService.ts` +- `src/shared/types/team.ts` + +Definition of done: + +- `OpenCodeTeamRuntimeAdapter.sendMessageToMember()` maps bridge `responseObservation`. +- `OpenCodeTeamRuntimeAdapter.observeMessageDelivery()` maps observe-only bridge `responseObservation`. +- `deliverOpenCodeMemberMessage()` returns `accepted`, `responsePending`, and `responseState`. +- IPC UI relay timeout maps to `responsePending` plus `acceptanceUnknown`, not terminal failure. +- UI `SendMessageResult.runtimeDelivery` can show pending response without treating it as send failure. + +### Phase 3 - Prompt Delivery Ledger + +Files: + +- `src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts` +- `src/main/services/team/opencode/store/RuntimeStoreManifest.ts` +- `test/main/services/team/OpenCodePromptDeliveryLedger.test.ts` + +Definition of done: + +- Store is versioned and validates records. +- Store is lane-scoped for secondary OpenCode lanes. +- `RuntimeStoreSchemaName`, `OPENCODE_RUNTIME_STORE_DESCRIPTORS`, and schema validators know `opencode.promptDeliveryLedger`. +- Duplicate record with same payload is idempotent. +- Same record ID with different payload hash fails loudly. +- Due retry query works. +- Terminal record pruning is deterministic. + +### Phase 4 - Relay Integration + +Files: + +- `src/main/services/team/TeamProvisioningService.ts` +- `test/main/services/team/TeamProvisioningServiceRelay.test.ts` + +Definition of done: + +- OpenCode row is not marked read at prompt acceptance if response is pending/unanswered. +- OpenCode row is marked read when response proof is `responded_*`. +- OpenCode row is not re-prompted when response is observed but read commit failed. +- Duplicate watcher events do not re-prompt before `nextAttemptAt`. +- One outstanding OpenCode delivery per member is enforced. +- Existing native relay tests still pass. + +### Phase 5 - Watchdog Scheduler + +Files: + +- `src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts` +- `src/main/services/team/TeamProvisioningService.ts` +- `src/main/index.ts` +- `test/main/services/team/TeamProvisioningServiceRelay.test.ts` + +Definition of done: + +- Startup/team activation scans due OpenCode prompt deliveries. +- File watcher events schedule nearest retry instead of tight loops. +- Bounded retry stops at `maxAttempts`. +- Team stop cancels or ignores due retries. + +### Phase 6 - UI Diagnostics + +Files: + +- `src/shared/types/team.ts` +- `src/renderer/store/slices/teamSlice.ts` +- `src/renderer/components/team/messages/MessageComposer.tsx` +- `src/renderer/components/team/dialogs/SendMessageDialog.tsx` +- `src/renderer/components/team/messages/MessagesPanel.tsx` + +Definition of done: + +- UI send does not clear as hard failure when OpenCode accepted but response pending. +- UI shows a clear pending response diagnostic. +- UI does not synthesize teammate replies. +- Real bridge failure still shows warning and preserves draft behavior. + +### Phase 7 - Fixture E2E Coverage + +Files: + +- `/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/fixtures/` +- `test/fixtures/team/opencode/` +- new orchestrator observer tests. +- new app relay ledger tests. + +Required fixture scenarios: + +1. Real success with OpenCode task/message tool calls. +2. Empty assistant turn after delivered prompt. +3. Session stale after prompt acceptance. +4. Permission blocked. +5. Plain assistant text fallback. +6. Multiple unread rows with one active outstanding record. +7. App restart with accepted ledger record and later transcript proof. +8. Long transcript where limited reconcile misses the delivered prompt and full-history fallback finds it. +9. Bridge timeout after possible prompt acceptance, followed by observe-before-retry. + +### Minimum Real-Fixture Assertions + +For every real OpenCode fixture test, assert all of these: + +- Delivered prompt is matched by inbound `messageId`, not by latest-user heuristic. +- Multiple retry prompts with the same inbound `messageId` are treated as one logical delivery. +- Multiple assistant children under one delivered prompt are all inspected. +- Empty assistant child does not count as responded. +- Bootstrap-only tool calls do not count as responded. +- Successful `agent-teams_message_send` counts as visible response, but read commit still requires correlation and semantic sufficiency. +- Successful task tool call counts as delivery response for task assignment. +- Errored `agent-teams_message_send` does not mark inbox read. +- Plain text response counts as responded but emits diagnostic. +- Tool names are normalized across `agent-teams_`, `mcp__agent-teams__`, and plain forms. +- Lowercase OpenCode execution tools such as `bash` and `read` count only when `actionMode` allows execution response proof. + +## Test Plan + +### Orchestrator + +```bash +cd /Users/belief/dev/projects/claude/agent_teams_orchestrator +bun test src/services/opencode/OpenCodeBridgeCommandHandler.test.ts src/services/opencode/OpenCodeDeliveryResponseObserver.test.ts +``` + +Target tests: + +- `sendMessage returns accepted with responded_tool_call when child assistant has MCP tool call`. +- `sendMessage returns visibleReplyCorrelation relayOfMessageId when message_send args reference inbound messageId`. +- `sendMessage returns visible_reply_missing_relayOfMessageId diagnostic when direct-child message_send lacks relayOfMessageId`. +- `sendMessage returns accepted with empty_assistant_turn when child assistant has no text/tool/error/finish`. +- `sendMessage returns accepted with pending when OpenCode is busy and no child exists`. +- `sendMessage returns accepted with permission_blocked when pending permission exists`. +- `sendMessage returns accepted with reconcile_failed warning when reconcile throws`. +- `observeMessageDelivery does not call promptAsync`. +- `observeMessageDelivery returns prompt_not_indexed for accepted prompt not visible in transcript yet`. +- `observeMessageDelivery uses full-history fallback when limited reconcile misses the prompt anchor`. +- `observeMessageDelivery is not routed through state-changing bridge command service`. +- `observer normalizes agent-teams, mcp agent-teams, and plain tool names`. +- Fixture test for real OpenCode transcript projection. + +### App main process + +```bash +cd /Users/belief/dev/projects/claude/claude_team +pnpm vitest run \ + test/main/services/team/TeamProvisioningServiceRelay.test.ts \ + test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts \ + test/main/ipc/teams.test.ts +``` + +Target tests: + +- `visible reply with relayOfMessageId commits OpenCode inbox read even when transcript observe is still prompt_not_indexed`. +- `visible reply destination row is preferred over transcript-only response proof`. +- `transcript message_send without destination row is re-observed once before retry`. +- `correlated ack-only visible reply to ask message stays unread and schedules visible answer retry`. +- `correlated ack-only visible reply to task assignment stays unread unless task or execution proof exists`. +- `semantic sufficiency classifier only blocks short exact ack-only phrases and allows concrete status text`. +- `accepted pending OpenCode delivery keeps inbox row unread and ledger accepted`. +- `responded OpenCode delivery marks inbox row read`. +- `direct ask with only non-visible tool activity stays unread and requests visible reply`. +- `reasoning-only assistant child does not mark direct ask read`. +- `task assignment with task_start marks delivery responded and read`. +- `missing ledger rebuilds unread row as acceptanceUnknown and observes before prompt`. +- `failed_terminal unread row is skipped by automatic relay and does not block newer rows`. +- `OpenCode secondary row with attachments fails terminal with attachment unsupported diagnostic and is not text-only delivered`. +- `pending record is not retried into a different lane after recipient provider/model changes`. +- `responded but mark-read failed stores inboxReadCommitError and does not retry prompt`. +- `duplicate watcher while accepted does not call bridge again`. +- `watchdog and watcher share one per-member delivery gate`. +- `onlyMessageId request behind active older delivery returns queued pending, not delivered`. +- `due unanswered row retries once and increments attempts`. +- `watchdog observes before retry and skips prompt when response appeared after previous timeout`. +- `bridge timeout with acceptanceUnknown observes before retrying`. +- `max attempts moves to failed_terminal`. +- `one outstanding record blocks newer unread rows`. +- `app restart scans accepted ledger and commits read when transcript now proves response`. + +### Controller + +```bash +cd /Users/belief/dev/projects/claude/claude_team +pnpm --filter agent-teams-controller test -- test/controller.test.js +``` + +Must stay green, including assignment notification taskRefs. + +Additional target tests: + +- `message_send accepts relayOfMessageId and persists it`. +- `OpenCode ack-only runtime_delivery without relayOfMessageId is rejected`. +- `OpenCode substantive runtime_delivery without relayOfMessageId is allowed but not treated as correlated delivery proof`. +- `source alone does not bypass idle ack filtering for OpenCode senders`. + +### Renderer + +```bash +cd /Users/belief/dev/projects/claude/claude_team +pnpm vitest run \ + test/renderer/store/teamSlice.test.ts \ + test/renderer/components/team/messages/MessagesPanel.test.ts \ + test/renderer/components/team/dialogs/SendMessageDialog.test.tsx +``` + +Target tests: + +- OpenCode accepted but response pending is not treated as send failure. +- OpenCode UI relay timeout is shown as pending/unknown, not terminal failure. +- Bridge failure still returns warning. +- Draft is not cleared on true send failure. +- Pending reply indicator remains while `responsePending` is true and clears on real reply or terminal delivery failure. +- Pending reply indicator clears when a user-inbox row arrives with matching `relayOfMessageId`. +- No fake teammate reply is projected. + +## Rollout And Safety + +Recommended rollout: + +- Default enabled for OpenCode only. +- Add an emergency kill switch: + +```ts +CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG=0 +``` + +If disabled: + +- Keep current prompt delivery behavior. +- Log that OpenCode prompt delivery watchdog is disabled. + +This is a pragmatic safety valve, not a product feature flag. + +## Logging + +Add structured logs for these events: + +- `opencode_prompt_delivery_ledger_created` +- `opencode_prompt_delivery_prompt_accepted` +- `opencode_prompt_delivery_response_observed` +- `opencode_prompt_delivery_unanswered` +- `opencode_prompt_delivery_retry_scheduled` +- `opencode_prompt_delivery_retry_attempted` +- `opencode_prompt_delivery_terminal_failure` +- `opencode_prompt_delivery_inbox_committed_read` + +Minimum fields: + +```ts +{ + teamName, + memberName, + laneId, + runId, + inboxMessageId, + runtimeSessionId, + status, + responseState, + attempts, + nextAttemptAt, + visibleReplyCorrelation, + visibleReplySemanticallySufficient, + reason, +} +``` + +Do not log full message text by default. Log `payloadHash` and first safe preview only under debug. + +## Production Risks And Mitigations + +### Risk: legitimate long-running work gets retried + +Mitigation: + +- If OpenCode status is `busy` or `retry`, do not retry. +- For task assignments, use longer grace. +- Meaningful tool calls count as response proof after filtering bootstrap and identity-only tools. + +### Risk: plain text response is not visible in Messages UI + +Mitigation: + +- Classify plain text as `responded_plain_text`. +- Do not retry. +- Add diagnostic so we can later improve transcript-to-UI projection. + +### Risk: unread rows accumulate after terminal failure + +Mitigation: + +- Keep terminal diagnostics in ledger. +- Skip terminal-failed rows during automatic relay selection so they do not block newer rows. +- Optionally mark terminal failures read only if UI has an explicit "ack failed delivery" action. Do not auto-read in v1. + +### Risk: user asks a question, model uses a tool, but never answers + +Mitigation: + +- Treat non-visible tool activity as sufficient only for task/do/delegate intents. +- For `ask` messages, require visible `agent-teams_message_send` or plain assistant text before read commit. +- Retry with copy that asks for a visible answer if the session goes idle after only non-visible tool activity. + +### Risk: model sends correlated acknowledgement but no answer/action + +Mitigation: + +- `relayOfMessageId` proves correlation only, not semantic completion. +- For asks and direct messages, ack-only text is not read-commit sufficient. +- For task/do delivery, ack-only text needs task or execution proof before read commit. +- Retry copy asks for concrete answer/status and tells the model not to send only acknowledgement. + +### Risk: retry prompt causes duplicate task work + +Mitigation: + +- Retry prompt explicitly says not to duplicate if already acted. +- Observer checks transcript before every retry. +- One outstanding delivery per member reduces prompt collisions. + +### Risk: app and orchestrator disagree on response states + +Mitigation: + +- Response state enum lives in bridge contract. +- App treats unknown response states as pending with diagnostic, not success. + +## Implementation Guardrails + +Reject an implementation if any of these are true: + +- It marks an OpenCode member inbox row read immediately after `promptAsync()` acceptance. +- It marks direct `ask` messages read after only non-visible tool activity. +- It uses summary/time/passive-message linking as OpenCode delivery proof instead of explicit `relayOfMessageId` or transcript parent/child proof. +- It treats `source="runtime_delivery"` alone as explicit context for OpenCode ack-only messages. +- It marks direct asks or task deliveries read after a correlated but ack-only visible message with no answer/status/tool proof. +- It requires every `message_send` caller to provide `relayOfMessageId`; only OpenCode runtime-delivery replies should be prompted to include it. +- It retries a prompt before running observe-only reconcile. +- It uses latest user message as response-proof anchor without exact `messageId` or pre-prompt cursor. +- It returns terminal missing-prompt states from a bounded transcript before trying full-history fallback when the anchor is absent. +- It treats bootstrap-only tools as response proof. +- It routes `opencode.observeMessageDelivery` through the state-changing bridge command service. +- It uses a team-root prompt ledger for secondary OpenCode lanes. +- It allows multiple active non-terminal OpenCode prompt deliveries for the same member. +- It treats mark-read failure after response proof as a reason to re-prompt. +- It hides terminal OpenCode delivery failure by silently marking the inbox row read. +- It lets a `failed_terminal` unread row block newer automatic OpenCode deliveries forever. +- It silently drops attachments when delivering an OpenCode secondary inbox row. +- It retries a ledger record through a newly resolved lane/provider instead of the record's original OpenCode lane identity. +- It changes native Codex, Claude, or Gemini teammate inbox semantics. + +The core invariant: + +```txt +OpenCode prompt accepted != OpenCode teammate responded. +``` + +Everything else follows from this. + +## Open Questions + +These are not blockers for v1, but they should be decided before implementation review: + +1. Should `failed_terminal` rows remain unread forever, or should the UI get an explicit "ack failed delivery" action? + Recommended: keep unread and surface diagnostics in v1. 🎯 8 🛡️ 8 🧠 4 + +2. Should `responded_plain_text` be enough to mark read? + Recommended: yes, with warning. Retrying plain text is more dangerous than accepting it. 🎯 8 🛡️ 8 🧠 3 + +3. Should max attempts be 3 or 2? + Recommended: 3 with delays `[30s, 90s, 180s]`. 🎯 8 🛡️ 8 🧠 2 + +4. Should OpenCode process only one unread row per member at a time? + Recommended: yes. This is probably as important as the retry ledger. 🎯 9 🛡️ 9 🧠 5 + +## Final Recommendation + +Implement the ledger with read-on-responded semantics and one outstanding OpenCode delivery per member. + +This fixes the real bug class: + +- prompt accepted but no action, +- inbox row already read, +- task stuck forever, +- no durable retry path. + +It also avoids the two common bad fixes: + +- prompt spam loops, +- fake frontend replies. diff --git a/mcp-server/src/tools/messageTools.ts b/mcp-server/src/tools/messageTools.ts index 91419a1c..3982bb6f 100644 --- a/mcp-server/src/tools/messageTools.ts +++ b/mcp-server/src/tools/messageTools.ts @@ -14,7 +14,7 @@ export function registerMessageTools(server: Pick) { 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) { 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) { from, summary, source, + relayOfMessageId, leadSessionId, attachments, taskRefs, @@ -64,6 +66,7 @@ export function registerMessageTools(server: Pick) { ...(from ? { from } : {}), ...(summary ? { summary } : {}), ...(source ? { source } : {}), + ...(relayOfMessageId ? { relayOfMessageId } : {}), ...(leadSessionId ? { leadSessionId } : {}), ...(attachments?.length ? { attachments } : {}), ...(taskRefs?.length ? { taskRefs } : {}), diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index befbdd40..fdae26fd 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -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 }]); diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts index 7fdbf13e..88c481c7 100644 --- a/packages/agent-graph/src/hooks/useGraphInteraction.ts +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -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(null); const dragNodeId = useRef(null); const isDragging = useRef(false); const mouseDownPos = useRef<{ x: number; y: number } | null>(null); const clickedNodeId = useRef(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( () => ({ diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index db34adde..b4fc758e 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -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; diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts index cd74c996..4a675f71 100644 --- a/packages/agent-graph/src/index.ts +++ b/packages/agent-graph/src/index.ts @@ -26,6 +26,7 @@ export type { GraphActivityItem, GraphOwnerSlotAssignment, GraphLayoutPort, + GraphLayoutMode, GraphLayoutVersion, GraphNodeKind, GraphNodeState, diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 080a0ae4..0e7aa03f 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -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>(); const processCountByOwnerId = new Map(); @@ -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(); 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(); const seenAssignments = new Set(); 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 { diff --git a/packages/agent-graph/src/ports/index.ts b/packages/agent-graph/src/ports/index.ts index 8bdc01dd..9710dd68 100644 --- a/packages/agent-graph/src/ports/index.ts +++ b/packages/agent-graph/src/ports/index.ts @@ -13,5 +13,6 @@ export type { GraphDomainRef, GraphOwnerSlotAssignment, GraphLayoutPort, + GraphLayoutMode, GraphLayoutVersion, } from './types'; diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index cf7b21cc..e0f566d0 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -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; } diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 54e632cf..dbf36ccf 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -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({
{topToolbarContent ? ( -
- {topToolbarContent} -
+
{topToolbarContent}
) : null}
@@ -190,12 +199,44 @@ export function GraphControls({ > toggle('paused')} - icon={filters.paused ? : } + icon={ + filters.paused ? ( + + ) : ( + + ) + } toolbar title={filters.paused ? 'Resume animation' : 'Pause animation'} /> + {onLayoutModeChange ? ( +
+ + ) : ( + + ) + } + active={layoutMode === 'grid-under-lead'} + toolbar + title={ + layoutMode === 'radial' ? 'Switch to rows layout' : 'Switch to radial layout' + } + /> +
+ ) : null} +
-
+
{icon} @@ -379,7 +420,7 @@ function ToolbarButton({ {title} @@ -405,12 +446,12 @@ function ToolbarToggle({ return ( +
+
+ ); +} + +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 ( + + ); + } + + return ( +
+ {use ? ( + + ) : null} + {setDefault ? ( + + ) : null} + {forget ? ( + + ) : null} + {configure ? ( + + ) : null} + {!use && !setDefault && !forget && !configure ? ( + No actions + ) : null} +
+ ); +} + +function ProviderRow({ + provider, + state, + active, + formOpen, + apiKeyValue, + busy, + disabled, + actions, +}: ProviderRowProps): JSX.Element { + return ( +
+
+ +
+ actions.startConnect(provider.providerId)} + onUse={() => actions.openModelPicker(provider.providerId, 'use')} + onSetDefault={() => actions.openModelPicker(provider.providerId, 'runtime-default')} + onForget={() => void actions.forgetProvider(provider.providerId)} + /> +
+
+ + {formOpen ? ( +
+
+ + actions.setApiKeyValue(event.target.value)} + placeholder="Paste API key" + className="h-9 text-sm" + autoFocus + /> +
+
+ + +
+
+ ) : null} + + {active && provider.state === 'connected' && provider.modelCount > 0 ? ( + + ) : null} +
+ ); +} + +function ModelBadges({ model }: { readonly model: RuntimeProviderModelDto }): JSX.Element { + return ( +
+ + {model.sourceLabel} + + {model.free ? ( + free + ) : null} + {model.default ? ( + default + ) : null} +
+ ); +} + +function ModelResult({ + result, +}: { + readonly result: RuntimeProviderModelTestResultDto | undefined; +}): JSX.Element | null { + if (!result) { + return null; + } + return ( +
+ {result.message} +
+ ); +} + +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 = ( + + ); + const setDefaultButton = ( + + ); + + return ( +
+
+ +
+ + {mode === 'runtime-default' ? setDefaultButton : useButton} + {mode === 'runtime-default' ? useButton : setDefaultButton} +
+
+ +
+ ); +} + +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 ( +
+
+ + actions.setModelQuery(event.target.value)} + placeholder="Search models" + className="h-9 pl-9 text-sm" + /> +
+ + {state.modelsError ? ( +
+ {state.modelsError} +
+ ) : null} + +
+ {!pickerOpen || state.modelsLoading ? ( +
+ + Loading models +
+ ) : null} + {pickerOpen && !state.modelsLoading && state.models.length === 0 && !state.modelsError ? ( +
No models found.
+ ) : null} + {pickerOpen + ? state.models.map((model) => ( + + )) + : null} +
+
+ ); +} + +export function RuntimeProviderManagementPanelView({ + state, + actions, + disabled, +}: RuntimeProviderManagementPanelViewProps): JSX.Element { + const selectedProviderId = state.selectedProviderId ?? state.providers[0]?.providerId ?? null; + + return ( +
+ void actions.refresh()} /> + + {state.error ? ( +
+ + {state.error} +
+ ) : null} + + {state.successMessage ? ( +
+ + {state.successMessage} +
+ ) : null} + +
+ {state.providers.map((provider) => ( + + ))} +
+ + {!state.loading && state.providers.length === 0 ? ( +
+ No OpenCode providers reported by the managed runtime. +
+ ) : null} +
+ ); +} diff --git a/src/main/index.ts b/src/main/index.ts index b02a12a0..09fc04e8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { 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 { 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 { ); 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 { removeIpcHandlers(); removeCodexAccountIpc(ipcMain); removeRecentProjectsIpc(ipcMain); + removeRuntimeProviderManagementIpc(ipcMain); }); await runShutdownStep('team backup dispose', () => teamBackupService?.dispose()); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 9e98f8a4..a796c4f9 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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' diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 60f24614..0458fd1d 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -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); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index b4c44e0c..85c26aa4 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -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 { - 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(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 { - 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 ? { diff --git a/src/main/services/team/TeamBackupService.ts b/src/main/services/team/TeamBackupService.ts index 7d12808e..fca34e30 100644 --- a/src/main/services/team/TeamBackupService.ts +++ b/src/main/services/team/TeamBackupService.ts @@ -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, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3a7b936f..e95bdaa0 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -118,6 +118,26 @@ import { import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal'; +import { + createOpenCodePromptDeliveryLedgerStore, + hashOpenCodePromptDeliveryPayload, + isOpenCodePromptDeliveryAttemptDue, + isOpenCodePromptResponseStateResponded, + type OpenCodePromptDeliveryLedgerRecord, + type OpenCodePromptDeliveryLedgerStore, + type OpenCodePromptDeliveryStatus, +} from './opencode/delivery/OpenCodePromptDeliveryLedger'; +import { + isOpenCodePromptDeliveryObserveLaterResponseState, + isOpenCodePromptDeliveryRetryableResponseState, + isOpenCodeVisibleReplySemanticallySufficient, + isOpenCodeVisibleReplyReadCommitAllowed, + OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS, + OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS, + OPENCODE_PROMPT_WATCHDOG_GLOBAL_CONCURRENCY, + OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY, + type OpenCodeVisibleReplyProof, +} from './opencode/delivery/OpenCodePromptDeliveryWatchdog'; import { type RuntimeDeliveryDestinationPort, RuntimeDeliveryDestinationRegistry, @@ -922,6 +942,15 @@ function getCanonicalSendMessageToolRule(to: string): string { return `Use the SendMessage tool with to="${to}".`; } +function getVisibleTaskReferenceFormattingRule(): string { + return [ + 'Task reference formatting (CRITICAL): In visible message/comment text, write task refs as plain # text, e.g. #abcd1234.', + 'Never wrap task refs or Markdown task links in backticks/code spans, because code spans are not linkified in Messages.', + 'Do NOT manually write [#abcd1234](task://...) in visible text.', + 'When a message tool supports taskRefs, include structured taskRefs metadata and let the app linkify the visible #abcd1234 text.', + ].join('\n'); +} + function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null { const runtimeConfig = ConfigManager.getInstance().getConfig().runtime.providerBackends; switch (providerId) { @@ -2401,6 +2430,7 @@ If tool search says agent-teams is still connecting, wait briefly and retry tool If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. ${getCanonicalSendMessageFieldRule()} +${getVisibleTaskReferenceFormattingRule()} Correct example: ${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} After member_briefing succeeds, stay silent until you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads. @@ -2432,6 +2462,7 @@ If tool search says agent-teams is still connecting, wait briefly and retry tool If member_briefing is still unavailable after that one retry, SendMessage "${leadName}" exactly one short natural-language sentence with the exact error text, then stop this turn and wait. Do NOT send only "bootstrap failed". Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. ${getCanonicalSendMessageFieldRule()} +${getVisibleTaskReferenceFormattingRule()} Correct example: ${buildCanonicalSendMessageExample({ to: leadName, summary: 'bootstrap error', message: 'exact error text' })} After member_briefing succeeds, stay silent unless you have a real blocker, question, or task result. Do NOT send raw tool output, JSON, dict/object dumps, or internal state payloads. @@ -2481,6 +2512,7 @@ If member_briefing is still unavailable after that one retry, send exactly one s Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). ${getCanonicalSendMessageFieldRule()} +${getVisibleTaskReferenceFormattingRule()} Correct example: ${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', message: 'your message' })} After member_briefing succeeds: @@ -2497,7 +2529,7 @@ After member_briefing succeeds: - CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply. - CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle. - CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment via task_add_comment BEFORE calling task_complete. Save the comment.id from the response — you will need it in the next step. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work. -- After task_complete, notify your team lead via SendMessage. Keep the visible message human-readable only: include the task ref, a brief summary (2-4 sentences), where the full result lives, and the next step. Do NOT paste tool-like calls such as task_get_comment { ... } into the visible message text. Instead write "Full details in task comment ". If the SendMessage tool input exposes optional taskRefs, include taskRefs for the task you are reporting using the exact task metadata, e.g. taskRefs: [{ taskId: "", displayId: "", teamName: "${teamName}" }]. Example visible message: "#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678." +- After task_complete, notify your team lead via SendMessage. Keep the visible message human-readable only: include the task ref as plain # text (not a code span and not a manual task:// Markdown link), a brief summary (2-4 sentences), where the full result lives, and the next step. Do NOT paste tool-like calls such as task_get_comment { ... } into the visible message text. Instead write "Full details in task comment ". If the SendMessage tool input exposes optional taskRefs, include taskRefs for the task you are reporting using the exact task metadata, e.g. taskRefs: [{ taskId: "", displayId: "", teamName: "${teamName}" }]. Example visible message: "#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678." - Review discipline: ${indentMultiline(buildMemberReviewFlowReminder(), ' ')} - Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply. @@ -2556,6 +2588,7 @@ ${providerArgLine}${modelArgLine}${effortArgLine} - prompt: If member_briefing is still unavailable after that one retry, send exactly one short natural-language message to your team lead "${leadName}" that includes the exact failure reason (for example the API error, validation error, or lookup failure), then stop this turn and wait. Do NOT send only "bootstrap failed". Do NOT keep searching for member_briefing, check tasks, or send repeated status/idle messages after reporting the bootstrap failure. IMPORTANT: When sending messages to the team lead, always use the exact name "${leadName}" in the \`to\` field of SendMessage. Never abbreviate or shorten it (e.g. do NOT use "lead" instead of "team-lead"). +${indentMultiline(getVisibleTaskReferenceFormattingRule(), ' ')} ${buildTeammateAgentBlockReminder()} ${actionModeProtocol} @@ -2576,7 +2609,7 @@ ${actionModeProtocol} - Only then run task_start when you truly begin. - If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST leave a short task comment saying what you are about to do, THEN run task_start, then do the work, and when finished leave a short result comment and run task_complete again. Never skip this comment -> reopen -> work -> comment -> done cycle. - CRITICAL: When you finish a task, your results (findings, research report, analysis, code changes summary, or any deliverable) MUST be posted as a task comment BEFORE calling task_complete. The task comment is the primary delivery channel — the user reads results on the task board. A SendMessage to the lead is NOT a substitute: direct messages are ephemeral and not visible on the board. If you only SendMessage without a task comment, the user will never see your work. - - After task_complete, notify your team lead via SendMessage. The task_add_comment response contains comment.id (UUID) — take its first 8 characters as the short commentId. Keep the visible message human-readable only: include the task ref, a brief summary (2-4 sentences), where the full result lives, and the next step. Do NOT paste tool-like calls such as task_get_comment { ... } into the visible message text. Instead write "Full details in task comment ". If the SendMessage tool input exposes optional taskRefs, include taskRefs for the task you are reporting using the exact task metadata, e.g. taskRefs: [{ taskId: "", displayId: "", teamName: "${teamName}" }]. Example visible message: "#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678." + - After task_complete, notify your team lead via SendMessage. The task_add_comment response contains comment.id (UUID) - take its first 8 characters as the short commentId. Keep the visible message human-readable only: include the task ref as plain # text (not a code span and not a manual task:// Markdown link), a brief summary (2-4 sentences), where the full result lives, and the next step. Do NOT paste tool-like calls such as task_get_comment { ... } into the visible message text. Instead write "Full details in task comment ". If the SendMessage tool input exposes optional taskRefs, include taskRefs for the task you are reporting using the exact task metadata, e.g. taskRefs: [{ taskId: "", displayId: "", teamName: "${teamName}" }]. Example visible message: "#abcd1234 done. Found 3 competitors, two lack kanban. Full details in task comment e5f6a7b8. Moving to #efgh5678." - Review discipline: ${indentMultiline(buildMemberReviewFlowReminder(), ' ')} - Beyond task-completion pings, direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply. @@ -3100,6 +3133,7 @@ Communication protocol (CRITICAL — you are running headless, no one sees your Message formatting: - When mentioning teammates by name in messages and text output, always use @ prefix (e.g. @alice, @bob) for UI highlighting. When mentioning another team, also use @ (e.g. @signal-ops). Do NOT use @ in tool parameters (recipient, owner, etc.) — those require plain names. +${getVisibleTaskReferenceFormattingRule()} ${agentBlockPolicy} ${membersFooter}`; @@ -3804,6 +3838,19 @@ interface NativeSameTeamFingerprint { interface OpenCodeMemberInboxDelivery { delivered: boolean; + accepted?: boolean; + responsePending?: boolean; + acceptanceUnknown?: boolean; + responseState?: NonNullable['state']; + ledgerStatus?: OpenCodePromptDeliveryStatus; + ledgerRecordId?: string; + laneId?: string; + visibleReplyMessageId?: string; + visibleReplyCorrelation?: + | 'relayOfMessageId' + | 'direct_child_message_send' + | 'plain_assistant_text'; + queuedBehindMessageId?: string; reason?: string; diagnostics?: string[]; } @@ -3831,7 +3878,7 @@ interface LiveInboxRelayResult { interface OpenCodeMemberInboxRelayOptions { onlyMessageId?: string; - source?: 'watcher' | 'ui-send' | 'manual'; + source?: 'watcher' | 'ui-send' | 'manual' | 'watchdog'; deliveryMetadata?: { replyRecipient?: string; actionMode?: AgentActionMode; @@ -3895,6 +3942,14 @@ export class TeamProvisioningService { string, Promise >(); + private readonly openCodePromptDeliveryWatchdogTimers = new Map(); + private readonly openCodePromptDeliveryWatchdogQueue: Array<{ + teamName: string; + run: () => Promise; + }> = []; + private openCodePromptDeliveryWatchdogInFlight = 0; + private openCodePromptDeliveryWatchdogDisabledLogged = false; + private readonly openCodePromptDeliveryWatchdogInFlightByTeam = new Map(); private readonly relayedMemberInboxMessageIds = new Map>(); private readonly pendingCrossTeamFirstReplies = new Map>(); private readonly recentCrossTeamLeadDeliveryMessageIds = new Map>(); @@ -4483,6 +4538,9 @@ export class TeamProvisioningService { sendMessageToMember( input: OpenCodeTeamRuntimeMessageInput ): Promise; + observeMessageDelivery?( + input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null } + ): Promise; }) | null { const adapter = this.getOpenCodeRuntimeAdapter(); @@ -4493,6 +4551,9 @@ export class TeamProvisioningService { sendMessageToMember( input: OpenCodeTeamRuntimeMessageInput ): Promise; + observeMessageDelivery?( + input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null } + ): Promise; }; } @@ -4523,6 +4584,606 @@ export class TeamProvisioningService { return providerId === 'opencode'; } + private isOpenCodeDeliveryResponseReadCommitAllowed(input: { + responseState?: NonNullable['state']; + actionMode?: AgentActionMode; + taskRefs?: TaskRef[]; + visibleReply?: OpenCodeVisibleReplyProof | null; + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; + }): boolean { + const state = input.responseState; + if (!state || !isOpenCodePromptResponseStateResponded(state)) { + return false; + } + if (state === 'responded_plain_text') { + return this.isOpenCodePlainTextResponseReadCommitAllowed({ + actionMode: input.actionMode, + taskRefs: input.taskRefs, + ledgerRecord: input.ledgerRecord, + }); + } + if (state === 'responded_visible_message') { + return isOpenCodeVisibleReplyReadCommitAllowed({ + actionMode: input.actionMode, + taskRefs: input.taskRefs, + visibleReply: input.visibleReply, + transcriptOnlyVisibleReply: !input.visibleReply, + }); + } + const hasTaskRefs = (input.taskRefs ?? []).length > 0; + return hasTaskRefs || input.actionMode === 'do' || input.actionMode === 'delegate'; + } + + private isOpenCodePlainTextResponseReadCommitAllowed(input: { + actionMode?: AgentActionMode; + taskRefs?: TaskRef[]; + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; + }): boolean { + const preview = input.ledgerRecord?.observedAssistantPreview?.trim(); + if (!preview) { + return true; + } + return isOpenCodeVisibleReplySemanticallySufficient({ + actionMode: input.actionMode, + taskRefs: input.taskRefs, + text: preview, + }).sufficient; + } + + private getOpenCodeDeliveryPendingReason(input: { + responseState?: NonNullable['state']; + actionMode?: AgentActionMode | null; + taskRefs?: TaskRef[]; + visibleReply?: OpenCodeVisibleReplyProof | null; + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; + }): string { + const record = input.ledgerRecord; + const state = input.responseState ?? record?.responseState; + if (record?.lastReason === 'visible_reply_ack_only_still_requires_answer') { + return 'visible_reply_ack_only_still_requires_answer'; + } + if (state === 'responded_plain_text') { + const preview = record?.observedAssistantPreview?.trim(); + if ( + preview && + !isOpenCodeVisibleReplySemanticallySufficient({ + actionMode: input.actionMode, + taskRefs: input.taskRefs, + text: preview, + }).sufficient + ) { + return 'plain_text_ack_only_still_requires_answer'; + } + } + if (state === 'responded_visible_message' && !input.visibleReply) { + return 'visible_reply_destination_not_found_yet'; + } + if (state === 'responded_non_visible_tool' || state === 'responded_tool_call') { + const hasTaskRefs = (input.taskRefs ?? []).length > 0; + if (!hasTaskRefs && input.actionMode !== 'do' && input.actionMode !== 'delegate') { + return 'visible_reply_still_required'; + } + } + if (state === 'empty_assistant_turn') { + return 'empty_assistant_turn'; + } + return record?.lastReason ?? 'opencode_delivery_response_pending'; + } + + private isOpenCodeDeliveryRetryablePendingResponse(input: { + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + visibleReply?: OpenCodeVisibleReplyProof | null; + readAllowed: boolean; + }): boolean { + if (input.readAllowed) { + return false; + } + if (isOpenCodePromptDeliveryRetryableResponseState(input.ledgerRecord.responseState)) { + return true; + } + if ( + input.ledgerRecord.lastReason === 'visible_reply_ack_only_still_requires_answer' || + input.ledgerRecord.lastReason === 'plain_text_ack_only_still_requires_answer' + ) { + return true; + } + if (input.ledgerRecord.responseState === 'responded_visible_message' && !input.visibleReply) { + return true; + } + if ( + input.ledgerRecord.responseState === 'responded_non_visible_tool' || + input.ledgerRecord.responseState === 'responded_tool_call' || + input.ledgerRecord.responseState === 'responded_plain_text' + ) { + return true; + } + return false; + } + + private buildOpenCodePromptDeliveryAttemptText(input: { + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; + text: string; + replyRecipient: string; + }): string { + const record = input.ledgerRecord; + if (!record || record.status === 'pending' || record.attempts <= 0) { + return input.text; + } + const visibleAnswerRequired = + record.lastReason === 'visible_reply_still_required' || + record.lastReason === 'plain_text_ack_only_still_requires_answer' || + (record.responseState === 'responded_non_visible_tool' && + record.actionMode === 'ask' && + record.taskRefs.length === 0); + const attemptNumber = Math.min(record.attempts + 1, record.maxAttempts); + const header = visibleAnswerRequired + ? [ + '', + `This is retry attempt ${attemptNumber}/${record.maxAttempts} for inbound app messageId "${record.inboxMessageId}".`, + `You accepted the earlier prompt but did not provide a visible/concrete answer for the recipient "${input.replyRecipient}".`, + `Please reply with agent-teams_message_send to "${input.replyRecipient}" and include relayOfMessageId="${record.inboxMessageId}". If that tool is unavailable, provide a concise plain-text answer.`, + 'Do not repeat tool work unless needed and do not reply only with acknowledgement.', + '', + ] + : [ + '', + `This is retry attempt ${attemptNumber}/${record.maxAttempts} for inbound app messageId "${record.inboxMessageId}".`, + 'The previous OpenCode turn was accepted, but the app still has no sufficient response proof for this message.', + `If you already acted on this message, do not duplicate work; send a concrete status via agent-teams_message_send with relayOfMessageId="${record.inboxMessageId}" or update the related task.`, + 'Do not reply only with acknowledgement.', + '', + ]; + return `${header.join('\n')}\n\n${input.text}`; + } + + private isOpenCodePromptAcceptanceUnknownFailure(diagnostics: readonly string[]): boolean { + return diagnostics.some((diagnostic) => isProbeTimeoutMessage(diagnostic)); + } + + private isOpenCodePromptDeliveryWatchdogEnabled(): boolean { + const enabled = process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG !== '0'; + if (!enabled && !this.openCodePromptDeliveryWatchdogDisabledLogged) { + this.openCodePromptDeliveryWatchdogDisabledLogged = true; + logger.info( + 'OpenCode prompt delivery watchdog is disabled by CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG=0; using legacy prompt acceptance semantics.' + ); + } + return enabled; + } + + private async findOpenCodeVisibleReplyByRelayOfMessageId(input: { + teamName: string; + replyRecipient?: string | null; + from: string; + relayOfMessageId: string; + }): Promise { + const relayOfMessageId = input.relayOfMessageId.trim(); + if (!relayOfMessageId) { + return null; + } + const candidates = await this.getOpenCodeVisibleReplyInboxCandidates({ + teamName: input.teamName, + replyRecipient: input.replyRecipient, + }); + const expectedFrom = input.from.trim().toLowerCase(); + for (const inboxName of candidates) { + const messages = await this.inboxReader + .getMessagesFor(input.teamName, inboxName) + .catch(() => []); + const matches = messages.filter( + (message): message is InboxMessage & { messageId: string } => + typeof message.messageId === 'string' && + message.messageId.trim().length > 0 && + message.relayOfMessageId === relayOfMessageId && + message.from.trim().toLowerCase() === expectedFrom + ); + const match = + matches.find((message) => message.source === 'runtime_delivery') ?? matches[0] ?? null; + if (match) { + return { + inboxName, + message: { ...match, messageId: match.messageId! }, + missingRuntimeDeliverySource: match.source !== 'runtime_delivery', + }; + } + } + return null; + } + + private async getOpenCodeVisibleReplyInboxCandidates(input: { + teamName: string; + replyRecipient?: string | null; + }): Promise { + const explicitRecipient = input.replyRecipient?.trim() || 'user'; + const candidates = [explicitRecipient]; + if (this.isOpenCodeLeadReplyRecipientAlias(explicitRecipient)) { + const configuredLeadName = await this.configReader + .getConfig(input.teamName) + .then( + (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null + ) + .catch(() => null); + if (configuredLeadName) { + candidates.push(configuredLeadName); + } + candidates.push('lead'); + candidates.push('team-lead'); + } + return candidates + .filter((value): value is string => Boolean(value && value.trim())) + .filter( + (value, index, list) => + list.findIndex((item) => item.toLowerCase() === value.toLowerCase()) === index + ); + } + + private isOpenCodeLeadReplyRecipientAlias(value: string): boolean { + const normalized = value + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-'); + return ( + normalized === 'lead' || + normalized === 'team-lead' || + normalized === 'teamlead' || + normalized === 'team-leader' + ); + } + + private async applyOpenCodeVisibleDestinationProof(input: { + ledger: OpenCodePromptDeliveryLedgerStore; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + teamName: string; + replyRecipient?: string | null; + memberName: string; + }): Promise<{ + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + visibleReply: OpenCodeVisibleReplyProof | null; + }> { + const visibleReply = await this.findOpenCodeVisibleReplyByRelayOfMessageId({ + teamName: input.teamName, + replyRecipient: input.replyRecipient ?? input.ledgerRecord.replyRecipient, + from: input.memberName, + relayOfMessageId: input.ledgerRecord.inboxMessageId, + }); + if (!visibleReply) { + return { ledgerRecord: input.ledgerRecord, visibleReply: null }; + } + const semantic = isOpenCodeVisibleReplyReadCommitAllowed({ + actionMode: input.ledgerRecord.actionMode, + taskRefs: input.ledgerRecord.taskRefs, + visibleReply, + }); + const ledgerRecord = await input.ledger.applyDestinationProof({ + id: input.ledgerRecord.id, + visibleReplyInbox: visibleReply.inboxName, + visibleReplyMessageId: visibleReply.message.messageId, + visibleReplyCorrelation: 'relayOfMessageId', + semanticallySufficient: semantic, + diagnostics: visibleReply.missingRuntimeDeliverySource + ? ['visible_reply_missing_runtime_delivery_source'] + : [], + observedAt: nowIso(), + }); + return { ledgerRecord, visibleReply }; + } + + private getOpenCodeDeliveryWatchdogKey(input: { + teamName: string; + memberName: string; + messageId: string; + }): string { + return `opencode-delivery:${input.teamName}:${input.memberName.toLowerCase()}:${input.messageId}`; + } + + private enqueueOpenCodePromptDeliveryWatchdogJob(input: { + teamName: string; + run: () => Promise; + }): void { + this.openCodePromptDeliveryWatchdogQueue.push(input); + this.drainOpenCodePromptDeliveryWatchdogQueue(); + } + + private drainOpenCodePromptDeliveryWatchdogQueue(): void { + while ( + this.openCodePromptDeliveryWatchdogInFlight < OPENCODE_PROMPT_WATCHDOG_GLOBAL_CONCURRENCY && + this.openCodePromptDeliveryWatchdogQueue.length > 0 + ) { + const nextIndex = this.openCodePromptDeliveryWatchdogQueue.findIndex( + (queued) => + (this.openCodePromptDeliveryWatchdogInFlightByTeam.get(queued.teamName) ?? 0) < + OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY + ); + if (nextIndex < 0) { + return; + } + const [job] = this.openCodePromptDeliveryWatchdogQueue.splice(nextIndex, 1); + if (!job) { + return; + } + this.openCodePromptDeliveryWatchdogInFlight += 1; + this.openCodePromptDeliveryWatchdogInFlightByTeam.set( + job.teamName, + (this.openCodePromptDeliveryWatchdogInFlightByTeam.get(job.teamName) ?? 0) + 1 + ); + void job + .run() + .catch((error: unknown) => { + logger.warn(`OpenCode prompt delivery watchdog job failed: ${getErrorMessage(error)}`); + }) + .finally(() => { + this.openCodePromptDeliveryWatchdogInFlight = Math.max( + 0, + this.openCodePromptDeliveryWatchdogInFlight - 1 + ); + const teamInFlight = + (this.openCodePromptDeliveryWatchdogInFlightByTeam.get(job.teamName) ?? 1) - 1; + if (teamInFlight > 0) { + this.openCodePromptDeliveryWatchdogInFlightByTeam.set(job.teamName, teamInFlight); + } else { + this.openCodePromptDeliveryWatchdogInFlightByTeam.delete(job.teamName); + } + this.drainOpenCodePromptDeliveryWatchdogQueue(); + }); + } + } + + private scheduleOpenCodePromptDeliveryWatchdog(input: { + teamName: string; + memberName: string; + messageId?: string | null; + delayMs: number; + }): void { + if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) { + return; + } + const messageId = input.messageId?.trim(); + if (!messageId) return; + const key = this.getOpenCodeDeliveryWatchdogKey({ + teamName: input.teamName, + memberName: input.memberName, + messageId, + }); + const existing = this.openCodePromptDeliveryWatchdogTimers.get(key); + if (existing) { + clearTimeout(existing); + } + const delayMs = Math.max(500, Math.min(input.delayMs, 60_000)); + const timer = setTimeout(() => { + this.openCodePromptDeliveryWatchdogTimers.delete(key); + this.enqueueOpenCodePromptDeliveryWatchdogJob({ + teamName: input.teamName, + run: async () => { + await this.relayOpenCodeMemberInboxMessages(input.teamName, input.memberName, { + onlyMessageId: messageId, + source: 'watchdog', + }); + }, + }); + }, delayMs); + this.openCodePromptDeliveryWatchdogTimers.set(key, timer); + } + + private getOpenCodeDeliveryNextDelayMs(input: { + responseState?: NonNullable['state']; + retry: boolean; + }): number { + if (input.retry) { + return OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS; + } + if (isOpenCodePromptDeliveryObserveLaterResponseState(input.responseState)) { + return OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS; + } + return OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS; + } + + private async scheduleOpenCodePromptLedgerFollowUp(input: { + ledger: OpenCodePromptDeliveryLedgerStore; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + teamName: string; + memberName: string; + retry: boolean; + reason: string; + }): Promise { + const now = nowIso(); + if (input.retry && input.ledgerRecord.attempts >= input.ledgerRecord.maxAttempts) { + return await input.ledger.markFailedTerminal({ + id: input.ledgerRecord.id, + reason: input.reason, + failedAt: now, + }); + } + const delayMs = this.getOpenCodeDeliveryNextDelayMs({ + responseState: input.ledgerRecord.responseState, + retry: input.retry, + }); + const nextAttemptAt = new Date(Date.now() + delayMs).toISOString(); + const ledgerRecord = await input.ledger.markNextAttemptScheduled({ + id: input.ledgerRecord.id, + status: input.retry ? 'retry_scheduled' : 'accepted', + nextAttemptAt, + reason: input.reason, + scheduledAt: now, + }); + this.logOpenCodePromptDeliveryEvent( + input.retry + ? 'opencode_prompt_delivery_retry_scheduled' + : 'opencode_prompt_delivery_response_observed', + ledgerRecord, + { retry: input.retry, reason: input.reason } + ); + this.scheduleOpenCodePromptDeliveryWatchdog({ + teamName: input.teamName, + memberName: input.memberName, + messageId: input.ledgerRecord.inboxMessageId, + delayMs, + }); + return ledgerRecord; + } + + private logOpenCodePromptDeliveryEvent( + event: string, + record: OpenCodePromptDeliveryLedgerRecord, + extra: Record = {} + ): void { + logger.info( + event, + JSON.stringify({ + teamName: record.teamName, + memberName: record.memberName, + laneId: record.laneId, + runId: record.runId, + inboxMessageId: record.inboxMessageId, + runtimeSessionId: record.runtimeSessionId, + status: record.status, + responseState: record.responseState, + attempts: record.attempts, + nextAttemptAt: record.nextAttemptAt, + visibleReplyCorrelation: record.visibleReplyCorrelation, + reason: record.lastReason, + ...extra, + }) + ); + } + + async scanOpenCodePromptDeliveryWatchdog(teamName: string): Promise { + if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) { + return 0; + } + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( + () => null + ); + if (!laneIndex) { + return 0; + } + return await this.scanOpenCodePromptDeliveryWatchdogForActiveLanes( + teamName, + Object.values(laneIndex.lanes) + .filter((lane) => lane.state === 'active') + .map((lane) => lane.laneId) + ); + } + + private async scanOpenCodePromptDeliveryWatchdogForActiveLanes( + teamName: string, + laneIds: string[] + ): Promise { + if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) { + return 0; + } + let scheduled = 0; + for (const laneId of [...new Set(laneIds.map((laneId) => laneId.trim()).filter(Boolean))]) { + const ledger = this.createOpenCodePromptDeliveryLedger(teamName, laneId); + await ledger.pruneTerminalRecords({ now: new Date() }).catch((error: unknown) => { + logger.warn( + `[${teamName}] OpenCode prompt delivery ledger prune failed for ${laneId}: ${getErrorMessage(error)}` + ); + }); + const records = await ledger.list().catch(() => []); + for (const record of records) { + if ( + record.status === 'failed_terminal' || + (record.status === 'responded' && record.inboxReadCommittedAt) + ) { + continue; + } + const nextAttemptMs = record.nextAttemptAt ? Date.parse(record.nextAttemptAt) : NaN; + const delayMs = Number.isFinite(nextAttemptMs) + ? Math.max(500, nextAttemptMs - Date.now()) + : OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS; + this.scheduleOpenCodePromptDeliveryWatchdog({ + teamName, + memberName: record.memberName, + messageId: record.inboxMessageId, + delayMs, + }); + scheduled += 1; + } + const members = await this.resolveOpenCodeMembersForRuntimeLane(teamName, laneId); + for (const memberName of members) { + const inboxMessages = await this.inboxReader + .getMessagesFor(teamName, memberName) + .catch(() => []); + for (const message of inboxMessages) { + if ( + message.read || + typeof message.text !== 'string' || + message.text.trim().length === 0 || + !this.hasStableMessageId(message) + ) { + continue; + } + const existing = await ledger + .getByInboxMessage({ + teamName, + memberName, + laneId, + inboxMessageId: message.messageId, + }) + .catch(() => null); + if (existing) { + continue; + } + const replyRecipient = + typeof message.from === 'string' && + message.from.trim() && + message.from.trim().toLowerCase() !== memberName.trim().toLowerCase() + ? message.from.trim() + : 'user'; + const now = nowIso(); + const record = await ledger.ensurePending({ + teamName, + memberName, + laneId, + runId: await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneId), + inboxMessageId: message.messageId, + inboxTimestamp: message.timestamp, + source: 'watchdog', + replyRecipient, + actionMode: message.actionMode ?? null, + taskRefs: message.taskRefs ?? [], + payloadHash: hashOpenCodePromptDeliveryPayload({ + text: message.text, + replyRecipient, + actionMode: message.actionMode ?? null, + taskRefs: message.taskRefs ?? [], + attachments: message.attachments, + source: 'watchdog', + }), + now, + }); + if (message.attachments?.length) { + await ledger.markFailedTerminal({ + id: record.id, + reason: 'opencode_attachments_not_supported_for_secondary_runtime', + failedAt: now, + }); + continue; + } + const recovered = await ledger.markAcceptanceUnknown({ + id: record.id, + reason: 'opencode_prompt_delivery_ledger_rebuilt_from_unread_inbox', + nextAttemptAt: now, + markedAt: now, + }); + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_retry_scheduled', + recovered, + { acceptanceUnknown: true, reason: recovered.lastReason } + ); + this.scheduleOpenCodePromptDeliveryWatchdog({ + teamName, + memberName: recovered.memberName, + messageId: recovered.inboxMessageId, + delayMs: 500, + }); + scheduled += 1; + } + } + } + return scheduled; + } + async deliverOpenCodeMemberMessage( teamName: string, input: { @@ -4532,8 +5193,10 @@ export class TeamProvisioningService { replyRecipient?: string; actionMode?: AgentActionMode; taskRefs?: TaskRef[]; + source?: OpenCodeMemberInboxRelayOptions['source']; + inboxTimestamp?: string; } - ): Promise<{ delivered: boolean; reason?: string; diagnostics?: string[] }> { + ): Promise { const adapter = this.getOpenCodeRuntimeMessageAdapter(); if (!adapter) { return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' }; @@ -4646,22 +5309,487 @@ export class TeamProvisioningService { return { delivered: false, reason: 'opencode_runtime_not_active' }; } + if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) { + const result = await adapter.sendMessageToMember({ + ...(runtimeRunId ? { runId: runtimeRunId } : {}), + teamName, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + text: input.text, + messageId: input.messageId, + replyRecipient: input.replyRecipient, + actionMode: input.actionMode, + taskRefs: input.taskRefs, + }); + return { + delivered: result.ok, + accepted: result.ok, + responsePending: false, + responseState: result.responseObservation?.state, + ...(result.ok + ? {} + : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), + diagnostics: result.diagnostics, + }; + } + + const messageId = input.messageId?.trim(); + const ledger = + messageId && input.source + ? this.createOpenCodePromptDeliveryLedger(teamName, laneIdentity.laneId) + : null; + const now = nowIso(); + const active = ledger + ? await ledger.getActiveForMember({ + teamName, + memberName: canonicalMemberName, + laneId: laneIdentity.laneId, + }) + : null; + if (active && active.inboxMessageId !== messageId) { + const activeDueMs = active.nextAttemptAt ? Date.parse(active.nextAttemptAt) : NaN; + this.scheduleOpenCodePromptDeliveryWatchdog({ + teamName, + memberName: canonicalMemberName, + messageId: active.inboxMessageId, + delayMs: Number.isFinite(activeDueMs) + ? Math.max(500, activeDueMs - Date.now()) + : OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS, + }); + return { + delivered: true, + accepted: false, + responsePending: true, + responseState: active.responseState, + ledgerStatus: active.status, + ledgerRecordId: active.id, + laneId: laneIdentity.laneId, + queuedBehindMessageId: active.inboxMessageId, + reason: 'opencode_delivery_response_pending', + diagnostics: [`OpenCode delivery is queued behind ${active.inboxMessageId}.`], + }; + } + + let ledgerRecord = messageId + ? await ledger?.ensurePending({ + teamName, + memberName: canonicalMemberName, + laneId: laneIdentity.laneId, + runId: runtimeRunId ?? null, + inboxMessageId: messageId, + inboxTimestamp: input.inboxTimestamp ?? now, + source: input.source ?? 'manual', + replyRecipient: input.replyRecipient ?? 'user', + actionMode: input.actionMode ?? null, + taskRefs: input.taskRefs ?? [], + payloadHash: hashOpenCodePromptDeliveryPayload({ + text: input.text, + replyRecipient: input.replyRecipient ?? 'user', + actionMode: input.actionMode ?? null, + taskRefs: input.taskRefs ?? [], + source: input.source, + }), + now, + }) + : null; + if (ledgerRecord?.createdAt === now) { + this.logOpenCodePromptDeliveryEvent('opencode_prompt_delivery_ledger_created', ledgerRecord); + } + + if (ledgerRecord && ledger && messageId) { + if (ledgerRecord.status === 'failed_terminal') { + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_terminal_failure', + ledgerRecord + ); + return { + delivered: false, + accepted: false, + responsePending: false, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal', + diagnostics: ledgerRecord.diagnostics, + }; + } + let proof = await this.applyOpenCodeVisibleDestinationProof({ + ledger, + ledgerRecord, + teamName, + replyRecipient: input.replyRecipient, + memberName: canonicalMemberName, + }); + ledgerRecord = proof.ledgerRecord; + let readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode ?? undefined, + taskRefs: ledgerRecord.taskRefs, + visibleReply: proof.visibleReply, + ledgerRecord, + }); + if (readAllowed) { + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_response_observed', + ledgerRecord, + { visibleReplySemanticallySufficient: true } + ); + return { + delivered: true, + accepted: true, + responsePending: false, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined, + visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined, + diagnostics: ledgerRecord.diagnostics, + }; + } + + const attemptDue = isOpenCodePromptDeliveryAttemptDue(ledgerRecord); + if (ledgerRecord.status !== 'pending' && !attemptDue) { + const nextAttemptMs = ledgerRecord.nextAttemptAt + ? Date.parse(ledgerRecord.nextAttemptAt) + : NaN; + this.scheduleOpenCodePromptDeliveryWatchdog({ + teamName, + memberName: canonicalMemberName, + messageId, + delayMs: Number.isFinite(nextAttemptMs) + ? Math.max(500, nextAttemptMs - Date.now()) + : OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS, + }); + return { + delivered: true, + accepted: true, + responsePending: true, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined, + visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined, + reason: ledgerRecord.lastReason ?? 'opencode_delivery_response_pending', + diagnostics: ledgerRecord.diagnostics, + }; + } + + if (ledgerRecord.status !== 'pending' && !adapter.observeMessageDelivery) { + return { + delivered: true, + accepted: true, + responsePending: true, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + reason: 'opencode_delivery_observe_bridge_unavailable', + diagnostics: [ + ...ledgerRecord.diagnostics, + 'OpenCode message delivery observe bridge is unavailable.', + ], + }; + } + + const retryDueBeforeObserve = + attemptDue && + (ledgerRecord.status === 'retry_scheduled' || ledgerRecord.status === 'failed_retryable'); + if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) { + const observed = await adapter.observeMessageDelivery({ + ...(runtimeRunId ? { runId: runtimeRunId } : {}), + teamName, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + text: input.text, + messageId, + replyRecipient: input.replyRecipient, + actionMode: input.actionMode, + taskRefs: input.taskRefs, + prePromptCursor: ledgerRecord.prePromptCursor, + }); + ledgerRecord = await ledger.applyObservation({ + id: ledgerRecord.id, + responseObservation: observed.responseObservation ?? { + state: observed.ok ? 'not_observed' : 'reconcile_failed', + deliveredUserMessageId: null, + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: observed.diagnostics[0] ?? null, + }, + diagnostics: observed.diagnostics, + observedAt: nowIso(), + }); + proof = await this.applyOpenCodeVisibleDestinationProof({ + ledger, + ledgerRecord, + teamName, + replyRecipient: input.replyRecipient, + memberName: canonicalMemberName, + }); + ledgerRecord = proof.ledgerRecord; + readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode ?? undefined, + taskRefs: ledgerRecord.taskRefs, + visibleReply: proof.visibleReply, + ledgerRecord, + }); + if (readAllowed) { + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_response_observed', + ledgerRecord, + { visibleReplySemanticallySufficient: true } + ); + return { + delivered: true, + accepted: true, + responsePending: false, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined, + visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined, + diagnostics: ledgerRecord.diagnostics, + }; + } + + const pendingReason = this.getOpenCodeDeliveryPendingReason({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode, + taskRefs: ledgerRecord.taskRefs, + visibleReply: proof.visibleReply, + ledgerRecord, + }); + const retryable = this.isOpenCodeDeliveryRetryablePendingResponse({ + ledgerRecord, + visibleReply: proof.visibleReply, + readAllowed, + }); + const retryDue = retryDueBeforeObserve; + if (!retryDue || !retryable) { + ledgerRecord = await this.scheduleOpenCodePromptLedgerFollowUp({ + ledger, + ledgerRecord, + teamName, + memberName: canonicalMemberName, + retry: retryable, + reason: pendingReason, + }); + return { + delivered: true, + accepted: true, + responsePending: true, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined, + visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined, + reason: ledgerRecord.lastReason ?? 'opencode_delivery_response_pending', + diagnostics: ledgerRecord.diagnostics, + }; + } + } + } + + const deliveryText = this.buildOpenCodePromptDeliveryAttemptText({ + ledgerRecord, + text: input.text, + replyRecipient: input.replyRecipient ?? ledgerRecord?.replyRecipient ?? 'user', + }); const result = await adapter.sendMessageToMember({ ...(runtimeRunId ? { runId: runtimeRunId } : {}), teamName, laneId: laneIdentity.laneId, memberName: canonicalMemberName, cwd, - text: input.text, + text: deliveryText, messageId: input.messageId, replyRecipient: input.replyRecipient, actionMode: input.actionMode, taskRefs: input.taskRefs, }); + if (ledgerRecord && ledger) { + ledgerRecord = await ledger.applyDeliveryResult({ + id: ledgerRecord.id, + accepted: result.ok, + attempted: true, + responseObservation: result.responseObservation, + sessionId: result.sessionId, + prePromptCursor: result.prePromptCursor, + diagnostics: result.diagnostics, + reason: result.ok ? result.responseObservation?.reason : result.diagnostics[0], + now: nowIso(), + }); + const proof = await this.applyOpenCodeVisibleDestinationProof({ + ledger, + ledgerRecord, + teamName, + replyRecipient: input.replyRecipient, + memberName: canonicalMemberName, + }); + ledgerRecord = proof.ledgerRecord; + this.logOpenCodePromptDeliveryEvent( + result.ok + ? ledgerRecord.status === 'unanswered' + ? 'opencode_prompt_delivery_unanswered' + : ledgerRecord.status === 'responded' + ? 'opencode_prompt_delivery_response_observed' + : 'opencode_prompt_delivery_prompt_accepted' + : 'opencode_prompt_delivery_retry_scheduled', + ledgerRecord, + { accepted: result.ok, reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null } + ); + } + const responseState = ledgerRecord?.responseState ?? result.responseObservation?.state; + const visibleReply = ledgerRecord + ? await this.findOpenCodeVisibleReplyByRelayOfMessageId({ + teamName, + replyRecipient: input.replyRecipient ?? ledgerRecord.replyRecipient, + from: canonicalMemberName, + relayOfMessageId: ledgerRecord.inboxMessageId, + }) + : null; + const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + responseState, + actionMode: input.actionMode, + taskRefs: input.taskRefs, + visibleReply, + ledgerRecord, + }); + if (ledgerRecord && result.ok && !readAllowed) { + const retry = this.isOpenCodeDeliveryRetryablePendingResponse({ + ledgerRecord, + visibleReply, + readAllowed, + }); + ledgerRecord = await this.scheduleOpenCodePromptLedgerFollowUp({ + ledger: ledger!, + ledgerRecord, + teamName, + memberName: canonicalMemberName, + retry, + reason: this.getOpenCodeDeliveryPendingReason({ + responseState: ledgerRecord.responseState, + actionMode: ledgerRecord.actionMode, + taskRefs: ledgerRecord.taskRefs, + visibleReply, + ledgerRecord, + }), + }); + if (ledgerRecord.status === 'failed_terminal') { + return { + delivered: false, + accepted: true, + responsePending: false, + responseState: ledgerRecord.responseState, + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal', + diagnostics: ledgerRecord.diagnostics.length + ? ledgerRecord.diagnostics + : [ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal'], + }; + } + } + if (ledgerRecord && !result.ok) { + const reason = this.isOpenCodePromptAcceptanceUnknownFailure(result.diagnostics) + ? 'opencode_prompt_acceptance_unknown_after_bridge_timeout' + : (result.diagnostics[0] ?? 'opencode_message_delivery_failed'); + if (reason === 'opencode_prompt_acceptance_unknown_after_bridge_timeout') { + const delayMs = OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS; + ledgerRecord = await ledger!.markAcceptanceUnknown({ + id: ledgerRecord.id, + reason, + nextAttemptAt: new Date(Date.now() + delayMs).toISOString(), + diagnostics: result.diagnostics, + markedAt: nowIso(), + }); + this.scheduleOpenCodePromptDeliveryWatchdog({ + teamName, + memberName: canonicalMemberName, + messageId: ledgerRecord.inboxMessageId, + delayMs, + }); + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_retry_scheduled', + ledgerRecord, + { acceptanceUnknown: true, reason } + ); + } else { + ledgerRecord = await this.scheduleOpenCodePromptLedgerFollowUp({ + ledger: ledger!, + ledgerRecord, + teamName, + memberName: canonicalMemberName, + retry: true, + reason, + }); + } + } + const responseVisibleReplyMessageId = + ledgerRecord?.visibleReplyMessageId ?? + result.responseObservation?.visibleReplyMessageId ?? + undefined; + const responseVisibleReplyCorrelation = + ledgerRecord?.visibleReplyCorrelation ?? + result.responseObservation?.visibleReplyCorrelation ?? + undefined; + const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok); + const responsePending = + acceptanceUnknown || (result.ok && Boolean(ledgerRecord || result.responseObservation)) + ? !readAllowed + : false; + const pendingReason = + responsePending && ledgerRecord + ? (ledgerRecord.lastReason ?? 'opencode_delivery_response_pending') + : null; + const diagnostics = + pendingReason && result.diagnostics.length === 0 + ? [pendingReason] + : ledgerRecord?.diagnostics.length + ? ledgerRecord.diagnostics + : result.diagnostics; return { - delivered: result.ok, - ...(result.ok ? {} : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), - diagnostics: result.diagnostics, + delivered: result.ok || acceptanceUnknown, + ...(ledgerRecord || result.responseObservation ? { accepted: result.ok } : {}), + ...(ledgerRecord || result.responseObservation ? { responsePending } : {}), + ...(acceptanceUnknown ? { acceptanceUnknown: true } : {}), + ...(ledgerRecord + ? { + ledgerStatus: ledgerRecord.status, + ledgerRecordId: ledgerRecord.id, + laneId: laneIdentity.laneId, + } + : {}), + ...(responseState + ? { + responseState, + ...(responseVisibleReplyMessageId + ? { visibleReplyMessageId: responseVisibleReplyMessageId } + : {}), + ...(responseVisibleReplyCorrelation + ? { visibleReplyCorrelation: responseVisibleReplyCorrelation } + : {}), + } + : {}), + ...(pendingReason + ? { reason: pendingReason } + : result.ok + ? {} + : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), + diagnostics, }; } @@ -4859,6 +5987,119 @@ export class TeamProvisioningService { return durableRunId || null; } + private async resolveOpenCodeMemberDeliveryIdentity( + teamName: string, + memberName: string + ): Promise< + | { + ok: true; + canonicalMemberName: string; + laneId: string; + } + | { + ok: false; + reason: + | 'recipient_is_not_opencode' + | 'recipient_removed' + | 'opencode_recipient_unavailable'; + } + > { + const [config, teamMeta, metaMembers] = await Promise.all([ + this.configReader.getConfig(teamName).catch(() => null), + this.teamMetaStore.getMeta(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + const normalizedMemberName = memberName.trim(); + const configMember = config?.members?.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + const metaMember = metaMembers.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + if (!configMember && !metaMember) { + return { ok: false, reason: 'opencode_recipient_unavailable' }; + } + const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; + const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; + const providerId = + normalizeTeamProviderLike(metaMember?.providerId) ?? + normalizeTeamProviderLike(metaProvider) ?? + normalizeTeamProviderLike(configMember?.providerId) ?? + normalizeTeamProviderLike(configProvider) ?? + inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); + if (providerId !== 'opencode') { + return { ok: false, reason: 'recipient_is_not_opencode' }; + } + const removedAt = + metaMember != null + ? metaMember.removedAt + : (configMember as { removedAt?: unknown } | undefined)?.removedAt; + if (removedAt != null) { + return { ok: false, reason: 'recipient_removed' }; + } + const canonicalMemberName = + metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + if (runtimeRun?.providerId === 'opencode') { + return { + ok: true, + canonicalMemberName, + laneId: 'primary', + }; + } + const leadMember = config?.members?.find((member) => isLeadMember(member)); + const leadProviderId = + normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ?? + normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? + normalizeOptionalTeamProviderId(leadMember?.providerId); + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId, + member: { + name: canonicalMemberName, + providerId, + }, + }); + return { + ok: true, + canonicalMemberName, + laneId: laneIdentity.laneId, + }; + } + + private async resolveOpenCodeMembersForRuntimeLane( + teamName: string, + laneId: string + ): Promise { + const [config, metaMembers] = await Promise.all([ + this.configReader.getConfig(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + const names = new Set(); + for (const member of config?.members ?? []) { + if (member.name?.trim()) { + names.add(member.name.trim()); + } + } + for (const member of metaMembers) { + if (member.name?.trim()) { + names.add(member.name.trim()); + } + } + const resolved: string[] = []; + for (const name of names) { + const identity = await this.resolveOpenCodeMemberDeliveryIdentity(teamName, name); + if (identity.ok && identity.laneId === laneId) { + resolved.push(identity.canonicalMemberName); + } + } + if (resolved.length > 0) { + return [...new Set(resolved)]; + } + const secondaryMatch = /^secondary:opencode:(.+)$/i.exec(laneId); + const fallbackMember = secondaryMatch?.[1]?.trim(); + return fallbackMember ? [fallbackMember] : []; + } + private async isOpenCodeRuntimeLaneIndexActive( teamName: string, laneId: string @@ -5073,6 +6314,18 @@ export class TeamProvisioningService { this.openCodeMemberInboxRelayInFlight.delete(key); } } + for (const key of Array.from(this.openCodePromptDeliveryWatchdogTimers.keys())) { + if (key.startsWith(`opencode-delivery:${teamName}:`)) { + const timer = this.openCodePromptDeliveryWatchdogTimers.get(key); + if (timer) clearTimeout(timer); + this.openCodePromptDeliveryWatchdogTimers.delete(key); + } + } + for (let index = this.openCodePromptDeliveryWatchdogQueue.length - 1; index >= 0; index -= 1) { + if (this.openCodePromptDeliveryWatchdogQueue[index]?.teamName === teamName) { + this.openCodePromptDeliveryWatchdogQueue.splice(index, 1); + } + } for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { if (key.startsWith(`${teamName}:`)) { this.relayedMemberInboxMessageIds.delete(key); @@ -6340,6 +7593,17 @@ export class TeamProvisioningService { ); } + private createOpenCodePromptDeliveryLedger(teamName: string, laneId: string) { + return createOpenCodePromptDeliveryLedgerStore({ + filePath: getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId, + fileName: 'opencode-prompt-delivery-ledger.json', + }), + }); + } + private createOpenCodeRuntimeDeliveryPorts(): RuntimeDeliveryDestinationPort[] { const userMessagesPort: RuntimeDeliveryDestinationPort = { kind: 'user_sent_messages', @@ -12087,6 +13351,12 @@ export class TeamProvisioningService { result.lastDelivery = { delivered: false, reason: 'recipient_is_not_opencode' }; return result; } + const memberIdentity = await this.resolveOpenCodeMemberDeliveryIdentity(teamName, memberName); + if (!memberIdentity.ok) { + result.lastDelivery = { delivered: false, reason: memberIdentity.reason }; + return result; + } + const promptLedger = this.createOpenCodePromptDeliveryLedger(teamName, memberIdentity.laneId); let inboxMessages: Awaited> = []; try { @@ -12141,6 +13411,36 @@ export class TeamProvisioningService { .slice(0, 10); for (const message of unread) { + const existingRecord = await promptLedger + .getByInboxMessage({ + teamName, + memberName: memberIdentity.canonicalMemberName, + laneId: memberIdentity.laneId, + inboxMessageId: message.messageId, + }) + .catch(() => null); + if (existingRecord?.status === 'failed_terminal') { + const diagnostic = + existingRecord.lastReason ?? + `opencode_prompt_delivery_failed_terminal: ${message.messageId}`; + result.diagnostics = [...(result.diagnostics ?? []), diagnostic]; + if (onlyMessageId) { + result.failed += 1; + result.lastDelivery = { + delivered: false, + accepted: false, + ledgerStatus: existingRecord.status, + ledgerRecordId: existingRecord.id, + laneId: memberIdentity.laneId, + reason: existingRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal', + diagnostics: existingRecord.diagnostics.length + ? existingRecord.diagnostics + : [diagnostic], + }; + } + continue; + } + const fallbackReplyRecipient = typeof message.from === 'string' && message.from.trim() && @@ -12148,6 +13448,53 @@ export class TeamProvisioningService { ? message.from.trim() : 'user'; result.attempted += 1; + if (message.attachments?.length) { + const reason = 'opencode_attachments_not_supported_for_secondary_runtime'; + const now = nowIso(); + const record = await promptLedger.ensurePending({ + teamName, + memberName: memberIdentity.canonicalMemberName, + laneId: memberIdentity.laneId, + runId: await this.resolveCurrentOpenCodeRuntimeRunId(teamName, memberIdentity.laneId), + inboxMessageId: message.messageId, + inboxTimestamp: message.timestamp, + source: options.source ?? 'watcher', + replyRecipient: options.deliveryMetadata?.replyRecipient ?? fallbackReplyRecipient, + actionMode: options.deliveryMetadata?.actionMode ?? message.actionMode ?? null, + taskRefs: options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? [], + payloadHash: hashOpenCodePromptDeliveryPayload({ + text: message.text, + replyRecipient: options.deliveryMetadata?.replyRecipient ?? fallbackReplyRecipient, + actionMode: options.deliveryMetadata?.actionMode ?? message.actionMode ?? null, + taskRefs: options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? [], + attachments: message.attachments, + source: options.source ?? 'watcher', + }), + now, + }); + const failed = await promptLedger.markFailedTerminal({ + id: record.id, + reason, + failedAt: now, + }); + this.logOpenCodePromptDeliveryEvent('opencode_prompt_delivery_terminal_failure', failed); + const diagnostics = failed.diagnostics.length ? failed.diagnostics : [reason]; + result.failed += 1; + result.lastDelivery = { + delivered: false, + accepted: false, + ledgerStatus: failed.status, + ledgerRecordId: failed.id, + laneId: memberIdentity.laneId, + reason, + diagnostics, + }; + result.diagnostics = [...(result.diagnostics ?? []), ...diagnostics]; + logger.warn( + `[${teamName}] OpenCode inbox relay refused attachment-only unsupported delivery for ${memberName}/${message.messageId}: ${reason}` + ); + continue; + } const delivery = await this.deliverOpenCodeMemberMessage(teamName, { memberName, text: message.text, @@ -12155,6 +13502,8 @@ export class TeamProvisioningService { replyRecipient: options.deliveryMetadata?.replyRecipient ?? fallbackReplyRecipient, actionMode: options.deliveryMetadata?.actionMode ?? message.actionMode, taskRefs: options.deliveryMetadata?.taskRefs ?? message.taskRefs, + source: options.source ?? 'watcher', + inboxTimestamp: message.timestamp, }); result.lastDelivery = delivery; if (!delivery.delivered) { @@ -12170,12 +13519,47 @@ export class TeamProvisioningService { ); break; } + if (delivery.responsePending) { + result.diagnostics = [ + ...(result.diagnostics ?? []), + ...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_delivery_response_pending']), + ]; + break; + } try { await this.markInboxMessagesRead(teamName, memberName, [message]); + if (delivery.ledgerRecordId && delivery.laneId) { + const committed = await this.createOpenCodePromptDeliveryLedger( + teamName, + delivery.laneId + ).markInboxReadCommitted({ + id: delivery.ledgerRecordId, + committedAt: nowIso(), + }); + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_inbox_committed_read', + committed + ); + } } catch (error) { const diagnostic = `opencode_inbox_mark_read_failed_after_delivery: ${getErrorMessage( error )}`; + if (delivery.ledgerRecordId && delivery.laneId) { + const failedCommit = await this.createOpenCodePromptDeliveryLedger( + teamName, + delivery.laneId + ).markInboxReadCommitFailed({ + id: delivery.ledgerRecordId, + error: diagnostic, + failedAt: nowIso(), + }); + this.logOpenCodePromptDeliveryEvent( + 'opencode_prompt_delivery_response_observed', + failedCommit, + { inboxReadCommitError: diagnostic } + ); + } result.failed += 1; result.lastDelivery = { delivered: false, @@ -12188,6 +13572,7 @@ export class TeamProvisioningService { } result.delivered += 1; result.relayed += 1; + break; } if (result.diagnostics?.length) { @@ -18868,6 +20253,22 @@ export class TeamProvisioningService { this.openCodeMemberInboxRelayInFlight.delete(key); } } + for (const key of Array.from(this.openCodePromptDeliveryWatchdogTimers.keys())) { + if (key.startsWith(`opencode-delivery:${run.teamName}:`)) { + const timer = this.openCodePromptDeliveryWatchdogTimers.get(key); + if (timer) clearTimeout(timer); + this.openCodePromptDeliveryWatchdogTimers.delete(key); + } + } + for ( + let index = this.openCodePromptDeliveryWatchdogQueue.length - 1; + index >= 0; + index -= 1 + ) { + if (this.openCodePromptDeliveryWatchdogQueue[index]?.teamName === run.teamName) { + this.openCodePromptDeliveryWatchdogQueue.splice(index, 1); + } + } for (const key of Array.from(this.relayedMemberInboxMessageIds.keys())) { if (key.startsWith(`${run.teamName}:`)) { this.relayedMemberInboxMessageIds.delete(key); diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 01db02a4..c5e99b39 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -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 = new Set([ 'opencode.reconcileTeam', 'opencode.stopTeam', 'opencode.sendMessage', + 'opencode.observeMessageDelivery', 'opencode.answerPermission', 'opencode.listRuntimePermissions', 'opencode.getRuntimeTranscript', diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index da16bcb1..86b1b435 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -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; @@ -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 { + 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( command: OpenCodeStateChangingTeamCommandName, body: TBody, diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts new file mode 100644 index 00000000..6db7b78f --- /dev/null +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -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([ + 'pending', + 'accepted', + 'responded', + 'unanswered', + 'retry_scheduled', + 'retried', + 'failed_retryable', + 'failed_terminal', +]); + +const OPENCODE_DELIVERY_RESPONSE_STATES = new Set([ + '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([ + 'watcher', + 'ui-send', + 'manual', + 'watchdog', +]); + +const OPENCODE_DELIVERY_VISIBLE_REPLY_CORRELATIONS = + new Set([ + 'relayOfMessageId', + 'direct_child_message_send', + 'plain_assistant_text', + ]); + +const AGENT_ACTION_MODES = new Set(['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) {} + + async ensurePending( + input: EnsureOpenCodePromptDeliveryInput + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + nextAttemptAt: string; + reason: string; + scheduledAt: string; + }): Promise { + 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 { + 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 { + 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 { + 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 { + return await this.updateExisting(input.id, (record) => ({ + ...record, + inboxReadCommitError: input.error, + diagnostics: mergeDiagnostics(record.diagnostics, [input.error]), + updatedAt: input.failedAt, + })); + } + + async list(): Promise { + return await this.readRequired(); + } + + async listDue(input: { + teamName?: string; + now: Date; + limit: number; + }): Promise { + 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 { + 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 { + 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({ + 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(); + 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) : 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; + 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()))]; +} diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts new file mode 100644 index 00000000..78394fd2 --- /dev/null +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts @@ -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} `) + ); +} diff --git a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts index 57c1589f..17f562f7 100644 --- a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts +++ b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts @@ -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' || diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 22314c88..099c3058 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -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; + observeOpenCodeTeamMessageDelivery?( + input: OpenCodeObserveMessageDeliveryCommandBody + ): Promise; } 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 { + 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, '', '', input.text, diff --git a/src/preload/index.ts b/src/preload/index.ts index b840f74d..21ad0c80 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 485dc4bc..1734304e 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => ({ platform: 'unknown', diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 01aec775..f72d34dc 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -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} {provider.providerId === 'opencode' ? : null} @@ -802,6 +804,7 @@ const InstalledBanner = ({ models={provider.models} modelAvailability={provider.modelAvailability} providerStatus={provider} + collapseAfter={15} /> {codexDashboardRateLimits!.map((item) => (
)} @@ -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 && ( diff --git a/src/renderer/components/runtime/ProviderModelBadges.tsx b/src/renderer/components/runtime/ProviderModelBadges.tsx index ad1fcf0a..baf50013 100644 --- a/src/renderer/components/runtime/ProviderModelBadges.tsx +++ b/src/renderer/components/runtime/ProviderModelBadges.tsx @@ -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 | 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 ( + + {formatModelBadgeLabel(providerId, model)} + {availabilityChip ? ( + + {availabilityChip} + + ) : null} + + ); + }; + + if (!shouldCollapse) { + return
{displayedModels.map(renderModelBadge)}
; + } return ( -
- {visibleModels.map((model) => { - const availabilityStatus = getAvailabilityStatus(model, modelAvailability); - const availabilityReason = getAvailabilityReason(model, modelAvailability); - const availabilityChip = getAvailabilityChip(availabilityStatus); - - return ( - - {formatModelBadgeLabel(providerId, model)} - {availabilityChip ? ( - - {availabilityChip} - - ) : null} - - ); - })} +
+
+ {displayedModels.map(renderModelBadge)} + {shouldCollapse && !expanded ? ( + + ) : null} +
+ {shouldCollapse && expanded ? ( + + ) : null}
); }; diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index af6cdd91..c625526c 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -19,6 +19,7 @@ import { resolveCodexFastMode, resolveCodexRuntimeSelection, } from '@features/codex-runtime-profile/renderer'; +import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Button } from '@renderer/components/ui/button'; import { @@ -712,8 +713,10 @@ export const ProviderRuntimeSettingsDialog = ({ const connectionManagedRuntime = selectedProvider ? isConnectionManagedRuntimeProvider(selectedProvider) : false; + const showRuntimeProviderManagement = selectedProvider?.providerId === 'opencode'; const hideConnectionMethodMeta = showConnectionMethodCards; const canConfigureRuntime = + !showRuntimeProviderManagement && !connectionManagedRuntime && (selectedProvider ? getVisibleProviderRuntimeBackendOptions(selectedProvider).length > 1 @@ -1161,323 +1164,303 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null} {selectedProvider ? ( -
-
-
-
- Connection -
-
- {getConnectionDescription(selectedProvider)} -
- {connectionProgressMessage ? ( -
- - {connectionProgressMessage} + showRuntimeProviderManagement ? ( + onRefreshProvider?.('opencode')} + /> + ) : ( +
+
+
+
+ Connection
- ) : null} -
- {canRequestSubscriptionLogin ? ( - - ) : null} -
- - {showConnectionMethodCards ? ( -
- - void handleAuthModeChange(authMode)} - /> - {connectionMethodCardsHint ? ( -
- {connectionMethodCardsHint} +
+ {getConnectionDescription(selectedProvider)}
- ) : null} -
- ) : configurableAuthModes.length > 0 && configuredAuthMode ? ( -
- - -
- {getAuthModeDescription(selectedProvider.providerId, configuredAuthMode)} -
-
- ) : null} - -
- {configuredAuthMode && !hideConnectionMethodMeta ? ( - - Mode:{' '} - {formatProviderAuthModeLabelForProvider( - selectedProvider.providerId, - configuredAuthMode - )} - - ) : null} - {connectionStatusLabel ? ( - - {connectionStatusLabel} - - ) : null} - {selectedProvider.connection?.apiKeyConfigured && !showApiKeySection ? ( - - {selectedProvider.connection.apiKeySourceLabel} - - ) : null} -
- - {selectedProvider.providerId === 'anthropic' ? ( -
-
- Fast mode default -
-
- Apply Claude Code Fast mode by default for new Anthropic team launches when the - resolved model and runtime allow it. -
- {anthropicFastModeSupported ? ( -
- {[ - { enabled: false, label: 'Default Off' }, - { enabled: true, label: 'Prefer Fast' }, - ].map((option) => ( - - ))} -
- ) : null} -
- {anthropicFastModeSupported && anthropicFastModeAvailable - ? anthropicFastModeEnabled - ? 'New Anthropic launches will request Fast mode by default when the resolved model supports it.' - : 'New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.' - : anthropicFastModeDisabledReason} -
-
- ) : null} - - {selectedProvider.providerId === 'codex' ? ( -
-
-
-
- ChatGPT account -
-
- Manage the local Codex app-server account session that powers - subscription-backed native launches. -
-
-
- - {codexLoginPending ? ( - - ) : codexHasActiveChatgptSession ? ( - - ) : ( - - )} + + {connectionProgressMessage} +
+ ) : null} +
+ {canRequestSubscriptionLogin ? ( + + ) : null} +
+ + {showConnectionMethodCards ? ( +
+ + void handleAuthModeChange(authMode)} + /> + {connectionMethodCardsHint ? ( +
+ {connectionMethodCardsHint} +
+ ) : null} +
+ ) : configurableAuthModes.length > 0 && configuredAuthMode ? ( +
+ + +
+ {getAuthModeDescription(selectedProvider.providerId, configuredAuthMode)}
+ ) : null} -
+
+ {configuredAuthMode && !hideConnectionMethodMeta ? ( - {codexHasActiveChatgptSession - ? 'Connected' - : codexNeedsReconnect - ? 'Reconnect required' - : codexLoginPending - ? 'Login in progress' - : 'Not connected'} + Mode:{' '} + {formatProviderAuthModeLabelForProvider( + selectedProvider.providerId, + configuredAuthMode + )} - {codexConnection ? ( + ) : null} + {connectionStatusLabel ? ( + + {connectionStatusLabel} + + ) : null} + {selectedProvider.connection?.apiKeyConfigured && !showApiKeySection ? ( + + {selectedProvider.connection.apiKeySourceLabel} + + ) : null} +
+ + {selectedProvider.providerId === 'anthropic' ? ( +
+
+ Fast mode default +
+
+ Apply Claude Code Fast mode by default for new Anthropic team launches when + the resolved model and runtime allow it. +
+ {anthropicFastModeSupported ? ( +
+ {[ + { enabled: false, label: 'Default Off' }, + { enabled: true, label: 'Prefer Fast' }, + ].map((option) => ( + + ))} +
+ ) : null} +
+ {anthropicFastModeSupported && anthropicFastModeAvailable + ? anthropicFastModeEnabled + ? 'New Anthropic launches will request Fast mode by default when the resolved model supports it.' + : 'New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.' + : anthropicFastModeDisabledReason} +
+
+ ) : null} + + {selectedProvider.providerId === 'codex' ? ( +
+
+
+
+ ChatGPT account +
+
+ Manage the local Codex app-server account session that powers + subscription-backed native launches. +
+
+
+ + {codexLoginPending ? ( + + ) : codexHasActiveChatgptSession ? ( + + ) : ( + + )} +
+
+ +
- App-server: {codexConnection.appServerState} + {codexHasActiveChatgptSession + ? 'Connected' + : codexNeedsReconnect + ? 'Reconnect required' + : codexLoginPending + ? 'Login in progress' + : 'Not connected'} - ) : null} - {codexConnection?.managedAccount?.planType ? ( - - Plan: {codexConnection.managedAccount.planType} - - ) : null} - {codexConnection?.managedAccount?.email ? ( - - {codexConnection.managedAccount.email} - - ) : null} -
- - {codexAccountPanelHint ? ( -
- {codexAccountPanelHint} + {codexConnection ? ( + + App-server: {codexConnection.appServerState} + + ) : null} + {codexConnection?.managedAccount?.planType ? ( + + Plan: {codexConnection.managedAccount.planType} + + ) : null} + {codexConnection?.managedAccount?.email ? ( + + {codexConnection.managedAccount.email} + + ) : null}
- ) : null} - {codexFastCapabilityHint ? ( -
- {codexFastCapabilityHint} -
- ) : null} - - {codexConnection?.rateLimits ? ( -
+ {codexAccountPanelHint ? (
- These percentages show used quota, not remaining quota.{' '} - {formatCodexUsageExplanation( - codexConnection.rateLimits.primary?.usedPercent, - codexConnection.rateLimits.primary?.windowDurationMins - )} - {codexConnection.rateLimits.secondary - ? ` Weekly limits are shown separately in the ${ - formatCodexWindowDurationLong( - codexConnection.rateLimits.secondary.windowDurationMins - ) ?? 'secondary' - } window.` - : ''} + {codexAccountPanelHint}
- -
-
- - - {codexConnection.rateLimits.secondary ? ( - - ) : ( -
-
- Weekly window -
-
- Weekly used (1w) -
-
- Not reported -
-
- Codex did not return a secondary window for this account snapshot. -
-
- )} -
- -
-
-
-
- Credits -
-
- {formatCodexCreditsValue(codexConnection.rateLimits.credits)} -
-
-
- Credits are shown separately from window-based subscription usage and - may be unavailable for plan-backed ChatGPT sessions. -
-
-
-
-
- ) : null} -
- ) : null} - - {showApiKeySection && apiKeyConfig ? ( -
-
-
-
-
- -
-
-
- {apiKeyConfig.title} -
-
- {apiKeyConfig.description} -
-
-
-
- {!showApiKeyForm ? ( - ) : null} -
-
- - {selectedProvider.connection?.apiKeyConfigured || selectedApiKey - ? 'Configured' - : 'Not configured'} - - {selectedApiKey ? ( - - {selectedApiKey.maskedValue} · {selectedApiKey.scope} - - ) : selectedProvider.connection?.apiKeySource === 'environment' ? ( - - {selectedProvider.connection.apiKeySourceLabel} - - ) : null} - {apiKeyStorageStatus && selectedApiKey ? ( - - Stored in {apiKeyStorageStatus.backend} - - ) : null} -
- - {showApiKeyForm ? ( -
-
- - setApiKeyValue(e.target.value)} - placeholder={apiKeyConfig.placeholder} - className="h-9 text-sm" - autoFocus - /> + : 'var(--color-text-secondary)', + backgroundColor: codexFastCapability?.selectable + ? 'rgba(34, 197, 94, 0.08)' + : 'transparent', + }} + > + {codexFastCapabilityHint}
+ ) : null} -
- - -
- - {(apiKeyError || apiKeysError) && ( + {codexConnection?.rateLimits ? ( +
- {apiKeyError ?? apiKeysError} + These percentages show used quota, not remaining quota.{' '} + {formatCodexUsageExplanation( + codexConnection.rateLimits.primary?.usedPercent, + codexConnection.rateLimits.primary?.windowDurationMins + )} + {codexConnection.rateLimits.secondary + ? ` Weekly limits are shown separately in the ${ + formatCodexWindowDurationLong( + codexConnection.rateLimits.secondary.windowDurationMins + ) ?? 'secondary' + } window.` + : ''}
- )} -
- {selectedApiKey ? ( - - ) : ( - - )} -
- - +
+
+
+ Credits +
+
+ {formatCodexCreditsValue(codexConnection.rateLimits.credits)} +
+
+
+ Credits are shown separately from window-based subscription usage + and may be unavailable for plan-backed ChatGPT sessions. +
+
+
+ ) : null} +
+ ) : null} + + {showApiKeySection && apiKeyConfig ? ( +
+
+
+
+
+ +
+
+
+ {apiKeyConfig.title} +
+
+ {apiKeyConfig.description} +
+
+
+
+ {!showApiKeyForm ? ( + + ) : null}
- ) : null} -
- ) : null} - {connectionError ? ( -
- - {connectionError} -
- ) : null} +
+ + {selectedProvider.connection?.apiKeyConfigured || selectedApiKey + ? 'Configured' + : 'Not configured'} + + {selectedApiKey ? ( + + {selectedApiKey.maskedValue} · {selectedApiKey.scope} + + ) : selectedProvider.connection?.apiKeySource === 'environment' ? ( + + {selectedProvider.connection.apiKeySourceLabel} + + ) : null} + {apiKeyStorageStatus && selectedApiKey ? ( + + Stored in {apiKeyStorageStatus.backend} + + ) : null} +
- {connectionAlert ? ( -
- - {connectionAlert} -
- ) : null} + {showApiKeyForm ? ( +
+
+ + setApiKeyValue(e.target.value)} + placeholder={apiKeyConfig.placeholder} + className="h-9 text-sm" + autoFocus + /> +
- {apiKeysLoading && !selectedApiKey ? ( -
- Loading stored credentials... -
- ) : null} -
+
+ + +
+ + {(apiKeyError || apiKeysError) && ( +
+ {apiKeyError ?? apiKeysError} +
+ )} + +
+ {selectedApiKey ? ( + + ) : ( + + )} +
+ + +
+
+
+ ) : null} +
+ ) : null} + + {connectionError ? ( +
+ + {connectionError} +
+ ) : null} + + {connectionAlert ? ( +
+ + {connectionAlert} +
+ ) : null} + + {apiKeysLoading && !selectedApiKey ? ( +
+ Loading stored credentials... +
+ ) : null} +
+ ) ) : null} {selectedProvider && canConfigureRuntime ? ( diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index db25a727..d86e8950 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -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' }) } diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index b0eb576b..e4fe80f7 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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(); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 113d3103..895e1286 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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} ) : sendWarning ? ( - - - {sendWarning} - + ) : null} {remaining < 200 ? ( ; @@ -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} ) : sendWarning ? ( - - - {sendWarning} - + ) : lastResult?.deduplicated ? ( diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 0e4895b9..23025ebb 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -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} diff --git a/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx b/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx new file mode 100644 index 00000000..986e6d19 --- /dev/null +++ b/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx @@ -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(null); + const [copiedKey, setCopiedKey] = useState(null); + const mountedRef = useRef(true); + const copiedResetTimerRef = useRef(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 => { + 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 ( + + + + {warning} + {debugDetails ? ( + + ) : null} + + {expanded && debugDetails ? ( + + + messageId + {debugDetails.messageId} + providerId + {debugDetails.providerId} + delivered + {String(debugDetails.delivered)} + responsePending + {String(debugDetails.responsePending)} + responseState + {debugDetails.responseState ?? 'null'} + ledgerStatus + {debugDetails.ledgerStatus ?? 'null'} + acceptanceUnknown + {String(debugDetails.acceptanceUnknown)} + reason + {debugDetails.reason ?? 'null'} + diagnostics + + {debugDetails.diagnostics.length ? debugDetails.diagnostics.join('; ') : '[]'} + + + + + ) : null} + + ); +} diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 720739f8..b52440d5 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -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 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(); + + 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; slotLayoutVersion: string; + graphLayoutModeByTeam: Record; + gridOwnerOrderByTeam: Record; slotAssignmentsByTeam: Record; teamMessagesByName: Record; memberActivityMetaByTeam: Record; @@ -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; @@ -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 = (set, selectedTeamData: null, teamDataCacheByName: {}, slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + graphLayoutModeByTeam: {}, + gridOwnerOrderByTeam: {}, slotAssignmentsByTeam: {}, teamMessagesByName: {}, memberActivityMetaByTeam: {}, @@ -2266,6 +2306,7 @@ export const createTeamSlice: StateCreator = (set, sendingMessage: false, sendMessageError: null, sendMessageWarning: null, + sendMessageDebugDetails: null, lastSendMessageResult: null, crossTeamTargets: [], crossTeamTargetsLoading: false, @@ -2912,6 +2953,62 @@ export const createTeamSlice: StateCreator = (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 = (set, sendingMessage: true, sendMessageError: null, sendMessageWarning: null, + sendMessageDebugDetails: null, lastSendMessageResult: null, }); try { @@ -3873,13 +3971,7 @@ export const createTeamSlice: StateCreator = (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 = (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 = (set, sendingMessage: false, lastSendMessageResult: null, sendMessageWarning: null, + sendMessageDebugDetails: null, sendMessageError: mapSendMessageError(error), }); throw error; @@ -3945,6 +4039,7 @@ export const createTeamSlice: StateCreator = (set, sendingMessage: true, sendMessageError: null, sendMessageWarning: null, + sendMessageDebugDetails: null, lastSendMessageResult: null, }); try { @@ -3953,6 +4048,7 @@ export const createTeamSlice: StateCreator = (set, sendingMessage: false, sendMessageError: null, sendMessageWarning: null, + sendMessageDebugDetails: null, lastSendMessageResult: { messageId: result.messageId, deliveredToInbox: result.deliveredToInbox, @@ -3965,6 +4061,7 @@ export const createTeamSlice: StateCreator = (set, sendingMessage: false, lastSendMessageResult: null, sendMessageWarning: null, + sendMessageDebugDetails: null, sendMessageError: mapSendMessageError(error), }); } diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts new file mode 100644 index 00000000..bc87c17b --- /dev/null +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -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 + ); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9cbc679e..ab23c592 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 334c3725..baf641cc 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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[]; }; diff --git a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts new file mode 100644 index 00000000..ccab2832 --- /dev/null +++ b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts @@ -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 Promise>(); + const ipcMain = { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { + 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, + }); + }); +}); diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 0d38cf73..1cfc6937 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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['responseState']; + ledgerStatus?: NonNullable['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(); diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 9a657f46..39e08c66 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -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([]); }); }); diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 81cea708..0bbc6195 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -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([]); }); }); diff --git a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts new file mode 100644 index 00000000..f4bc92b5 --- /dev/null +++ b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts @@ -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) => 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) => void + ): Promise> { + 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[]; + }; + 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, + ]); + }); +}); diff --git a/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts index 8397040c..13de5b75 100644 --- a/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts +++ b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts @@ -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; }> { 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; +}> { + 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( diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 2d9e3d84..aa0eb900 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -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"}]' diff --git a/test/main/services/team/TeamBackupService.test.ts b/test/main/services/team/TeamBackupService.test.ts index 26c8c2d6..b67d7a3d 100644 --- a/test/main/services/team/TeamBackupService.test.ts +++ b/test/main/services/team/TeamBackupService.test.ts @@ -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 }; + 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(); + } + }); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 35fb063e..7f294d2d 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -3071,6 +3071,1003 @@ describe('TeamProvisioningService', () => { }); }); + it('observes accepted OpenCode prompt delivery before sending the same inbox row again', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'pending', + deliveredUserMessageId: 'oc-user-1', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_response_pending', + }, + diagnostics: [], + })); + const observeMessageDelivery = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: { + state: 'responded_plain_text', + deliveredUserMessageId: 'oc-user-1', + assistantMessageId: 'oc-assistant-1', + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + observeMessageDelivery, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-ledger-1', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'pending', + }); + const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + fileName: 'opencode-prompt-delivery-ledger.json', + }); + const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array<{ nextAttemptAt: string | null }>; + }; + ledgerEnvelope.data[0].nextAttemptAt = '2000-01-01T00:00:00.000Z'; + await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8'); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-ledger-1', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: false, + responseState: 'responded_plain_text', + }); + + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + expect(observeMessageDelivery).toHaveBeenCalledTimes(1); + expect(observeMessageDelivery).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 'msg-ledger-1', + prePromptCursor: 'cursor-before', + }) + ); + }); + + it('keeps OpenCode ack-only plain-text responses pending instead of committing read', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'responded_plain_text', + deliveredUserMessageId: 'oc-user-ack', + assistantMessageId: 'oc-assistant-ack', + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: 'Понял', + reason: null, + }, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Please answer directly.', + messageId: 'msg-ack-only', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'responded_plain_text', + reason: 'plain_text_ack_only_still_requires_answer', + }); + + const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + fileName: 'opencode-prompt-delivery-ledger.json', + }); + const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array<{ lastReason: string | null; nextAttemptAt: string | null }>; + }; + expect(ledgerEnvelope.data[0]).toMatchObject({ + lastReason: 'plain_text_ack_only_still_requires_answer', + }); + expect(ledgerEnvelope.data[0].nextAttemptAt).toBeTruthy(); + }); + + it('treats OpenCode send bridge timeouts as acceptance-unknown observe-first records', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: false, + providerId: 'opencode', + memberName: String(input.memberName), + diagnostics: ['OpenCode message bridge failed: OpenCode bridge command timed out'], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + observeMessageDelivery: vi.fn(), + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Please handle this.', + messageId: 'msg-timeout-unknown', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: false, + responsePending: true, + acceptanceUnknown: true, + reason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout', + }); + + const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + fileName: 'opencode-prompt-delivery-ledger.json', + }); + const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array<{ + acceptanceUnknown: boolean; + status: string; + lastReason: string | null; + nextAttemptAt: string | null; + }>; + }; + expect(ledgerEnvelope.data[0]).toMatchObject({ + acceptanceUnknown: true, + status: 'failed_retryable', + lastReason: 'opencode_prompt_acceptance_unknown_after_bridge_timeout', + }); + expect(ledgerEnvelope.data[0].nextAttemptAt).toBeTruthy(); + }); + + it('marks OpenCode payload hash mismatch terminal without sending a duplicate prompt', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'pending', + deliveredUserMessageId: 'oc-user-payload', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_response_pending', + }, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Original text.', + messageId: 'msg-payload-mismatch', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Changed text under the same message id.', + messageId: 'msg-payload-mismatch', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: false, + responsePending: false, + reason: 'opencode_prompt_delivery_payload_mismatch', + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + }); + + it('accepts visible OpenCode replies written to the configured lead inbox for lead aliases', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'team-lead.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'team-lead', + text: 'Here is the concrete answer.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-lead-1', + relayOfMessageId: 'msg-lead-alias', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Please answer the lead.', + messageId: 'msg-lead-alias', + replyRecipient: 'lead', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_visible_message', + visibleReplyMessageId: 'reply-lead-1', + visibleReplyCorrelation: 'relayOfMessageId', + diagnostics: [], + }); + expect(sendMessageToMember).not.toHaveBeenCalled(); + }); + + it('uses legacy OpenCode prompt acceptance semantics when the watchdog is disabled', async () => { + const previous = process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG; + process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = '0'; + try { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: { + state: 'pending', + deliveredUserMessageId: 'oc-user-disabled', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_response_pending', + }, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Please answer eventually.', + messageId: 'msg-watchdog-disabled', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'pending', + diagnostics: [], + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + } finally { + if (previous === undefined) { + delete process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG; + } else { + process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = previous; + } + } + }); + + it('retries OpenCode direct asks after non-visible tool activity with an explicit retry header', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: sendMessageToMember.mock.calls.length === 1 ? 'responded_non_visible_tool' : 'pending', + deliveredUserMessageId: 'oc-user-ask', + assistantMessageId: sendMessageToMember.mock.calls.length === 1 ? 'oc-assistant-read' : null, + toolCallNames: sendMessageToMember.mock.calls.length === 1 ? ['read'] : [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: sendMessageToMember.mock.calls.length === 1 ? null : 'assistant_response_pending', + }, + diagnostics: [], + })); + const observeMessageDelivery = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: { + state: 'responded_non_visible_tool', + deliveredUserMessageId: 'oc-user-ask', + assistantMessageId: 'oc-assistant-read', + toolCallNames: ['read'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + observeMessageDelivery, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'What did you find?', + messageId: 'msg-visible-required', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'responded_non_visible_tool', + reason: 'visible_reply_still_required', + }); + + const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + fileName: 'opencode-prompt-delivery-ledger.json', + }); + const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array<{ nextAttemptAt: string | null }>; + }; + ledgerEnvelope.data[0].nextAttemptAt = '2000-01-01T00:00:00.000Z'; + await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8'); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'What did you find?', + messageId: 'msg-visible-required', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + }); + + expect(observeMessageDelivery).toHaveBeenCalledTimes(1); + expect(sendMessageToMember).toHaveBeenCalledTimes(2); + expect(sendMessageToMember.mock.calls[1]?.[0]).toMatchObject({ + messageId: 'msg-visible-required', + text: expect.stringContaining(''), + }); + const retryText = String(sendMessageToMember.mock.calls[1]?.[0].text ?? ''); + expect(retryText).toContain('relayOfMessageId="msg-visible-required"'); + expect(retryText).toContain('agent-teams_message_send'); + expect(retryText).toContain('What did you find?'); + }); + + it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => { + const svc = new TeamProvisioningService(); + const emptyResponseObservation = { + state: 'empty_assistant_turn' as const, + deliveredUserMessageId: 'oc-user-empty', + assistantMessageId: 'oc-assistant-empty', + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'empty_assistant_turn', + }; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: emptyResponseObservation, + diagnostics: [], + })); + const observeMessageDelivery = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: emptyResponseObservation, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + observeMessageDelivery, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + const deliver = () => + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Please answer.', + messageId: 'msg-max-attempts', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }); + const forceDue = async () => { + const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + fileName: 'opencode-prompt-delivery-ledger.json', + }); + const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array<{ nextAttemptAt: string | null }>; + }; + ledgerEnvelope.data[0].nextAttemptAt = '2000-01-01T00:00:00.000Z'; + await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8'); + }; + + await expect(deliver()).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'empty_assistant_turn', + }); + await forceDue(); + await expect(deliver()).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'empty_assistant_turn', + }); + await forceDue(); + await expect(deliver()).resolves.toMatchObject({ + delivered: false, + accepted: true, + responsePending: false, + responseState: 'empty_assistant_turn', + ledgerStatus: 'failed_terminal', + reason: 'empty_assistant_turn', + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(3); + expect(observeMessageDelivery).toHaveBeenCalledTimes(2); + }); + + it('queues newer OpenCode deliveries behind one active unresolved member delivery', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'pending' as const, + deliveredUserMessageId: 'oc-user-pending', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'assistant_response_pending', + }, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'First prompt.', + messageId: 'msg-active-old', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: true, + responseState: 'pending', + }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Second prompt.', + messageId: 'msg-active-new', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:05.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: false, + responsePending: true, + queuedBehindMessageId: 'msg-active-old', + reason: 'opencode_delivery_response_pending', + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + }); + it('uses lane-scoped manifest activeRunId for OpenCode member delivery after restart', async () => { const svc = new TeamProvisioningService(); const teamName = 'team-a'; diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 20607fb2..55c35dfb 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -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 # 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', diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index aa32b36c..91398136 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -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) => { 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(); + 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) => { + 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) => { + 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'; diff --git a/test/renderer/components/extensions/ExtensionStoreView.test.ts b/test/renderer/components/extensions/ExtensionStoreView.test.ts index f765f01c..e59b2a2f 100644 --- a/test/renderer/components/extensions/ExtensionStoreView.test.ts +++ b/test/renderer/components/extensions/ExtensionStoreView.test.ts @@ -50,7 +50,7 @@ vi.mock('@renderer/store', () => ({ })); vi.mock('zustand/react/shallow', () => ({ - useShallow: (selector: T) => selector, + useShallow: (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(); diff --git a/test/renderer/components/runtime/ProviderModelBadges.test.tsx b/test/renderer/components/runtime/ProviderModelBadges.test.tsx new file mode 100644 index 00000000..373ef52e --- /dev/null +++ b/test/renderer/components/runtime/ProviderModelBadges.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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'); + }); +}); diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index 389b7c7d..ab517ea9 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -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) => { if (section === 'providerConnections') { - const nextProviderConnections = data as Partial; + 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.'); }); }); diff --git a/test/renderer/components/team/dialogs/SendMessageDialog.test.tsx b/test/renderer/components/team/dialogs/SendMessageDialog.test.tsx new file mode 100644 index 00000000..d5caec99 --- /dev/null +++ b/test/renderer/components/team/dialogs/SendMessageDialog.test.tsx @@ -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) => 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) => + 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) => + 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> = {}) { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClose = vi.fn(); + const onSend = vi.fn['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((_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['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(); + }); + }); +}); diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 1c0eaeb3..3ea42df9 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -10,6 +10,7 @@ const storeState = { sendingMessage: false, sendMessageError: null, sendMessageWarning: null, + sendMessageDebugDetails: null, lastSendMessageResult: null, teams: [], openTeamTab: vi.fn(), diff --git a/test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx b/test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx new file mode 100644 index 00000000..e4fd38d0 --- /dev/null +++ b/test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx @@ -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> = {}) { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + act(() => { + root.render( + + ); + }); + + 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( + + ); + }); + + 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(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/GraphControls.test.ts b/test/renderer/features/agent-graph/GraphControls.test.ts index 17f98ea6..f519ddc3 100644 --- a/test/renderer/features/agent-graph/GraphControls.test.ts +++ b/test/renderer/features/agent-graph/GraphControls.test.ts @@ -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(); + }); + }); }); diff --git a/test/renderer/features/agent-graph/GraphView.test.ts b/test/renderer/features/agent-graph/GraphView.test.ts index ddfff5bd..b85db7d7 100644 --- a/test/renderer/features/agent-graph/GraphView.test.ts +++ b/test/renderer/features/agent-graph/GraphView.test.ts @@ -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, })); @@ -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 () => { diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 0422ae18..5fedb8f6 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -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'); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index a1e1fb60..99a012e9 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -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); diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts new file mode 100644 index 00000000..28d5ebcd --- /dev/null +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -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 { + 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' + ); + }); +}); diff --git a/test/renderer/features/runtime-provider-management/providerManagementView.test.ts b/test/renderer/features/runtime-provider-management/providerManagementView.test.ts new file mode 100644 index 00000000..641fd8ef --- /dev/null +++ b/test/renderer/features/runtime-provider-management/providerManagementView.test.ts @@ -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 { + 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); + }); +}); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 12385635..237ee822 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -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((resolve) => { - resolveCodexStatus = resolve; - }); + let resolveCodexStatus!: (value: CliInstallationStatus['providers'][number]) => void; + const pendingCodexStatus = new Promise( + (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((resolve) => { - resolveProviderStatus = resolve; - }); + const pendingProviderStatus = new Promise( + (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', () => { diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index a4443e3d..9f7b2671 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -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({