From 6602076b5335e22ba852ec36451a627c3e58daec Mon Sep 17 00:00:00 2001 From: JATMN Date: Wed, 8 Jul 2026 18:15:48 -0700 Subject: [PATCH] refactor(messages): extract system factories (6 of 8) (#1903) * refactor(messages): extract system factories * test(messages): cover system factory extraction --- src/services/compact/compact.test.ts | 6 + src/utils/messages.ts | 341 ++------------------- src/utils/messages/systemFactories.test.ts | 233 ++++++++++++++ src/utils/messages/systemFactories.ts | 329 ++++++++++++++++++++ 4 files changed, 587 insertions(+), 322 deletions(-) create mode 100644 src/utils/messages/systemFactories.test.ts create mode 100644 src/utils/messages/systemFactories.ts diff --git a/src/services/compact/compact.test.ts b/src/services/compact/compact.test.ts index 8c9d1faeb..ae62be186 100644 --- a/src/services/compact/compact.test.ts +++ b/src/services/compact/compact.test.ts @@ -45,6 +45,9 @@ const _realDiskOutputModule = await import( const _realMessagesModule = await import( `../../utils/messages.js?real=${Date.now()}-${Math.random()}` ) +const _realSystemFactoriesModule = await import( + `../../utils/messages/systemFactories.js?real=${Date.now()}-${Math.random()}` +) const _realSlowOperationsModule = await import( `../../utils/slowOperations.js?real=${Date.now()}-${Math.random()}` ) @@ -663,6 +666,9 @@ afterAll(async () => { MAX_TASK_OUTPUT_BYTES_DISPLAY: _realDiskOutputModule.MAX_TASK_OUTPUT_BYTES_DISPLAY, })) mock.module('../../utils/messages.js', () => ({ ..._realMessagesModule })) + mock.module('../../utils/messages/systemFactories.js', () => ({ + ..._realSystemFactoriesModule, + })) mock.module('../../utils/slowOperations.js', () => ({ ..._realSlowOperationsModule, })) diff --git a/src/utils/messages.ts b/src/utils/messages.ts index f49f184c6..c476930df 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -4246,328 +4246,25 @@ function createToolUseMessage( }) } -export function createSystemMessage( - content: string, - level: SystemMessageLevel, - toolUseID?: string, - preventContinuation?: boolean, -): SystemInformationalMessage { - return { - type: 'system', - subtype: 'informational', - content, - isMeta: false, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - toolUseID, - level, - ...(preventContinuation && { preventContinuation }), - } -} - -export function createPermissionRetryMessage( - commands: string[], -): SystemPermissionRetryMessage { - return { - type: 'system', - subtype: 'permission_retry', - content: `Allowed ${commands.join(', ')}`, - commands, - level: 'info', - isMeta: false, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - } -} - -export function createBridgeStatusMessage( - url: string, - upgradeNudge?: string, -): SystemBridgeStatusMessage { - return { - type: 'system', - subtype: 'bridge_status', - content: `/remote-control is active. Code in CLI or at ${url}`, - url, - upgradeNudge, - isMeta: false, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - } -} - -export function createScheduledTaskFireMessage( - content: string, -): SystemScheduledTaskFireMessage { - return { - type: 'system', - subtype: 'scheduled_task_fire', - content, - isMeta: false, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - } -} - -export function createStopHookSummaryMessage( - hookCount: number, - hookInfos: StopHookInfo[], - hookErrors: string[], - preventedContinuation: boolean, - stopReason: string | undefined, - hasOutput: boolean, - level: SystemMessageLevel, - toolUseID?: string, - hookLabel?: string, - totalDurationMs?: number, -): SystemStopHookSummaryMessage { - return { - type: 'system', - subtype: 'stop_hook_summary', - hookCount, - hookInfos, - hookErrors, - preventedContinuation, - stopReason, - hasOutput, - level, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - toolUseID, - hookLabel, - totalDurationMs, - } -} - -export function createTurnDurationMessage( - durationMs: number, - budget?: { tokens: number; limit: number; nudges: number }, - messageCount?: number, -): SystemTurnDurationMessage { - return { - type: 'system', - subtype: 'turn_duration', - durationMs, - budgetTokens: budget?.tokens, - budgetLimit: budget?.limit, - budgetNudges: budget?.nudges, - messageCount, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - isMeta: false, - } -} - -export function createAwaySummaryMessage( - content: string, -): SystemAwaySummaryMessage { - return { - type: 'system', - subtype: 'away_summary', - content, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - isMeta: false, - } -} - -export function createMemorySavedMessage( - writtenPaths: string[], -): SystemMemorySavedMessage { - return { - type: 'system', - subtype: 'memory_saved', - writtenPaths, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - isMeta: false, - } -} - -export function createAgentsKilledMessage(): SystemAgentsKilledMessage { - return { - type: 'system', - subtype: 'agents_killed', - timestamp: new Date().toISOString(), - uuid: randomUUID(), - isMeta: false, - } -} - -export function createApiMetricsMessage(metrics: { - ttftMs: number - otps: number - isP50?: boolean - hookDurationMs?: number - turnDurationMs?: number - toolDurationMs?: number - classifierDurationMs?: number - toolCount?: number - hookCount?: number - classifierCount?: number - configWriteCount?: number -}): SystemApiMetricsMessage { - return { - type: 'system', - subtype: 'api_metrics', - ttftMs: metrics.ttftMs, - otps: metrics.otps, - isP50: metrics.isP50, - hookDurationMs: metrics.hookDurationMs, - turnDurationMs: metrics.turnDurationMs, - toolDurationMs: metrics.toolDurationMs, - classifierDurationMs: metrics.classifierDurationMs, - toolCount: metrics.toolCount, - hookCount: metrics.hookCount, - classifierCount: metrics.classifierCount, - configWriteCount: metrics.configWriteCount, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - isMeta: false, - } -} - -export function createCommandInputMessage( - content: string, -): SystemLocalCommandMessage { - return { - type: 'system', - subtype: 'local_command', - content, - level: 'info', - timestamp: new Date().toISOString(), - uuid: randomUUID(), - isMeta: false, - } -} - -export function createCompactBoundaryMessage( - trigger: 'manual' | 'auto', - preTokens: number, - lastPreCompactMessageUuid?: UUID, - userContext?: string, - messagesSummarized?: number, -): SystemCompactBoundaryMessage { - return { - type: 'system', - subtype: 'compact_boundary', - content: `Conversation compacted`, - isMeta: false, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - level: 'info', - compactMetadata: { - trigger, - preTokens, - userContext, - messagesSummarized, - }, - ...(lastPreCompactMessageUuid && { - logicalParentUuid: lastPreCompactMessageUuid, - }), - } -} - -export function createMicrocompactBoundaryMessage( - trigger: 'auto', - preTokens: number, - tokensSaved: number, - compactedToolIds: string[], - clearedAttachmentUUIDs: string[], -): SystemMicrocompactBoundaryMessage { - logForDebugging( - `[microcompact] saved ~${formatTokens(tokensSaved)} tokens (cleared ${compactedToolIds.length} tool results)`, - ) - return { - type: 'system', - subtype: 'microcompact_boundary', - content: 'Context microcompacted', - isMeta: false, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - level: 'info', - microcompactMetadata: { - trigger, - preTokens, - tokensSaved, - compactedToolIds, - clearedAttachmentUUIDs, - }, - } -} - -export function createSystemAPIErrorMessage( - error: APIError, - retryInMs: number, - retryAttempt: number, - maxRetries: number, -): SystemAPIErrorMessage { - return { - type: 'system', - subtype: 'api_error', - level: 'error', - cause: error.cause instanceof Error ? error.cause : undefined, - error, - retryInMs, - retryAttempt, - maxRetries, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - } -} - -/** - * Checks if a message is a compact boundary marker - */ -export function isCompactBoundaryMessage( - message: Message | NormalizedMessage, -): message is SystemCompactBoundaryMessage { - return message?.type === 'system' && message.subtype === 'compact_boundary' -} - -/** - * Finds the index of the last compact boundary marker in the messages array - * @returns The index of the last compact boundary, or -1 if none found - */ -export function findLastCompactBoundaryIndex< - T extends Message | NormalizedMessage, ->(messages: T[]): number { - // Scan backwards to find the most recent compact boundary - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - if (message && isCompactBoundaryMessage(message)) { - return i - } - } - return -1 // No boundary found -} - -/** - * Returns messages from the last compact boundary onward (including the boundary). - * If no boundary exists, returns all messages. - * - * Also filters snipped messages by default (when HISTORY_SNIP is enabled) — - * the REPL keeps full history for UI scrollback, so model-facing paths need - * both compact-slice AND snip-filter applied. Pass `{ includeSnipped: true }` - * to opt out (e.g., REPL.tsx fullscreen compact handler which preserves - * snipped messages in scrollback). - * - * Note: The boundary itself is a system message and will be filtered by normalizeMessagesForAPI. - */ -export function getMessagesAfterCompactBoundary< - T extends Message | NormalizedMessage, ->(messages: T[], options?: { includeSnipped?: boolean }): T[] { - const boundaryIndex = findLastCompactBoundaryIndex(messages) - const sliced = boundaryIndex === -1 ? messages : messages.slice(boundaryIndex) - if (!options?.includeSnipped && feature('HISTORY_SNIP')) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { projectSnippedView } = - require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js') - /* eslint-enable @typescript-eslint/no-require-imports */ - return projectSnippedView(sliced as Message[]) as T[] - } - return sliced -} +export { + createAgentsKilledMessage, + createApiMetricsMessage, + createAwaySummaryMessage, + createBridgeStatusMessage, + createCommandInputMessage, + createCompactBoundaryMessage, + createMemorySavedMessage, + createMicrocompactBoundaryMessage, + createPermissionRetryMessage, + createScheduledTaskFireMessage, + createStopHookSummaryMessage, + createSystemAPIErrorMessage, + createSystemMessage, + createTurnDurationMessage, + findLastCompactBoundaryIndex, + getMessagesAfterCompactBoundary, + isCompactBoundaryMessage, +} from "./messages/systemFactories.js" export function shouldShowUserMessage( message: NormalizedMessage, diff --git a/src/utils/messages/systemFactories.test.ts b/src/utils/messages/systemFactories.test.ts new file mode 100644 index 000000000..4a0b49cd4 --- /dev/null +++ b/src/utils/messages/systemFactories.test.ts @@ -0,0 +1,233 @@ +import { feature } from 'bun:bundle' +import { expect, test } from 'bun:test' +import { + createAgentsKilledMessage, + createApiMetricsMessage, + createAwaySummaryMessage, + createBridgeStatusMessage, + createCommandInputMessage, + createCompactBoundaryMessage, + createMemorySavedMessage, + createMicrocompactBoundaryMessage, + createPermissionRetryMessage, + createScheduledTaskFireMessage, + createStopHookSummaryMessage, + createSystemAPIErrorMessage, + createSystemMessage, + createTurnDurationMessage, + findLastCompactBoundaryIndex, + getMessagesAfterCompactBoundary, + isCompactBoundaryMessage, +} from './systemFactories.js' +import type { Message } from '../../types/message.js' + +test('createSystemMessage builds an informational system message', () => { + const message = createSystemMessage('ready', 'info', 'toolu_1', true) + + expect(message).toMatchObject({ + type: 'system', + subtype: 'informational', + content: 'ready', + level: 'info', + toolUseID: 'toolu_1', + preventContinuation: true, + }) +}) + +test('system factory helpers build their expected message shapes', () => { + expect(createPermissionRetryMessage(['Bash(ls)', 'Read(*)'])).toMatchObject({ + type: 'system', + subtype: 'permission_retry', + content: 'Allowed Bash(ls), Read(*)', + commands: ['Bash(ls)', 'Read(*)'], + level: 'info', + isMeta: false, + }) + expect(createBridgeStatusMessage('https://remote.test', 'upgrade')).toMatchObject({ + type: 'system', + subtype: 'bridge_status', + content: '/remote-control is active. Code in CLI or at https://remote.test', + url: 'https://remote.test', + upgradeNudge: 'upgrade', + }) + expect(createScheduledTaskFireMessage('run task')).toMatchObject({ + type: 'system', + subtype: 'scheduled_task_fire', + content: 'run task', + isMeta: false, + }) + expect( + createStopHookSummaryMessage( + 2, + [{ hookName: 'stop', command: 'echo ok', matcher: undefined }], + ['failed'], + true, + 'stop_sequence', + true, + 'warning', + 'toolu_stop', + 'Stop', + 123, + ), + ).toMatchObject({ + type: 'system', + subtype: 'stop_hook_summary', + hookCount: 2, + hookErrors: ['failed'], + preventedContinuation: true, + stopReason: 'stop_sequence', + hasOutput: true, + level: 'warning', + toolUseID: 'toolu_stop', + hookLabel: 'Stop', + totalDurationMs: 123, + }) + expect( + createTurnDurationMessage( + 250, + { tokens: 10, limit: 100, nudges: 1 }, + 4, + ), + ).toMatchObject({ + type: 'system', + subtype: 'turn_duration', + durationMs: 250, + budgetTokens: 10, + budgetLimit: 100, + budgetNudges: 1, + messageCount: 4, + isMeta: false, + }) + expect(createAwaySummaryMessage('away')).toMatchObject({ + type: 'system', + subtype: 'away_summary', + content: 'away', + isMeta: false, + }) + expect(createMemorySavedMessage(['/tmp/memory.md'])).toMatchObject({ + type: 'system', + subtype: 'memory_saved', + writtenPaths: ['/tmp/memory.md'], + isMeta: false, + }) + expect(createAgentsKilledMessage()).toMatchObject({ + type: 'system', + subtype: 'agents_killed', + isMeta: false, + }) + expect( + createApiMetricsMessage({ + ttftMs: 12, + otps: 34, + isP50: true, + hookDurationMs: 5, + turnDurationMs: 6, + toolDurationMs: 7, + classifierDurationMs: 8, + toolCount: 2, + hookCount: 3, + classifierCount: 1, + configWriteCount: 4, + }), + ).toMatchObject({ + type: 'system', + subtype: 'api_metrics', + ttftMs: 12, + otps: 34, + isP50: true, + hookDurationMs: 5, + turnDurationMs: 6, + toolDurationMs: 7, + classifierDurationMs: 8, + toolCount: 2, + hookCount: 3, + classifierCount: 1, + configWriteCount: 4, + isMeta: false, + }) + expect(createCommandInputMessage('test')).toMatchObject({ + type: 'system', + subtype: 'local_command', + content: 'test', + level: 'info', + isMeta: false, + }) +}) + +test('microcompact and API error factories preserve branch-specific fields', () => { + const microcompact = createMicrocompactBoundaryMessage( + 'auto', + 1000, + 250, + ['toolu_1'], + ['attachment_1'], + ) + expect(microcompact).toMatchObject({ + type: 'system', + subtype: 'microcompact_boundary', + content: 'Context microcompacted', + level: 'info', + isMeta: false, + microcompactMetadata: { + trigger: 'auto', + preTokens: 1000, + tokensSaved: 250, + compactedToolIds: ['toolu_1'], + clearedAttachmentUUIDs: ['attachment_1'], + }, + }) + + const cause = new Error('network down') + const errorWithCause = { message: 'api failed', cause } as any + expect(createSystemAPIErrorMessage(errorWithCause, 100, 2, 5)).toMatchObject({ + type: 'system', + subtype: 'api_error', + level: 'error', + error: errorWithCause, + cause, + retryInMs: 100, + retryAttempt: 2, + maxRetries: 5, + }) + + const errorWithoutErrorCause = { message: 'api failed', cause: 'string cause' } as any + expect( + createSystemAPIErrorMessage(errorWithoutErrorCause, 200, 1, 3).cause, + ).toBeUndefined() +}) + +test('compact boundary helpers find and slice from the latest boundary', () => { + const first = createSystemMessage('old', 'info') + const boundary = createCompactBoundaryMessage('manual', 100) + const last = createSystemMessage('new', 'info') + + expect(isCompactBoundaryMessage(boundary)).toBe(true) + expect(findLastCompactBoundaryIndex([first, boundary, last])).toBe(1) + expect(getMessagesAfterCompactBoundary([first, boundary, last])).toEqual([ + boundary, + last, + ]) +}) + +test('compact boundary slicing also applies snip projection when enabled', () => { + const removed = createSystemMessage('removed', 'info') + const kept = createSystemMessage('kept', 'info') + const snipBoundary = { + ...createSystemMessage('snipped', 'info'), + subtype: 'snip_boundary', + snipMetadata: { removedUuids: [removed.uuid] }, + } as Message + + const messages = [removed, snipBoundary, kept] + const result = getMessagesAfterCompactBoundary(messages) + + if (feature('HISTORY_SNIP')) { + expect(result).toEqual([snipBoundary, kept]) + } else { + expect(result).toEqual(messages) + } + + expect(getMessagesAfterCompactBoundary(messages, { includeSnipped: true })).toEqual( + messages, + ) +}) diff --git a/src/utils/messages/systemFactories.ts b/src/utils/messages/systemFactories.ts new file mode 100644 index 000000000..b60d00087 --- /dev/null +++ b/src/utils/messages/systemFactories.ts @@ -0,0 +1,329 @@ +import { feature } from "bun:bundle" +import type { APIError } from "@anthropic-ai/sdk" +import { randomUUID, type UUID } from "crypto" +import type { Message, NormalizedMessage, StopHookInfo, SystemAgentsKilledMessage, SystemAPIErrorMessage, SystemApiMetricsMessage, SystemAwaySummaryMessage, SystemBridgeStatusMessage, SystemCompactBoundaryMessage, SystemInformationalMessage, SystemLocalCommandMessage, SystemMemorySavedMessage, SystemMicrocompactBoundaryMessage, SystemPermissionRetryMessage, SystemScheduledTaskFireMessage, SystemStopHookSummaryMessage, SystemTurnDurationMessage, SystemMessageLevel } from "../../types/message.js" +import { formatTokens } from "../format.js" +import { logForDebugging } from "../debug.js" + +export function createSystemMessage( + content: string, + level: SystemMessageLevel, + toolUseID?: string, + preventContinuation?: boolean, +): SystemInformationalMessage { + return { + type: 'system', + subtype: 'informational', + content, + isMeta: false, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + toolUseID, + level, + ...(preventContinuation && { preventContinuation }), + } +} + +export function createPermissionRetryMessage( + commands: string[], +): SystemPermissionRetryMessage { + return { + type: 'system', + subtype: 'permission_retry', + content: `Allowed ${commands.join(', ')}`, + commands, + level: 'info', + isMeta: false, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + } +} + +export function createBridgeStatusMessage( + url: string, + upgradeNudge?: string, +): SystemBridgeStatusMessage { + return { + type: 'system', + subtype: 'bridge_status', + content: `/remote-control is active. Code in CLI or at ${url}`, + url, + upgradeNudge, + isMeta: false, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + } +} + +export function createScheduledTaskFireMessage( + content: string, +): SystemScheduledTaskFireMessage { + return { + type: 'system', + subtype: 'scheduled_task_fire', + content, + isMeta: false, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + } +} + +export function createStopHookSummaryMessage( + hookCount: number, + hookInfos: StopHookInfo[], + hookErrors: string[], + preventedContinuation: boolean, + stopReason: string | undefined, + hasOutput: boolean, + level: SystemMessageLevel, + toolUseID?: string, + hookLabel?: string, + totalDurationMs?: number, +): SystemStopHookSummaryMessage { + return { + type: 'system', + subtype: 'stop_hook_summary', + hookCount, + hookInfos, + hookErrors, + preventedContinuation, + stopReason, + hasOutput, + level, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + toolUseID, + hookLabel, + totalDurationMs, + } +} + +export function createTurnDurationMessage( + durationMs: number, + budget?: { tokens: number; limit: number; nudges: number }, + messageCount?: number, +): SystemTurnDurationMessage { + return { + type: 'system', + subtype: 'turn_duration', + durationMs, + budgetTokens: budget?.tokens, + budgetLimit: budget?.limit, + budgetNudges: budget?.nudges, + messageCount, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + isMeta: false, + } +} + +export function createAwaySummaryMessage( + content: string, +): SystemAwaySummaryMessage { + return { + type: 'system', + subtype: 'away_summary', + content, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + isMeta: false, + } +} + +export function createMemorySavedMessage( + writtenPaths: string[], +): SystemMemorySavedMessage { + return { + type: 'system', + subtype: 'memory_saved', + writtenPaths, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + isMeta: false, + } +} + +export function createAgentsKilledMessage(): SystemAgentsKilledMessage { + return { + type: 'system', + subtype: 'agents_killed', + timestamp: new Date().toISOString(), + uuid: randomUUID(), + isMeta: false, + } +} + +export function createApiMetricsMessage(metrics: { + ttftMs: number + otps: number + isP50?: boolean + hookDurationMs?: number + turnDurationMs?: number + toolDurationMs?: number + classifierDurationMs?: number + toolCount?: number + hookCount?: number + classifierCount?: number + configWriteCount?: number +}): SystemApiMetricsMessage { + return { + type: 'system', + subtype: 'api_metrics', + ttftMs: metrics.ttftMs, + otps: metrics.otps, + isP50: metrics.isP50, + hookDurationMs: metrics.hookDurationMs, + turnDurationMs: metrics.turnDurationMs, + toolDurationMs: metrics.toolDurationMs, + classifierDurationMs: metrics.classifierDurationMs, + toolCount: metrics.toolCount, + hookCount: metrics.hookCount, + classifierCount: metrics.classifierCount, + configWriteCount: metrics.configWriteCount, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + isMeta: false, + } +} + +export function createCommandInputMessage( + content: string, +): SystemLocalCommandMessage { + return { + type: 'system', + subtype: 'local_command', + content, + level: 'info', + timestamp: new Date().toISOString(), + uuid: randomUUID(), + isMeta: false, + } +} + +export function createCompactBoundaryMessage( + trigger: 'manual' | 'auto', + preTokens: number, + lastPreCompactMessageUuid?: UUID, + userContext?: string, + messagesSummarized?: number, +): SystemCompactBoundaryMessage { + return { + type: 'system', + subtype: 'compact_boundary', + content: `Conversation compacted`, + isMeta: false, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + level: 'info', + compactMetadata: { + trigger, + preTokens, + userContext, + messagesSummarized, + }, + ...(lastPreCompactMessageUuid && { + logicalParentUuid: lastPreCompactMessageUuid, + }), + } +} + +export function createMicrocompactBoundaryMessage( + trigger: 'auto', + preTokens: number, + tokensSaved: number, + compactedToolIds: string[], + clearedAttachmentUUIDs: string[], +): SystemMicrocompactBoundaryMessage { + logForDebugging( + `[microcompact] saved ~${formatTokens(tokensSaved)} tokens (cleared ${compactedToolIds.length} tool results)`, + ) + return { + type: 'system', + subtype: 'microcompact_boundary', + content: 'Context microcompacted', + isMeta: false, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + level: 'info', + microcompactMetadata: { + trigger, + preTokens, + tokensSaved, + compactedToolIds, + clearedAttachmentUUIDs, + }, + } +} + +export function createSystemAPIErrorMessage( + error: APIError, + retryInMs: number, + retryAttempt: number, + maxRetries: number, +): SystemAPIErrorMessage { + return { + type: 'system', + subtype: 'api_error', + level: 'error', + cause: error.cause instanceof Error ? error.cause : undefined, + error, + retryInMs, + retryAttempt, + maxRetries, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + } +} + +/** + * Checks if a message is a compact boundary marker + */ +export function isCompactBoundaryMessage( + message: Message | NormalizedMessage, +): message is SystemCompactBoundaryMessage { + return message?.type === 'system' && message.subtype === 'compact_boundary' +} + +/** + * Finds the index of the last compact boundary marker in the messages array + * @returns The index of the last compact boundary, or -1 if none found + */ +export function findLastCompactBoundaryIndex< + T extends Message | NormalizedMessage, +>(messages: T[]): number { + // Scan backwards to find the most recent compact boundary + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message && isCompactBoundaryMessage(message)) { + return i + } + } + return -1 // No boundary found +} + +/** + * Returns messages from the last compact boundary onward (including the boundary). + * If no boundary exists, returns all messages. + * + * Also filters snipped messages by default (when HISTORY_SNIP is enabled) — + * the REPL keeps full history for UI scrollback, so model-facing paths need + * both compact-slice AND snip-filter applied. Pass `{ includeSnipped: true }` + * to opt out (e.g., REPL.tsx fullscreen compact handler which preserves + * snipped messages in scrollback). + * + * Note: The boundary itself is a system message and will be filtered by normalizeMessagesForAPI. + */ +export function getMessagesAfterCompactBoundary< + T extends Message | NormalizedMessage, +>(messages: T[], options?: { includeSnipped?: boolean }): T[] { + const boundaryIndex = findLastCompactBoundaryIndex(messages) + const sliced = boundaryIndex === -1 ? messages : messages.slice(boundaryIndex) + if (!options?.includeSnipped && feature('HISTORY_SNIP')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { projectSnippedView } = + require('../../services/compact/snipProjection.js') as typeof import('../../services/compact/snipProjection.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + return projectSnippedView(sliced as Message[]) as T[] + } + return sliced +}