fix(opencode): harden team delivery reconciliation
This commit is contained in:
parent
3ed6dd3159
commit
bcbcb621c2
5 changed files with 579 additions and 122 deletions
|
|
@ -5,6 +5,7 @@ import {
|
|||
type CodexNativeImageArgPart,
|
||||
type OpenCodeFilePart,
|
||||
} from '@features/agent-attachments/main';
|
||||
import { AgentAttachmentError } from '@features/agent-attachments/core/domain';
|
||||
import {
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicRuntimeSelection,
|
||||
|
|
@ -322,6 +323,15 @@ import type {
|
|||
} from './runtime';
|
||||
import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main';
|
||||
|
||||
type OpenCodeRuntimeMessageAdapter = TeamLaunchRuntimeAdapter & {
|
||||
sendMessageToMember(
|
||||
input: OpenCodeTeamRuntimeMessageInput
|
||||
): Promise<OpenCodeTeamRuntimeMessageResult>;
|
||||
observeMessageDelivery?(
|
||||
input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null }
|
||||
): Promise<OpenCodeTeamRuntimeMessageResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Kill a team CLI process using SIGKILL (uncatchable).
|
||||
*
|
||||
|
|
@ -6739,28 +6749,12 @@ export class TeamProvisioningService {
|
|||
return this.runtimeAdapterRegistry.get('opencode');
|
||||
}
|
||||
|
||||
private getOpenCodeRuntimeMessageAdapter():
|
||||
| (TeamLaunchRuntimeAdapter & {
|
||||
sendMessageToMember(
|
||||
input: OpenCodeTeamRuntimeMessageInput
|
||||
): Promise<OpenCodeTeamRuntimeMessageResult>;
|
||||
observeMessageDelivery?(
|
||||
input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null }
|
||||
): Promise<OpenCodeTeamRuntimeMessageResult>;
|
||||
})
|
||||
| null {
|
||||
private getOpenCodeRuntimeMessageAdapter(): OpenCodeRuntimeMessageAdapter | null {
|
||||
const adapter = this.getOpenCodeRuntimeAdapter();
|
||||
if (!adapter || !('sendMessageToMember' in adapter)) {
|
||||
return null;
|
||||
}
|
||||
return adapter as TeamLaunchRuntimeAdapter & {
|
||||
sendMessageToMember(
|
||||
input: OpenCodeTeamRuntimeMessageInput
|
||||
): Promise<OpenCodeTeamRuntimeMessageResult>;
|
||||
observeMessageDelivery?(
|
||||
input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null }
|
||||
): Promise<OpenCodeTeamRuntimeMessageResult>;
|
||||
};
|
||||
return adapter as OpenCodeRuntimeMessageAdapter;
|
||||
}
|
||||
|
||||
private resolveRuntimeRecipientProviderIdFromSources(
|
||||
|
|
@ -7844,6 +7838,161 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private async observeOpenCodeDirectUserDeliveryInlineIfNeeded(input: {
|
||||
adapter: OpenCodeRuntimeMessageAdapter;
|
||||
ledger: OpenCodePromptDeliveryLedgerStore;
|
||||
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId: string;
|
||||
cwd: string;
|
||||
text: string;
|
||||
messageId: string;
|
||||
runtimeRunId?: string | null;
|
||||
replyRecipient?: string | null;
|
||||
actionMode?: AgentActionMode;
|
||||
messageKind?: OpenCodeTeamRuntimeMessageInput['messageKind'];
|
||||
taskRefs?: TaskRef[];
|
||||
promptAccepted: boolean;
|
||||
visibleReply?: OpenCodeVisibleReplyProof | null;
|
||||
}): Promise<{
|
||||
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
||||
visibleReply: OpenCodeVisibleReplyProof | null;
|
||||
}> {
|
||||
let ledgerRecord = input.ledgerRecord;
|
||||
let visibleReply = input.visibleReply ?? null;
|
||||
const observeMessageDelivery = input.adapter.observeMessageDelivery;
|
||||
const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||
responseState: ledgerRecord.responseState,
|
||||
actionMode: ledgerRecord.actionMode ?? undefined,
|
||||
taskRefs: ledgerRecord.taskRefs,
|
||||
visibleReply,
|
||||
ledgerRecord,
|
||||
});
|
||||
const shouldObserveInline =
|
||||
observeMessageDelivery &&
|
||||
input.promptAccepted &&
|
||||
this.isOpenCodeDirectUserPromptDelivery(ledgerRecord) &&
|
||||
(ledgerRecord.source === 'manual' ||
|
||||
(ledgerRecord.responseState === 'tool_error' &&
|
||||
this.hasOpenCodeObservedMessageSendToolCall(ledgerRecord))) &&
|
||||
!readAllowed &&
|
||||
!visibleReply &&
|
||||
!ledgerRecord.visibleReplyMessageId;
|
||||
|
||||
if (!shouldObserveInline || !observeMessageDelivery) {
|
||||
return { ledgerRecord, visibleReply };
|
||||
}
|
||||
|
||||
for (let inlineObserveAttempt = 1; inlineObserveAttempt <= 4; inlineObserveAttempt += 1) {
|
||||
await sleep(OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS);
|
||||
let observed: OpenCodeTeamRuntimeMessageResult;
|
||||
try {
|
||||
observed = await observeMessageDelivery.call(input.adapter, {
|
||||
...(input.runtimeRunId ? { runId: input.runtimeRunId } : {}),
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
memberName: input.memberName,
|
||||
cwd: input.cwd,
|
||||
text: input.text,
|
||||
messageId: input.messageId,
|
||||
replyRecipient: input.replyRecipient ?? undefined,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
taskRefs: input.taskRefs,
|
||||
prePromptCursor: ledgerRecord.prePromptCursor,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = `opencode_direct_user_delivery_inline_observe_failed: ${getErrorMessage(
|
||||
error
|
||||
)}`;
|
||||
ledgerRecord = await input.ledger.applyObservation({
|
||||
id: ledgerRecord.id,
|
||||
responseObservation: {
|
||||
state: 'reconcile_failed',
|
||||
deliveredUserMessageId: null,
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason,
|
||||
},
|
||||
diagnostics: [
|
||||
`opencode_direct_user_delivery_inline_observe_attempt_${inlineObserveAttempt}`,
|
||||
reason,
|
||||
],
|
||||
observedAt: nowIso(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
laneId: input.laneId,
|
||||
runId: input.runtimeRunId,
|
||||
runtimeSessionId: observed.sessionId,
|
||||
runtimePid: observed.runtimePid,
|
||||
reason: 'opencode_delivery_inline_observe_runtime_pid_observed',
|
||||
});
|
||||
const observedResponse = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||
observed.responseObservation
|
||||
);
|
||||
const hadMessageSendToolError = this.hasOpenCodeObservedMessageSendToolCall(ledgerRecord);
|
||||
ledgerRecord = await input.ledger.applyObservation({
|
||||
id: ledgerRecord.id,
|
||||
responseObservation: observedResponse ?? {
|
||||
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: [
|
||||
`opencode_direct_user_delivery_inline_observe_attempt_${inlineObserveAttempt}`,
|
||||
...(hadMessageSendToolError ? ['opencode_message_send_tool_error_inline_observe'] : []),
|
||||
...observed.diagnostics,
|
||||
],
|
||||
observedAt: nowIso(),
|
||||
});
|
||||
const proof = await this.applyOpenCodeVisibleDestinationProof({
|
||||
ledger: input.ledger,
|
||||
ledgerRecord,
|
||||
teamName: input.teamName,
|
||||
replyRecipient: input.replyRecipient,
|
||||
memberName: input.memberName,
|
||||
});
|
||||
ledgerRecord = proof.ledgerRecord;
|
||||
visibleReply = proof.visibleReply;
|
||||
const materialized = await this.materializeOpenCodePlainTextReplyIfNeeded({
|
||||
ledger: input.ledger,
|
||||
ledgerRecord,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
visibleReply,
|
||||
});
|
||||
ledgerRecord = materialized.ledgerRecord;
|
||||
visibleReply = materialized.visibleReply;
|
||||
const observedReadAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||
responseState: ledgerRecord.responseState,
|
||||
actionMode: ledgerRecord.actionMode ?? undefined,
|
||||
taskRefs: ledgerRecord.taskRefs,
|
||||
visibleReply,
|
||||
ledgerRecord,
|
||||
});
|
||||
if (observedReadAllowed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { ledgerRecord, visibleReply };
|
||||
}
|
||||
|
||||
private getOpenCodeDeliveryWatchdogKey(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
|
|
@ -8788,14 +8937,27 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
const openCodeFileParts: OpenCodeFilePart[] =
|
||||
input.attachments?.length && laneIdentity.laneOwnerProviderId === 'opencode'
|
||||
? buildOpenCodeAttachmentDeliveryParts({
|
||||
text: input.text,
|
||||
model: metaMember?.model ?? configMember?.model ?? '',
|
||||
attachments: input.attachments,
|
||||
}).fileParts
|
||||
: [];
|
||||
let openCodeFileParts: OpenCodeFilePart[] = [];
|
||||
if (input.attachments?.length && laneIdentity.laneOwnerProviderId === 'opencode') {
|
||||
try {
|
||||
openCodeFileParts = buildOpenCodeAttachmentDeliveryParts({
|
||||
text: input.text,
|
||||
model: metaMember?.model ?? configMember?.model ?? '',
|
||||
attachments: input.attachments,
|
||||
}).fileParts;
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof AgentAttachmentError
|
||||
? error.code
|
||||
: 'opencode_attachment_delivery_prepare_failed';
|
||||
const diagnostic = `opencode_attachment_delivery_prepare_failed: ${getErrorMessage(error)}`;
|
||||
return {
|
||||
delivered: false,
|
||||
reason,
|
||||
diagnostics: [diagnostic],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) {
|
||||
const result = await adapter.sendMessageToMember({
|
||||
|
|
@ -9251,6 +9413,25 @@ export class TeamProvisioningService {
|
|||
visibleReply: proof.visibleReply,
|
||||
});
|
||||
ledgerRecord = proof.ledgerRecord;
|
||||
proof = await this.observeOpenCodeDirectUserDeliveryInlineIfNeeded({
|
||||
adapter,
|
||||
ledger,
|
||||
ledgerRecord,
|
||||
teamName,
|
||||
memberName: canonicalMemberName,
|
||||
laneId: laneIdentity.laneId,
|
||||
cwd,
|
||||
text: input.text,
|
||||
messageId: ledgerRecord.inboxMessageId,
|
||||
runtimeRunId,
|
||||
replyRecipient: input.replyRecipient,
|
||||
actionMode: input.actionMode,
|
||||
messageKind: input.messageKind,
|
||||
taskRefs: input.taskRefs,
|
||||
promptAccepted,
|
||||
visibleReply: proof.visibleReply,
|
||||
});
|
||||
ledgerRecord = proof.ledgerRecord;
|
||||
this.logOpenCodePromptDeliveryEvent(
|
||||
promptAccepted
|
||||
? ledgerRecord.status === 'unanswered'
|
||||
|
|
@ -20040,7 +20221,7 @@ export class TeamProvisioningService {
|
|||
|
||||
private asOpenCodeAttachmentPayload(meta: AttachmentMeta): AttachmentPayload | null {
|
||||
const data = (meta as Partial<AttachmentPayload>).data;
|
||||
return typeof data === 'string' && data.length > 0
|
||||
return typeof data === 'string'
|
||||
? {
|
||||
...meta,
|
||||
data,
|
||||
|
|
@ -20284,6 +20465,7 @@ export class TeamProvisioningService {
|
|||
const effectiveTaskRefs =
|
||||
existingRecord?.taskRefs ?? options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? [];
|
||||
const effectiveSource = existingRecord?.source ?? options.source ?? 'watcher';
|
||||
result.attempted += 1;
|
||||
const attachmentPayloads = await this.resolveOpenCodeInboxAttachmentPayloads({
|
||||
teamName,
|
||||
message,
|
||||
|
|
@ -20298,7 +20480,6 @@ export class TeamProvisioningService {
|
|||
};
|
||||
break;
|
||||
}
|
||||
result.attempted += 1;
|
||||
const delivery = await this.deliverOpenCodeMemberMessage(teamName, {
|
||||
memberName,
|
||||
text: message.text,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"generatedAt": "2026-05-08T21:28:15.433Z",
|
||||
"generatedAt": "2026-05-08T22:48:31.416Z",
|
||||
"runsPerModel": 1,
|
||||
"qualification": {
|
||||
"minimumAverageScore": 80,
|
||||
|
|
@ -25,8 +25,8 @@
|
|||
"runtimeTransportFailures": 0,
|
||||
"modelBehaviorFailures": 0,
|
||||
"harnessFailures": 0,
|
||||
"p50DurationMs": 173403,
|
||||
"p95DurationMs": 173403,
|
||||
"p50DurationMs": 118757,
|
||||
"p95DurationMs": 118757,
|
||||
"stagePassRates": {
|
||||
"launchBootstrap": {
|
||||
"passed": 1,
|
||||
|
|
@ -217,17 +217,17 @@
|
|||
"outcome": "passed",
|
||||
"failureCategory": "none",
|
||||
"primaryFailure": null,
|
||||
"durationMs": 173403,
|
||||
"durationMs": 118757,
|
||||
"hardFailure": false,
|
||||
"stageDurationsMs": {
|
||||
"setup": 191,
|
||||
"launchBootstrap": 24869,
|
||||
"materializeTasks": 33,
|
||||
"directReply": 11982,
|
||||
"peerRelayAB": 24984,
|
||||
"peerRelayBC": 24320,
|
||||
"concurrentReplies": 77988,
|
||||
"hygiene": 2
|
||||
"setup": 225,
|
||||
"launchBootstrap": 20591,
|
||||
"materializeTasks": 36,
|
||||
"directReply": 14820,
|
||||
"peerRelayAB": 32039,
|
||||
"peerRelayBC": 27306,
|
||||
"concurrentReplies": 15426,
|
||||
"hygiene": 1
|
||||
},
|
||||
"stageFailures": {},
|
||||
"taskRefChecks": {
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
"latencyStable": true
|
||||
},
|
||||
"diagnostics": [
|
||||
"runId=55b87bf5-abe4-4c21-9b98-db1ed8c22cfb"
|
||||
"runId=44f5aa40-e169-49ed-9ea3-4c72aaf4a9f1"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# OpenCode Model Gauntlet Results
|
||||
|
||||
Generated: 2026-05-08T21:28:15.433Z
|
||||
Generated: 2026-05-08T22:48:31.416Z
|
||||
|
||||
Runs per model: 1
|
||||
Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0
|
||||
|
|
@ -13,7 +13,7 @@ Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC
|
|||
|
||||
| Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 |
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| `opencode/big-pickle` | Recommended | low | 100 | 100 | 0 | 100 | 100 | 1/1 | 1/1 | cleanTranscript 1/1 (100%) | concurrentBob 1/1 (100%) | none | - | 0 | 0 | 0 | 0 | 173403ms | 173403ms |
|
||||
| `opencode/big-pickle` | Recommended | low | 100 | 100 | 0 | 100 | 100 | 1/1 | 1/1 | cleanTranscript 1/1 (100%) | concurrentBob 1/1 (100%) | none | - | 0 | 0 | 0 | 0 | 118757ms | 118757ms |
|
||||
|
||||
## opencode/big-pickle
|
||||
|
||||
|
|
@ -33,5 +33,5 @@ Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0.
|
|||
|
||||
| Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics |
|
||||
| ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- |
|
||||
| 1 | passed | none | 100 | yes | 173403ms | - | concurrentReplies:77988ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:ok, concurrentTom:ok | - | runId=55b87bf5-abe4-4c21-9b98-db1ed8c22cfb |
|
||||
| 1 | passed | none | 100 | yes | 118757ms | - | peerRelayAB:32039ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:ok, concurrentTom:ok | - | runId=44f5aa40-e169-49ed-9ea3-4c72aaf4a9f1 |
|
||||
|
||||
|
|
|
|||
|
|
@ -376,6 +376,66 @@ describe('OpenCode semantic model gauntlet report helpers', () => {
|
|||
expect(markdown).toContain('Protocol totals: badMessages=1');
|
||||
});
|
||||
|
||||
it('does not count token prefixes as duplicate user replies', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-gauntlet-hygiene-'));
|
||||
const tempClaudeRoot = path.join(tempDir, '.claude');
|
||||
const teamName = 'prefix-token-team';
|
||||
try {
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes');
|
||||
await fs.mkdir(inboxDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(inboxDir, 'user.json'),
|
||||
`${JSON.stringify(
|
||||
[
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'user',
|
||||
text: 'GAUNTLET_DIRECT_BOB_OK_1',
|
||||
timestamp: '2026-04-26T00:00:01.000Z',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'user',
|
||||
text: 'GAUNTLET_DIRECT_BOB_OK_11',
|
||||
timestamp: '2026-04-26T00:00:02.000Z',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
from: 'bob',
|
||||
to: 'user',
|
||||
text: 'GAUNTLET_DIRECT_BOB_OK_12',
|
||||
timestamp: '2026-04-26T00:00:03.000Z',
|
||||
read: false,
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
inspectMessageHygiene({
|
||||
teamName,
|
||||
members: ['bob'],
|
||||
expectedUserReplyTokens: [
|
||||
'GAUNTLET_DIRECT_BOB_OK_1',
|
||||
'GAUNTLET_DIRECT_BOB_OK_11',
|
||||
'GAUNTLET_DIRECT_BOB_OK_12',
|
||||
],
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
noDuplicateTokens: true,
|
||||
duplicateOrMissingTokens: [],
|
||||
});
|
||||
} finally {
|
||||
setClaudeBasePathOverride(null);
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('ranks failure impact by lost weighted score instead of raw failure count only', () => {
|
||||
const model = createTestGauntletModel({
|
||||
runs: [
|
||||
|
|
@ -1131,6 +1191,15 @@ function hasTaskRef(message: InboxMessage, expected: TaskRef): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function textContainsExactToken(text: string | undefined, token: string): boolean {
|
||||
const pattern = new RegExp(`(^|[^A-Za-z0-9_])${escapeRegExp(token)}($|[^A-Za-z0-9_])`);
|
||||
return pattern.test(text ?? '');
|
||||
}
|
||||
|
||||
async function inspectMessageHygiene(input: {
|
||||
teamName: string;
|
||||
members: string[];
|
||||
|
|
@ -1167,7 +1236,8 @@ async function inspectMessageHygiene(input: {
|
|||
diagnostics.push(`badMessages=${JSON.stringify(badMessages.slice(0, 5))}`);
|
||||
}
|
||||
const duplicateTokens = input.expectedUserReplyTokens.filter((token) => {
|
||||
const count = userMessages.filter((message) => message.text?.includes(token)).length;
|
||||
const count = userMessages.filter((message) => textContainsExactToken(message.text, token))
|
||||
.length;
|
||||
return count !== 1;
|
||||
});
|
||||
if (duplicateTokens.length > 0) {
|
||||
|
|
|
|||
|
|
@ -439,6 +439,7 @@ async function configureOpenCodeBobDeliveryService(input: {
|
|||
svc: TeamProvisioningService;
|
||||
sendMessageToMember: ReturnType<typeof vi.fn>;
|
||||
observeMessageDelivery?: ReturnType<typeof vi.fn>;
|
||||
memberModel?: string;
|
||||
}): Promise<void> {
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
|
|
@ -469,7 +470,11 @@ async function configureOpenCodeBobDeliveryService(input: {
|
|||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: input.memberModel ?? 'minimax-m2.5-free',
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
|
|
@ -484,7 +489,7 @@ async function configureOpenCodeBobDeliveryService(input: {
|
|||
{
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
model: input.memberModel ?? 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
|
@ -5156,15 +5161,17 @@ describe('TeamProvisioningService', () => {
|
|||
delivered: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith({
|
||||
runId: 'opencode-run-bob',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
text: 'hello bob',
|
||||
messageId: 'msg-1',
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'opencode-run-bob',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
text: 'hello bob',
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('persists verified OpenCode bridge runtime pids so member cards can show memory', async () => {
|
||||
|
|
@ -6082,7 +6089,7 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('observes OpenCode message_send tool errors quickly before retrying duplicate prompts', async () => {
|
||||
it('waits through delayed OpenCode message_send tool-error fallback inline', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const taskRef = {
|
||||
taskId: 'task-tool-error-observe-first',
|
||||
|
|
@ -6108,36 +6115,60 @@ describe('TeamProvisioningService', () => {
|
|||
},
|
||||
diagnostics: ['OpenCode tool failed without output'],
|
||||
}));
|
||||
const observeMessageDelivery = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
let observeAttempts = 0;
|
||||
const opencodeAdapter = {
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
responseObservation: {
|
||||
state: 'responded_plain_text',
|
||||
deliveredUserMessageId: 'oc-user-tool-error-observe',
|
||||
assistantMessageId: 'oc-assistant-plain-fallback',
|
||||
toolCallNames: ['agent-teams_message_send'],
|
||||
visibleMessageToolCallId: 'call-message-send-observe',
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: 'plain_assistant_text',
|
||||
latestAssistantPreview: 'GAUNTLET_OBSERVE_FIRST_OK_1',
|
||||
reason: 'assistant_replied_with_plain_text',
|
||||
},
|
||||
diagnostics: ['Observed OpenCode plain-text fallback after message_send tool error'],
|
||||
}));
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
observeMessageDelivery: vi.fn(async function (
|
||||
this: unknown,
|
||||
input: Record<string, unknown>
|
||||
) {
|
||||
expect(this).toBe(opencodeAdapter);
|
||||
observeAttempts += 1;
|
||||
return {
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
observeMessageDelivery,
|
||||
} as any,
|
||||
])
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
responseObservation:
|
||||
observeAttempts === 1
|
||||
? {
|
||||
state: 'pending',
|
||||
deliveredUserMessageId: 'oc-user-tool-error-observe',
|
||||
assistantMessageId: null,
|
||||
toolCallNames: ['agent-teams_message_send'],
|
||||
visibleMessageToolCallId: 'call-message-send-observe',
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'assistant_reply_not_visible_yet',
|
||||
}
|
||||
: {
|
||||
state: 'responded_plain_text',
|
||||
deliveredUserMessageId: 'oc-user-tool-error-observe',
|
||||
assistantMessageId: 'oc-assistant-plain-fallback',
|
||||
toolCallNames: ['agent-teams_message_send'],
|
||||
visibleMessageToolCallId: 'call-message-send-observe',
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: 'plain_assistant_text',
|
||||
latestAssistantPreview: 'GAUNTLET_OBSERVE_FIRST_OK_1',
|
||||
reason: 'assistant_replied_with_plain_text',
|
||||
},
|
||||
diagnostics: [
|
||||
observeAttempts === 1
|
||||
? 'OpenCode assistant reply not visible yet'
|
||||
: 'Observed OpenCode plain-text fallback after message_send tool error',
|
||||
],
|
||||
};
|
||||
}),
|
||||
} as any;
|
||||
const observeMessageDelivery = opencodeAdapter.observeMessageDelivery;
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([opencodeAdapter])
|
||||
);
|
||||
|
||||
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
|
||||
|
|
@ -6190,45 +6221,14 @@ describe('TeamProvisioningService', () => {
|
|||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
responseState: 'tool_error',
|
||||
reason: 'tool_error_without_required_delivery_proof',
|
||||
});
|
||||
|
||||
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 }>;
|
||||
};
|
||||
const nextAttemptAt = ledgerEnvelope.data[0]?.nextAttemptAt;
|
||||
expect(nextAttemptAt).toBeTruthy();
|
||||
const delayMs = Date.parse(nextAttemptAt!) - Date.now();
|
||||
expect(delayMs).toBeGreaterThanOrEqual(0);
|
||||
expect(delayMs).toBeLessThanOrEqual(5_000);
|
||||
|
||||
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: 'Reply to user with GAUNTLET_OBSERVE_FIRST_OK_1.',
|
||||
messageId: 'msg-tool-error-observe-first',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'ask',
|
||||
taskRefs: [taskRef],
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: false,
|
||||
responseState: 'responded_plain_text',
|
||||
visibleReplyCorrelation: 'plain_assistant_text',
|
||||
diagnostics: expect.arrayContaining([
|
||||
'opencode_message_send_tool_error_inline_observe',
|
||||
'opencode_direct_user_delivery_inline_observe_attempt_2',
|
||||
'opencode_plain_text_reply_materialized_to_user_inbox',
|
||||
]),
|
||||
});
|
||||
|
||||
const userInbox = JSON.parse(
|
||||
|
|
@ -6247,13 +6247,219 @@ describe('TeamProvisioningService', () => {
|
|||
taskRefs: [taskRef],
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
|
||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(1);
|
||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(2);
|
||||
expect(observeMessageDelivery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: 'msg-tool-error-observe-first',
|
||||
prePromptCursor: 'cursor-before-tool-error',
|
||||
})
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it('keeps accepted OpenCode delivery retryable when inline observe throws', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const taskRef = {
|
||||
taskId: 'task-tool-error-observe-throws',
|
||||
displayId: 'obsthrow',
|
||||
teamName: 'team-a',
|
||||
};
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
prePromptCursor: 'cursor-before-observe-throws',
|
||||
responseObservation: {
|
||||
state: 'tool_error',
|
||||
deliveredUserMessageId: 'oc-user-observe-throws',
|
||||
assistantMessageId: 'oc-assistant-observe-throws',
|
||||
toolCallNames: ['agent-teams_message_send'],
|
||||
visibleMessageToolCallId: 'call-message-send-observe-throws',
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'message_send_tool_error_without_visible_reply_proof',
|
||||
},
|
||||
diagnostics: ['OpenCode tool failed without output'],
|
||||
}));
|
||||
const observeMessageDelivery = vi.fn(async () => {
|
||||
throw new Error('observe bridge fs write failed');
|
||||
});
|
||||
await configureOpenCodeBobDeliveryService({
|
||||
svc,
|
||||
sendMessageToMember,
|
||||
observeMessageDelivery,
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'Reply to user with GAUNTLET_OBSERVE_THROW_OK_1.',
|
||||
messageId: 'msg-tool-error-observe-throws',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'ask',
|
||||
taskRefs: [taskRef],
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
responseState: 'reconcile_failed',
|
||||
ledgerStatus: 'retry_scheduled',
|
||||
reason: expect.stringContaining('opencode_direct_user_delivery_inline_observe_failed'),
|
||||
diagnostics: expect.arrayContaining([
|
||||
'opencode_direct_user_delivery_inline_observe_attempt_1',
|
||||
expect.stringContaining('observe bridge fs write failed'),
|
||||
]),
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
|
||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(1);
|
||||
}, 10_000);
|
||||
|
||||
it('resolves stored attachment payloads for OpenCode inbox relay before delivery', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
responseObservation: {
|
||||
state: 'responded_plain_text',
|
||||
deliveredUserMessageId: 'oc-user-attachment',
|
||||
assistantMessageId: 'oc-assistant-attachment',
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: 'I reviewed the attached image and can proceed.',
|
||||
reason: 'assistant_replied_with_plain_text',
|
||||
},
|
||||
diagnostics: [],
|
||||
}));
|
||||
await configureOpenCodeBobDeliveryService({
|
||||
svc,
|
||||
sendMessageToMember,
|
||||
memberModel: 'openai/gpt-5.4-mini',
|
||||
});
|
||||
await (svc as any).attachmentStore.saveAttachments('team-a', 'msg-image-attachment', [
|
||||
{
|
||||
id: 'att-1',
|
||||
filename: 'diagram.png',
|
||||
mimeType: 'image/png',
|
||||
size: 5,
|
||||
data: 'aW1nMQ==',
|
||||
},
|
||||
]);
|
||||
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
|
||||
await fsPromises.mkdir(inboxDir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
path.join(inboxDir, 'bob.json'),
|
||||
`${JSON.stringify(
|
||||
[
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'bob',
|
||||
text: 'Review this image.',
|
||||
timestamp: '2026-04-25T10:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'msg-image-attachment',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-1',
|
||||
filename: 'diagram.png',
|
||||
mimeType: 'image/png',
|
||||
size: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.relayOpenCodeMemberInboxMessages('team-a', 'bob', {
|
||||
onlyMessageId: 'msg-image-attachment',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
attempted: 1,
|
||||
delivered: 1,
|
||||
failed: 0,
|
||||
relayed: 1,
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: 'msg-image-attachment',
|
||||
fileParts: [
|
||||
{
|
||||
type: 'file',
|
||||
mime: 'image/png',
|
||||
url: 'data:image/png;base64,aW1nMQ==',
|
||||
filename: 'diagram.png',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps OpenCode inbox relay unread when attachment payload data is unavailable', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn();
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
|
||||
await fsPromises.mkdir(inboxDir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
path.join(inboxDir, 'bob.json'),
|
||||
`${JSON.stringify(
|
||||
[
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'bob',
|
||||
text: 'Review this image.',
|
||||
timestamp: '2026-04-25T10:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'msg-missing-attachment',
|
||||
attachments: [
|
||||
{
|
||||
id: 'missing-att',
|
||||
filename: 'missing.png',
|
||||
mimeType: 'image/png',
|
||||
size: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.relayOpenCodeMemberInboxMessages('team-a', 'bob', {
|
||||
onlyMessageId: 'msg-missing-attachment',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 1,
|
||||
relayed: 0,
|
||||
lastDelivery: {
|
||||
delivered: false,
|
||||
reason: 'opencode_inbox_attachment_payload_unavailable: missing-att',
|
||||
},
|
||||
});
|
||||
expect(sendMessageToMember).not.toHaveBeenCalled();
|
||||
|
||||
const inbox = JSON.parse(await fsPromises.readFile(path.join(inboxDir, 'bob.json'), 'utf8'));
|
||||
expect(inbox[0]).toMatchObject({
|
||||
messageId: 'msg-missing-attachment',
|
||||
read: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats OpenCode send bridge timeouts as acceptance-unknown observe-first records', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue