fix(team): route opencode lead through runtime

This commit is contained in:
777genius 2026-06-06 21:04:56 +03:00
parent 0551c9c363
commit 2f5454ab3c
7 changed files with 303 additions and 55 deletions

View file

@ -1827,9 +1827,9 @@ OpenCode lead rule:
- Mixed team with native Codex/Claude/Gemini lead: keep the existing `relayLeadInboxMessages()` path. - Mixed team with native Codex/Claude/Gemini lead: keep the existing `relayLeadInboxMessages()` path.
- OpenCode teammate or secondary lane: use `relayOpenCodeMemberInboxMessages()`. - OpenCode teammate or secondary lane: use `relayOpenCodeMemberInboxMessages()`.
- Pure OpenCode lead inbox in v1: do not mark messages read and do not report delivery success unless a real stored OpenCode `team-lead` session exists. Return a diagnostic like `opencode_lead_runtime_session_missing`. - Pure OpenCode lead inbox: launch and store a real OpenCode `team-lead` runtime session, then relay through `relayOpenCodeMemberInboxMessages()`. Do not mark messages read unless that delivery is accepted.
- Do not fake lead delivery by sending to a random teammate session. That would make messages appear delivered while the actual recipient never saw them. - Do not fake lead delivery by sending to a random teammate session. That would make messages appear delivered while the actual recipient never saw them.
- A future explicit OpenCode lead lane can reuse this selector by teaching the bridge to create/store a `team-lead` session and by passing `agent: "team-lead"` where the bridge supports it. That is not part of this v1 seam. - If the stored `team-lead` session is missing, keep the row retryable instead of falling back to another teammate.
FileWatcher change: FileWatcher change:
@ -2967,13 +2967,12 @@ it('routes native lead inbox relay through the legacy stdin path', async () => {
``` ```
```ts ```ts
it('does not silently consume pure OpenCode lead inbox when no lead session exists', async () => { it('relays pure OpenCode lead inbox through the stored lead session', async () => {
// Configure a pure OpenCode runtime-adapter team where isTeamAlive() is true via runtimeAdapterRunByTeam. // Configure a pure OpenCode runtime-adapter team with a stored team-lead session.
// Ensure there is no stored OpenCode session record for the canonical lead name.
// Seed inboxes/<lead>.json with one unread message. // Seed inboxes/<lead>.json with one unread message.
// Call relayInboxFileToLiveRecipient(teamName, leadName). // Call relayInboxFileToLiveRecipient(teamName, leadName).
// Assert diagnostics include opencode_lead_runtime_session_missing. // Assert the relay kind is opencode_member and the prompt targets team-lead.
// Assert the inbox row remains unread and no teammate session received the prompt. // Assert the inbox row is marked read only after accepted runtime delivery.
}); });
``` ```
@ -3464,8 +3463,8 @@ Avoid heavy E2E until targeted tests pass.
- UI direct sends to live OpenCode teammates either confirm runtime delivery or show a visible warning; there is no log-only post-send delivery failure. - UI direct sends to live OpenCode teammates either confirm runtime delivery or show a visible warning; there is no log-only post-send delivery failure.
- Persisted inbox messages addressed to OpenCode teammates are live-relayed to their runtime lanes, while native teammates keep file-watch behavior and lead keeps lead relay behavior. - Persisted inbox messages addressed to OpenCode teammates are live-relayed to their runtime lanes, while native teammates keep file-watch behavior and lead keeps lead relay behavior.
- OpenCode inbox relay is direct-to-runtime and does not reuse native `relayMemberInboxMessages()` / `SendMessage` forwarding. - OpenCode inbox relay is direct-to-runtime and does not reuse native `relayMemberInboxMessages()` / `SendMessage` forwarding.
- Pure OpenCode lead inbox delivery is not silently consumed: without a real OpenCode lead session, rows remain unread and diagnostics say `opencode_lead_runtime_session_missing` or equivalent. - Pure OpenCode lead inbox delivery uses the stored `team-lead` runtime session and does not silently fall back to another teammate.
- Renderer send-message actions return `SendMessageResult` on success and reject on real send failure, so pending-reply cleanup is not dependent on dead `.catch()` paths. - Renderer send-message actions return `SendMessageResult` on success and reject on real send failure, so pending-reply cleanup is not dependent on dead `.catch()` paths.
- `message_send` cannot create `from: "user", to: "user"` rows; user-directed MCP replies require a configured teammate sender. - `message_send` cannot create `from: "user", to: "user"` rows; user-directed MCP replies require a configured teammate sender.
- OpenCode replies appear in Messages UI without frontend fake state. - OpenCode replies appear in Messages UI without frontend fake state.
- Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, unsupported OpenCode lead diagnostics, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape. - Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, pure OpenCode lead relay, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape.

View file

@ -3028,9 +3028,10 @@ async function handleSendMessage(
: leadName !== null && memberName === leadName; : leadName !== null && memberName === leadName;
const actionMode = payload.actionMode; const actionMode = payload.actionMode;
const recipientProviderId = !isLeadRecipient const recipientProviderId = await provisioning.resolveRuntimeRecipientProviderId(
? await provisioning.resolveRuntimeRecipientProviderId(tn, memberName) tn,
: undefined; memberName
);
const isOpenCodeRecipient = recipientProviderId === 'opencode'; const isOpenCodeRecipient = recipientProviderId === 'opencode';
// Attachments are routed through explicit provider transports only. // Attachments are routed through explicit provider transports only.
@ -3051,7 +3052,7 @@ async function handleSendMessage(
} }
// Smart routing: lead + alive → stdin direct, else → inbox // Smart routing: lead + alive → stdin direct, else → inbox
if (isLeadRecipient && isAlive) { if (isLeadRecipient && isAlive && !isOpenCodeRecipient) {
const resolvedLeadName = leadName ?? memberName; const resolvedLeadName = leadName ?? memberName;
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName); const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster); const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);

View file

@ -712,24 +712,23 @@ export function createPersistedLaunchSnapshot(params: {
teamName: string; teamName: string;
expectedMembers: readonly string[]; expectedMembers: readonly string[];
bootstrapExpectedMembers?: readonly string[]; bootstrapExpectedMembers?: readonly string[];
includeLeadMembers?: boolean;
leadSessionId?: string; leadSessionId?: string;
launchPhase?: PersistedTeamLaunchPhase; launchPhase?: PersistedTeamLaunchPhase;
members?: Record<string, PersistedTeamLaunchMemberState>; members?: Record<string, PersistedTeamLaunchMemberState>;
updatedAt?: string; updatedAt?: string;
}): PersistedTeamLaunchSnapshot { }): PersistedTeamLaunchSnapshot {
const updatedAt = params.updatedAt ?? new Date().toISOString(); const updatedAt = params.updatedAt ?? new Date().toISOString();
const shouldKeepExpectedMemberName = (name: string): boolean =>
name.length > 0 && name !== 'user' && (params.includeLeadMembers || !isLeadMember({ name }));
const expectedMembers = Array.from( const expectedMembers = Array.from(
new Set( new Set(params.expectedMembers.map(normalizeMemberName).filter(shouldKeepExpectedMemberName))
params.expectedMembers
.map(normalizeMemberName)
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
)
); );
const bootstrapExpectedMembers = Array.from( const bootstrapExpectedMembers = Array.from(
new Set( new Set(
(params.bootstrapExpectedMembers ?? expectedMembers) (params.bootstrapExpectedMembers ?? expectedMembers)
.map(normalizeMemberName) .map(normalizeMemberName)
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })) .filter(shouldKeepExpectedMemberName)
) )
); );
const members = params.members ?? {}; const members = params.members ?? {};

View file

@ -3344,12 +3344,7 @@ interface OpenCodeMemberInboxRelayResult {
} }
interface LiveInboxRelayResult { interface LiveInboxRelayResult {
kind: kind: 'ignored' | 'native_lead' | 'native_member_noop' | 'opencode_member';
| 'ignored'
| 'native_lead'
| 'native_member_noop'
| 'opencode_member'
| 'opencode_lead_unsupported';
relayed: number; relayed: number;
diagnostics?: string[]; diagnostics?: string[];
lastDelivery?: OpenCodeMemberInboxDelivery; lastDelivery?: OpenCodeMemberInboxDelivery;
@ -14859,7 +14854,13 @@ export class TeamProvisioningService {
if (!memberName) continue; if (!memberName) continue;
const isLead = isLeadMember({ name: memberName, agentType: member.agentType }); const isLead = isLeadMember({ name: memberName, agentType: member.agentType });
if (isLead) { const candidateLaunchMember = launchSnapshot?.members[memberName];
const candidateRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
const leadRuntimeProviderId =
normalizeOptionalTeamProviderId(candidateRuntimeAdapterEvidence?.providerId) ??
normalizeOptionalTeamProviderId(candidateLaunchMember?.providerId) ??
normalizeOptionalTeamProviderId(member.providerId);
if (isLead && leadRuntimeProviderId !== 'opencode') {
const pid = run?.child?.pid; const pid = run?.child?.pid;
const usageStats = pid const usageStats = pid
? this.buildRuntimeProcessLoadStatsSafely(teamName, memberName, { ? this.buildRuntimeProcessLoadStatsSafely(teamName, memberName, {
@ -14929,6 +14930,7 @@ export class TeamProvisioningService {
const liveRuntimeMember = getLiveRuntimeMember(memberName); const liveRuntimeMember = getLiveRuntimeMember(memberName);
const spawnStatusMember = getSpawnStatusMember(memberName); const spawnStatusMember = getSpawnStatusMember(memberName);
const launchMember = launchSnapshot?.members[memberName]; const launchMember = launchSnapshot?.members[memberName];
const runtimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
const activeRunMember = activeRunMemberByName.get(memberName); const activeRunMember = activeRunMemberByName.get(memberName);
const activeRunModel = activeRunMember?.model?.trim(); const activeRunModel = activeRunMember?.model?.trim();
const activeRunProviderId = const activeRunProviderId =
@ -14969,6 +14971,16 @@ export class TeamProvisioningService {
member.providerBackendId member.providerBackendId
); );
const isOpenCodeMember = memberProviderId === 'opencode'; const isOpenCodeMember = memberProviderId === 'opencode';
const runtimeAdapterSessionId =
typeof runtimeAdapterEvidence?.sessionId === 'string'
? runtimeAdapterEvidence.sessionId.trim()
: '';
const runtimeAdapterPid =
typeof runtimeAdapterEvidence?.runtimePid === 'number' &&
Number.isFinite(runtimeAdapterEvidence.runtimePid) &&
runtimeAdapterEvidence.runtimePid > 0
? runtimeAdapterEvidence.runtimePid
: undefined;
const configuredCwd = const configuredCwd =
typeof activeRunMember?.cwd === 'string' typeof activeRunMember?.cwd === 'string'
? activeRunMember.cwd.trim() ? activeRunMember.cwd.trim()
@ -14985,7 +14997,9 @@ export class TeamProvisioningService {
metricsPid > 0 && metricsPid > 0 &&
liveRuntimeMember?.pidSource !== 'agent_process_table'; liveRuntimeMember?.pidSource !== 'agent_process_table';
const rssPid = isSharedOpenCodeHost ? metricsPid : (liveRuntimeMember?.pid ?? metricsPid); const rssPid = isSharedOpenCodeHost ? metricsPid : (liveRuntimeMember?.pid ?? metricsPid);
const displayPid = isSharedOpenCodeHost ? rssPid : liveRuntimeMember?.pid; const displayPid = isSharedOpenCodeHost
? rssPid
: (liveRuntimeMember?.pid ?? runtimeAdapterPid);
const restartable = isOpenCodeMember const restartable = isOpenCodeMember
? !isSharedOpenCodeHost && Boolean(liveRuntimeMember?.pid) ? !isSharedOpenCodeHost && Boolean(liveRuntimeMember?.pid)
: isSharedOpenCodeHost : isSharedOpenCodeHost
@ -14994,6 +15008,8 @@ export class TeamProvisioningService {
const historicalBootstrapConfirmed = const historicalBootstrapConfirmed =
launchMember?.bootstrapConfirmed === true || launchMember?.bootstrapConfirmed === true ||
launchMember?.launchState === 'confirmed_alive' || launchMember?.launchState === 'confirmed_alive' ||
runtimeAdapterEvidence?.bootstrapConfirmed === true ||
runtimeAdapterEvidence?.launchState === 'confirmed_alive' ||
spawnStatusMember?.bootstrapConfirmed === true || spawnStatusMember?.bootstrapConfirmed === true ||
spawnStatusMember?.launchState === 'confirmed_alive'; spawnStatusMember?.launchState === 'confirmed_alive';
const spawnStatusConfirmsBootstrap = const spawnStatusConfirmsBootstrap =
@ -15003,7 +15019,9 @@ export class TeamProvisioningService {
isOpenCodeMember && isOpenCodeMember &&
(typeof liveRuntimeMember?.pid === 'number' || (typeof liveRuntimeMember?.pid === 'number' ||
typeof liveRuntimeMember?.metricsPid === 'number' || typeof liveRuntimeMember?.metricsPid === 'number' ||
typeof liveRuntimeMember?.runtimeSessionId === 'string'); typeof liveRuntimeMember?.runtimeSessionId === 'string' ||
typeof runtimeAdapterPid === 'number' ||
runtimeAdapterSessionId.length > 0);
const confirmedOpenCodeRuntimeAlive = const confirmedOpenCodeRuntimeAlive =
isOpenCodeMember && isOpenCodeMember &&
canUseLiveSpawnStatusRuntimeTruth && canUseLiveSpawnStatusRuntimeTruth &&
@ -15012,6 +15030,12 @@ export class TeamProvisioningService {
spawnStatusMember?.hardFailure !== true && spawnStatusMember?.hardFailure !== true &&
spawnStatusMember?.launchState !== 'failed_to_start' && spawnStatusMember?.launchState !== 'failed_to_start' &&
spawnStatusMember?.launchState !== 'runtime_pending_permission'; spawnStatusMember?.launchState !== 'runtime_pending_permission';
const confirmedOpenCodeRuntimeAdapterAlive =
isOpenCodeMember &&
runtimeAdapterEvidence?.bootstrapConfirmed === true &&
runtimeAdapterEvidence.runtimeAlive === true &&
runtimeAdapterEvidence.hardFailure !== true &&
hasOpenCodeRuntimeHandle;
const confirmedSpawnRuntimeFallback = const confirmedSpawnRuntimeFallback =
!isOpenCodeMember && !isOpenCodeMember &&
spawnStatusConfirmsBootstrap && spawnStatusConfirmsBootstrap &&
@ -15026,6 +15050,7 @@ export class TeamProvisioningService {
const effectiveAlive = const effectiveAlive =
liveRuntimeMember?.alive === true || liveRuntimeMember?.alive === true ||
confirmedOpenCodeRuntimeAlive || confirmedOpenCodeRuntimeAlive ||
confirmedOpenCodeRuntimeAdapterAlive ||
confirmedSpawnRuntimeFallback; confirmedSpawnRuntimeFallback;
const effectiveLivenessKind = const effectiveLivenessKind =
confirmedOpenCodeRuntimeAlive && confirmedOpenCodeRuntimeAlive &&
@ -15154,7 +15179,9 @@ export class TeamProvisioningService {
...(liveRuntimeMember?.metricsPid ? { runtimePid: liveRuntimeMember.metricsPid } : {}), ...(liveRuntimeMember?.metricsPid ? { runtimePid: liveRuntimeMember.metricsPid } : {}),
...(liveRuntimeMember?.runtimeSessionId ...(liveRuntimeMember?.runtimeSessionId
? { runtimeSessionId: liveRuntimeMember.runtimeSessionId } ? { runtimeSessionId: liveRuntimeMember.runtimeSessionId }
: {}), : runtimeAdapterSessionId
? { runtimeSessionId: runtimeAdapterSessionId }
: {}),
...(liveRuntimeMember?.runtimeLastSeenAt ...(liveRuntimeMember?.runtimeLastSeenAt
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt } ? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
: {}), : {}),
@ -19888,6 +19915,29 @@ export class TeamProvisioningService {
}; };
} }
private buildOpenCodeRuntimeAdapterLaunchMembers(
request: TeamCreateRequest | TeamLaunchRequest,
members: TeamCreateRequest['members']
): TeamCreateRequest['members'] {
if (resolveTeamProviderId(request.providerId) !== 'opencode') {
return members;
}
if (members.some((member) => isLeadMember(member))) {
return members;
}
return [
{
name: 'team-lead',
role: 'Team Lead',
providerId: 'opencode',
model: request.model,
effort: request.effort,
},
...members,
];
}
private async resolveOpenCodeMemberWorkspacesForRuntime(params: { private async resolveOpenCodeMemberWorkspacesForRuntime(params: {
teamName: string; teamName: string;
baseCwd: string; baseCwd: string;
@ -21530,6 +21580,10 @@ export class TeamProvisioningService {
leadProviderId: launchRequest.providerId, leadProviderId: launchRequest.providerId,
members: materialized.members, members: materialized.members,
}); });
const runtimeLaunchMembers = this.buildOpenCodeRuntimeAdapterLaunchMembers(
launchRequest,
effectiveMembers
);
const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName); const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName);
const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName); const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName);
await fs.promises.mkdir(teamDir, { recursive: true }); await fs.promises.mkdir(teamDir, { recursive: true });
@ -21558,7 +21612,7 @@ export class TeamProvisioningService {
return this.runOpenCodeTeamRuntimeAdapterLaunch({ return this.runOpenCodeTeamRuntimeAdapterLaunch({
request: launchRequest, request: launchRequest,
members: effectiveMembers, members: runtimeLaunchMembers,
prompt: launchRequest.prompt?.trim() ?? '', prompt: launchRequest.prompt?.trim() ?? '',
sourceWarning: undefined, sourceWarning: undefined,
onProgress, onProgress,
@ -21594,6 +21648,10 @@ export class TeamProvisioningService {
leadProviderId: launchRequest.providerId, leadProviderId: launchRequest.providerId,
members: materialized.members, members: materialized.members,
}); });
const runtimeLaunchMembers = this.buildOpenCodeRuntimeAdapterLaunchMembers(
launchRequest,
effectiveMembers
);
await this.updateConfigProjectPath(launchRequest.teamName, launchRequest.cwd); await this.updateConfigProjectPath(launchRequest.teamName, launchRequest.cwd);
let existingTasks: TeamTask[] = []; let existingTasks: TeamTask[] = [];
@ -21613,7 +21671,7 @@ export class TeamProvisioningService {
return this.runOpenCodeTeamRuntimeAdapterLaunch({ return this.runOpenCodeTeamRuntimeAdapterLaunch({
request: launchRequest, request: launchRequest,
members: effectiveMembers, members: runtimeLaunchMembers,
prompt, prompt,
sourceWarning: warning, sourceWarning: warning,
onProgress, onProgress,
@ -21901,6 +21959,7 @@ export class TeamProvisioningService {
teamName: input.teamName, teamName: input.teamName,
expectedMembers: input.expectedMembers.map((member) => member.name), expectedMembers: input.expectedMembers.map((member) => member.name),
bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name), bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name),
includeLeadMembers: true,
leadSessionId: result.leadSessionId, leadSessionId: result.leadSessionId,
launchPhase: committedResult.launchPhase, launchPhase: committedResult.launchPhase,
members, members,
@ -23462,13 +23521,21 @@ export class TeamProvisioningService {
); );
if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) { if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) {
if (isOpenCodeRecipient) { if (isOpenCodeRecipient) {
const diagnostic = const relayOptions: OpenCodeMemberInboxRelayOptions = {
'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.'; source: options.source ?? 'watcher',
logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`); ...(options.onlyMessageId ? { onlyMessageId: options.onlyMessageId } : {}),
...(options.deliveryMetadata ? { deliveryMetadata: options.deliveryMetadata } : {}),
};
const relay = await this.relayOpenCodeMemberInboxMessages(
teamName,
inboxName,
relayOptions
);
return { return {
kind: 'opencode_lead_unsupported', kind: 'opencode_member',
relayed: 0, relayed: relay.relayed,
diagnostics: [diagnostic], diagnostics: relay.diagnostics,
lastDelivery: relay.lastDelivery,
}; };
} }
return { return {

View file

@ -251,9 +251,8 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
break; break;
} }
if ( if (
lastRelay.kind === 'opencode_lead_unsupported' || lastRelay.lastDelivery?.delivered === false &&
(lastRelay.lastDelivery?.delivered === false && lastRelay.lastDelivery.responsePending !== true
lastRelay.lastDelivery.responsePending !== true)
) { ) {
break; break;
} }

View file

@ -223,6 +223,11 @@ describe('Team agent launch matrix safe e2e', () => {
}); });
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot('pure-opencode-safe-e2e'); const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot('pure-opencode-safe-e2e');
expect(runtimeSnapshot.members['team-lead']).toMatchObject({
alive: true,
providerId: 'opencode',
runtimeModel: 'opencode/big-pickle',
});
expect(runtimeSnapshot.members.alice).toMatchObject({ expect(runtimeSnapshot.members.alice).toMatchObject({
alive: true, alive: true,
providerId: 'opencode', providerId: 'opencode',
@ -240,8 +245,8 @@ describe('Team agent launch matrix safe e2e', () => {
}) })
) as { expectedMembers: string[]; members: Record<string, unknown>; teamLaunchState: string }; ) as { expectedMembers: string[]; members: Record<string, unknown>; teamLaunchState: string };
expect(launchState.teamLaunchState).toBe('clean_success'); expect(launchState.teamLaunchState).toBe('clean_success');
expect(launchState.expectedMembers).toEqual(['alice', 'bob']); expect(launchState.expectedMembers).toEqual(['team-lead', 'alice', 'bob']);
expect(Object.keys(launchState.members)).toEqual(['alice', 'bob']); expect(Object.keys(launchState.members)).toEqual(['team-lead', 'alice', 'bob']);
await expect( await expect(
readCommittedOpenCodeBootstrapSessionEvidence({ readCommittedOpenCodeBootstrapSessionEvidence({
teamsBasePath: getTeamsBasePath(), teamsBasePath: getTeamsBasePath(),
@ -278,7 +283,7 @@ describe('Team agent launch matrix safe e2e', () => {
expect(runId).toBe(adapter.launchInputs[0]?.runId); expect(runId).toBe(adapter.launchInputs[0]?.runId);
expect(adapter.bootstrapCheckins).toEqual([ expect(adapter.bootstrapCheckins).toEqual([
{ {
memberName: 'team-lead', memberName: 'alice',
runId, runId,
state: 'accepted', state: 'accepted',
}, },
@ -350,7 +355,6 @@ describe('Team agent launch matrix safe e2e', () => {
expect(runId).toBe(adapter.launchInputs[0]?.runId); expect(runId).toBe(adapter.launchInputs[0]?.runId);
expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([ expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([
'team-lead',
'alice', 'alice',
'bob', 'bob',
]); ]);
@ -11164,6 +11168,94 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs[0]?.runId).not.toBe(first.runId); expect(adapter.messageInputs[0]?.runId).not.toBe(first.runId);
}); });
it('delivers pure OpenCode lead inbox messages through the primary runtime lane end-to-end', async () => {
const teamName = 'pure-opencode-lead-inbox-delivery-safe-e2e';
const adapter = new VisibleReplyOpenCodeRuntimeAdapter({
replySource: 'runtime_delivery',
});
const svc = new TeamProvisioningService();
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
const launch = await svc.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'opencode',
model: 'opencode/big-pickle',
skipPermissions: true,
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
},
() => undefined
);
const messageId = 'msg-pure-opencode-lead-inbox';
const leadInboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', 'team-lead.json');
await fs.mkdir(path.dirname(leadInboxPath), { recursive: true });
await fs.writeFile(
leadInboxPath,
`${JSON.stringify(
[
{
from: 'user',
to: 'team-lead',
text: 'coordinate this pure opencode team',
timestamp: '2026-05-08T10:05:00.000Z',
read: false,
messageId,
},
],
null,
2
)}\n`,
'utf8'
);
await expect(
svc.relayInboxFileToLiveRecipient(teamName, 'team-lead', {
onlyMessageId: messageId,
source: 'ui-send',
deliveryMetadata: {
replyRecipient: 'user',
actionMode: 'do',
},
})
).resolves.toMatchObject({
kind: 'opencode_member',
relayed: 1,
lastDelivery: {
delivered: true,
accepted: true,
responsePending: false,
responseState: 'responded_visible_message',
visibleReplyMessageId: `reply-${messageId}`,
},
});
expect(adapter.messageInputs).toHaveLength(1);
expect(adapter.messageInputs[0]).toMatchObject({
runId: launch.runId,
teamName,
laneId: 'primary',
memberName: 'team-lead',
text: 'coordinate this pure opencode team',
messageId,
replyRecipient: 'user',
actionMode: 'do',
});
const leadInbox = await readInboxRows(teamName, 'team-lead');
expect(leadInbox[0]).toMatchObject({
messageId,
read: true,
});
const userInbox = await readInboxRows(teamName, 'user');
expect(userInbox[0]).toMatchObject({
from: 'team-lead',
to: 'user',
source: 'runtime_delivery',
messageId: `reply-${messageId}`,
relayOfMessageId: messageId,
});
});
it('surfaces pure OpenCode delivery permission blocks as the shared tool approval dialog', async () => { it('surfaces pure OpenCode delivery permission blocks as the shared tool approval dialog', async () => {
const teamName = 'pure-opencode-delivery-permission-approval-safe-e2e'; const teamName = 'pure-opencode-delivery-permission-approval-safe-e2e';
const adapter = new PermissionBlockedOpenCodeRuntimeAdapter(); const adapter = new PermissionBlockedOpenCodeRuntimeAdapter();

View file

@ -1664,6 +1664,66 @@ Messages:
expect(payload).not.toContain('MessageId: m-ordinary-11'); expect(payload).not.toContain('MessageId: m-ordinary-11');
}); });
it('keeps native member work-sync rows unread without accepted report proof', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
service.setMemberWorkSyncAcceptedReportChecker(async () => false);
seedMemberInbox(teamName, 'alice', [
{
from: 'system',
text: 'Call member_work_sync_status, then member_work_sync_report.',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
messageId: 'm-work-sync-unproved',
messageKind: 'member_work_sync_nudge',
workSyncIntent: 'agenda_sync',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const firstRelayed = await service.relayMemberInboxMessages(teamName, 'alice');
const rowsAfterFirst = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
) as Array<{ read?: boolean }>;
expect(firstRelayed).toBe(1);
expect(rowsAfterFirst[0]?.read).toBe(false);
const secondRelayed = await service.relayMemberInboxMessages(teamName, 'alice');
expect(secondRelayed).toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(2);
});
it('read-commits native member work-sync rows after accepted report proof', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
service.setMemberWorkSyncAcceptedReportChecker(async () => true);
seedMemberInbox(teamName, 'alice', [
{
from: 'system',
text: 'Call member_work_sync_status, then member_work_sync_report.',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
messageId: 'm-work-sync-proved',
messageKind: 'member_work_sync_nudge',
workSyncIntent: 'agenda_sync',
},
]);
attachAliveRun(service, teamName);
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
const rows = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
) as Array<{ read?: boolean }>;
expect(relayed).toBe(1);
expect(rows[0]?.read).toBe(true);
await expect(service.relayMemberInboxMessages(teamName, 'alice')).resolves.toBe(0);
});
it('retries a work-sync nudge after member relay times out before stdin write completes', async () => { it('retries a work-sync nudge after member relay times out before stdin write completes', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const service = new TeamProvisioningService(); const service = new TeamProvisioningService();
@ -4170,7 +4230,7 @@ Messages:
expect(rows[0].read).toBe(true); expect(rows[0].read).toBe(true);
}); });
it('leaves OpenCode lead inbox rows unread with an explicit unsupported diagnostic', async () => { it('routes OpenCode lead inbox rows through OpenCode member relay', async () => {
const service = new TeamProvisioningService(); const service = new TeamProvisioningService();
const teamName = 'my-team'; const teamName = 'my-team';
hoisted.files.set( hoisted.files.set(
@ -4198,19 +4258,50 @@ Messages:
messageId: 'opencode-lead-unread-1', messageId: 'opencode-lead-unread-1',
}, },
]); ]);
const relaySpy = vi.spyOn(service, 'relayOpenCodeMemberInboxMessages').mockResolvedValue({
relayed: 1,
attempted: 1,
delivered: 1,
failed: 0,
diagnostics: ['fake OpenCode lead relay ready'],
lastDelivery: {
delivered: true,
accepted: true,
responsePending: false,
},
});
const relay = await service.relayInboxFileToLiveRecipient(teamName, 'team-lead'); const relay = await service.relayInboxFileToLiveRecipient(teamName, 'team-lead', {
onlyMessageId: 'opencode-lead-unread-1',
source: 'ui-send',
deliveryMetadata: {
replyRecipient: 'user',
actionMode: 'do',
},
});
expect(relay).toMatchObject({ kind: 'opencode_lead_unsupported', relayed: 0 }); expect(relay).toMatchObject({
expect(relay.diagnostics?.join('\n')).toContain('opencode_lead_runtime_session_missing'); kind: 'opencode_member',
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( relayed: 1,
'opencode_lead_runtime_session_missing' diagnostics: ['fake OpenCode lead relay ready'],
lastDelivery: {
delivered: true,
accepted: true,
responsePending: false,
},
});
expect(relaySpy).toHaveBeenCalledWith(
teamName,
'team-lead',
expect.objectContaining({
onlyMessageId: 'opencode-lead-unread-1',
source: 'ui-send',
deliveryMetadata: expect.objectContaining({
replyRecipient: 'user',
actionMode: 'do',
}),
})
); );
vi.mocked(console.warn).mockClear();
const rows = JSON.parse(
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
);
expect(rows[0].read).toBe(false);
}); });
it('keeps failed OpenCode member inbox relay rows unread for retry', async () => { it('keeps failed OpenCode member inbox relay rows unread for retry', async () => {