|
| 1 | +import { Loader2, MinusCircle, Moon, XCircle } from 'lucide-react' |
| 2 | +import { |
| 3 | + BaseClientTool, |
| 4 | + type BaseClientToolMetadata, |
| 5 | + ClientToolCallState, |
| 6 | +} from '@/lib/copilot/tools/client/base-tool' |
| 7 | +import { createLogger } from '@/lib/logs/console/logger' |
| 8 | + |
| 9 | +/** Maximum sleep duration in seconds (3 minutes) */ |
| 10 | +const MAX_SLEEP_SECONDS = 180 |
| 11 | + |
| 12 | +/** Track sleep start times for calculating elapsed time on wake */ |
| 13 | +const sleepStartTimes: Record<string, number> = {} |
| 14 | + |
| 15 | +interface SleepArgs { |
| 16 | + seconds?: number |
| 17 | +} |
| 18 | + |
| 19 | +/** |
| 20 | + * Format seconds into a human-readable duration string |
| 21 | + */ |
| 22 | +function formatDuration(seconds: number): string { |
| 23 | + if (seconds >= 60) { |
| 24 | + return `${Math.round(seconds / 60)} minute${seconds >= 120 ? 's' : ''}` |
| 25 | + } |
| 26 | + return `${seconds} second${seconds !== 1 ? 's' : ''}` |
| 27 | +} |
| 28 | + |
| 29 | +export class SleepClientTool extends BaseClientTool { |
| 30 | + static readonly id = 'sleep' |
| 31 | + |
| 32 | + constructor(toolCallId: string) { |
| 33 | + super(toolCallId, SleepClientTool.id, SleepClientTool.metadata) |
| 34 | + } |
| 35 | + |
| 36 | + static readonly metadata: BaseClientToolMetadata = { |
| 37 | + displayNames: { |
| 38 | + [ClientToolCallState.generating]: { text: 'Preparing to sleep', icon: Loader2 }, |
| 39 | + [ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 }, |
| 40 | + [ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 }, |
| 41 | + [ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon }, |
| 42 | + [ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle }, |
| 43 | + [ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle }, |
| 44 | + [ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle }, |
| 45 | + [ClientToolCallState.background]: { text: 'Resumed', icon: Moon }, |
| 46 | + }, |
| 47 | + // No interrupt - auto-execute immediately |
| 48 | + getDynamicText: (params, state) => { |
| 49 | + const seconds = params?.seconds |
| 50 | + if (typeof seconds === 'number' && seconds > 0) { |
| 51 | + const displayTime = formatDuration(seconds) |
| 52 | + switch (state) { |
| 53 | + case ClientToolCallState.success: |
| 54 | + return `Slept for ${displayTime}` |
| 55 | + case ClientToolCallState.executing: |
| 56 | + case ClientToolCallState.pending: |
| 57 | + return `Sleeping for ${displayTime}` |
| 58 | + case ClientToolCallState.generating: |
| 59 | + return `Preparing to sleep for ${displayTime}` |
| 60 | + case ClientToolCallState.error: |
| 61 | + return `Failed to sleep for ${displayTime}` |
| 62 | + case ClientToolCallState.rejected: |
| 63 | + return `Skipped sleeping for ${displayTime}` |
| 64 | + case ClientToolCallState.aborted: |
| 65 | + return `Aborted sleeping for ${displayTime}` |
| 66 | + case ClientToolCallState.background: { |
| 67 | + // Calculate elapsed time from when sleep started |
| 68 | + const elapsedSeconds = params?._elapsedSeconds |
| 69 | + if (typeof elapsedSeconds === 'number' && elapsedSeconds > 0) { |
| 70 | + return `Resumed after ${formatDuration(Math.round(elapsedSeconds))}` |
| 71 | + } |
| 72 | + return 'Resumed early' |
| 73 | + } |
| 74 | + } |
| 75 | + } |
| 76 | + return undefined |
| 77 | + }, |
| 78 | + } |
| 79 | + |
| 80 | + /** |
| 81 | + * Get elapsed seconds since sleep started |
| 82 | + */ |
| 83 | + getElapsedSeconds(): number { |
| 84 | + const startTime = sleepStartTimes[this.toolCallId] |
| 85 | + if (!startTime) return 0 |
| 86 | + return (Date.now() - startTime) / 1000 |
| 87 | + } |
| 88 | + |
| 89 | + async handleReject(): Promise<void> { |
| 90 | + await super.handleReject() |
| 91 | + this.setState(ClientToolCallState.rejected) |
| 92 | + } |
| 93 | + |
| 94 | + async handleAccept(args?: SleepArgs): Promise<void> { |
| 95 | + const logger = createLogger('SleepClientTool') |
| 96 | + |
| 97 | + // Use a timeout slightly longer than max sleep (3 minutes + buffer) |
| 98 | + const timeoutMs = (MAX_SLEEP_SECONDS + 30) * 1000 |
| 99 | + |
| 100 | + await this.executeWithTimeout(async () => { |
| 101 | + const params = args || {} |
| 102 | + logger.debug('handleAccept() called', { |
| 103 | + toolCallId: this.toolCallId, |
| 104 | + state: this.getState(), |
| 105 | + hasArgs: !!args, |
| 106 | + seconds: params.seconds, |
| 107 | + }) |
| 108 | + |
| 109 | + // Validate and clamp seconds |
| 110 | + let seconds = typeof params.seconds === 'number' ? params.seconds : 0 |
| 111 | + if (seconds < 0) seconds = 0 |
| 112 | + if (seconds > MAX_SLEEP_SECONDS) seconds = MAX_SLEEP_SECONDS |
| 113 | + |
| 114 | + logger.debug('Starting sleep', { seconds }) |
| 115 | + |
| 116 | + // Track start time for elapsed calculation |
| 117 | + sleepStartTimes[this.toolCallId] = Date.now() |
| 118 | + |
| 119 | + this.setState(ClientToolCallState.executing) |
| 120 | + |
| 121 | + try { |
| 122 | + // Sleep for the specified duration |
| 123 | + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) |
| 124 | + |
| 125 | + logger.debug('Sleep completed successfully') |
| 126 | + this.setState(ClientToolCallState.success) |
| 127 | + await this.markToolComplete(200, `Slept for ${seconds} seconds`) |
| 128 | + } catch (error) { |
| 129 | + const message = error instanceof Error ? error.message : String(error) |
| 130 | + logger.error('Sleep failed', { error: message }) |
| 131 | + this.setState(ClientToolCallState.error) |
| 132 | + await this.markToolComplete(500, message) |
| 133 | + } finally { |
| 134 | + // Clean up start time tracking |
| 135 | + delete sleepStartTimes[this.toolCallId] |
| 136 | + } |
| 137 | + }, timeoutMs) |
| 138 | + } |
| 139 | + |
| 140 | + async execute(args?: SleepArgs): Promise<void> { |
| 141 | + // Auto-execute without confirmation - go straight to executing |
| 142 | + await this.handleAccept(args) |
| 143 | + } |
| 144 | +} |
0 commit comments