fix(team): route opencode lead through runtime
This commit is contained in:
parent
0551c9c363
commit
2f5454ab3c
7 changed files with 303 additions and 55 deletions
|
|
@ -1827,9 +1827,9 @@ OpenCode lead rule:
|
|||
|
||||
- Mixed team with native Codex/Claude/Gemini lead: keep the existing `relayLeadInboxMessages()` path.
|
||||
- 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.
|
||||
- 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:
|
||||
|
||||
|
|
@ -2967,13 +2967,12 @@ it('routes native lead inbox relay through the legacy stdin path', async () => {
|
|||
```
|
||||
|
||||
```ts
|
||||
it('does not silently consume pure OpenCode lead inbox when no lead session exists', async () => {
|
||||
// Configure a pure OpenCode runtime-adapter team where isTeamAlive() is true via runtimeAdapterRunByTeam.
|
||||
// Ensure there is no stored OpenCode session record for the canonical lead name.
|
||||
it('relays pure OpenCode lead inbox through the stored lead session', async () => {
|
||||
// Configure a pure OpenCode runtime-adapter team with a stored team-lead session.
|
||||
// Seed inboxes/<lead>.json with one unread message.
|
||||
// Call relayInboxFileToLiveRecipient(teamName, leadName).
|
||||
// Assert diagnostics include opencode_lead_runtime_session_missing.
|
||||
// Assert the inbox row remains unread and no teammate session received the prompt.
|
||||
// Assert the relay kind is opencode_member and the prompt targets team-lead.
|
||||
// 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- `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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -3028,9 +3028,10 @@ async function handleSendMessage(
|
|||
: leadName !== null && memberName === leadName;
|
||||
const actionMode = payload.actionMode;
|
||||
|
||||
const recipientProviderId = !isLeadRecipient
|
||||
? await provisioning.resolveRuntimeRecipientProviderId(tn, memberName)
|
||||
: undefined;
|
||||
const recipientProviderId = await provisioning.resolveRuntimeRecipientProviderId(
|
||||
tn,
|
||||
memberName
|
||||
);
|
||||
const isOpenCodeRecipient = recipientProviderId === 'opencode';
|
||||
|
||||
// Attachments are routed through explicit provider transports only.
|
||||
|
|
@ -3051,7 +3052,7 @@ async function handleSendMessage(
|
|||
}
|
||||
|
||||
// Smart routing: lead + alive → stdin direct, else → inbox
|
||||
if (isLeadRecipient && isAlive) {
|
||||
if (isLeadRecipient && isAlive && !isOpenCodeRecipient) {
|
||||
const resolvedLeadName = leadName ?? memberName;
|
||||
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
|
||||
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
|
||||
|
|
|
|||
|
|
@ -712,24 +712,23 @@ export function createPersistedLaunchSnapshot(params: {
|
|||
teamName: string;
|
||||
expectedMembers: readonly string[];
|
||||
bootstrapExpectedMembers?: readonly string[];
|
||||
includeLeadMembers?: boolean;
|
||||
leadSessionId?: string;
|
||||
launchPhase?: PersistedTeamLaunchPhase;
|
||||
members?: Record<string, PersistedTeamLaunchMemberState>;
|
||||
updatedAt?: string;
|
||||
}): PersistedTeamLaunchSnapshot {
|
||||
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(
|
||||
new Set(
|
||||
params.expectedMembers
|
||||
.map(normalizeMemberName)
|
||||
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
|
||||
)
|
||||
new Set(params.expectedMembers.map(normalizeMemberName).filter(shouldKeepExpectedMemberName))
|
||||
);
|
||||
const bootstrapExpectedMembers = Array.from(
|
||||
new Set(
|
||||
(params.bootstrapExpectedMembers ?? expectedMembers)
|
||||
.map(normalizeMemberName)
|
||||
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
|
||||
.filter(shouldKeepExpectedMemberName)
|
||||
)
|
||||
);
|
||||
const members = params.members ?? {};
|
||||
|
|
|
|||
|
|
@ -3344,12 +3344,7 @@ interface OpenCodeMemberInboxRelayResult {
|
|||
}
|
||||
|
||||
interface LiveInboxRelayResult {
|
||||
kind:
|
||||
| 'ignored'
|
||||
| 'native_lead'
|
||||
| 'native_member_noop'
|
||||
| 'opencode_member'
|
||||
| 'opencode_lead_unsupported';
|
||||
kind: 'ignored' | 'native_lead' | 'native_member_noop' | 'opencode_member';
|
||||
relayed: number;
|
||||
diagnostics?: string[];
|
||||
lastDelivery?: OpenCodeMemberInboxDelivery;
|
||||
|
|
@ -14859,7 +14854,13 @@ export class TeamProvisioningService {
|
|||
if (!memberName) continue;
|
||||
|
||||
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 usageStats = pid
|
||||
? this.buildRuntimeProcessLoadStatsSafely(teamName, memberName, {
|
||||
|
|
@ -14929,6 +14930,7 @@ export class TeamProvisioningService {
|
|||
const liveRuntimeMember = getLiveRuntimeMember(memberName);
|
||||
const spawnStatusMember = getSpawnStatusMember(memberName);
|
||||
const launchMember = launchSnapshot?.members[memberName];
|
||||
const runtimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||
const activeRunMember = activeRunMemberByName.get(memberName);
|
||||
const activeRunModel = activeRunMember?.model?.trim();
|
||||
const activeRunProviderId =
|
||||
|
|
@ -14969,6 +14971,16 @@ export class TeamProvisioningService {
|
|||
member.providerBackendId
|
||||
);
|
||||
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 =
|
||||
typeof activeRunMember?.cwd === 'string'
|
||||
? activeRunMember.cwd.trim()
|
||||
|
|
@ -14985,7 +14997,9 @@ export class TeamProvisioningService {
|
|||
metricsPid > 0 &&
|
||||
liveRuntimeMember?.pidSource !== 'agent_process_table';
|
||||
const rssPid = isSharedOpenCodeHost ? metricsPid : (liveRuntimeMember?.pid ?? metricsPid);
|
||||
const displayPid = isSharedOpenCodeHost ? rssPid : liveRuntimeMember?.pid;
|
||||
const displayPid = isSharedOpenCodeHost
|
||||
? rssPid
|
||||
: (liveRuntimeMember?.pid ?? runtimeAdapterPid);
|
||||
const restartable = isOpenCodeMember
|
||||
? !isSharedOpenCodeHost && Boolean(liveRuntimeMember?.pid)
|
||||
: isSharedOpenCodeHost
|
||||
|
|
@ -14994,6 +15008,8 @@ export class TeamProvisioningService {
|
|||
const historicalBootstrapConfirmed =
|
||||
launchMember?.bootstrapConfirmed === true ||
|
||||
launchMember?.launchState === 'confirmed_alive' ||
|
||||
runtimeAdapterEvidence?.bootstrapConfirmed === true ||
|
||||
runtimeAdapterEvidence?.launchState === 'confirmed_alive' ||
|
||||
spawnStatusMember?.bootstrapConfirmed === true ||
|
||||
spawnStatusMember?.launchState === 'confirmed_alive';
|
||||
const spawnStatusConfirmsBootstrap =
|
||||
|
|
@ -15003,7 +15019,9 @@ export class TeamProvisioningService {
|
|||
isOpenCodeMember &&
|
||||
(typeof liveRuntimeMember?.pid === 'number' ||
|
||||
typeof liveRuntimeMember?.metricsPid === 'number' ||
|
||||
typeof liveRuntimeMember?.runtimeSessionId === 'string');
|
||||
typeof liveRuntimeMember?.runtimeSessionId === 'string' ||
|
||||
typeof runtimeAdapterPid === 'number' ||
|
||||
runtimeAdapterSessionId.length > 0);
|
||||
const confirmedOpenCodeRuntimeAlive =
|
||||
isOpenCodeMember &&
|
||||
canUseLiveSpawnStatusRuntimeTruth &&
|
||||
|
|
@ -15012,6 +15030,12 @@ export class TeamProvisioningService {
|
|||
spawnStatusMember?.hardFailure !== true &&
|
||||
spawnStatusMember?.launchState !== 'failed_to_start' &&
|
||||
spawnStatusMember?.launchState !== 'runtime_pending_permission';
|
||||
const confirmedOpenCodeRuntimeAdapterAlive =
|
||||
isOpenCodeMember &&
|
||||
runtimeAdapterEvidence?.bootstrapConfirmed === true &&
|
||||
runtimeAdapterEvidence.runtimeAlive === true &&
|
||||
runtimeAdapterEvidence.hardFailure !== true &&
|
||||
hasOpenCodeRuntimeHandle;
|
||||
const confirmedSpawnRuntimeFallback =
|
||||
!isOpenCodeMember &&
|
||||
spawnStatusConfirmsBootstrap &&
|
||||
|
|
@ -15026,6 +15050,7 @@ export class TeamProvisioningService {
|
|||
const effectiveAlive =
|
||||
liveRuntimeMember?.alive === true ||
|
||||
confirmedOpenCodeRuntimeAlive ||
|
||||
confirmedOpenCodeRuntimeAdapterAlive ||
|
||||
confirmedSpawnRuntimeFallback;
|
||||
const effectiveLivenessKind =
|
||||
confirmedOpenCodeRuntimeAlive &&
|
||||
|
|
@ -15154,7 +15179,9 @@ export class TeamProvisioningService {
|
|||
...(liveRuntimeMember?.metricsPid ? { runtimePid: liveRuntimeMember.metricsPid } : {}),
|
||||
...(liveRuntimeMember?.runtimeSessionId
|
||||
? { runtimeSessionId: liveRuntimeMember.runtimeSessionId }
|
||||
: {}),
|
||||
: runtimeAdapterSessionId
|
||||
? { runtimeSessionId: runtimeAdapterSessionId }
|
||||
: {}),
|
||||
...(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: {
|
||||
teamName: string;
|
||||
baseCwd: string;
|
||||
|
|
@ -21530,6 +21580,10 @@ export class TeamProvisioningService {
|
|||
leadProviderId: launchRequest.providerId,
|
||||
members: materialized.members,
|
||||
});
|
||||
const runtimeLaunchMembers = this.buildOpenCodeRuntimeAdapterLaunchMembers(
|
||||
launchRequest,
|
||||
effectiveMembers
|
||||
);
|
||||
const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName);
|
||||
const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName);
|
||||
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||
|
|
@ -21558,7 +21612,7 @@ export class TeamProvisioningService {
|
|||
|
||||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||
request: launchRequest,
|
||||
members: effectiveMembers,
|
||||
members: runtimeLaunchMembers,
|
||||
prompt: launchRequest.prompt?.trim() ?? '',
|
||||
sourceWarning: undefined,
|
||||
onProgress,
|
||||
|
|
@ -21594,6 +21648,10 @@ export class TeamProvisioningService {
|
|||
leadProviderId: launchRequest.providerId,
|
||||
members: materialized.members,
|
||||
});
|
||||
const runtimeLaunchMembers = this.buildOpenCodeRuntimeAdapterLaunchMembers(
|
||||
launchRequest,
|
||||
effectiveMembers
|
||||
);
|
||||
await this.updateConfigProjectPath(launchRequest.teamName, launchRequest.cwd);
|
||||
|
||||
let existingTasks: TeamTask[] = [];
|
||||
|
|
@ -21613,7 +21671,7 @@ export class TeamProvisioningService {
|
|||
|
||||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||
request: launchRequest,
|
||||
members: effectiveMembers,
|
||||
members: runtimeLaunchMembers,
|
||||
prompt,
|
||||
sourceWarning: warning,
|
||||
onProgress,
|
||||
|
|
@ -21901,6 +21959,7 @@ export class TeamProvisioningService {
|
|||
teamName: input.teamName,
|
||||
expectedMembers: input.expectedMembers.map((member) => member.name),
|
||||
bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name),
|
||||
includeLeadMembers: true,
|
||||
leadSessionId: result.leadSessionId,
|
||||
launchPhase: committedResult.launchPhase,
|
||||
members,
|
||||
|
|
@ -23462,13 +23521,21 @@ export class TeamProvisioningService {
|
|||
);
|
||||
if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) {
|
||||
if (isOpenCodeRecipient) {
|
||||
const diagnostic =
|
||||
'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.';
|
||||
logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`);
|
||||
const relayOptions: OpenCodeMemberInboxRelayOptions = {
|
||||
source: options.source ?? 'watcher',
|
||||
...(options.onlyMessageId ? { onlyMessageId: options.onlyMessageId } : {}),
|
||||
...(options.deliveryMetadata ? { deliveryMetadata: options.deliveryMetadata } : {}),
|
||||
};
|
||||
const relay = await this.relayOpenCodeMemberInboxMessages(
|
||||
teamName,
|
||||
inboxName,
|
||||
relayOptions
|
||||
);
|
||||
return {
|
||||
kind: 'opencode_lead_unsupported',
|
||||
relayed: 0,
|
||||
diagnostics: [diagnostic],
|
||||
kind: 'opencode_member',
|
||||
relayed: relay.relayed,
|
||||
diagnostics: relay.diagnostics,
|
||||
lastDelivery: relay.lastDelivery,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -251,9 +251,8 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
|||
break;
|
||||
}
|
||||
if (
|
||||
lastRelay.kind === 'opencode_lead_unsupported' ||
|
||||
(lastRelay.lastDelivery?.delivered === false &&
|
||||
lastRelay.lastDelivery.responsePending !== true)
|
||||
lastRelay.lastDelivery?.delivered === false &&
|
||||
lastRelay.lastDelivery.responsePending !== true
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,6 +223,11 @@ describe('Team agent launch matrix 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({
|
||||
alive: true,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -240,8 +245,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
})
|
||||
) as { expectedMembers: string[]; members: Record<string, unknown>; teamLaunchState: string };
|
||||
expect(launchState.teamLaunchState).toBe('clean_success');
|
||||
expect(launchState.expectedMembers).toEqual(['alice', 'bob']);
|
||||
expect(Object.keys(launchState.members)).toEqual(['alice', 'bob']);
|
||||
expect(launchState.expectedMembers).toEqual(['team-lead', 'alice', 'bob']);
|
||||
expect(Object.keys(launchState.members)).toEqual(['team-lead', 'alice', 'bob']);
|
||||
await expect(
|
||||
readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
|
|
@ -278,7 +283,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
||||
expect(adapter.bootstrapCheckins).toEqual([
|
||||
{
|
||||
memberName: 'team-lead',
|
||||
memberName: 'alice',
|
||||
runId,
|
||||
state: 'accepted',
|
||||
},
|
||||
|
|
@ -350,7 +355,6 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
|
||||
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
||||
expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([
|
||||
'team-lead',
|
||||
'alice',
|
||||
'bob',
|
||||
]);
|
||||
|
|
@ -11164,6 +11168,94 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
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 () => {
|
||||
const teamName = 'pure-opencode-delivery-permission-approval-safe-e2e';
|
||||
const adapter = new PermissionBlockedOpenCodeRuntimeAdapter();
|
||||
|
|
|
|||
|
|
@ -1664,6 +1664,66 @@ Messages:
|
|||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
const service = new TeamProvisioningService();
|
||||
|
|
@ -4170,7 +4230,7 @@ Messages:
|
|||
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 teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
|
|
@ -4198,19 +4258,50 @@ Messages:
|
|||
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.diagnostics?.join('\n')).toContain('opencode_lead_runtime_session_missing');
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||
'opencode_lead_runtime_session_missing'
|
||||
expect(relay).toMatchObject({
|
||||
kind: 'opencode_member',
|
||||
relayed: 1,
|
||||
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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue