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,
|
memberName: input.memberName,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
});
|
});
|
||||||
|
const nowMs = Date.parse(input.nowIso);
|
||||||
return {
|
return {
|
||||||
busy: true,
|
busy: true,
|
||||||
reason: 'busy_signal_error',
|
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 {
|
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;
|
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