test(team): cover opencode repair policies

This commit is contained in:
777genius 2026-05-06 19:22:14 +03:00
parent 937999c3e3
commit e96a74f4fa
4 changed files with 240 additions and 2 deletions

View file

@ -22,10 +22,13 @@ export class CompositeMemberWorkSyncBusySignal implements MemberWorkSyncBusySign
memberName: input.memberName,
error: String(error),
});
const nowMs = Date.parse(input.nowIso);
return {
busy: true,
reason: 'busy_signal_error',
retryAfterIso: new Date(Date.parse(input.nowIso) + 60_000).toISOString(),
retryAfterIso: new Date(
(Number.isFinite(nowMs) ? nowMs : Date.now()) + 60_000
).toISOString(),
};
}
}

View file

@ -116,7 +116,13 @@ function hasSideEffectTool(tools: Set<string>): boolean {
}
function taskIdList(taskRefs: TaskRef[]): string | null {
const ids = [...new Set(taskRefs.map((taskRef) => taskRef.taskId?.trim()).filter(Boolean))];
const ids = [
...new Set(
taskRefs
.map((taskRef) => taskRef.taskId?.trim())
.filter((taskId): taskId is string => Boolean(taskId))
),
];
return ids.length > 0 ? ids.map((id) => `"${id}"`).join(', ') : null;
}

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import { decideMemberWorkSyncNudgeActivation } from '@features/member-work-sync/core/application';
import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '@features/member-work-sync/contracts';
function status(overrides: Partial<MemberWorkSyncStatus> = {}): MemberWorkSyncStatus {
return {
teamName: 'team-a',
memberName: 'alice',
state: 'needs_sync',
agenda: {
teamName: 'team-a',
memberName: 'alice',
generatedAt: '2026-05-06T00:00:00.000Z',
fingerprint: 'agenda:v1:test',
items: [
{
taskId: 'task-1',
displayId: '#1',
subject: 'Do work',
kind: 'work',
assignee: 'alice',
priority: 'normal',
reason: 'assigned',
evidence: { status: 'in_progress' },
},
],
diagnostics: [],
},
shadow: {
reconciledBy: 'queue',
wouldNudge: true,
fingerprintChanged: false,
},
evaluatedAt: '2026-05-06T00:00:00.000Z',
diagnostics: [],
providerId: 'opencode',
...overrides,
};
}
function metrics(overrides: Partial<MemberWorkSyncTeamMetrics> = {}): MemberWorkSyncTeamMetrics {
return {
teamName: 'team-a',
generatedAt: '2026-05-06T00:00:00.000Z',
memberCount: 1,
stateCounts: {
caught_up: 0,
needs_sync: 1,
still_working: 0,
blocked: 0,
inactive: 0,
unknown: 0,
},
actionableItemCount: 1,
wouldNudgeCount: 1,
fingerprintChangeCount: 0,
reportAcceptedCount: 0,
reportRejectedCount: 0,
recentEvents: [],
phase2Readiness: {
state: 'collecting_shadow_data',
reasons: ['insufficient_status_events'],
thresholds: {
minObservedMembers: 1,
minStatusEvents: 20,
minObservationHours: 1,
maxWouldNudgesPerMemberHour: 2,
maxFingerprintChangesPerMemberHour: 1,
maxReportRejectionRate: 0.2,
},
rates: {
observationHours: 0,
statusEventCount: 1,
wouldNudgesPerMemberHour: 1,
fingerprintChangesPerMemberHour: 0,
reportRejectionRate: 0,
},
diagnostics: ['phase2_readiness:insufficient_status_events'],
},
...overrides,
};
}
describe('MemberWorkSyncNudgeActivationPolicy', () => {
it('activates OpenCode targeted nudges while shadow data is still collecting', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: status(),
metrics: metrics(),
})
).toEqual({ active: true, reason: 'opencode_targeted_shadow_collecting' });
});
it('keeps non-OpenCode providers behind phase2 readiness while collecting', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: status({ providerId: 'anthropic' }),
metrics: metrics(),
})
).toEqual({ active: false, reason: 'phase2_not_ready' });
});
it('does not activate when blocking safety metrics are present', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: status(),
metrics: metrics({
phase2Readiness: {
...metrics().phase2Readiness,
reasons: ['insufficient_status_events', 'would_nudge_rate_high'],
},
}),
})
).toEqual({ active: false, reason: 'blocking_metrics' });
});
it('keeps existing shadow_ready behavior for all providers', () => {
expect(
decideMemberWorkSyncNudgeActivation({
status: status({ providerId: 'codex' }),
metrics: metrics({
phase2Readiness: {
...metrics().phase2Readiness,
state: 'shadow_ready',
reasons: [],
},
}),
})
).toEqual({ active: true, reason: 'shadow_ready' });
});
});

View file

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
decideOpenCodePromptDeliveryRepair,
type OpenCodePromptDeliveryRepairInput,
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy';
function base(overrides: Partial<OpenCodePromptDeliveryRepairInput> = {}) {
return {
teamName: 'team-a',
memberName: 'alice',
inboxMessageId: 'msg-1',
replyRecipient: 'user',
messageKind: 'default',
actionMode: 'ask',
taskRefs: [],
status: 'responded',
responseState: 'empty_assistant_turn',
attempts: 1,
maxAttempts: 3,
pendingReason: 'empty_assistant_turn',
readAllowed: false,
inboxReadCommitted: false,
visibleReplyFound: false,
hasKnownProgressProof: false,
toolCallNames: [],
acceptanceUnknown: false,
hardFailureKind: 'none',
...overrides,
} satisfies OpenCodePromptDeliveryRepairInput;
}
describe('OpenCodePromptDeliveryRepairPolicy', () => {
it('adds no-assistant response repair without treating it as success', () => {
const decision = decideOpenCodePromptDeliveryRepair(base());
expect(decision.kind).toBe('no_assistant_response');
expect(decision.retryable).toBe(true);
expect(decision.controlText).toContain('You must not end this turn empty.');
expect(decision.controlText).toContain('relayOfMessageId="msg-1"');
});
it('requires member work sync status and report for work-sync nudges', () => {
const decision = decideOpenCodePromptDeliveryRepair(
base({
messageKind: 'member_work_sync_nudge',
actionMode: 'do',
taskRefs: [{ taskId: 'task-1', displayId: '#1', teamName: 'team-a' }],
responseState: 'responded_plain_text',
pendingReason: 'plain_text_ack_only_still_requires_answer',
})
);
expect(decision.kind).toBe('work_sync_report_required');
expect(decision.controlText).toContain('member_work_sync_status');
expect(decision.controlText).toContain('member_work_sync_report');
expect(decision.controlText).toContain('"task-1"');
expect(decision.controlText).not.toContain('reportToken=');
});
it('does not repair terminal, permission, or session failures', () => {
expect(
decideOpenCodePromptDeliveryRepair(
base({ status: 'failed_terminal', responseState: 'empty_assistant_turn' })
)
).toMatchObject({ kind: 'none', retryable: false });
expect(
decideOpenCodePromptDeliveryRepair(
base({ responseState: 'permission_blocked', hardFailureKind: 'permission' })
)
).toMatchObject({ kind: 'none', retryable: false });
expect(
decideOpenCodePromptDeliveryRepair(
base({ responseState: 'session_error', hardFailureKind: 'session' })
)
).toMatchObject({ kind: 'none', retryable: false });
});
it('does not ask to repeat side-effect tools after tool_error', () => {
const decision = decideOpenCodePromptDeliveryRepair(
base({
responseState: 'tool_error',
pendingReason: 'tool_error_without_required_delivery_proof',
toolCallNames: ['bash'],
actionMode: 'do',
taskRefs: [{ taskId: 'task-2', displayId: '#2', teamName: 'team-a' }],
})
);
expect(decision.kind).toBe('progress_proof_required');
expect(decision.controlText).toContain('Do not repeat side-effectful commands');
expect(decision.controlText).toContain('"task-2"');
});
});