source dump of claude code
at main 991 lines 31 kB view raw
1import { randomUUID } from 'crypto' 2import { rm } from 'fs' 3import { appendFile, copyFile, mkdir } from 'fs/promises' 4import { dirname, isAbsolute, join, relative } from 'path' 5import { getCwdState } from '../../bootstrap/state.js' 6import type { CompletionBoundary } from '../../state/AppStateStore.js' 7import { 8 type AppState, 9 IDLE_SPECULATION_STATE, 10 type SpeculationResult, 11 type SpeculationState, 12} from '../../state/AppStateStore.js' 13import { commandHasAnyCd } from '../../tools/BashTool/bashPermissions.js' 14import { checkReadOnlyConstraints } from '../../tools/BashTool/readOnlyValidation.js' 15import type { SpeculationAcceptMessage } from '../../types/logs.js' 16import type { Message } from '../../types/message.js' 17import { createChildAbortController } from '../../utils/abortController.js' 18import { count } from '../../utils/array.js' 19import { getGlobalConfig } from '../../utils/config.js' 20import { logForDebugging } from '../../utils/debug.js' 21import { errorMessage } from '../../utils/errors.js' 22import { 23 type FileStateCache, 24 mergeFileStateCaches, 25 READ_FILE_STATE_CACHE_SIZE, 26} from '../../utils/fileStateCache.js' 27import { 28 type CacheSafeParams, 29 createCacheSafeParams, 30 runForkedAgent, 31} from '../../utils/forkedAgent.js' 32import { formatDuration, formatNumber } from '../../utils/format.js' 33import type { REPLHookContext } from '../../utils/hooks/postSamplingHooks.js' 34import { logError } from '../../utils/log.js' 35import type { SetAppState } from '../../utils/messageQueueManager.js' 36import { 37 createSystemMessage, 38 createUserMessage, 39 INTERRUPT_MESSAGE, 40 INTERRUPT_MESSAGE_FOR_TOOL_USE, 41} from '../../utils/messages.js' 42import { getClaudeTempDir } from '../../utils/permissions/filesystem.js' 43import { extractReadFilesFromMessages } from '../../utils/queryHelpers.js' 44import { getTranscriptPath } from '../../utils/sessionStorage.js' 45import { jsonStringify } from '../../utils/slowOperations.js' 46import { 47 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 48 logEvent, 49} from '../analytics/index.js' 50import { 51 generateSuggestion, 52 getPromptVariant, 53 getSuggestionSuppressReason, 54 logSuggestionSuppressed, 55 shouldFilterSuggestion, 56} from './promptSuggestion.js' 57 58const MAX_SPECULATION_TURNS = 20 59const MAX_SPECULATION_MESSAGES = 100 60 61const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']) 62const SAFE_READ_ONLY_TOOLS = new Set([ 63 'Read', 64 'Glob', 65 'Grep', 66 'ToolSearch', 67 'LSP', 68 'TaskGet', 69 'TaskList', 70]) 71 72function safeRemoveOverlay(overlayPath: string): void { 73 rm( 74 overlayPath, 75 { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }, 76 () => {}, 77 ) 78} 79 80function getOverlayPath(id: string): string { 81 return join(getClaudeTempDir(), 'speculation', String(process.pid), id) 82} 83 84function denySpeculation( 85 message: string, 86 reason: string, 87): { 88 behavior: 'deny' 89 message: string 90 decisionReason: { type: 'other'; reason: string } 91} { 92 return { 93 behavior: 'deny', 94 message, 95 decisionReason: { type: 'other', reason }, 96 } 97} 98 99async function copyOverlayToMain( 100 overlayPath: string, 101 writtenPaths: Set<string>, 102 cwd: string, 103): Promise<boolean> { 104 let allCopied = true 105 for (const rel of writtenPaths) { 106 const src = join(overlayPath, rel) 107 const dest = join(cwd, rel) 108 try { 109 await mkdir(dirname(dest), { recursive: true }) 110 await copyFile(src, dest) 111 } catch { 112 allCopied = false 113 logForDebugging(`[Speculation] Failed to copy ${rel} to main`) 114 } 115 } 116 return allCopied 117} 118 119export type ActiveSpeculationState = Extract< 120 SpeculationState, 121 { status: 'active' } 122> 123 124function logSpeculation( 125 id: string, 126 outcome: 'accepted' | 'aborted' | 'error', 127 startTime: number, 128 suggestionLength: number, 129 messages: Message[], 130 boundary: CompletionBoundary | null, 131 extras?: Record<string, string | number | boolean | undefined>, 132): void { 133 logEvent('tengu_speculation', { 134 speculation_id: 135 id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 136 outcome: 137 outcome as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 138 duration_ms: Date.now() - startTime, 139 suggestion_length: suggestionLength, 140 tools_executed: countToolsInMessages(messages), 141 completed: boundary !== null, 142 boundary_type: boundary?.type as 143 | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 144 | undefined, 145 boundary_tool: getBoundaryTool(boundary) as 146 | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 147 | undefined, 148 boundary_detail: getBoundaryDetail(boundary) as 149 | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 150 | undefined, 151 ...extras, 152 }) 153} 154 155function countToolsInMessages(messages: Message[]): number { 156 const blocks = messages 157 .filter(isUserMessageWithArrayContent) 158 .flatMap(m => m.message.content) 159 .filter( 160 (b): b is { type: string; is_error?: boolean } => 161 typeof b === 'object' && b !== null && 'type' in b, 162 ) 163 return count(blocks, b => b.type === 'tool_result' && !b.is_error) 164} 165 166function getBoundaryTool( 167 boundary: CompletionBoundary | null, 168): string | undefined { 169 if (!boundary) return undefined 170 switch (boundary.type) { 171 case 'bash': 172 return 'Bash' 173 case 'edit': 174 case 'denied_tool': 175 return boundary.toolName 176 case 'complete': 177 return undefined 178 } 179} 180 181function getBoundaryDetail( 182 boundary: CompletionBoundary | null, 183): string | undefined { 184 if (!boundary) return undefined 185 switch (boundary.type) { 186 case 'bash': 187 return boundary.command.slice(0, 200) 188 case 'edit': 189 return boundary.filePath 190 case 'denied_tool': 191 return boundary.detail 192 case 'complete': 193 return undefined 194 } 195} 196 197function isUserMessageWithArrayContent( 198 m: Message, 199): m is Message & { message: { content: unknown[] } } { 200 return m.type === 'user' && 'message' in m && Array.isArray(m.message.content) 201} 202 203export function prepareMessagesForInjection(messages: Message[]): Message[] { 204 // Find tool_use IDs that have SUCCESSFUL results (not errors/interruptions) 205 // Pending tool_use blocks (no result) and interrupted ones will be stripped 206 type ToolResult = { 207 type: 'tool_result' 208 tool_use_id: string 209 is_error?: boolean 210 content?: unknown 211 } 212 const isToolResult = (b: unknown): b is ToolResult => 213 typeof b === 'object' && 214 b !== null && 215 (b as ToolResult).type === 'tool_result' && 216 typeof (b as ToolResult).tool_use_id === 'string' 217 const isSuccessful = (b: ToolResult) => 218 !b.is_error && 219 !( 220 typeof b.content === 'string' && 221 b.content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE) 222 ) 223 224 const toolIdsWithSuccessfulResults = new Set( 225 messages 226 .filter(isUserMessageWithArrayContent) 227 .flatMap(m => m.message.content) 228 .filter(isToolResult) 229 .filter(isSuccessful) 230 .map(b => b.tool_use_id), 231 ) 232 233 const keep = (b: { 234 type: string 235 id?: string 236 tool_use_id?: string 237 text?: string 238 }) => 239 b.type !== 'thinking' && 240 b.type !== 'redacted_thinking' && 241 !(b.type === 'tool_use' && !toolIdsWithSuccessfulResults.has(b.id!)) && 242 !( 243 b.type === 'tool_result' && 244 !toolIdsWithSuccessfulResults.has(b.tool_use_id!) 245 ) && 246 // Abort during speculation yields a standalone interrupt user message 247 // (query.ts createUserInterruptionMessage). Strip it so it isn't surfaced 248 // to the model as real user input. 249 !( 250 b.type === 'text' && 251 (b.text === INTERRUPT_MESSAGE || 252 b.text === INTERRUPT_MESSAGE_FOR_TOOL_USE) 253 ) 254 255 return messages 256 .map(msg => { 257 if (!('message' in msg) || !Array.isArray(msg.message.content)) return msg 258 const content = msg.message.content.filter(keep) 259 if (content.length === msg.message.content.length) return msg 260 if (content.length === 0) return null 261 // Drop messages where all remaining blocks are whitespace-only text 262 // (API rejects these with 400: "text content blocks must contain non-whitespace text") 263 const hasNonWhitespaceContent = content.some( 264 (b: { type: string; text?: string }) => 265 b.type !== 'text' || (b.text !== undefined && b.text.trim() !== ''), 266 ) 267 if (!hasNonWhitespaceContent) return null 268 return { ...msg, message: { ...msg.message, content } } as typeof msg 269 }) 270 .filter((m): m is Message => m !== null) 271} 272 273function createSpeculationFeedbackMessage( 274 messages: Message[], 275 boundary: CompletionBoundary | null, 276 timeSavedMs: number, 277 sessionTotalMs: number, 278): Message | null { 279 if (process.env.USER_TYPE !== 'ant') return null 280 281 if (messages.length === 0 || timeSavedMs === 0) return null 282 283 const toolUses = countToolsInMessages(messages) 284 const tokens = boundary?.type === 'complete' ? boundary.outputTokens : null 285 286 const parts = [] 287 if (toolUses > 0) { 288 parts.push(`Speculated ${toolUses} tool ${toolUses === 1 ? 'use' : 'uses'}`) 289 } else { 290 const turns = messages.length 291 parts.push(`Speculated ${turns} ${turns === 1 ? 'turn' : 'turns'}`) 292 } 293 294 if (tokens !== null) { 295 parts.push(`${formatNumber(tokens)} tokens`) 296 } 297 298 const savedText = `+${formatDuration(timeSavedMs)} saved` 299 const sessionSuffix = 300 sessionTotalMs !== timeSavedMs 301 ? ` (${formatDuration(sessionTotalMs)} this session)` 302 : '' 303 304 return createSystemMessage( 305 `[ANT-ONLY] ${parts.join(' · ')} · ${savedText}${sessionSuffix}`, 306 'warning', 307 ) 308} 309 310function updateActiveSpeculationState( 311 setAppState: SetAppState, 312 updater: (state: ActiveSpeculationState) => Partial<ActiveSpeculationState>, 313): void { 314 setAppState(prev => { 315 if (prev.speculation.status !== 'active') return prev 316 const current = prev.speculation as ActiveSpeculationState 317 const updates = updater(current) 318 // Check if any values actually changed to avoid unnecessary re-renders 319 const hasChanges = Object.entries(updates).some( 320 ([key, value]) => current[key as keyof ActiveSpeculationState] !== value, 321 ) 322 if (!hasChanges) return prev 323 return { 324 ...prev, 325 speculation: { ...current, ...updates }, 326 } 327 }) 328} 329 330function resetSpeculationState(setAppState: SetAppState): void { 331 setAppState(prev => { 332 if (prev.speculation.status === 'idle') return prev 333 return { ...prev, speculation: IDLE_SPECULATION_STATE } 334 }) 335} 336 337export function isSpeculationEnabled(): boolean { 338 const enabled = 339 process.env.USER_TYPE === 'ant' && 340 (getGlobalConfig().speculationEnabled ?? true) 341 logForDebugging(`[Speculation] enabled=${enabled}`) 342 return enabled 343} 344 345async function generatePipelinedSuggestion( 346 context: REPLHookContext, 347 suggestionText: string, 348 speculatedMessages: Message[], 349 setAppState: SetAppState, 350 parentAbortController: AbortController, 351): Promise<void> { 352 try { 353 const appState = context.toolUseContext.getAppState() 354 const suppressReason = getSuggestionSuppressReason(appState) 355 if (suppressReason) { 356 logSuggestionSuppressed(`pipeline_${suppressReason}`) 357 return 358 } 359 360 const augmentedContext: REPLHookContext = { 361 ...context, 362 messages: [ 363 ...context.messages, 364 createUserMessage({ content: suggestionText }), 365 ...speculatedMessages, 366 ], 367 } 368 369 const pipelineAbortController = createChildAbortController( 370 parentAbortController, 371 ) 372 if (pipelineAbortController.signal.aborted) return 373 374 const promptId = getPromptVariant() 375 const { suggestion, generationRequestId } = await generateSuggestion( 376 pipelineAbortController, 377 promptId, 378 createCacheSafeParams(augmentedContext), 379 ) 380 381 if (pipelineAbortController.signal.aborted) return 382 if (shouldFilterSuggestion(suggestion, promptId)) return 383 384 logForDebugging( 385 `[Speculation] Pipelined suggestion: "${suggestion!.slice(0, 50)}..."`, 386 ) 387 updateActiveSpeculationState(setAppState, () => ({ 388 pipelinedSuggestion: { 389 text: suggestion!, 390 promptId, 391 generationRequestId, 392 }, 393 })) 394 } catch (error) { 395 if (error instanceof Error && error.name === 'AbortError') return 396 logForDebugging( 397 `[Speculation] Pipelined suggestion failed: ${errorMessage(error)}`, 398 ) 399 } 400} 401 402export async function startSpeculation( 403 suggestionText: string, 404 context: REPLHookContext, 405 setAppState: (f: (prev: AppState) => AppState) => void, 406 isPipelined = false, 407 cacheSafeParams?: CacheSafeParams, 408): Promise<void> { 409 if (!isSpeculationEnabled()) return 410 411 // Abort any existing speculation before starting a new one 412 abortSpeculation(setAppState) 413 414 const id = randomUUID().slice(0, 8) 415 416 const abortController = createChildAbortController( 417 context.toolUseContext.abortController, 418 ) 419 420 if (abortController.signal.aborted) return 421 422 const startTime = Date.now() 423 const messagesRef = { current: [] as Message[] } 424 const writtenPathsRef = { current: new Set<string>() } 425 const overlayPath = getOverlayPath(id) 426 const cwd = getCwdState() 427 428 try { 429 await mkdir(overlayPath, { recursive: true }) 430 } catch { 431 logForDebugging('[Speculation] Failed to create overlay directory') 432 return 433 } 434 435 const contextRef = { current: context } 436 437 setAppState(prev => ({ 438 ...prev, 439 speculation: { 440 status: 'active', 441 id, 442 abort: () => abortController.abort(), 443 startTime, 444 messagesRef, 445 writtenPathsRef, 446 boundary: null, 447 suggestionLength: suggestionText.length, 448 toolUseCount: 0, 449 isPipelined, 450 contextRef, 451 }, 452 })) 453 454 logForDebugging(`[Speculation] Starting speculation ${id}`) 455 456 try { 457 const result = await runForkedAgent({ 458 promptMessages: [createUserMessage({ content: suggestionText })], 459 cacheSafeParams: cacheSafeParams ?? createCacheSafeParams(context), 460 skipTranscript: true, 461 canUseTool: async (tool, input) => { 462 const isWriteTool = WRITE_TOOLS.has(tool.name) 463 const isSafeReadOnlyTool = SAFE_READ_ONLY_TOOLS.has(tool.name) 464 465 // Check permission mode BEFORE allowing file edits 466 if (isWriteTool) { 467 const appState = context.toolUseContext.getAppState() 468 const { mode, isBypassPermissionsModeAvailable } = 469 appState.toolPermissionContext 470 471 const canAutoAcceptEdits = 472 mode === 'acceptEdits' || 473 mode === 'bypassPermissions' || 474 (mode === 'plan' && isBypassPermissionsModeAvailable) 475 476 if (!canAutoAcceptEdits) { 477 logForDebugging(`[Speculation] Stopping at file edit: ${tool.name}`) 478 const editPath = ( 479 'file_path' in input ? input.file_path : undefined 480 ) as string | undefined 481 updateActiveSpeculationState(setAppState, () => ({ 482 boundary: { 483 type: 'edit', 484 toolName: tool.name, 485 filePath: editPath ?? '', 486 completedAt: Date.now(), 487 }, 488 })) 489 abortController.abort() 490 return denySpeculation( 491 'Speculation paused: file edit requires permission', 492 'speculation_edit_boundary', 493 ) 494 } 495 } 496 497 // Handle file path rewriting for overlay isolation 498 if (isWriteTool || isSafeReadOnlyTool) { 499 const pathKey = 500 'notebook_path' in input 501 ? 'notebook_path' 502 : 'path' in input 503 ? 'path' 504 : 'file_path' 505 const filePath = input[pathKey] as string | undefined 506 if (filePath) { 507 const rel = relative(cwd, filePath) 508 if (isAbsolute(rel) || rel.startsWith('..')) { 509 if (isWriteTool) { 510 logForDebugging( 511 `[Speculation] Denied ${tool.name}: path outside cwd: ${filePath}`, 512 ) 513 return denySpeculation( 514 'Write outside cwd not allowed during speculation', 515 'speculation_write_outside_root', 516 ) 517 } 518 return { 519 behavior: 'allow' as const, 520 updatedInput: input, 521 decisionReason: { 522 type: 'other' as const, 523 reason: 'speculation_read_outside_root', 524 }, 525 } 526 } 527 528 if (isWriteTool) { 529 // Copy-on-write: copy original to overlay if not yet there 530 if (!writtenPathsRef.current.has(rel)) { 531 const overlayFile = join(overlayPath, rel) 532 await mkdir(dirname(overlayFile), { recursive: true }) 533 try { 534 await copyFile(join(cwd, rel), overlayFile) 535 } catch { 536 // Original may not exist (new file creation) - that's fine 537 } 538 writtenPathsRef.current.add(rel) 539 } 540 input = { ...input, [pathKey]: join(overlayPath, rel) } 541 } else { 542 // Read: redirect to overlay if file was previously written 543 if (writtenPathsRef.current.has(rel)) { 544 input = { ...input, [pathKey]: join(overlayPath, rel) } 545 } 546 // Otherwise read from main (no rewrite) 547 } 548 549 logForDebugging( 550 `[Speculation] ${isWriteTool ? 'Write' : 'Read'} ${filePath} -> ${input[pathKey]}`, 551 ) 552 553 return { 554 behavior: 'allow' as const, 555 updatedInput: input, 556 decisionReason: { 557 type: 'other' as const, 558 reason: 'speculation_file_access', 559 }, 560 } 561 } 562 // Read tools without explicit path (e.g. Glob/Grep defaulting to CWD) are safe 563 if (isSafeReadOnlyTool) { 564 return { 565 behavior: 'allow' as const, 566 updatedInput: input, 567 decisionReason: { 568 type: 'other' as const, 569 reason: 'speculation_read_default_cwd', 570 }, 571 } 572 } 573 // Write tools with undefined path → fall through to default deny 574 } 575 576 // Stop at non-read-only bash commands 577 if (tool.name === 'Bash') { 578 const command = 579 'command' in input && typeof input.command === 'string' 580 ? input.command 581 : '' 582 if ( 583 !command || 584 checkReadOnlyConstraints({ command }, commandHasAnyCd(command)) 585 .behavior !== 'allow' 586 ) { 587 logForDebugging( 588 `[Speculation] Stopping at bash: ${command.slice(0, 50) || 'missing command'}`, 589 ) 590 updateActiveSpeculationState(setAppState, () => ({ 591 boundary: { type: 'bash', command, completedAt: Date.now() }, 592 })) 593 abortController.abort() 594 return denySpeculation( 595 'Speculation paused: bash boundary', 596 'speculation_bash_boundary', 597 ) 598 } 599 // Read-only bash command — allow during speculation 600 return { 601 behavior: 'allow' as const, 602 updatedInput: input, 603 decisionReason: { 604 type: 'other' as const, 605 reason: 'speculation_readonly_bash', 606 }, 607 } 608 } 609 610 // Deny all other tools by default 611 logForDebugging(`[Speculation] Stopping at denied tool: ${tool.name}`) 612 const detail = String( 613 ('url' in input && input.url) || 614 ('file_path' in input && input.file_path) || 615 ('path' in input && input.path) || 616 ('command' in input && input.command) || 617 '', 618 ).slice(0, 200) 619 updateActiveSpeculationState(setAppState, () => ({ 620 boundary: { 621 type: 'denied_tool', 622 toolName: tool.name, 623 detail, 624 completedAt: Date.now(), 625 }, 626 })) 627 abortController.abort() 628 return denySpeculation( 629 `Tool ${tool.name} not allowed during speculation`, 630 'speculation_unknown_tool', 631 ) 632 }, 633 querySource: 'speculation', 634 forkLabel: 'speculation', 635 maxTurns: MAX_SPECULATION_TURNS, 636 overrides: { abortController, requireCanUseTool: true }, 637 onMessage: msg => { 638 if (msg.type === 'assistant' || msg.type === 'user') { 639 messagesRef.current.push(msg) 640 if (messagesRef.current.length >= MAX_SPECULATION_MESSAGES) { 641 abortController.abort() 642 } 643 if (isUserMessageWithArrayContent(msg)) { 644 const newTools = count( 645 msg.message.content as { type: string; is_error?: boolean }[], 646 b => b.type === 'tool_result' && !b.is_error, 647 ) 648 if (newTools > 0) { 649 updateActiveSpeculationState(setAppState, prev => ({ 650 toolUseCount: prev.toolUseCount + newTools, 651 })) 652 } 653 } 654 } 655 }, 656 }) 657 658 if (abortController.signal.aborted) return 659 660 updateActiveSpeculationState(setAppState, () => ({ 661 boundary: { 662 type: 'complete' as const, 663 completedAt: Date.now(), 664 outputTokens: result.totalUsage.output_tokens, 665 }, 666 })) 667 668 logForDebugging( 669 `[Speculation] Complete: ${countToolsInMessages(messagesRef.current)} tools`, 670 ) 671 672 // Pipeline: generate the next suggestion while we wait for the user to accept 673 void generatePipelinedSuggestion( 674 contextRef.current, 675 suggestionText, 676 messagesRef.current, 677 setAppState, 678 abortController, 679 ) 680 } catch (error) { 681 abortController.abort() 682 683 if (error instanceof Error && error.name === 'AbortError') { 684 safeRemoveOverlay(overlayPath) 685 resetSpeculationState(setAppState) 686 return 687 } 688 689 safeRemoveOverlay(overlayPath) 690 691 // eslint-disable-next-line no-restricted-syntax -- custom fallback message, not toError(e) 692 logError(error instanceof Error ? error : new Error('Speculation failed')) 693 694 logSpeculation( 695 id, 696 'error', 697 startTime, 698 suggestionText.length, 699 messagesRef.current, 700 null, 701 { 702 error_type: error instanceof Error ? error.name : 'Unknown', 703 error_message: errorMessage(error).slice( 704 0, 705 200, 706 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 707 error_phase: 708 'start' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 709 is_pipelined: isPipelined, 710 }, 711 ) 712 713 resetSpeculationState(setAppState) 714 } 715} 716 717export async function acceptSpeculation( 718 state: SpeculationState, 719 setAppState: (f: (prev: AppState) => AppState) => void, 720 cleanMessageCount: number, 721): Promise<SpeculationResult | null> { 722 if (state.status !== 'active') return null 723 724 const { 725 id, 726 messagesRef, 727 writtenPathsRef, 728 abort, 729 startTime, 730 suggestionLength, 731 isPipelined, 732 } = state 733 const messages = messagesRef.current 734 const overlayPath = getOverlayPath(id) 735 const acceptedAt = Date.now() 736 737 abort() 738 739 if (cleanMessageCount > 0) { 740 await copyOverlayToMain(overlayPath, writtenPathsRef.current, getCwdState()) 741 } 742 safeRemoveOverlay(overlayPath) 743 744 // Use snapshot boundary as default (available since state.status === 'active' was checked above) 745 let boundary: CompletionBoundary | null = state.boundary 746 let timeSavedMs = 747 Math.min(acceptedAt, boundary?.completedAt ?? Infinity) - startTime 748 749 setAppState(prev => { 750 // Refine with latest React state if speculation is still active 751 if (prev.speculation.status === 'active' && prev.speculation.boundary) { 752 boundary = prev.speculation.boundary 753 const endTime = Math.min(acceptedAt, boundary.completedAt ?? Infinity) 754 timeSavedMs = endTime - startTime 755 } 756 return { 757 ...prev, 758 speculation: IDLE_SPECULATION_STATE, 759 speculationSessionTimeSavedMs: 760 prev.speculationSessionTimeSavedMs + timeSavedMs, 761 } 762 }) 763 764 logForDebugging( 765 boundary === null 766 ? `[Speculation] Accept ${id}: still running, using ${messages.length} messages` 767 : `[Speculation] Accept ${id}: already complete`, 768 ) 769 770 logSpeculation( 771 id, 772 'accepted', 773 startTime, 774 suggestionLength, 775 messages, 776 boundary, 777 { 778 message_count: messages.length, 779 time_saved_ms: timeSavedMs, 780 is_pipelined: isPipelined, 781 }, 782 ) 783 784 if (timeSavedMs > 0) { 785 const entry: SpeculationAcceptMessage = { 786 type: 'speculation-accept', 787 timestamp: new Date().toISOString(), 788 timeSavedMs, 789 } 790 void appendFile(getTranscriptPath(), jsonStringify(entry) + '\n', { 791 mode: 0o600, 792 }).catch(() => { 793 logForDebugging( 794 '[Speculation] Failed to write speculation-accept to transcript', 795 ) 796 }) 797 } 798 799 return { messages, boundary, timeSavedMs } 800} 801 802export function abortSpeculation(setAppState: SetAppState): void { 803 setAppState(prev => { 804 if (prev.speculation.status !== 'active') return prev 805 806 const { 807 id, 808 abort, 809 startTime, 810 boundary, 811 suggestionLength, 812 messagesRef, 813 isPipelined, 814 } = prev.speculation 815 816 logForDebugging(`[Speculation] Aborting ${id}`) 817 818 logSpeculation( 819 id, 820 'aborted', 821 startTime, 822 suggestionLength, 823 messagesRef.current, 824 boundary, 825 { abort_reason: 'user_typed', is_pipelined: isPipelined }, 826 ) 827 828 abort() 829 safeRemoveOverlay(getOverlayPath(id)) 830 831 return { ...prev, speculation: IDLE_SPECULATION_STATE } 832 }) 833} 834 835export async function handleSpeculationAccept( 836 speculationState: ActiveSpeculationState, 837 speculationSessionTimeSavedMs: number, 838 setAppState: SetAppState, 839 input: string, 840 deps: { 841 setMessages: (f: (prev: Message[]) => Message[]) => void 842 readFileState: { current: FileStateCache } 843 cwd: string 844 }, 845): Promise<{ queryRequired: boolean }> { 846 try { 847 const { setMessages, readFileState, cwd } = deps 848 849 // Clear prompt suggestion state. logOutcomeAtSubmission logged the accept 850 // but was called with skipReset to avoid aborting speculation before we use it. 851 setAppState(prev => { 852 if ( 853 prev.promptSuggestion.text === null && 854 prev.promptSuggestion.promptId === null 855 ) { 856 return prev 857 } 858 return { 859 ...prev, 860 promptSuggestion: { 861 text: null, 862 promptId: null, 863 shownAt: 0, 864 acceptedAt: 0, 865 generationRequestId: null, 866 }, 867 } 868 }) 869 870 // Capture speculation messages before any state updates - must be stable reference 871 const speculationMessages = speculationState.messagesRef.current 872 let cleanMessages = prepareMessagesForInjection(speculationMessages) 873 874 // Inject user message first for instant visual feedback before any async work 875 const userMessage = createUserMessage({ content: input }) 876 setMessages(prev => [...prev, userMessage]) 877 878 const result = await acceptSpeculation( 879 speculationState, 880 setAppState, 881 cleanMessages.length, 882 ) 883 884 const isComplete = result?.boundary?.type === 'complete' 885 886 // When speculation didn't complete, the follow-up query needs the 887 // conversation to end with a user message. Drop trailing assistant 888 // messages — models that don't support prefill 889 // reject conversations ending with an assistant turn. The model will 890 // regenerate this content in the follow-up query. 891 if (!isComplete) { 892 const lastNonAssistant = cleanMessages.findLastIndex( 893 m => m.type !== 'assistant', 894 ) 895 cleanMessages = cleanMessages.slice(0, lastNonAssistant + 1) 896 } 897 898 const timeSavedMs = result?.timeSavedMs ?? 0 899 const newSessionTotal = speculationSessionTimeSavedMs + timeSavedMs 900 const feedbackMessage = createSpeculationFeedbackMessage( 901 cleanMessages, 902 result?.boundary ?? null, 903 timeSavedMs, 904 newSessionTotal, 905 ) 906 907 // Inject speculated messages 908 setMessages(prev => [...prev, ...cleanMessages]) 909 910 const extracted = extractReadFilesFromMessages( 911 cleanMessages, 912 cwd, 913 READ_FILE_STATE_CACHE_SIZE, 914 ) 915 readFileState.current = mergeFileStateCaches( 916 readFileState.current, 917 extracted, 918 ) 919 920 if (feedbackMessage) { 921 setMessages(prev => [...prev, feedbackMessage]) 922 } 923 924 logForDebugging( 925 `[Speculation] ${result?.boundary?.type ?? 'incomplete'}, injected ${cleanMessages.length} messages`, 926 ) 927 928 // Promote pipelined suggestion if speculation completed fully 929 if (isComplete && speculationState.pipelinedSuggestion) { 930 const { text, promptId, generationRequestId } = 931 speculationState.pipelinedSuggestion 932 logForDebugging( 933 `[Speculation] Promoting pipelined suggestion: "${text.slice(0, 50)}..."`, 934 ) 935 setAppState(prev => ({ 936 ...prev, 937 promptSuggestion: { 938 text, 939 promptId, 940 shownAt: Date.now(), 941 acceptedAt: 0, 942 generationRequestId, 943 }, 944 })) 945 946 // Start speculation on the pipelined suggestion 947 const augmentedContext: REPLHookContext = { 948 ...speculationState.contextRef.current, 949 messages: [ 950 ...speculationState.contextRef.current.messages, 951 createUserMessage({ content: input }), 952 ...cleanMessages, 953 ], 954 } 955 void startSpeculation(text, augmentedContext, setAppState, true) 956 } 957 958 return { queryRequired: !isComplete } 959 } catch (error) { 960 // Fail open: log error and fall back to normal query flow 961 /* eslint-disable no-restricted-syntax -- custom fallback message, not toError(e) */ 962 logError( 963 error instanceof Error 964 ? error 965 : new Error('handleSpeculationAccept failed'), 966 ) 967 /* eslint-enable no-restricted-syntax */ 968 logSpeculation( 969 speculationState.id, 970 'error', 971 speculationState.startTime, 972 speculationState.suggestionLength, 973 speculationState.messagesRef.current, 974 speculationState.boundary, 975 { 976 error_type: error instanceof Error ? error.name : 'Unknown', 977 error_message: errorMessage(error).slice( 978 0, 979 200, 980 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 981 error_phase: 982 'accept' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 983 is_pipelined: speculationState.isPipelined, 984 }, 985 ) 986 safeRemoveOverlay(getOverlayPath(speculationState.id)) 987 resetSpeculationState(setAppState) 988 // Query required so user's message is processed normally (without speculated work) 989 return { queryRequired: true } 990 } 991}