test(team): cover opencode repair policies
This commit is contained in:
parent
937999c3e3
commit
e96a74f4fa
4 changed files with 240 additions and 2 deletions
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue