diff --git a/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts b/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts index c53549be..242b7456 100644 --- a/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts +++ b/src/features/member-work-sync/main/infrastructure/CompositeMemberWorkSyncBusySignal.ts @@ -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(), }; } } diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts index cda1d47b..8bea8b0f 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts @@ -116,7 +116,13 @@ function hasSideEffectTool(tools: Set): 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; } diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts new file mode 100644 index 00000000..86a6d1a5 --- /dev/null +++ b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts @@ -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 { + 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 { + 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' }); + }); +}); diff --git a/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts new file mode 100644 index 00000000..73d4d653 --- /dev/null +++ b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts @@ -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 = {}) { + 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"'); + }); +});