diff --git a/.changeset/compaction-recovery-pointer.md b/.changeset/compaction-recovery-pointer.md new file mode 100644 index 000000000..e8dc82bc3 --- /dev/null +++ b/.changeset/compaction-recovery-pointer.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Point compacted conversation notes at the on-disk event log so later turns can recover exact outputs. diff --git a/.changeset/compaction-resume-anchor.md b/.changeset/compaction-resume-anchor.md new file mode 100644 index 000000000..01220a82e --- /dev/null +++ b/.changeset/compaction-resume-anchor.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +After context compaction, keep a short continue-work instruction as the latest user message. diff --git a/.changeset/file-history-turn-snapshots.md b/.changeset/file-history-turn-snapshots.md new file mode 100644 index 000000000..e9acf08de --- /dev/null +++ b/.changeset/file-history-turn-snapshots.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add turn-level file history for Edit and Write. Clients can list a turn's file changes and read captured content through the session file-history API. diff --git a/.changeset/notify-user-updates.md b/.changeset/notify-user-updates.md new file mode 100644 index 000000000..7073b560e --- /dev/null +++ b/.changeset/notify-user-updates.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add an experimental NotifyUser tool that posts mid-turn updates in a TUI Updates panel. Enable it with PYTHINKER_CODE_EXPERIMENTAL_NOTIFY_USER=1, `[experimental] notify_user = true`, or `/experiments`. diff --git a/.changeset/print-flush-and-fork-cutoff.md b/.changeset/print-flush-and-fork-cutoff.md new file mode 100644 index 000000000..ce258d1c1 --- /dev/null +++ b/.changeset/print-flush-and-fork-cutoff.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Keep print-mode shutdown from skipping journal flush, and keep truncated forks from dropping later work in the retained turn. diff --git a/.changeset/print-flush-journals-on-exit.md b/.changeset/print-flush-journals-on-exit.md new file mode 100644 index 000000000..200a0093d --- /dev/null +++ b/.changeset/print-flush-journals-on-exit.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Keep print-mode session journals complete when the process exits or receives a termination signal. diff --git a/.changeset/read-character-budgets.md b/.changeset/read-character-budgets.md new file mode 100644 index 000000000..e310380d8 --- /dev/null +++ b/.changeset/read-character-budgets.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Read large files in pages with a character budget instead of a 1000-line or 100 KB cap. Set `[read] default_max_chars` and `[read] max_chars` in config.toml, or pass `max_chars` and `column_offset` on Read. diff --git a/.changeset/session-rating-survey.md b/.changeset/session-rating-survey.md new file mode 100644 index 000000000..767b01262 --- /dev/null +++ b/.changeset/session-rating-survey.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add an occasional session rating prompt above the editor. Turn it off with `disable_feedback_survey = true` in `tui.toml` or Settings → Feedback survey. diff --git a/apps/pythinker-code/src/cli/run-shell.ts b/apps/pythinker-code/src/cli/run-shell.ts index da8f3c657..edd910777 100644 --- a/apps/pythinker-code/src/cli/run-shell.ts +++ b/apps/pythinker-code/src/cli/run-shell.ts @@ -17,7 +17,7 @@ import { withTelemetryContext, } from '@pymodel/pythinker-telemetry'; -import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, TUI_HOST_UI_CAPABILITIES } from '#/constant/app'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; @@ -66,6 +66,7 @@ export async function runShell( homeDir: telemetryBootstrap.homeDir, identity: createPythinkerCodeHostIdentity(version), skillDirs: opts.skillsDirs, + uiCapabilities: TUI_HOST_UI_CAPABILITIES, telemetry: telemetryClient, sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false }, }; @@ -104,6 +105,7 @@ export async function runShell( workDir, startupNotice: configWarning, engineV2, + telemetryDisabled: config.telemetry === false, }); initializeCliTelemetry({ diff --git a/apps/pythinker-code/src/cli/v2/run-v2-print.ts b/apps/pythinker-code/src/cli/v2/run-v2-print.ts index e8cbd276d..1ccc2b04b 100644 --- a/apps/pythinker-code/src/cli/v2/run-v2-print.ts +++ b/apps/pythinker-code/src/cli/v2/run-v2-print.ts @@ -22,6 +22,7 @@ import { AgentCron, AgentGoal, IAgentLifecycleService, + IAgentLoopService, IAgentPermissionModeService, IAgentProfileService, IAgentPromptService, @@ -30,6 +31,7 @@ import { IBootstrapService, IConfigService, IEventBus, + IEventDispatcher, IHostFileSystem, ISessionIndex, IWorkspaceInstanceManager, @@ -125,6 +127,8 @@ import { const PROMPT_UI_MODE = 'print'; /** Re-check `goalActive` at least this often while waiting for goal turns. */ const GOAL_WAIT_POLL_MS = 250; +/** Re-check each agent's prompt queue while waiting for it to drain at exit. */ +const PROMPT_QUIESCE_POLL_MS = 10; /** * Slack on top of a scheduled cron fire time while waiting for the steered * turn: covers the 1s tick poll interval plus fire → inject → turn-launch @@ -197,6 +201,9 @@ export async function runV2Print( } let restorePermission = async (): Promise => {}; + let quiesceAgents = async (_signal?: AbortSignal): Promise => {}; + let releaseQuiescence: (() => void) | undefined; + let flushWires = async (): Promise => {}; let removeTerminationCleanup: (() => void) | undefined; let cleanupPromise: Promise | undefined; let telemetryService: ITelemetryService | undefined; @@ -204,16 +211,27 @@ export async function runV2Print( const pending = (cleanupPromise ??= (async () => { removeTerminationCleanup?.(); setCrashPhase('shutdown'); + const quiesceAbort = new AbortController(); try { await restorePermission(); + await raceWithTimeout(quiesceAgents(quiesceAbort.signal), CLI_SHUTDOWN_TIMEOUT_MS).catch( + () => {}, + ); } finally { + quiesceAbort.abort(); try { - if (telemetryService !== undefined) { - await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS); - } - } finally { - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}); + await Promise.all([ + raceWithTimeout(flushWires(), CLI_SHUTDOWN_TIMEOUT_MS).catch(() => {}), + telemetryService !== undefined + ? raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS).catch( + () => {}, + ) + : Promise.resolve(), + shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}), + ]); app.dispose(); + } finally { + releaseQuiescence?.(); } } })()); @@ -262,6 +280,10 @@ export async function runV2Print( const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); restorePermission = resolved.restorePermission; + quiesceAgents = async (signal) => { + releaseQuiescence = await quiesceSessionAgents(resolved.session, resolved.agent, signal); + }; + flushWires = () => flushSessionWires(resolved.session, resolved.agent); telemetryService.setContext({ sessionId: resolved.session.id, model: resolved.telemetryModel }); setTelemetryContext({ sessionId: resolved.session.id }); @@ -935,6 +957,149 @@ function countPendingBackgroundTasks(session: ISessionScopeHandle): number { return count; } +function collectSessionAgentHandles( + session: ISessionScopeHandle, + mainAgent: IAgentScopeHandle, +): IAgentScopeHandle[] { + const agentManager = session.accessor.get(IAgentLifecycleService); + const handles = new Set([mainAgent]); + for (const agent of agentManager.list()) { + const handle = agentManager.handleOf(agent.agentId); + if (handle !== undefined) handles.add(handle); + } + return [...handles]; +} + +export function abortPromise(signal: AbortSignal | undefined): Promise { + if (signal === undefined) return new Promise(() => {}); + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); +} + +export async function settleOrAbort( + signal: AbortSignal | undefined, + work: Promise, +): Promise { + if (signal?.aborted) return; + await Promise.race([work.then(() => undefined, () => undefined), abortPromise(signal)]); +} + +export async function delayOrAbort(signal: AbortSignal | undefined, ms: number): Promise { + if (signal?.aborted) return; + await new Promise((resolve) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = (): void => { + clearTimeout(timer); + resolve(); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +async function quiesceSessionAgents( + session: ISessionScopeHandle, + mainAgent: IAgentScopeHandle, + signal?: AbortSignal, +): Promise<(() => void) | undefined> { + const handles = collectSessionAgentHandles(session, mainAgent); + const promptServices = handles.flatMap((handle) => { + try { + return [handle.accessor.get(IAgentPromptService)]; + } catch { + return []; + } + }); + const loops = handles.flatMap((handle) => { + try { + return [handle.accessor.get(IAgentLoopService)]; + } catch { + return []; + } + }); + await settleOrAbort( + signal, + Promise.allSettled( + handles.flatMap((handle) => { + try { + return [handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed')]; + } catch { + return []; + } + }), + ), + ); + for (;;) { + if (signal?.aborted) return undefined; + await settleOrAbort( + signal, + Promise.allSettled(promptServices.map((service) => service.drain())), + ); + if (signal?.aborted) return undefined; + for (const loop of loops) { + for (const turnId of loop.status().pendingTurnIds) loop.cancel(turnId); + loop.cancel(); + } + await settleOrAbort( + signal, + Promise.allSettled(loops.map((loop) => loop.settled())), + ); + const guards: { dispose(): void }[] = []; + let frozen = true; + for (const loop of loops) { + let guard: { dispose(): void } | undefined; + try { + guard = loop.tryAcquireQuiescence(); + } catch { + continue; + } + if (guard === undefined) { + frozen = false; + break; + } + guards.push(guard); + } + const busy = promptServices.some((service) => { + try { + const snapshot = service.list(); + return snapshot.launching || snapshot.active !== undefined || snapshot.pending.length > 0; + } catch { + return false; + } + }); + if (signal?.aborted) { + for (const guard of guards) guard.dispose(); + return undefined; + } + if (frozen && !busy) { + return () => { + for (const guard of guards) guard.dispose(); + }; + } + for (const guard of guards) guard.dispose(); + await delayOrAbort(signal, PROMPT_QUIESCE_POLL_MS); + } +} + +async function flushSessionWires( + session: ISessionScopeHandle, + mainAgent: IAgentScopeHandle, +): Promise { + await Promise.allSettled( + collectSessionAgentHandles(session, mainAgent).flatMap((handle) => { + try { + return [handle.accessor.get(IEventDispatcher).flush()]; + } catch { + return []; + } + }), + ); +} + async function drainBackgroundTasks( session: ISessionScopeHandle, ceilingS: number | undefined, diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index 78f4eef51..48b0349d6 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -1,4 +1,4 @@ -import { ErrorCodes } from '@pymodel/pythinker-code-sdk'; +import { ErrorCodes, type HostUiCapability } from '@pymodel/pythinker-code-sdk'; export const PRODUCT_NAME = 'Pythinker Code'; export const CLI_COMMAND_NAME = 'pythinker'; @@ -7,6 +7,7 @@ export const PROCESS_NAME = 'pythinker-code'; // Used in telemetry app names and HTTP User-Agent headers. export const CLI_USER_AGENT_PRODUCT = 'pythinker-code-cli'; export const CLI_UI_MODE = 'shell'; +export const TUI_HOST_UI_CAPABILITIES: readonly HostUiCapability[] = ['update_panel']; // Telemetry ui_mode for the `pythinker web` host. Same product // as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode. export const WEB_UI_MODE = 'web'; @@ -78,6 +79,7 @@ export const PYTHINKER_CODE_UPDATE_REEXEC_ENV = 'PYTHINKER_CODE_UPDATE_REEXEC'; export const PYTHINKER_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; export const PYTHINKER_CODE_BANNER_DIR_NAME = 'banner'; export const PYTHINKER_CODE_BANNER_STATE_FILE_NAME = 'state.json'; +export const PYTHINKER_CODE_SURVEY_STATE_FILE_NAME = 'feedback-survey-state.json'; // SDK/core error code that tells the TUI to show a login-required startup // notice. Derived from sdk's ErrorCodes so a future rename in core diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index 1df48f988..021ab6e03 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -19,6 +19,7 @@ import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selec import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; +import { SurveyPreferenceSelectorComponent } from '../components/dialogs/survey-preference-selector'; import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config'; @@ -60,6 +61,8 @@ export function currentTuiConfig(host: Pick): TuiConf disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, renderLatex: host.state.appState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true, cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, + disableFeedbackSurvey: + host.state.appState.disableFeedbackSurvey ?? DEFAULT_TUI_CONFIG.disableFeedbackSurvey, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, statusLine: host.state.appState.statusLine ?? DEFAULT_TUI_CONFIG.statusLine, @@ -708,7 +711,7 @@ export async function applyExperimentalFeatureChanges( setExperimentalFeatures(features); host.refreshSlashCommandAutocomplete(); host.restoreEditor(); - if (host.session !== undefined) { + if (host.session !== undefined && changes.some((change) => change.id !== 'notify_user')) { const reloadedSession = await host.harness.reloadSession({ id: host.session.id }); await host.reloadCurrentSessionView( reloadedSession, @@ -717,6 +720,14 @@ export async function applyExperimentalFeatureChanges( } else { host.showStatus('Experimental features updated.', 'success'); } + if ( + host.session !== undefined && + changes.some((change) => change.id === 'notify_user' && change.enabled) + ) { + host.showNotice( + 'Start a new session to use Updates if this session was created with the feature disabled.', + ); + } if (changes.some((change) => change.id === 'tower')) { host.showNotice('Tower mode takes effect after restarting Pythinker Code.'); } @@ -839,7 +850,63 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio case 'theme': showThemePicker(host); return; case 'editor': showEditorPicker(host); return; case 'experiments': void showExperimentsPanel(host); return; + case 'survey': showSurveyPreferencePicker(host); return; case 'upgrade': showUpdatePreferencePicker(host); return; case 'usage': void showUsage(host); return; } } + +export function showSurveyPreferencePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new SurveyPreferenceSelectorComponent({ + currentValue: host.state.appState.disableFeedbackSurvey !== true, + onSelect: (value) => { + host.restoreEditor(); + void applySurveyPreferenceChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +type SurveyPreferenceHost = { + readonly state: { + readonly appState: Pick< + SlashCommandHost['state']['appState'], + 'theme' | 'editorCommand' | 'notifications' | 'upgrade' | 'disableFeedbackSurvey' + >; + }; + setAppState( + patch: Pick, + ): void; + showStatus(msg: string, color?: string): void; +}; + +export async function applySurveyPreferenceChoice( + host: SurveyPreferenceHost, + enabled: boolean, +): Promise { + const disableFeedbackSurvey = !enabled; + if (disableFeedbackSurvey === (host.state.appState.disableFeedbackSurvey === true)) { + host.showStatus(`Feedback survey already ${enabled ? 'enabled' : 'disabled'}.`); + return; + } + + try { + await saveTuiConfig({ + ...currentTuiConfig(host as unknown as SlashCommandHost), + disableFeedbackSurvey, + }); + } catch (error) { + host.showStatus( + `Failed to save session rating setting: ${formatErrorMessage(error)}`, + 'error', + ); + return; + } + + host.setAppState({ disableFeedbackSurvey }); + host.showStatus(`Feedback survey ${enabled ? 'enabled' : 'disabled'}.`); +} diff --git a/apps/pythinker-code/src/tui/commands/reload.ts b/apps/pythinker-code/src/tui/commands/reload.ts index 716d900ec..96a0fce72 100644 --- a/apps/pythinker-code/src/tui/commands/reload.ts +++ b/apps/pythinker-code/src/tui/commands/reload.ts @@ -73,6 +73,7 @@ export async function applyReloadedTuiConfig( disablePasteBurst: config.disablePasteBurst, renderLatex: config.renderLatex, cacheExpiryHint: config.cacheExpiryHint, + disableFeedbackSurvey: config.disableFeedbackSurvey, notifications: config.notifications, upgrade: config.upgrade, statusLine: config.statusLine, diff --git a/apps/pythinker-code/src/tui/components/chrome/notify-panel.ts b/apps/pythinker-code/src/tui/components/chrome/notify-panel.ts new file mode 100644 index 000000000..5ca99fb60 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/chrome/notify-panel.ts @@ -0,0 +1,336 @@ +/** + * NotifyPanel — mid-turn updates, shown as a bordered box right above the + * input area (below the Todo panel), visually matching the editor. + * + * Updates are grouped into CHANNELS, one per agent: the main agent plus one + * channel per subagent that posts a `NotifyUser` update. The top border is a + * tab strip of channel labels; `Ctrl+N` focuses the box, then `←`/`→` switch + * channels and `↑`/`↓` page through the current channel's updates. While the + * user is not focused, the view follows the latest activity across channels; + * while focused it stays put and other channels collect an unread dot. + * Entries render at their natural height — the box adapts instead of + * truncating. When the turn ends the box folds to a one-line stub until the + * user focuses it; the host clears the box when the next turn starts. + */ + +import type { Component } from '@pymodel/pi-tui'; +import { Markdown, truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; +import chalk from 'chalk'; + +import { MAIN_AGENT_ID } from '#/tui/constant/pythinker-tui'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; + +import { wrapWithSideBorders } from '../editor/custom-editor'; + +export interface NotifyEntry { + readonly id: string; + readonly agentId: string; + readonly agentName?: string; + readonly time: number; + readonly text: string; +} + +export interface NotifyChannelView { + readonly label: string; + readonly entries: readonly { readonly id: string; readonly text: string }[]; + readonly unread: number; +} + +interface NotifyChannel { + readonly key: string; + readonly label: string; + readonly entries: NotifyEntry[]; + /** Page (entry index) the user last read in this channel. */ + page: number; + /** Ids of entries that arrived while the user was reading another channel. */ + readonly unreadIds: Set; +} + +function padToVisibleWidth(text: string, target: number): string { + const truncated = truncateToWidth(text, target); + const gap = target - visibleWidth(truncated); + return gap > 0 ? truncated + ' '.repeat(gap) : truncated; +} + +export class NotifyPanelComponent implements Component { + private readonly channels: NotifyChannel[] = []; + /** Key of the channel on display; channels themselves are append-only. */ + private activeKey = MAIN_AGENT_ID; + private focused = false; + private ended = false; + /** Turn ended and no one is reading: the box folds to a one-line stub. */ + private collapsed = false; + + /** + * Add or update an entry in a channel. A repeated `id` updates the entry in + * place; a new id appends. While unfocused the view jumps to the entry's + * channel and its tail — the latest activity always wins; while focused the + * user's page stays put and background channels collect unread instead. + */ + upsert(entry: NotifyEntry): void { + const ch = this.channelFor(entry); + const existing = ch.entries.find((item) => item.id === entry.id); + const isNew = existing === undefined; + if (existing !== undefined) ch.entries[ch.entries.indexOf(existing)] = entry; + else ch.entries.push(entry); + if (!this.focused) { + this.activeKey = ch.key; + ch.page = ch.entries.length - 1; + ch.unreadIds.clear(); + } else if (isNew && ch.key !== this.activeKey) { + ch.unreadIds.add(entry.id); + } + this.ended = false; + this.collapsed = false; + } + + remove(id: string): boolean { + const ch = this.channels.find((channel) => channel.entries.some((entry) => entry.id === id)); + if (ch === undefined) return false; + const index = ch.entries.findIndex((entry) => entry.id === id); + ch.entries.splice(index, 1); + if (ch.entries.length === 0) { + this.channels.splice(this.channels.indexOf(ch), 1); + if (ch.key === this.activeKey) this.activeKey = this.channels.at(-1)?.key ?? MAIN_AGENT_ID; + } else { + ch.page = Math.min(ch.page, ch.entries.length - 1); + ch.unreadIds.delete(id); + } + if (this.channels.length === 0) this.focused = false; + return true; + } + + clear(): void { + this.channels.length = 0; + this.activeKey = MAIN_AGENT_ID; + this.focused = false; + this.ended = false; + this.collapsed = false; + } + + isEmpty(): boolean { + return this.channels.length === 0; + } + + getChannels(): readonly NotifyChannelView[] { + return this.channels.map((ch) => ({ + label: ch.label, + entries: ch.entries.map((entry) => ({ id: entry.id, text: entry.text })), + unread: ch.unreadIds.size, + })); + } + + /** Every entry across channels, in display order (main first, then arrival). */ + getEntries(): readonly NotifyEntry[] { + return this.channels.flatMap((ch) => ch.entries); + } + + /** + * The turn that produced these updates has ended: dim the title and fold + * the box down to a one-line stub so the final reply owns the screen. A + * focused user keeps reading — the fold lands when they blur. + */ + setEnded(ended: boolean): void { + this.ended = ended; + if (ended && !this.focused) this.collapsed = true; + if (!ended) this.collapsed = false; + } + + /** Grab keyboard paging for the box; returns false when there is nothing to read. */ + focus(): boolean { + if (this.channels.length === 0) return false; + this.focused = true; + this.collapsed = false; + this.activeChannel().unreadIds.clear(); + return true; + } + + blur(): boolean { + if (!this.focused) return false; + this.focused = false; + if (this.ended) this.collapsed = true; + return true; + } + + isFocused(): boolean { + return this.focused; + } + + /** Older channel (`←`), landing on its latest update; false on the leftmost tab. */ + prevChannel(): boolean { + const index = this.activeIndex(); + if (index <= 0) return false; + this.activeKey = this.channels[index - 1]!.key; + const ch = this.activeChannel(); + ch.page = ch.entries.length - 1; + ch.unreadIds.clear(); + return true; + } + + /** Newer channel (`→`), landing on its latest update; false on the rightmost tab. */ + nextChannel(): boolean { + const index = this.activeIndex(); + if (index >= this.channels.length - 1) return false; + this.activeKey = this.channels[index + 1]!.key; + const ch = this.activeChannel(); + ch.page = ch.entries.length - 1; + ch.unreadIds.clear(); + return true; + } + + /** Older update in the current channel (`↑`); false on the first page. */ + prevPage(): boolean { + if (this.channels.length === 0) return false; + const ch = this.activeChannel(); + if (ch.page <= 0) return false; + ch.page -= 1; + return true; + } + + /** Newer update in the current channel (`↓`); false on the latest page. */ + nextPage(): boolean { + if (this.channels.length === 0) return false; + const ch = this.activeChannel(); + if (ch.page >= ch.entries.length - 1) return false; + ch.page += 1; + return true; + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.channels.length === 0) return []; + const c = currentTheme.palette; + const paint = chalk.hex(this.focused ? c.primary : c.border); + + if (this.collapsed) { + return ['', this.stubLine(width)].map((line) => truncateToWidth(line, width)); + } + + const innerWidth = Math.max(1, width - 6); + const ch = this.activeChannel(); + const entry = ch.entries[Math.min(ch.page, ch.entries.length - 1)]!; + const bodyRows = new Markdown( + entry.text.trim(), + 0, + 0, + createMarkdownTheme(), + undefined, + createMarkdownOptions(), + ).render(innerWidth); + + const padRow = (row: string): string => ` ${padToVisibleWidth(row, width - 6)} `; + const emptyRow = ' '.repeat(width); + const lines: string[] = ['─'.repeat(width), emptyRow]; + for (const row of bodyRows) { + lines.push(padRow(row)); + } + lines.push(emptyRow, '─'.repeat(width)); + + const boxed = wrapWithSideBorders(lines, paint, { label: this.title(width) }); + return ['', ...boxed].map((line) => truncateToWidth(line, width)); + } + + private channelFor(entry: NotifyEntry): NotifyChannel { + const key = entry.agentId; + const found = this.channels.find((ch) => ch.key === key); + if (found !== undefined) return found; + const created: NotifyChannel = { + key, + label: this.labelFor(entry.agentName ?? key), + entries: [], + page: 0, + unreadIds: new Set(), + }; + if (key === MAIN_AGENT_ID) this.channels.unshift(created); + else this.channels.push(created); + return created; + } + + /** Dedup identical labels as `explore`, `explore(2)`, `explore(3)`, … */ + private labelFor(base: string): string { + if (!this.channels.some((ch) => ch.label === base)) return base; + let n = 2; + while (this.channels.some((ch) => ch.label === `${base}(${String(n)})`)) n += 1; + return `${base}(${String(n)})`; + } + + private activeIndex(): number { + const index = this.channels.findIndex((ch) => ch.key === this.activeKey); + return Math.max(0, index); + } + + private activeChannel(): NotifyChannel { + return this.channels[this.activeIndex()]!; + } + + /** + * The collapsed one-liner: an expand hint marker, the channel tabs, the + * total update count, and a preview of the current entry's first line — + * everything the user needs to decide whether to open the box. + */ + private stubLine(width: number): string { + const c = currentTheme.palette; + const dim = chalk.hex(c.textDim); + const marker = chalk.hex(c.primary)('▸'); + const tabs = this.channels + .map((channel) => this.renderTab(channel, channel.key === this.activeKey)) + .join(dim(' · ')); + const total = this.channels.reduce((sum, ch) => sum + ch.entries.length, 0); + const noun = total === 1 ? 'update' : 'updates'; + const head = ` ${marker} ${tabs} ${dim(`· ${String(total)} ${noun}`)}`; + const tail = ` ${dim('· ctrl+n')}`; + const preview = this.stubPreviewText(); + if (preview !== undefined) { + const budget = width - visibleWidth(head) - visibleWidth(tail) - 3; + if (budget >= 12) return `${head} ${dim('·')} ${dim(truncateToWidth(preview, budget))}${tail}`; + } + if (visibleWidth(head) + visibleWidth(tail) <= width) return `${head}${tail}`; + return ` ${marker} ${dim(`${String(total)} ${noun} · ctrl+n`)}`; + } + + /** First non-empty line of the current entry, stripped of list/bold markers. */ + private stubPreviewText(): string | undefined { + const ch = this.activeChannel(); + const entry = ch.entries[Math.min(ch.page, ch.entries.length - 1)]; + if (entry === undefined) return undefined; + const firstLine = entry.text + .trim() + .split('\n') + .find((line) => line.trim().length > 0) + ?.trim() + .replace(/^[-*#>\s]+/, '') + .replaceAll('**', ''); + return firstLine === undefined || firstLine.length === 0 ? undefined : firstLine; + } + + /** + * The styled top-border label: a tab strip of channel labels (active tab + * highlighted, background channels with unread dotted), then the page + * indicator and key hints, slimmed down progressively as width shrinks. + */ + private title(width: number): string | undefined { + const c = currentTheme.palette; + const ch = this.activeChannel(); + const page = `${String(ch.page + 1)}/${String(ch.entries.length)}`; + const state = this.ended ? ' · turn ended' : ''; + const hint = this.focused ? ' · ← → agent · ↑ ↓ update · esc close' : ' · ctrl+n page'; + const paintTitle = this.ended ? chalk.hex(c.textDim).bold : chalk.hex(c.primary).bold; + const tabs = this.channels + .map((channel) => this.renderTab(channel, channel.key === this.activeKey)) + .join(chalk.hex(c.textDim)(' · ')); + const full = ` ${tabs} · Updates ${page}${state}${hint} `; + if (visibleWidth(full) <= width - 4) return paintTitle(full); + const compact = ` ${ch.label} · Updates ${page}${state} `; + return visibleWidth(compact) <= width - 4 ? paintTitle(compact) : undefined; + } + + private renderTab(channel: NotifyChannel, active: boolean): string { + const c = currentTheme.palette; + if (active) return chalk.hex(c.primary).bold(channel.label); + const label = channel.unreadIds.size > 0 ? `${channel.label}●` : channel.label; + return channel.unreadIds.size > 0 ? chalk.hex(c.primary)(label) : chalk.hex(c.textDim)(label); + } +} diff --git a/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts index 81e4b8d12..ba3c7f2f4 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts @@ -6,6 +6,7 @@ export type SettingsSelection = | 'editor' | 'permission' | 'experiments' + | 'survey' | 'upgrade' | 'usage'; @@ -35,6 +36,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Experiments', description: 'Turn experimental features on or off.', }, + { + value: 'survey', + label: 'Feedback survey', + description: 'Show or hide the occasional session rating prompt.', + }, { value: 'upgrade', label: 'Automatic updates', @@ -54,6 +60,7 @@ function isSettingsSelection(value: string): value is SettingsSelection { value === 'editor' || value === 'permission' || value === 'experiments' || + value === 'survey' || value === 'upgrade' || value === 'usage' ); diff --git a/apps/pythinker-code/src/tui/components/dialogs/survey-preference-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/survey-preference-selector.ts new file mode 100644 index 000000000..0ef788e88 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/dialogs/survey-preference-selector.ts @@ -0,0 +1,34 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const SURVEY_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'on', + label: 'On', + description: 'Show the occasional rating prompt above the editor.', + }, + { + value: 'off', + label: 'Off', + description: 'Never show the rating prompt.', + }, +]; + +export interface SurveyPreferenceSelectorOptions { + readonly currentValue: boolean; + readonly onSelect: (value: boolean) => void; + readonly onCancel: () => void; +} + +export class SurveyPreferenceSelectorComponent extends ChoicePickerComponent { + constructor(opts: SurveyPreferenceSelectorOptions) { + super({ + title: 'Feedback survey', + options: [...SURVEY_PREFERENCE_OPTIONS], + currentValue: opts.currentValue ? 'on' : 'off', + onSelect: (value) => { + opts.onSelect(value === 'on'); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/pythinker-code/src/tui/components/editor/custom-editor.ts b/apps/pythinker-code/src/tui/components/editor/custom-editor.ts index ea3a0fbe1..5d3f6c315 100644 --- a/apps/pythinker-code/src/tui/components/editor/custom-editor.ts +++ b/apps/pythinker-code/src/tui/components/editor/custom-editor.ts @@ -125,6 +125,7 @@ export class CustomEditor extends Editor { * double-Esc so only two consecutive Escape presses trigger the shortcut. */ public onNonEscapeInput?: () => void; + public onPreInput?: (data: string) => boolean; public onCtrlD?: () => void; public onCtrlC?: () => void; public onToggleToolExpand?: () => void; @@ -134,6 +135,8 @@ export class CustomEditor extends Editor { public onCtrlB?: () => boolean; /** Return `true` to consume Ctrl+T (the todo list had overflow to toggle); return `false`/`undefined` to fall through to the editor default. */ public onToggleTodoExpand?: () => boolean; + public onPageNotify?: () => boolean; + public onNotifyPanelKey?: (key: 'left' | 'right' | 'up' | 'down' | 'escape') => boolean; public onUndo?: () => void; public onTextPaste?: () => void; /** @@ -256,7 +259,7 @@ export class CustomEditor extends Editor { return false; } - private hasAutocompleteActivity(): boolean { + public hasAutocompleteActivity(): boolean { const autocomplete = this as unknown as AutocompleteInternals; return ( this.isShowingAutocomplete() || @@ -382,6 +385,10 @@ export class CustomEditor extends Editor { this.onNonEscapeInput?.(); } + if (this.onPreInput?.(normalized) === true) { + return; + } + // When a paste marker was just expanded, discard the trailing bracketed // paste data that the terminal sends alongside the Ctrl-V keystroke. if (this.consumingPaste) { @@ -478,6 +485,30 @@ export class CustomEditor extends Editor { if (this.onToggleTodoExpand?.() === true) return; } + if (matchesKey(normalized, Key.ctrl('n'))) { + if (this.onPageNotify?.() === true) return; + } + + if ( + !this.hasAutocompleteActivity() && + (matchesKey(normalized, Key.left) || + matchesKey(normalized, Key.right) || + matchesKey(normalized, Key.up) || + matchesKey(normalized, Key.down) || + matchesKey(normalized, Key.escape)) + ) { + const panelKey = matchesKey(normalized, Key.left) + ? ('left' as const) + : matchesKey(normalized, Key.right) + ? ('right' as const) + : matchesKey(normalized, Key.up) + ? ('up' as const) + : matchesKey(normalized, Key.down) + ? ('down' as const) + : ('escape' as const); + if (this.onNotifyPanelKey?.(panelKey) === true) return; + } + if (matchesKey(normalized, 'shift+tab')) { this.onShiftTab?.(); return; diff --git a/apps/pythinker-code/src/tui/components/messages/tool-call.ts b/apps/pythinker-code/src/tui/components/messages/tool-call.ts index 2cb7869dd..4bdc4b9b3 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-call.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-call.ts @@ -5,7 +5,7 @@ import { isAbsolute, relative, sep } from 'node:path'; -import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; +import { Container, Markdown, Spacer, Text, truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; import type { Component, TUI } from '@pymodel/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; @@ -27,6 +27,9 @@ import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@pymodel/pythinker-code-sdk'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; +import { notifyResultState } from '#/tui/utils/notify-result'; +import { isExperimentalFlagEnabled } from '#/tui/commands/experimental-flags'; import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; import { formatTokenCount } from '#/utils/usage/usage-format'; @@ -441,6 +444,7 @@ export function extractKeyArgument( // Prefer the short `description` so the header preview never spills a // multi-line `prompt` into the TUI chrome. Agent: ['description', 'prompt'], + NotifyUser: ['message'], }; // Glob: concatenate multiple args into a single summary so the header @@ -860,6 +864,8 @@ export class ToolCallComponent extends Container { private callPreviewHidesContent(): boolean { const { name, args } = this.toolCall; switch (name) { + case 'NotifyUser': + return isExperimentalFlagEnabled('notify_user') && str(args['message']).trim().length > 0; case 'Bash': return str(args['command']).includes('\n'); case 'Edit': { @@ -1634,6 +1640,31 @@ export class ToolCallComponent extends Container { return label; } + if (toolCall.name === 'NotifyUser' && isExperimentalFlagEnabled('notify_user')) { + if (isTruncated) { + return `${bullet}${currentTheme.boldFg('error', 'Update cut off')}${currentTheme.dim(' (arguments truncated by max_tokens)')}`; + } + const delivery = notifyResultState(result?.output); + const label = isFinished + ? isError + ? 'Could not send you an update' + : delivery === 'displayed' + ? 'Sent you an update' + : delivery === 'suppressed' + ? 'Update not displayed' + : 'Update completed' + : 'Sending you an update'; + const tone = isError ? 'error' : 'primary'; + const preview = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir); + const head = `${bullet}${currentTheme.boldFg(tone, label)}`; + if (preview === null) return head; + return { + head: `${head}${currentTheme.dim(' (')}`, + flex: { text: preview.text, style: dimHeaderStyle, keep: 'head' }, + tail: currentTheme.dim(')'), + }; + } + if (toolCall.name === 'AskUserQuestion') { const isBackgroundAsk = toolCall.args['background'] === true; const label = isFinished @@ -2151,6 +2182,15 @@ export class ToolCallComponent extends Container { this.buildPlanPreview(); return; } + if (name === 'NotifyUser' && isExperimentalFlagEnabled('notify_user')) { + if (!this.expanded) return; + const message = str(this.toolCall.args['message']).trim(); + if (message.length === 0) return; + this.addChild( + new Markdown(message, 2, 0, this.markdownTheme, undefined, createMarkdownOptions()), + ); + return; + } if (this.result === undefined && this.toolCall.truncated === true) { this.addChild( new Text( @@ -2393,6 +2433,15 @@ export class ToolCallComponent extends Container { return; } + if ( + this.toolCall.name === 'NotifyUser' && + isExperimentalFlagEnabled('notify_user') && + !result.is_error && + notifyResultState(result.output) === 'displayed' + ) { + return; + } + if ( this.toolCall.name === 'AskUserQuestion' && this.toolCall.args['background'] !== true && diff --git a/apps/pythinker-code/src/tui/components/panes/survey-panel.ts b/apps/pythinker-code/src/tui/components/panes/survey-panel.ts new file mode 100644 index 000000000..2212d2fe8 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/panes/survey-panel.ts @@ -0,0 +1,96 @@ +import type { Component } from '@pymodel/pi-tui'; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@pymodel/pi-tui'; + +import { + SURVEY_MIN_OPTIONS_WIDTH, + SURVEY_OPTION_GAP, + SURVEY_OPTION_LABELS, + SURVEY_QUESTION, +} from '../../constant/survey'; + +import { currentTheme } from '../../theme'; +import type { SurveyResponse } from '../../utils/survey-policy'; + +export type SurveyPanelPhase = 'open' | 'pending' | 'thanks'; + +export interface SurveyPanelView { + phase: SurveyPanelPhase; + response?: Exclude; + hoverIndex?: number; +} + +const DOT = '●'; +const DOT_PREFIX_WIDTH = 2; +const OPTION_INDENT = ' '; + +const RESPONSE_LABELS: Record, string> = { + bad: 'Bad', + fine: 'Fine', + good: 'Good', +}; +const THANKS = 'Thanks for your feedback!'; + +export class SurveyPanelComponent implements Component { + constructor(private readonly view: SurveyPanelView) {} + + invalidate(): void {} + + render(width: number): string[] { + if (width < 1) return ['']; + switch (this.view.phase) { + case 'open': + return this.renderOpen(width); + case 'pending': { + const label = + this.view.response === undefined ? '' : RESPONSE_LABELS[this.view.response]; + return this.renderStatusLine(width, currentTheme.fg('textDim', `Feedback: ${label} · [escape: undo]`)); + } + case 'thanks': + return this.renderStatusLine(width, currentTheme.fg('success', THANKS)); + } + } + + private renderOpen(width: number): string[] { + const title = wrapTextWithAnsi(SURVEY_QUESTION, Math.max(1, width - DOT_PREFIX_WIDTH)).map( + (line, index) => + (index === 0 ? this.dotPrefix() : ' '.repeat(DOT_PREFIX_WIDTH)) + + currentTheme.boldFg('textStrong', line), + ); + const optionsLine = OPTION_INDENT + this.styledOptions(); + if (visibleWidth(optionsLine) <= width) { + return [...title, optionsLine]; + } + if (width >= SURVEY_MIN_OPTIONS_WIDTH) { + return [ + ...title, + ...this.styledOptionsPerLine().map((option) => OPTION_INDENT + option), + ]; + } + return title; + } + + private renderStatusLine(width: number, styledText: string): string[] { + return [truncateToWidth(this.dotPrefix() + styledText, width)]; + } + + private dotPrefix(): string { + return currentTheme.fg('accent', DOT) + ' '; + } + + private styledOptions(): string { + return SURVEY_OPTION_LABELS.map((label, index) => this.styleOption(label, index)).join( + ' '.repeat(SURVEY_OPTION_GAP), + ); + } + + private styledOptionsPerLine(): string[] { + return SURVEY_OPTION_LABELS.map((label, index) => this.styleOption(label, index)); + } + + private styleOption(label: string, index: number): string { + if (this.view.hoverIndex === index) { + return currentTheme.bg('border', currentTheme.boldFg('textStrong', label)); + } + return currentTheme.fg('text', label); + } +} diff --git a/apps/pythinker-code/src/tui/config.ts b/apps/pythinker-code/src/tui/config.ts index b492b12a8..ffe98474d 100644 --- a/apps/pythinker-code/src/tui/config.ts +++ b/apps/pythinker-code/src/tui/config.ts @@ -56,6 +56,7 @@ export const TuiConfigFileSchema = z.object({ render_latex: z.boolean().optional(), disable_paste_burst: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), + disable_feedback_survey: z.boolean().optional(), editor: z .object({ command: z.string().optional(), @@ -84,6 +85,7 @@ export const TuiConfigSchema = z.object({ /** Present in every normalized config; optional only so hand-built test * fixtures from before this field existed still typecheck. */ cacheExpiryHint: z.boolean().optional(), + disableFeedbackSurvey: z.boolean().optional(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, @@ -111,6 +113,7 @@ export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + disableFeedbackSurvey: false, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -198,6 +201,8 @@ export function normalizeTuiConfig( renderLatex: config.render_latex ?? DEFAULT_TUI_CONFIG.renderLatex, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, + disableFeedbackSurvey: + config.disable_feedback_survey ?? DEFAULT_TUI_CONFIG.disableFeedbackSurvey, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, @@ -248,6 +253,7 @@ theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | c render_latex = ${String(config.renderLatex !== false)} # false keeps LaTeX math in assistant messages as raw source disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit +disable_feedback_survey = ${String(config.disableFeedbackSurvey === true)} # true hides the occasional session rating prompt [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 87a09b7f7..b143a2124 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -22,6 +22,7 @@ export const RESULT_PREVIEW_LINES = 3; export const SHELL_OUTPUT_PREVIEW_LINES = 10; export const THINKING_PREVIEW_LINES = 2; export const COMMAND_PREVIEW_LINES = 10; +export const NOTIFY_PANEL_PAGE_LINES = 8; // The ellipsis marking a single-row line (card header, outcome row) that was // cut to the terminal width or that stands in for hidden output lines. @@ -29,7 +30,7 @@ export const TRUNCATION_ELLIPSIS = '…'; // ANSI escape sequences (CSI, OSC) — tool output can carry them — that a // width-aware cut must treat as zero-width atomic units: never counted toward // the budget, never split in half. -export const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g; +export const ANSI_ESCAPE_PATTERN = /\x1B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\))/g; // Code units a single terminal cell may hold before a tail-preserving cut's // window can no longer see it: a ZWJ family emoji is about eleven per two // cells, and combining sequences run longer. diff --git a/apps/pythinker-code/src/tui/constant/survey.ts b/apps/pythinker-code/src/tui/constant/survey.ts new file mode 100644 index 000000000..79ddfbf8d --- /dev/null +++ b/apps/pythinker-code/src/tui/constant/survey.ts @@ -0,0 +1,71 @@ +export const SURVEY_IDLE_EVALUATION_DELAY_MS = 2000; + +export const SURVEY_IDLE_STABILITY_MS = 2000; + +export const SURVEY_MOUNT_PROTECTION_MS = 600; + +export const SURVEY_DIGIT_DEBOUNCE_MS = 400; + +export const SURVEY_PENDING_UNDO_WINDOW_MS = 3000; + +export const SURVEY_THANKS_DURATION_MS = 5000; + +export const SURVEY_CONFIG_REFRESH_INTERVAL_MS = 3_600_000; + +export const SURVEY_MIN_OPTIONS_WIDTH = 12; + +export const SURVEY_QUESTION = 'How is Pythinker doing this session? (optional)'; + +export const SURVEY_OPTION_LABELS = ['1: Bad', '2: Fine', '3: Good', '0: Dismiss'] as const; + +export const SURVEY_OPTION_GAP = 2; + +const SURVEY_SPACER_ROWS = 1; +const SURVEY_PANEL_HORIZONTAL_CHROME = 2; +const SURVEY_EDITOR_MIN_ROWS = 3; +const SURVEY_TRANSCRIPT_MIN_ROWS = 1; +const SURVEY_FOOTER_MIN_ROWS = 1; + +export function surveyMinTotalHeight(contentWidth: number): number { + const innerWidth = Math.max(1, contentWidth - SURVEY_PANEL_HORIZONTAL_CHROME); + const inlineWidth = + SURVEY_OPTION_LABELS.reduce((total, label) => total + label.length, 0) + + SURVEY_OPTION_GAP * (SURVEY_OPTION_LABELS.length - 1); + const optionsRows = innerWidth >= inlineWidth ? 1 : SURVEY_OPTION_LABELS.length; + return ( + SURVEY_SPACER_ROWS + + surveyQuestionRows(innerWidth) + + optionsRows + + SURVEY_EDITOR_MIN_ROWS + + SURVEY_TRANSCRIPT_MIN_ROWS + + SURVEY_FOOTER_MIN_ROWS + ); +} + +function surveyQuestionRows(width: number): number { + let rows = 1; + let lineLength = 0; + for (const word of SURVEY_QUESTION.split(' ')) { + if (lineLength > 0 && lineLength + 1 + word.length > width) { + rows += 1; + lineLength = word.length; + } else { + lineLength += (lineLength > 0 ? 1 : 0) + word.length; + } + } + return rows; +} + +export const SURVEY_ORDERED_LIST_START = /^[ \t]*\d{1,2}[.)][ \t]/m; + +export const SURVEY_SINGLE_OPTION_DIGIT = /^[0-3]$/; + +export const SURVEY_OPTION_COUNT = 4; + +export const SURVEY_DISMISS_OPTION_INDEX = SURVEY_OPTION_COUNT - 1; + +export const SURVEY_DIGIT_RESPONSES: Record = { + '1': 'bad', + '2': 'fine', + '3': 'good', +}; diff --git a/apps/pythinker-code/src/tui/controllers/btw-panel.ts b/apps/pythinker-code/src/tui/controllers/btw-panel.ts index 45f31e411..cef7e3ecd 100644 --- a/apps/pythinker-code/src/tui/controllers/btw-panel.ts +++ b/apps/pythinker-code/src/tui/controllers/btw-panel.ts @@ -55,6 +55,10 @@ export class BtwPanelController { panel.submit(initialPrompt, inlineSkillActivations); } + isActive(): boolean { + return this.active !== undefined; + } + clear(): void { const active = this.active; if (active !== undefined && this.shouldCancelOnUnmount(active.panel)) { diff --git a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts index 4e30028a4..00c6156e3 100644 --- a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts @@ -7,6 +7,7 @@ import type { ThinkingEffort, } from '@pymodel/pythinker-code-sdk'; import { compressImageForModel } from '@pymodel/pythinker-code-sdk'; +import { Key, matchesKey } from '@pymodel/pi-tui'; import { ClipboardMediaError, @@ -43,6 +44,7 @@ import type { } from '../types'; import type { TUIState } from '../tui-state'; import type { BtwPanelController } from './btw-panel'; +import type { SurveyController } from './survey-controller'; export interface EditorKeyboardHost { state: TUIState; @@ -64,6 +66,7 @@ export interface EditorKeyboardHost { handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; + readonly surveyController: SurveyController; readonly skillCommandMap: Map; steerMessage(session: Session, input: readonly SteerInputItem[]): void; steerSkillActivation(session: Session, skillName: string, skillArgs: string): void; @@ -84,6 +87,8 @@ export interface EditorKeyboardHost { updateQueueDisplay(): void; toggleToolOutputExpansion(): void; toggleTodoPanelExpansion(): void; + toggleNotifyPanelFocus(): boolean; + handleNotifyPanelKey(key: 'left' | 'right' | 'up' | 'down' | 'escape'): boolean; detachCurrentForegroundTask(): void; cancelRunningShellCommand(): void; hideSessionPicker(): void; @@ -110,11 +115,20 @@ export class EditorKeyboardController { const editor = host.state.editor; editor.onSubmit = (text: string) => { + if (host.surveyController.handleSubmit(text)) return; host.handleUserInput(text); }; + editor.onPreInput = (data: string) => { + if (matchesKey(data, Key.escape)) this.clearPendingExit(); + const consumed = host.surveyController.handlePreInput(data); + if (consumed) this.clearPendingUndoEsc(); + return consumed; + }; + editor.onChange = (text: string) => { if (this.pendingExit) this.clearPendingExit(); + host.surveyController.handleEditorChange(text); host.updateEditorBorderHighlight(text); // Expanding paste markers costs a full-text pass, and only `/goal` // input can trip the objective length limit — so skip the expansion @@ -282,6 +296,7 @@ export class EditorKeyboardController { }; editor.onOpenExternalEditor = () => { + host.surveyController.closeSilently(); host.track('shortcut_editor'); void this.openExternalEditor(); }; @@ -301,6 +316,15 @@ export class EditorKeyboardController { return true; }; + editor.onPageNotify = (): boolean => { + if (!host.toggleNotifyPanelFocus()) return false; + this.clearPendingExit(); + host.track('shortcut_notify_page'); + return true; + }; + + editor.onNotifyPanelKey = (key) => host.handleNotifyPanelKey(key); + editor.onCtrlS = () => { if ( host.state.appState.streamingPhase === 'idle' || diff --git a/apps/pythinker-code/src/tui/controllers/notify.ts b/apps/pythinker-code/src/tui/controllers/notify.ts new file mode 100644 index 000000000..50adf31c9 --- /dev/null +++ b/apps/pythinker-code/src/tui/controllers/notify.ts @@ -0,0 +1,295 @@ +import type { Event, ResumedSessionState } from '@pymodel/pythinker-code-sdk'; + +import type { NotifyEntry } from '#/tui/components/chrome/notify-panel'; +import { MAIN_AGENT_ID } from '#/tui/constant/pythinker-tui'; +import type { TUIState } from '#/tui/tui-state'; +import { argsRecord } from '#/tui/utils/event-payload'; +import { notifyResultState } from '#/tui/utils/notify-result'; +import { isTerminalBackgroundTask } from '#/tui/utils/message-replay'; + +interface PendingUpdate { + readonly agentId: string; + readonly turnId: number; + readonly step: number; + readonly time: number; + readonly text: string; +} + +/** + * A harness-generated entry marking that work was delegated — shown + * immediately (not result-gated like `NotifyUser` calls), because its whole + * point is to explain the silence while the subagent runs. + */ +function delegationText(name: string, args: Record): string | undefined { + if (name === 'Agent') { + const description = args['description']; + if (typeof description !== 'string' || description.trim().length === 0) return undefined; + const kind = typeof args['subagent_type'] === 'string' ? args['subagent_type'] : 'subagent'; + return `▸ Delegated to ${kind}: **${description.trim()}**`; + } + const items = args['items']; + const newCount = Array.isArray(items) ? items.length : 0; + const resumeIds = args['resume_agent_ids']; + const resumedCount = + resumeIds !== null && typeof resumeIds === 'object' && !Array.isArray(resumeIds) + ? Object.keys(resumeIds).length + : 0; + const total = newCount + resumedCount; + if (total === 0) return undefined; + return `▸ Delegated to a dynamic workflow of ${String(total)} subagents`; +} + +export class NotifyController { + private enabled = false; + private mounted = false; + private mainTurnId: number | undefined; + private readonly running = new Map(); + private readonly steps = new Map(); + private readonly pending = new Map(); + private readonly settled = new Map(); + private readonly endedTurns = new Map(); + private readonly agentNames = new Map(); + + constructor( + private readonly state: Pick, + ) {} + + setEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.reset(); + this.enabled = enabled; + } + + reset(): void { + this.running.clear(); + this.steps.clear(); + this.mainTurnId = undefined; + this.clear(); + this.settled.clear(); + this.endedTurns.clear(); + this.agentNames.clear(); + } + + clear(): void { + for (const [key, update] of this.pending) this.settled.set(key, update.agentId); + this.pending.clear(); + if (!this.enabled && !this.mounted) return; + const { notifyPanel, notifyPanelContainer } = this.state; + if (notifyPanel.isEmpty() && notifyPanelContainer.children.length === 0) return; + notifyPanel.clear(); + notifyPanelContainer.clear(); + this.mounted = false; + this.state.ui.requestRender(); + } + + toggleFocus(): boolean { + if (!this.enabled) return false; + const panel = this.state.notifyPanel; + const changed = panel.isFocused() ? panel.blur() : panel.focus(); + if (!changed) return false; + this.state.ui.requestRender(); + return true; + } + + handlePanelKey(key: 'left' | 'right' | 'up' | 'down' | 'escape'): boolean { + if (!this.enabled || !this.state.notifyPanel.isFocused()) return false; + const panel = this.state.notifyPanel; + if (key === 'escape') panel.blur(); + else if (key === 'left') panel.prevChannel(); + else if (key === 'right') panel.nextChannel(); + else if (key === 'up') panel.prevPage(); + else panel.nextPage(); + this.state.ui.requestRender(); + return true; + } + + handleEvent(event: Event): void { + if (!this.enabled) return; + const agentId = event.agentId; + // oxlint-disable-next-line typescript-eslint/switch-exhaustiveness-check -- Only progress and agent lifecycle events affect this projection. + switch (event.type) { + case 'subagent.spawned': + this.agentNames.set(event.subagentId, event.subagentName); + this.running.set(event.subagentId, undefined); + break; + case 'subagent.started': + this.running.set(event.subagentId, undefined); + break; + case 'subagent.completed': + case 'subagent.failed': + this.running.delete(event.subagentId); + this.dropPending(event.subagentId); + break; + case 'background.task.started': + case 'background.task.terminated': { + const { info } = event; + if (info.kind !== 'agent' || info.agentId === undefined) return; + if (isTerminalBackgroundTask(info)) { + this.running.delete(info.agentId); + this.dropPending(info.agentId); + } else { + this.running.set(info.agentId, this.running.get(info.agentId)); + } + break; + } + case 'turn.started': + if ( + (this.endedTurns.get(agentId) ?? -1) >= event.turnId || + (this.running.get(agentId) ?? -1) >= event.turnId + ) + return; + if (agentId === MAIN_AGENT_ID && this.mainTurnId !== event.turnId) { + this.clear(); + this.mainTurnId = event.turnId; + } + this.dropPending(agentId); + this.forgetSettled(agentId); + this.running.set(agentId, event.turnId); + this.steps.set(agentId, 0); + break; + case 'turn.ended': + if ( + (this.endedTurns.get(agentId) ?? -1) >= event.turnId || + (this.running.get(agentId) ?? -1) > event.turnId + ) + return; + if ( + agentId === MAIN_AGENT_ID && + this.mainTurnId !== undefined && + this.mainTurnId !== event.turnId + ) + return; + if (this.running.get(agentId) === event.turnId || this.running.get(agentId) === undefined) { + this.running.delete(agentId); + } + this.dropPending(agentId, event.turnId); + this.endedTurns.set(agentId, event.turnId); + this.forgetSettled(agentId); + if (agentId === MAIN_AGENT_ID) this.state.notifyPanel.setEnded(true); + break; + case 'turn.step.started': + this.steps.set(agentId, event.step); + break; + case 'turn.step.interrupted': + case 'turn.step.retrying': + this.dropPending(agentId, event.turnId, event.step); + break; + case 'turn.step.completed': + if (event.finishReason === 'max_tokens') { + this.dropPending(agentId, event.turnId, event.step); + } + break; + case 'tool.call.started': { + if ( + (this.endedTurns.get(agentId) ?? -1) >= event.turnId || + (this.running.get(agentId) ?? -1) > event.turnId + ) + return; + if (agentId === MAIN_AGENT_ID && (event.name === 'Agent' || event.name === 'AgentDynamicWorkflow')) { + const text = delegationText(event.name, argsRecord(event.args)); + if (text !== undefined) { + this.state.notifyPanel.upsert({ + id: `delegation:${String(event.turnId)}:${event.toolCallId}`, + agentId, + time: Date.now(), + text, + }); + } + break; + } + const key = JSON.stringify([agentId, event.turnId, event.toolCallId]); + if (this.settled.has(key)) return; + if (event.name !== 'NotifyUser') return; + const message = argsRecord(event.args)['message']; + if (typeof message !== 'string' || message.trim().length === 0) return; + this.pending.set(key, { + agentId, + turnId: event.turnId, + step: this.steps.get(agentId) ?? 0, + time: Date.now(), + text: message, + }); + break; + } + case 'tool.result': { + const key = JSON.stringify([agentId, event.turnId, event.toolCallId]); + const update = this.pending.get(key); + if (update === undefined) { + if ( + agentId === MAIN_AGENT_ID && + (event.isError === true || event.synthetic === true) && + this.state.notifyPanel.remove(`delegation:${String(event.turnId)}:${event.toolCallId}`) + ) { + this.render(); + } + return; + } + this.pending.delete(key); + this.settled.set(key, agentId); + if ( + event.isError !== true && + event.synthetic !== true && + notifyResultState(event.output) === 'displayed' + ) { + const entry: NotifyEntry = { + id: key, + agentId, + agentName: this.agentNames.get(agentId), + time: update.time, + text: update.text, + }; + this.state.notifyPanel.upsert(entry); + } + break; + } + default: + return; + } + if (this.running.size === 0) this.state.notifyPanel.setEnded(true); + this.render(); + } + + restore(snapshot: ResumedSessionState | undefined): void { + if (!this.enabled || snapshot === undefined) return; + this.reset(); + for (const agent of Object.values(snapshot.agents)) { + for (const task of agent.background) { + if (task.kind !== 'agent' || task.agentId === undefined) continue; + if (isTerminalBackgroundTask(task)) continue; + this.running.set(task.agentId, undefined); + if (task.subagentType !== undefined) this.agentNames.set(task.agentId, task.subagentType); + } + } + this.render(); + } + + private dropPending(agentId: string, turnId?: number, step?: number): void { + for (const [key, update] of this.pending) { + if ( + update.agentId !== agentId || + (turnId !== undefined && update.turnId !== turnId) || + (step !== undefined && update.step !== step) + ) + continue; + this.pending.delete(key); + this.settled.set(key, update.agentId); + } + } + + private forgetSettled(agentId: string): void { + for (const [key, source] of this.settled) if (source === agentId) this.settled.delete(key); + } + + private render(): void { + const { notifyPanel, notifyPanelContainer, ui } = this.state; + if (notifyPanel.isEmpty()) { + if (notifyPanelContainer.children.length === 0) return; + notifyPanelContainer.clear(); + this.mounted = false; + } else { + if (notifyPanelContainer.children.length === 0) notifyPanelContainer.addChild(notifyPanel); + this.mounted = true; + } + ui.requestRender(); + } +} diff --git a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index 8b1b1b9d0..d7210057f 100644 --- a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts @@ -74,15 +74,18 @@ import { nextTranscriptId } from '../utils/transcript-id'; import type { BtwPanelController } from './btw-panel'; import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier'; import type { StreamingUIController } from './streaming-ui'; +import type { SurveyController } from './survey-controller'; import type { TasksBrowserController } from './tasks-browser'; import { SubAgentEventHandler } from './subagent-event-handler'; -import type { - AppState, - LivePaneState, - QueuedMessage, - ToolCallBlockData, - ToolResultBlockData, - TranscriptEntry, +import { NotifyController } from './notify'; +import { + sumTokenUsage, + type AppState, + type LivePaneState, + type QueuedMessage, + type ToolCallBlockData, + type ToolResultBlockData, + type TranscriptEntry, } from '../types'; import type { TUIState } from '../tui-state'; import { createGoal as startGoalCommand } from '../commands/goal'; @@ -117,9 +120,11 @@ export interface SessionEventHost { handleTurnEnded?(event: TurnEndedEvent): void; readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; + readonly surveyController: SurveyController; } export class SessionEventHandler { + readonly notifications: NotifyController; readonly subAgentEventHandler: SubAgentEventHandler; private readonly pluginUpdateNotifier: PluginUpdateNotifier; @@ -127,6 +132,7 @@ export class SessionEventHandler { private readonly host: SessionEventHost, pluginUpdateNotifier?: PluginUpdateNotifier, ) { + this.notifications = new NotifyController(host.state); this.subAgentEventHandler = new SubAgentEventHandler(host, { backgroundTasks: this.backgroundTasks, backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal, @@ -170,6 +176,7 @@ export class SessionEventHandler { this.backgroundTasks.clear(); this.backgroundTaskTranscriptedTerminal.clear(); this.subAgentEventHandler.resetRuntimeState(); + this.notifications.reset(); this.renderedSkillActivationIds.clear(); this.renderedPluginCommandActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); @@ -255,6 +262,7 @@ export class SessionEventHandler { } handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { + this.notifications.handleEvent(event); if (this.subAgentEventHandler.routeChildAgentEvent(event)) return; if ('turnId' in event && event.turnId !== undefined) { @@ -619,6 +627,7 @@ export class SessionEventHandler { private handleToolCall(event: ToolCallStartedEvent): void { const { streamingUI } = this.host; + this.host.surveyController.notifyToolCallStarted(); streamingUI.flushNow(); const { turnId, step } = streamingUI.getTurnContext(); const toolCall: ToolCallBlockData = { @@ -742,6 +751,9 @@ export class SessionEventHandler { } if (event.model !== undefined) patch.model = event.model; if (event.thinkingEffort !== undefined) patch.thinkingEffort = event.thinkingEffort; + if (event.usage?.total !== undefined) { + patch.cumulativeTokens = sumTokenUsage(event.usage.total); + } if (Object.keys(patch).length > 0) this.host.setAppState(patch); if (event.dynamicWorkflowMode === false) { this.host.state.dynamicWorkflowModeEntry = undefined; @@ -1130,6 +1142,7 @@ export class SessionEventHandler { } private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { + this.host.surveyController.notifyCompactionFinished(); const hasActiveTurn = this.host.streamingUI.hasActiveTurn(); if (!hasActiveTurn) { const next = this.host.shiftQueuedMessage(); diff --git a/apps/pythinker-code/src/tui/controllers/session-replay.ts b/apps/pythinker-code/src/tui/controllers/session-replay.ts index 826c1d8f0..6547c65d1 100644 --- a/apps/pythinker-code/src/tui/controllers/session-replay.ts +++ b/apps/pythinker-code/src/tui/controllers/session-replay.ts @@ -127,6 +127,7 @@ export class SessionReplayRenderer { this.hydrateSnapshot(main); this.renderRecords(main); this.applyTerminalBackgroundAgentStatuses(main); + this.host.sessionEventHandler.notifications.restore(session.getResumeState()); this.host.mergeAllTurnSteps(); return true; } catch (error) { diff --git a/apps/pythinker-code/src/tui/controllers/survey-controller.ts b/apps/pythinker-code/src/tui/controllers/survey-controller.ts new file mode 100644 index 000000000..78fceec8a --- /dev/null +++ b/apps/pythinker-code/src/tui/controllers/survey-controller.ts @@ -0,0 +1,669 @@ +import { randomUUID } from 'node:crypto'; + +import { isTelemetryDisabledByEnv } from '@pymodel/pythinker-telemetry'; +import { Key, matchesKey, Spacer } from '@pymodel/pi-tui'; + +import { + peekSurveyPopupConfig, + peekSurveyPopupConfigFresh, + type SurveyPopupConfig, +} from '#/utils/survey-popup-config'; +import { readSurveyLastShownTime, writeSurveyLastShownTime } from '#/utils/survey-state-store'; +import { currentPythinkerRegion } from '#/utils/region'; + +import { SurveyPanelComponent, type SurveyPanelView } from '../components/panes/survey-panel'; +import { CHROME_GUTTER } from '../constant/rendering'; +import { printableChar } from '../utils/printable-key'; +import { + SURVEY_DIGIT_DEBOUNCE_MS, + SURVEY_IDLE_EVALUATION_DELAY_MS, + SURVEY_MOUNT_PROTECTION_MS, + SURVEY_PENDING_UNDO_WINDOW_MS, + SURVEY_ORDERED_LIST_START, + SURVEY_SINGLE_OPTION_DIGIT, + SURVEY_OPTION_COUNT, + SURVEY_DISMISS_OPTION_INDEX, + SURVEY_DIGIT_RESPONSES, + SURVEY_MIN_OPTIONS_WIDTH, + surveyMinTotalHeight, + SURVEY_CONFIG_REFRESH_INTERVAL_MS, + SURVEY_THANKS_DURATION_MS, +} from '../constant/survey'; +import type { TUIState } from '../tui-state'; +import { + buildSurveyEventProperties, + evaluateSurveyGate, + SURVEY_EVENT_NAMES, + SURVEY_MACHINE_CLOSED, + surveyMachineReduce, + type LongContextArmGateInput, + type SessionArmGateInput, + type SharedArmGateInput, + type SurveyAppearance, + type SurveyEventEnvironmentFields, + type SurveyKind, + type SurveyMachineAction, + type SurveyMachineEffect, + type SurveyMachineState, +} from '../utils/survey-policy'; +import type { BtwPanelController } from './btw-panel'; + +export interface SurveyHost { + readonly state: TUIState; + readonly btwPanelController: BtwPanelController; + track(event: string, props?: Record): void; +} + +export interface SurveyControllerDeps { + readonly config?: () => SurveyPopupConfig; + readonly monotonicNow?: () => number; + readonly wallNow?: () => number; + readonly random?: () => number; + readonly appearanceId?: () => string; + readonly setTimer?: (fn: () => void, ms: number) => unknown; + readonly clearTimer?: (handle: unknown) => void; + readonly telemetryDisabled?: () => boolean; + readonly feedbackSurveyDisabled?: () => boolean; + readonly refreshConfig?: () => unknown; + readonly terminalHeight?: () => number; + readonly configFresh?: () => boolean; + readonly terminalWidth?: () => number; + readonly configRegion?: () => string; + readonly readGlobalLastShown?: () => Promise; + readonly writeGlobalLastShown?: (wallTime: number) => void; +} + +const defaultDeps = { + monotonicNow: () => performance.now(), + wallNow: () => Date.now(), + random: () => Math.random(), + appearanceId: () => randomUUID(), + setTimer: (fn: () => void, ms: number) => setTimeout(fn, ms), + clearTimer: (handle: unknown) => { + clearTimeout(handle as Parameters[0]); + }, + telemetryDisabled: () => isTelemetryDisabledByEnv(), + config: () => peekSurveyPopupConfig(), + configFresh: () => peekSurveyPopupConfigFresh(), + terminalHeight: () => process.stdout.rows, + configRegion: () => currentPythinkerRegion(), + terminalWidth: () => process.stdout.columns, + readGlobalLastShown: readSurveyLastShownTime, + writeGlobalLastShown: writeSurveyLastShownTime, +} satisfies Omit< + Required, + 'feedbackSurveyDisabled' | 'refreshConfig' +>; + +export class SurveyController { + private machine: SurveyMachineState = SURVEY_MACHINE_CLOSED; + private readonly view: SurveyPanelView = { phase: 'open' }; + private mounted = false; + private mountedAt: number; + private userTurnCount = 0; + private lastShownAt: number | undefined; + private userTurnsAtLastShown: number | undefined; + private appearanceCount = 0; + private globalLastShownAt: number | undefined; + private longContextRollConsumed = false; + private generation = 0; + private idleSince: number | undefined; + private openedAt = 0; + private stickySample: { readonly turnCount: number; readonly value: number } | undefined; + private lastTypedDigit: string | undefined; + private openedEditorText: string | undefined; + private idleTimer: unknown; + private digitTimer: unknown; + private phaseTimer: unknown; + private toolCallCount = 0; + private compactionCount = 0; + private currentTurnUserOrigin: boolean | undefined; + private evaluationPending = false; + private appearanceConfig: SurveyPopupConfig | undefined; + private configReady = false; + private cooldownReady = false; + private configRefreshedAt = 0; + private coldRefreshAttemptedAt = 0; + private configRegion: string | undefined; + + constructor( + private readonly host: SurveyHost, + private readonly deps: SurveyControllerDeps = {}, + ) { + this.mountedAt = this.now(); + this.reset(); + this.refreshConfig(); + this.coldRefreshAttemptedAt = this.now(); + } + + reset(): void { + this.generation += 1; + this.clearIdleTimer(); + this.clearDigitTimer(); + this.clearPhaseTimer(); + this.applyClose(); + this.machine = SURVEY_MACHINE_CLOSED; + this.mountedAt = this.now(); + this.userTurnCount = 0; + this.lastShownAt = undefined; + this.userTurnsAtLastShown = undefined; + this.appearanceCount = 0; + this.longContextRollConsumed = false; + this.idleSince = undefined; + this.stickySample = undefined; + this.toolCallCount = 0; + this.compactionCount = 0; + this.appearanceConfig = undefined; + this.currentTurnUserOrigin = undefined; + this.evaluationPending = false; + const generation = this.generation; + this.cooldownReady = false; + void (this.deps.readGlobalLastShown ?? defaultDeps.readGlobalLastShown)() + .then((lastShown) => { + if (this.generation !== generation) return; + if (lastShown !== undefined) { + this.globalLastShownAt = Math.max(lastShown, this.globalLastShownAt ?? 0); + } + this.cooldownReady = true; + }) + .catch(() => { + if (this.generation === generation) this.cooldownReady = true; + }); + } + + dispose(): void { + this.generation += 1; + this.clearIdleTimer(); + this.clearDigitTimer(); + this.clearPhaseTimer(); + this.applyClose(); + } + + notifyTurnStarted(userOrigin: boolean): void { + this.currentTurnUserOrigin = userOrigin; + this.idleSince = undefined; + this.clearIdleTimer(); + if (this.machine.phase !== 'closed') this.applyAction({ type: 'close-silently' }); + if (userOrigin) { + this.userTurnCount += 1; + this.evaluationPending = false; + } + } + + notifyTurnEnded(): void { + if (this.currentTurnUserOrigin === true) this.evaluationPending = true; + this.currentTurnUserOrigin = undefined; + if (!this.evaluationPending) return; + this.idleSince = this.now(); + this.clearIdleTimer(); + this.idleTimer = this.setT(() => { + this.idleTimer = undefined; + this.evaluationPending = false; + this.evaluate(); + }, SURVEY_IDLE_EVALUATION_DELAY_MS); + } + + notifyToolCallStarted(): void { + this.toolCallCount += 1; + } + + notifyCompactionFinished(): void { + this.compactionCount += 1; + } + + notifyInputModeChanged(mode: 'prompt' | 'bash'): void { + if (mode !== 'bash') return; + if (this.machine.phase === 'closed') return; + if (this.machine.phase === 'open') { + this.applyAction({ type: 'abandon' }); + return; + } + this.applyAction({ type: 'close-silently' }); + } + + closeSilently(): void { + if (this.machine.phase === 'closed') return; + this.applyAction({ type: 'close-silently' }); + } + + handlePreInput(data: string): boolean { + const phase = this.machine.phase; + if (phase === 'closed') return false; + if (this.inMountProtection()) { + const printable = printableChar(data); + if (SURVEY_SINGLE_OPTION_DIGIT.test(printable)) { + this.lastTypedDigit = printable; + } + return false; + } + if (this.host.state.editor.hasAutocompleteActivity()) return false; + if (matchesKey(data, Key.escape)) { + switch (phase) { + case 'open': + if (this.tooNarrow() || this.tooShort()) return false; + this.applyAction({ type: 'dismiss' }); + return true; + case 'pending': + this.applyAction({ type: 'undo' }); + return true; + case 'thanks': + this.applyAction({ type: 'close-silently' }); + return true; + } + } + if (this.tooNarrow()) return false; + if (this.tooShort()) return false; + if (phase !== 'open') return false; + const editor = this.host.state.editor; + const empty = editor.getText().length === 0; + const printable = printableChar(data); + if (SURVEY_SINGLE_OPTION_DIGIT.test(printable)) { + this.lastTypedDigit = printable; + if (!empty) this.clearDigitTimer(); + return false; + } + this.lastTypedDigit = undefined; + if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) { + if (!empty) this.clearDigitTimer(); + return false; + } + if (!empty) { + this.clearDigitTimer(); + return false; + } + if (matchesKey(data, Key.left)) { + this.moveHover(-1); + return true; + } + if (matchesKey(data, Key.right)) { + this.moveHover(1); + return true; + } + return false; + } + + handleEditorChange(text: string): void { + if (this.machine.phase !== 'open') return; + const wasTyped = text === this.lastTypedDigit; + this.lastTypedDigit = undefined; + if (this.inMountProtection()) { + if (text.length === 0 || wasTyped) return; + this.applyAction({ type: 'abandon' }); + return; + } + if (this.openedEditorText !== undefined) { + if (text.length > 0 && text !== this.openedEditorText) { + this.openedEditorText = undefined; + } else if (text === this.openedEditorText && !wasTyped) { + this.clearDigitTimer(); + return; + } + } + const bashMode = this.host.state.editor.inputMode === 'bash'; + if (text.length === 0) { + this.clearDigitTimer(); + return; + } + if (!bashMode && SURVEY_SINGLE_OPTION_DIGIT.test(text)) { + if (!wasTyped) { + this.applyAction({ type: 'abandon' }); + return; + } + this.clearDigitTimer(); + this.digitTimer = this.setT(() => { + this.digitTimer = undefined; + if (this.tooNarrow() || this.tooShort()) return; + this.chooseDigit(text); + }, SURVEY_DIGIT_DEBOUNCE_MS); + return; + } + this.applyAction({ type: 'abandon' }); + } + + handleSubmit(text: string): boolean { + if (this.machine.phase !== 'open') return false; + if (this.inMountProtection()) return false; + if (this.tooNarrow()) return false; + if (this.tooShort()) return false; + if (this.host.state.editor.inputMode === 'bash') { + this.applyAction({ type: 'abandon' }); + return false; + } + if (SURVEY_SINGLE_OPTION_DIGIT.test(text) && text !== this.openedEditorText) { + this.chooseDigit(text); + return true; + } + if (text.trim().length === 0 && this.view.hoverIndex !== undefined) { + this.chooseHovered(); + return true; + } + if (text.trim().length > 0) { + this.applyAction({ type: 'abandon' }); + } + return false; + } + + private evaluate(): void { + if (this.machine.phase !== 'closed') return; + if (!this.configReady) return; + if (!this.cooldownReady) return; + const region = (this.deps.configRegion ?? defaultDeps.configRegion)(); + const cacheCold = !(this.deps.configFresh ?? defaultDeps.configFresh)(); + if ( + cacheCold && + this.now() - this.coldRefreshAttemptedAt >= SURVEY_CONFIG_REFRESH_INTERVAL_MS + ) { + this.coldRefreshAttemptedAt = this.now(); + if (this.refreshConfig()) return; + } else if ( + (region !== this.configRegion || + this.now() - this.configRefreshedAt >= SURVEY_CONFIG_REFRESH_INTERVAL_MS) && + this.refreshConfig() + ) { + return; + } + const config = (this.deps.config ?? defaultDeps.config)(); + const verdict = evaluateSurveyGate({ ...this.gateInputs(), config }); + if (verdict.longContextRollConsumed === true) this.longContextRollConsumed = true; + if (!verdict.show) return; + this.open(verdict.survey, config); + } + + private gateInputs(): { session: SessionArmGateInput; longContext: LongContextArmGateInput } { + const { appState } = this.host.state; + const now = this.now(); + const shared: SharedArmGateInput = { + phase: this.machine.phase, + turnInProgress: appState.streamingPhase !== 'idle' || appState.isCompacting, + idleForMs: this.idleSince === undefined ? 0 : now - this.idleSince, + promptActive: this.promptActive(), + editorBashActive: this.host.state.editor.inputMode === 'bash', + editorAutocompleteActive: this.host.state.editor.hasAutocompleteActivity(), + externalEditorActive: this.host.state.externalEditorRunning, + terminalWidth: (this.deps.terminalWidth ?? defaultDeps.terminalWidth)() - 2 * CHROME_GUTTER, + terminalHeight: (this.deps.terminalHeight ?? defaultDeps.terminalHeight)(), + feedbackSurveyDisabled: + this.deps.feedbackSurveyDisabled?.() ?? + this.host.state.appState.disableFeedbackSurvey === true, + telemetryDisabled: (this.deps.telemetryDisabled ?? defaultDeps.telemetryDisabled)(), + currentModel: appState.model, + lastUserMessageStartsOrderedList: this.lastUserMessageStartsOrderedList(), + }; + return { + session: { + ...shared, + mountedForMs: now - this.mountedAt, + userTurnsSinceMount: this.userTurnCount, + msSinceLastShown: this.lastShownAt === undefined ? undefined : now - this.lastShownAt, + userTurnsSinceLastShown: + this.userTurnsAtLastShown === undefined + ? undefined + : this.userTurnCount - this.userTurnsAtLastShown, + sample: this.currentSample(), + msSinceGlobalLastShown: + this.globalLastShownAt === undefined ? undefined : this.wallNow() - this.globalLastShownAt, + }, + longContext: { + ...shared, + cumulativeTokens: appState.cumulativeTokens ?? 0, + virtualContextTokens: appState.contextTokens, + mountRollConsumed: this.longContextRollConsumed, + drawMountRoll: () => (this.deps.random ?? defaultDeps.random)(), + }, + }; + } + + private promptActive(): boolean { + const { state } = this.host; + return ( + state.editorReplacementMounted || + state.activeDialog !== null || + state.livePane.pendingApproval !== null || + state.livePane.pendingQuestion !== null || + state.tasksBrowser !== undefined || + this.host.btwPanelController.isActive() + ); + } + + private lastUserMessageStartsOrderedList(): boolean { + const entries = this.host.state.transcriptEntries; + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index]!; + if (entry.kind !== 'user' || entry.bullet === '') continue; + return SURVEY_ORDERED_LIST_START.test(entry.content); + } + return false; + } + + private currentSample(): number { + if (this.stickySample?.turnCount !== this.userTurnCount) { + this.stickySample = { + turnCount: this.userTurnCount, + value: (this.deps.random ?? defaultDeps.random)(), + }; + } + return this.stickySample.value; + } + + private open(survey: SurveyKind, config: SurveyPopupConfig): void { + this.appearanceCount += 1; + const appearance: SurveyAppearance = { + survey, + appearanceId: (this.deps.appearanceId ?? defaultDeps.appearanceId)(), + appearanceIndex: this.appearanceCount, + }; + const shownAt = this.now(); + this.appearanceConfig = config; + this.applyAction({ type: 'open', appearance }); + if (this.machine.phase !== 'open') return; + this.openedAt = shownAt; + this.openedEditorText = this.host.state.editor.getText(); + this.lastShownAt = shownAt; + this.userTurnsAtLastShown = this.userTurnCount; + if (survey !== 'session') return; + this.globalLastShownAt = this.wallNow(); + try { + (this.deps.writeGlobalLastShown ?? defaultDeps.writeGlobalLastShown)( + this.globalLastShownAt, + ); + } catch {} + } + + private applyAction(action: SurveyMachineAction): void { + this.clearDigitTimer(); + this.clearPhaseTimer(); + const transition = surveyMachineReduce(this.machine, action); + if (transition.state === this.machine && transition.effects.length === 0) return; + const appearance = transition.state.appearance ?? this.machine.appearance; + this.machine = transition.state; + for (const effect of transition.effects) { + this.runEffect(effect, appearance); + } + this.syncView(); + } + + private runEffect(effect: SurveyMachineEffect, appearance: SurveyAppearance | undefined): void { + switch (effect.type) { + case 'report': { + if (appearance === undefined) return; + this.host.track( + SURVEY_EVENT_NAMES[appearance.survey], + buildSurveyEventProperties( + { + event_type: effect.eventType, + appearance_id: appearance.appearanceId, + appearance_index: appearance.appearanceIndex, + response: effect.response, + }, + this.environmentFields(), + this.appearanceConfig ?? (this.deps.config ?? defaultDeps.config)(), + ), + ); + return; + } + case 'schedule': { + if (effect.timer === 'pending-settle') { + this.phaseTimer = this.setT(() => { + this.phaseTimer = undefined; + this.applyAction({ type: 'settle' }); + }, SURVEY_PENDING_UNDO_WINDOW_MS); + } else { + this.phaseTimer = this.setT(() => { + this.phaseTimer = undefined; + this.applyAction({ type: 'thanks-elapsed' }); + }, SURVEY_THANKS_DURATION_MS); + } + } + } + } + + private syncView(): void { + if (this.machine.phase === 'closed') { + this.view.hoverIndex = undefined; + this.applyClose(); + return; + } + this.view.phase = this.machine.phase; + this.view.response = + this.machine.response === undefined || this.machine.response === 'dismissed' + ? undefined + : this.machine.response; + if (this.machine.phase !== 'open') this.view.hoverIndex = undefined; + this.mount(); + this.host.state.ui.requestRender(); + } + + private applyClose(): void { + if (!this.mounted) return; + this.mounted = false; + this.lastTypedDigit = undefined; + this.openedEditorText = undefined; + this.host.state.surveyContainer.clear(); + this.host.state.ui.requestRender(); + } + + private mount(): void { + if (this.mounted) return; + this.mounted = true; + const container = this.host.state.surveyContainer; + container.clear(); + container.addChild(new Spacer(1)); + container.addChild(new SurveyPanelComponent(this.view)); + } + + private chooseDigit(digit: string): void { + const response = SURVEY_DIGIT_RESPONSES[digit]; + if (response === undefined) { + this.applyAction({ type: 'dismiss' }); + } else { + this.applyAction({ type: 'select', response }); + } + this.host.state.editor.setText(''); + } + + private chooseHovered(): void { + const hoverIndex = this.view.hoverIndex; + if (hoverIndex === undefined) return; + if (hoverIndex === SURVEY_DISMISS_OPTION_INDEX) { + this.applyAction({ type: 'dismiss' }); + return; + } + const response = SURVEY_DIGIT_RESPONSES[String(hoverIndex + 1)]; + if (response === undefined) return; + this.applyAction({ type: 'select', response }); + } + + private moveHover(delta: number): void { + const current = this.view.hoverIndex; + this.view.hoverIndex = + current === undefined + ? (delta > 0 ? 0 : SURVEY_OPTION_COUNT - 1) + : (current + delta + SURVEY_OPTION_COUNT) % SURVEY_OPTION_COUNT; + this.host.state.ui.requestRender(); + } + + private environmentFields(): SurveyEventEnvironmentFields { + const { appState } = this.host.state; + return { + current_model: appState.model, + user_turn_count: this.userTurnCount, + cumulative_tokens: appState.cumulativeTokens ?? 0, + virtual_context_tokens: appState.contextTokens, + tool_call_count: this.toolCallCount, + compaction_count: this.compactionCount, + permission_mode: appState.permissionMode, + thinking_effort: appState.thinkingEffort, + }; + } + + private refreshConfig(): boolean { + this.configRefreshedAt = this.now(); + this.configRegion = (this.deps.configRegion ?? defaultDeps.configRegion)(); + const markReady = () => { + this.configReady = true; + }; + if (this.deps.refreshConfig !== undefined) { + this.configReady = false; + void Promise.resolve(this.deps.refreshConfig()) + .catch(() => undefined) + .finally(markReady); + return true; + } + markReady(); + return false; + } + + private tooNarrow(): boolean { + return ( + (this.deps.terminalWidth ?? defaultDeps.terminalWidth)() - 2 * CHROME_GUTTER < + SURVEY_MIN_OPTIONS_WIDTH + ); + } + + private tooShort(): boolean { + return ( + (this.deps.terminalHeight ?? defaultDeps.terminalHeight)() < + surveyMinTotalHeight( + (this.deps.terminalWidth ?? defaultDeps.terminalWidth)() - 2 * CHROME_GUTTER, + ) + ); + } + + private inMountProtection(): boolean { + return this.now() - this.openedAt < SURVEY_MOUNT_PROTECTION_MS; + } + + private now(): number { + return (this.deps.monotonicNow ?? defaultDeps.monotonicNow)(); + } + + private wallNow(): number { + return (this.deps.wallNow ?? defaultDeps.wallNow)(); + } + + private setT(fn: () => void, ms: number): unknown { + return (this.deps.setTimer ?? defaultDeps.setTimer)(fn, ms); + } + + private clearT(handle: unknown): void { + (this.deps.clearTimer ?? defaultDeps.clearTimer)(handle); + } + + private clearIdleTimer(): void { + if (this.idleTimer === undefined) return; + this.clearT(this.idleTimer); + this.idleTimer = undefined; + } + + private clearDigitTimer(): void { + if (this.digitTimer === undefined) return; + this.clearT(this.digitTimer); + this.digitTimer = undefined; + } + + private clearPhaseTimer(): void { + if (this.phaseTimer === undefined) return; + this.clearT(this.phaseTimer); + this.phaseTimer = undefined; + } +} diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 9583acbc9..91a0d8e12 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -17,6 +17,7 @@ import type { TurnStartedEvent, WorkspaceTrustInfo, } from '@pymodel/pythinker-code-sdk'; +import { isTelemetryDisabledByEnv } from '@pymodel/pythinker-telemetry'; import { deleteAllKittyImages, type Component, @@ -112,6 +113,7 @@ import { SessionEventHandler } from './controllers/session-event-handler'; import { SessionReplayRenderer } from './controllers/session-replay'; import { StagingLeaseTracker, type StagingLease } from './controllers/staging-leases'; import { StreamingUIController } from './controllers/streaming-ui'; +import { SurveyController } from './controllers/survey-controller'; import { TasksBrowserController } from './controllers/tasks-browser'; import { installRainbowHatch } from './easter-eggs/hatch'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; @@ -126,6 +128,7 @@ import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; import { createTUIState, type TUIState } from './tui-state'; import { INITIAL_LIVE_PANE, + sumTokenUsage, type AppState, type InlineSkillActivation, type PythinkerTUIOptions, @@ -205,6 +208,7 @@ export interface PythinkerTUIStartupInput { readonly startupNotice?: string; /** agent-core-v2 engine; enables the startup workspace-trust prompt. */ readonly engineV2?: boolean; + readonly telemetryDisabled?: boolean; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; @@ -220,6 +224,21 @@ function waitingSpinnerLabel(retry: StepRetryState | null): string { return retry === null ? '' : formatStepRetryLabel(retry); } +function isUserSubmittedTurnOrigin(origin: TurnStartedEvent['origin'] | undefined): boolean { + if (origin === undefined) return false; + switch (origin.kind) { + case 'user': + return true; + case 'skill_activation': + case 'plugin_command': + return origin.trigger === 'user-slash'; + case 'shell_command': + return origin.phase === 'input'; + default: + return false; + } +} + function sameStringArrays(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]); } @@ -248,6 +267,7 @@ function createInitialAppState(input: PythinkerTUIStartupInput): AppState { contextUsage: 0, contextTokens: 0, maxContextTokens: 0, + cumulativeTokens: 0, isCompacting: false, isReplaying: false, streamingPhase: 'idle', @@ -259,6 +279,7 @@ function createInitialAppState(input: PythinkerTUIStartupInput): AppState { disablePasteBurst: input.tuiConfig.disablePasteBurst, renderLatex: input.tuiConfig.renderLatex, cacheExpiryHint: input.tuiConfig.cacheExpiryHint, + disableFeedbackSurvey: input.tuiConfig.disableFeedbackSurvey, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, statusLine: input.tuiConfig.statusLine, @@ -319,6 +340,7 @@ export class PythinkerTUI { private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; private backgroundRefreshPromise: Promise | undefined; + private readonly telemetryDisabled: boolean; /** Whether the harness runs on the agent-core-v2 engine (lazy session creation). */ readonly engineV2: boolean; private startupNotice: string | undefined; @@ -340,6 +362,7 @@ export class PythinkerTUI { readonly sessionEventHandler: SessionEventHandler; readonly sessionReplay: SessionReplayRenderer; readonly tasksBrowserController: TasksBrowserController; + readonly surveyController: SurveyController; readonly editorKeyboard: EditorKeyboardController; /** Timer that auto-clears the one-shot "moved to background" footer hint. */ @@ -408,6 +431,7 @@ export class PythinkerTUI { }, }; this.options = tuiOptions; + this.telemetryDisabled = startupInput.telemetryDisabled ?? false; this.engineV2 = startupInput.engineV2 ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); @@ -438,6 +462,9 @@ export class PythinkerTUI { this.sessionEventHandler = new SessionEventHandler(this); this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); + this.surveyController = new SurveyController(this, { + telemetryDisabled: () => isTelemetryDisabledByEnv() || this.telemetryDisabled, + }); this.editorKeyboard = new EditorKeyboardController(this, this.imageStore); this.editorKeyboard.install(); this.buildLayout(); @@ -493,6 +520,7 @@ export class PythinkerTUI { } refreshSlashCommandAutocomplete(): void { + this.sessionEventHandler.notifications.setEnabled(isExperimentalFlagEnabled('notify_user')); this.setupAutocomplete(); } @@ -744,6 +772,7 @@ export class PythinkerTUI { private async init(): Promise { setExperimentalFeatures(await this.harness.getExperimentalFeatures()); + this.sessionEventHandler.notifications.setEnabled(isExperimentalFlagEnabled('notify_user')); await this.authFlow.refreshAvailableModels(); this.backgroundRefreshPromise = this.refreshProviderModelsInBackground(); @@ -882,6 +911,7 @@ export class PythinkerTUI { this.streamingUI.resetToolUi(); this.disposeTranscriptChildren(); this.editorKeyboard.dispose(); + this.surveyController.dispose(); this.state.footer.dispose(); for (const dispose of this.reverseRpcDisposers) { dispose(); @@ -1000,8 +1030,10 @@ export class PythinkerTUI { ui.addChild(this.state.transcriptContainer); ui.addChild(this.state.activityContainer); ui.addChild(this.state.todoPanelContainer); + ui.addChild(this.state.notifyPanelContainer); ui.addChild(this.state.queueContainer); ui.addChild(this.state.btwPanelContainer); + ui.addChild(this.state.surveyContainer); ui.addChild(this.state.editorContainer); // Footer is mounted later (mountFooter), not here. } @@ -1039,6 +1071,7 @@ export class PythinkerTUI { main.addChild(this.state.transcriptContainer); main.addChild(this.state.activityContainer); main.addChild(this.state.todoPanelContainer); + main.addChild(this.state.notifyPanelContainer); main.addChild(this.state.queueContainer); main.addChild(this.state.btwPanelContainer); main.addChild(this.state.editorContainer); @@ -1057,6 +1090,7 @@ export class PythinkerTUI { handleInputModeChange(mode: 'prompt' | 'bash'): void { this.setAppState({ inputMode: mode }); + this.surveyController.notifyInputModeChanged(mode); this.updateEditorBorderHighlight(); } @@ -1579,10 +1613,12 @@ export class PythinkerTUI { handleTurnStarted(event: TurnStartedEvent): void { this.staging.handleTurnStarted(event); + this.surveyController.notifyTurnStarted(isUserSubmittedTurnOrigin(event.origin)); } handleTurnEnded(event: TurnEndedEvent): void { this.staging.handleTurnEnded(event); + this.surveyController.notifyTurnEnded(); } releaseStagingMedia(mediaAttachmentIds: readonly number[]): void { @@ -2241,6 +2277,8 @@ export class PythinkerTUI { contextTokens: status.contextTokens, maxContextTokens: status.maxContextTokens, contextUsage: status.contextUsage, + cumulativeTokens: + status.usage?.total === undefined ? 0 : sumTokenUsage(status.usage.total), sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, }); @@ -2435,6 +2473,7 @@ export class PythinkerTUI { resetSessionRuntime(): void { this.aborted = false; + this.surveyController.reset(); this.streamingUI.discardPending(); this.clearQueuedMessages(); this.state.dynamicWorkflowModeEntry = undefined; @@ -2788,6 +2827,7 @@ export class PythinkerTUI { this.clearTerminalInlineImages(); this.state.todoPanel.clear(); this.state.todoPanelContainer.clear(); + this.sessionEventHandler.notifications.clear(); const stagingFileIds = this.imageStore.clear(); this.staging.deleteStaged(stagingFileIds); this.renderWelcome(); @@ -3295,6 +3335,14 @@ export class PythinkerTUI { this.state.ui.requestRender(); } + toggleNotifyPanelFocus(): boolean { + return this.sessionEventHandler.notifications.toggleFocus(); + } + + handleNotifyPanelKey(key: 'left' | 'right' | 'up' | 'down' | 'escape'): boolean { + return this.sessionEventHandler.notifications.handlePanelKey(key); + } + toggleTodoPanelExpansion(): void { this.state.todoPanel.toggleExpanded(); this.state.ui.requestRender(); @@ -3566,6 +3614,7 @@ export class PythinkerTUI { // ========================================================================= mountEditorReplacement(panel: Component & Focusable): void { + this.surveyController.closeSilently(); this.state.editorReplacementMounted = true; this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); diff --git a/apps/pythinker-code/src/tui/tui-state.ts b/apps/pythinker-code/src/tui/tui-state.ts index 0feff7b9e..b9971a6f5 100644 --- a/apps/pythinker-code/src/tui/tui-state.ts +++ b/apps/pythinker-code/src/tui/tui-state.ts @@ -14,6 +14,7 @@ import { openUrl } from '#/utils/open-url'; import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; +import { NotifyPanelComponent } from './components/chrome/notify-panel'; import { TodoPanelComponent } from './components/chrome/todo-panel'; import type { SessionRow } from './components/dialogs/session-picker'; import { CustomEditor } from './components/editor/custom-editor'; @@ -40,11 +41,14 @@ export interface TUIState { activityContainer: Container; todoPanelContainer: Container; todoPanel: TodoPanelComponent; + notifyPanelContainer: Container; + notifyPanel: NotifyPanelComponent; queueContainer: Container; btwPanelContainer: Container; + surveyContainer: Container; editorContainer: Container; /** - * Fullscreen mode only: the bottom dock (activity/todo/queue/btw/editor + + * Fullscreen mode only: the bottom dock (activity/todo/notify/queue/btw/survey/editor + * footer) stacked under the transcript ScrollView. Undefined in regular * mode, where all chrome is a direct child of the root container. */ @@ -126,8 +130,11 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanel = new TodoPanelComponent(); + const notifyPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const notifyPanel = new NotifyPanelComponent(); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const surveyContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editor = new CustomEditor(ui, { disablePasteBurst: initialAppState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, @@ -154,8 +161,10 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { dockContainer = new VStack(); dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 }); dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(notifyPanelContainer, { shrink: 1, minSize: 0 }); dockContainer.addChild(queueContainer, { shrink: 1, minSize: 0 }); dockContainer.addChild(btwPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(surveyContainer, { shrink: 0, minSize: 0 }); dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 }); const root = new VStack(); root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 }); @@ -170,8 +179,11 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { activityContainer, todoPanelContainer, todoPanel, + notifyPanelContainer, + notifyPanel, queueContainer, btwPanelContainer, + surveyContainer, editorContainer, dockContainer, editor, diff --git a/apps/pythinker-code/src/tui/types.ts b/apps/pythinker-code/src/tui/types.ts index c9f08930d..67df2d8d8 100644 --- a/apps/pythinker-code/src/tui/types.ts +++ b/apps/pythinker-code/src/tui/types.ts @@ -6,6 +6,7 @@ import type { ProviderConfig, PromptPart, ThinkingEffort, + TokenUsage, ToolInputDisplay, } from '@pymodel/pythinker-code-sdk'; @@ -63,6 +64,7 @@ export interface AppState { contextUsage: number; contextTokens: number; maxContextTokens: number; + cumulativeTokens?: number; isCompacting: boolean; isReplaying: boolean; streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; @@ -78,6 +80,7 @@ export interface AppState { renderLatex?: boolean; /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ cacheExpiryHint?: boolean; + disableFeedbackSurvey?: boolean; notifications: NotificationsConfig; upgrade: UpgradePreferences; /** Footer status line customization from tui.toml; absent means the default layout. */ @@ -92,6 +95,10 @@ export interface AppState { banner?: BannerState | null; } +export function sumTokenUsage(total: TokenUsage): number { + return total.inputOther + total.output + total.inputCacheRead + total.inputCacheCreation; +} + export interface StepRetryState { /** Upcoming attempt number (1-based). */ nextAttempt: number; diff --git a/apps/pythinker-code/src/tui/utils/notify-result.ts b/apps/pythinker-code/src/tui/utils/notify-result.ts new file mode 100644 index 000000000..120af014e --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/notify-result.ts @@ -0,0 +1,5 @@ +export function notifyResultState(output: unknown): 'displayed' | 'suppressed' | undefined { + if (output === 'Update shown to the user.') return 'displayed'; + if (output === 'Notifications are disabled; the update was not displayed.') return 'suppressed'; + return undefined; +} diff --git a/apps/pythinker-code/src/tui/utils/survey-policy.ts b/apps/pythinker-code/src/tui/utils/survey-policy.ts new file mode 100644 index 000000000..5e82041d2 --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/survey-policy.ts @@ -0,0 +1,378 @@ + +import type { SurveyPopupConfig } from '#/utils/survey-popup-config'; + +import { + SURVEY_IDLE_STABILITY_MS, + SURVEY_MIN_OPTIONS_WIDTH, + surveyMinTotalHeight, +} from '../constant/survey'; + +export type SurveyKind = 'session' | 'long_context'; + +export type SurveyGateSkipReason = + | 'mount-roll-consumed' + | 'survey-active' + | 'turn-in-progress' + | 'idle-too-short' + | 'ordered-list-ambiguity' + | 'prompt-active' + | 'editor-bash-active' + | 'editor-autocomplete-active' + | 'external-editor-active' + | 'terminal-too-narrow' + | 'terminal-too-short' + | 'feature-disabled' + | 'telemetry-disabled' + | 'model-gated' + | 'warmup' + | 'pacing' + | 'threshold-invalid' + | 'below-threshold' + | 'sampled-out' + | 'global-cooldown'; + +export interface SharedArmGateInput { + readonly phase: SurveyPhase; + readonly turnInProgress: boolean; + readonly idleForMs: number; + readonly promptActive: boolean; + readonly editorBashActive: boolean; + readonly editorAutocompleteActive: boolean; + readonly externalEditorActive: boolean; + readonly terminalWidth: number; + readonly terminalHeight: number; + readonly feedbackSurveyDisabled: boolean; + readonly telemetryDisabled: boolean; + readonly currentModel: string; + readonly lastUserMessageStartsOrderedList: boolean; +} + +export interface SessionArmGateInput extends SharedArmGateInput { + readonly mountedForMs: number; + readonly userTurnsSinceMount: number; + readonly msSinceLastShown: number | undefined; + readonly userTurnsSinceLastShown: number | undefined; + readonly sample: number; + readonly msSinceGlobalLastShown: number | undefined; +} + +export interface LongContextArmGateInput extends SharedArmGateInput { + readonly cumulativeTokens: number; + readonly virtualContextTokens: number; + readonly mountRollConsumed: boolean; + readonly drawMountRoll: () => number; +} + +export interface SurveyGateInput { + readonly session: SessionArmGateInput; + readonly longContext: LongContextArmGateInput; + readonly config: SurveyPopupConfig; +} + +export type SurveyGateVerdict = + | { + readonly show: true; + readonly survey: SurveyKind; + readonly longContextRollConsumed?: boolean; + } + | { + readonly show: false; + readonly reason: SurveyGateSkipReason; + readonly longContextRollConsumed?: boolean; + }; + +function modelGatePasses(onForModels: readonly string[], currentModel: string): boolean { + if (onForModels.length === 0) return false; + if (onForModels.includes('*')) return true; + return onForModels.includes(currentModel); +} + +function evaluateSessionArm(input: SurveyGateInput): SurveyGateVerdict { + const { session, config } = input; + if (session.phase !== 'closed') return { show: false, reason: 'survey-active' }; + if (session.turnInProgress) return { show: false, reason: 'turn-in-progress' }; + if (session.idleForMs < SURVEY_IDLE_STABILITY_MS) { + return { show: false, reason: 'idle-too-short' }; + } + if (session.lastUserMessageStartsOrderedList) { + return { show: false, reason: 'ordered-list-ambiguity' }; + } + if (session.promptActive) return { show: false, reason: 'prompt-active' }; + if (session.editorBashActive) return { show: false, reason: 'editor-bash-active' }; + if (session.editorAutocompleteActive) { + return { show: false, reason: 'editor-autocomplete-active' }; + } + if (session.externalEditorActive) { + return { show: false, reason: 'external-editor-active' }; + } + if (session.terminalWidth < SURVEY_MIN_OPTIONS_WIDTH) { + return { show: false, reason: 'terminal-too-narrow' }; + } + if (session.terminalHeight < surveyMinTotalHeight(session.terminalWidth)) { + return { show: false, reason: 'terminal-too-short' }; + } + if (session.feedbackSurveyDisabled) return { show: false, reason: 'feature-disabled' }; + if (session.telemetryDisabled) return { show: false, reason: 'telemetry-disabled' }; + if (!modelGatePasses(config.on_for_models, session.currentModel)) { + return { show: false, reason: 'model-gated' }; + } + if (session.msSinceLastShown === undefined) { + if ( + session.mountedForMs < config.min_time_before_feedback_ms || + session.userTurnsSinceMount < config.min_user_turns_before_feedback + ) { + return { show: false, reason: 'warmup' }; + } + } else if ( + session.msSinceLastShown < config.min_time_between_feedback_ms || + (session.userTurnsSinceLastShown ?? 0) < config.min_user_turns_between_feedback + ) { + return { show: false, reason: 'pacing' }; + } + if (session.sample >= config.probability) return { show: false, reason: 'sampled-out' }; + if ( + session.msSinceGlobalLastShown !== undefined && + session.msSinceGlobalLastShown < config.min_time_between_global_feedback_ms + ) { + return { show: false, reason: 'global-cooldown' }; + } + return { show: true, survey: 'session' }; +} + +export function evaluateLongContextArm(input: SurveyGateInput): SurveyGateVerdict { + const { longContext, config } = input; + if (longContext.mountRollConsumed) return { show: false, reason: 'mount-roll-consumed' }; + if (longContext.phase !== 'closed') return { show: false, reason: 'survey-active' }; + if (longContext.turnInProgress) return { show: false, reason: 'turn-in-progress' }; + if (longContext.idleForMs < SURVEY_IDLE_STABILITY_MS) { + return { show: false, reason: 'idle-too-short' }; + } + if (longContext.lastUserMessageStartsOrderedList) { + return { show: false, reason: 'ordered-list-ambiguity' }; + } + if (longContext.promptActive) return { show: false, reason: 'prompt-active' }; + if (longContext.editorBashActive) return { show: false, reason: 'editor-bash-active' }; + if (longContext.editorAutocompleteActive) { + return { show: false, reason: 'editor-autocomplete-active' }; + } + if (longContext.externalEditorActive) { + return { show: false, reason: 'external-editor-active' }; + } + if (longContext.terminalWidth < SURVEY_MIN_OPTIONS_WIDTH) { + return { show: false, reason: 'terminal-too-narrow' }; + } + if (longContext.terminalHeight < surveyMinTotalHeight(longContext.terminalWidth)) { + return { show: false, reason: 'terminal-too-short' }; + } + if (longContext.feedbackSurveyDisabled) return { show: false, reason: 'feature-disabled' }; + if (longContext.telemetryDisabled) return { show: false, reason: 'telemetry-disabled' }; + if (!modelGatePasses(config.on_for_models, longContext.currentModel)) { + return { show: false, reason: 'model-gated' }; + } + if (!(config.long_context_survey_threshold > 0)) { + return { show: false, reason: 'threshold-invalid' }; + } + const counter = + config.long_context_trigger_mode === 'cumulative' + ? longContext.cumulativeTokens + : longContext.virtualContextTokens; + if (counter < config.long_context_survey_threshold) { + return { show: false, reason: 'below-threshold' }; + } + if (longContext.drawMountRoll() >= config.long_context_probability) { + return { show: false, reason: 'sampled-out', longContextRollConsumed: true }; + } + return { show: true, survey: 'long_context', longContextRollConsumed: true }; +} + +export function evaluateSurveyGate(input: SurveyGateInput): SurveyGateVerdict { + const longContext = evaluateLongContextArm(input); + if (longContext.show) return longContext; + const session = evaluateSessionArm(input); + if (longContext.longContextRollConsumed === true) { + return { ...session, longContextRollConsumed: true }; + } + return session; +} + +export type SurveyPhase = 'closed' | 'open' | 'pending' | 'thanks'; + +export type SurveyResponse = 'bad' | 'fine' | 'good' | 'dismissed'; + +export type SurveyEventType = 'appeared' | 'responded' | 'abandoned'; + +export interface SurveyAppearance { + readonly survey: SurveyKind; + readonly appearanceId: string; + readonly appearanceIndex: number; +} + +export interface SurveyMachineState { + readonly phase: SurveyPhase; + readonly appearance: SurveyAppearance | undefined; + readonly response: SurveyResponse | undefined; +} + +export const SURVEY_MACHINE_CLOSED: SurveyMachineState = { + phase: 'closed', + appearance: undefined, + response: undefined, +}; + +const SURVEY_PRIORITY: Record = { session: 0, long_context: 1 }; + +export type SurveyMachineAction = + | { readonly type: 'open'; readonly appearance: SurveyAppearance } + | { readonly type: 'select'; readonly response: 'bad' | 'fine' | 'good' } + | { readonly type: 'dismiss' } + | { readonly type: 'undo' } + | { readonly type: 'settle' } + | { readonly type: 'thanks-elapsed' } + | { readonly type: 'abandon' } + | { readonly type: 'close-silently' }; + +export type SurveyMachineEffect = + | { + readonly type: 'report'; + readonly eventType: SurveyEventType; + readonly response?: SurveyResponse; + } + | { readonly type: 'schedule'; readonly timer: 'pending-settle' | 'thanks-close' }; + +export interface SurveyMachineTransition { + readonly state: SurveyMachineState; + readonly effects: readonly SurveyMachineEffect[]; +} + +const NO_EFFECTS: readonly SurveyMachineEffect[] = []; + +function noTransition(state: SurveyMachineState): SurveyMachineTransition { + return { state, effects: NO_EFFECTS }; +} + +export function surveyMachineReduce( + state: SurveyMachineState, + action: SurveyMachineAction, +): SurveyMachineTransition { + switch (action.type) { + case 'open': { + if (state.phase === 'closed') { + return { + state: { phase: 'open', appearance: action.appearance, response: undefined }, + effects: [{ type: 'report', eventType: 'appeared' }], + }; + } + if (state.phase !== 'open' || state.appearance === undefined) { + return noTransition(state); + } + if (SURVEY_PRIORITY[action.appearance.survey] <= SURVEY_PRIORITY[state.appearance.survey]) { + return noTransition(state); + } + return { + state: { phase: 'open', appearance: action.appearance, response: undefined }, + effects: [{ type: 'report', eventType: 'appeared' }], + }; + } + case 'select': { + if (state.phase !== 'open') return noTransition(state); + return { + state: { ...state, phase: 'pending', response: action.response }, + effects: [{ type: 'schedule', timer: 'pending-settle' }], + }; + } + case 'dismiss': { + if (state.phase !== 'open') return noTransition(state); + return { + state: SURVEY_MACHINE_CLOSED, + effects: [{ type: 'report', eventType: 'responded', response: 'dismissed' }], + }; + } + case 'undo': { + if (state.phase !== 'pending') return noTransition(state); + return { + state: { ...state, phase: 'open', response: undefined }, + effects: NO_EFFECTS, + }; + } + case 'settle': { + if (state.phase !== 'pending' || state.response === undefined) { + return noTransition(state); + } + return { + state: { ...state, phase: 'thanks' }, + effects: [ + { type: 'report', eventType: 'responded', response: state.response }, + { type: 'schedule', timer: 'thanks-close' }, + ], + }; + } + case 'thanks-elapsed': { + if (state.phase !== 'thanks') return noTransition(state); + return { state: SURVEY_MACHINE_CLOSED, effects: NO_EFFECTS }; + } + case 'abandon': { + if (state.phase !== 'open') return noTransition(state); + return { + state: SURVEY_MACHINE_CLOSED, + effects: [{ type: 'report', eventType: 'abandoned' }], + }; + } + case 'close-silently': { + if (state.phase === 'closed') return noTransition(state); + if (state.phase === 'pending' && state.response !== undefined) { + return { + state: SURVEY_MACHINE_CLOSED, + effects: [{ type: 'report', eventType: 'responded', response: state.response }], + }; + } + return { state: SURVEY_MACHINE_CLOSED, effects: NO_EFFECTS }; + } + } +} + +export const SURVEY_EVENT_NAMES: Record = { + session: 'feedback_survey', + long_context: 'long_context_survey', +}; + +export interface SurveyEventCoreFields { + readonly event_type: SurveyEventType; + readonly appearance_id: string; + readonly appearance_index: number; + readonly response?: SurveyResponse; +} + +export interface SurveyEventEnvironmentFields { + readonly current_model: string; + readonly user_turn_count: number; + readonly cumulative_tokens: number; + readonly virtual_context_tokens: number; + readonly tool_call_count: number; + readonly compaction_count: number; + readonly permission_mode: string; + readonly thinking_effort: string; +} + +export function buildSurveyEventProperties( + core: SurveyEventCoreFields, + environment: SurveyEventEnvironmentFields, + config: SurveyPopupConfig, +): Record { + return { + event_type: core.event_type, + appearance_id: core.appearance_id, + appearance_index: core.appearance_index, + response: core.response, + ...environment, + config_probability: config.probability, + config_on_for_models: config.on_for_models.join(','), + config_min_time_before_feedback_ms: config.min_time_before_feedback_ms, + config_min_user_turns_before_feedback: config.min_user_turns_before_feedback, + config_min_time_between_feedback_ms: config.min_time_between_feedback_ms, + config_min_user_turns_between_feedback: config.min_user_turns_between_feedback, + config_min_time_between_global_feedback_ms: config.min_time_between_global_feedback_ms, + config_long_context_survey_threshold: config.long_context_survey_threshold, + config_long_context_probability: config.long_context_probability, + config_long_context_trigger_mode: config.long_context_trigger_mode, + }; +} diff --git a/apps/pythinker-code/src/utils/paths.ts b/apps/pythinker-code/src/utils/paths.ts index b9d59fc15..00507b4e6 100644 --- a/apps/pythinker-code/src/utils/paths.ts +++ b/apps/pythinker-code/src/utils/paths.ts @@ -21,6 +21,7 @@ import { PYTHINKER_CODE_NATIVE_STAGED_STATE_FILE_NAME, PYTHINKER_CODE_NATIVE_STAGING_DIR_NAME, PYTHINKER_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, + PYTHINKER_CODE_SURVEY_STATE_FILE_NAME, PYTHINKER_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, PYTHINKER_CODE_UPDATE_INSTALL_STATE_FILE_NAME, PYTHINKER_CODE_UPDATE_DIR_NAME, @@ -126,6 +127,10 @@ export function getBannerStateFile(): string { return join(getCacheDir(), PYTHINKER_CODE_BANNER_DIR_NAME, PYTHINKER_CODE_BANNER_STATE_FILE_NAME); } +export function getSurveyStateFile(): string { + return join(getDataDir(), PYTHINKER_CODE_SURVEY_STATE_FILE_NAME); +} + /** * Return the user input history file for a given working directory. * Layout: `/user-history/.jsonl`. diff --git a/apps/pythinker-code/src/utils/persistence.ts b/apps/pythinker-code/src/utils/persistence.ts index 0b60e5109..50cca0c77 100644 --- a/apps/pythinker-code/src/utils/persistence.ts +++ b/apps/pythinker-code/src/utils/persistence.ts @@ -6,6 +6,7 @@ * these helpers. */ +import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { appendFile, link, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; @@ -75,6 +76,22 @@ export async function writeJsonFile( } } +export function writeJsonFileSync(filePath: string, schema: z.ZodType, value: T): void { + assertNonConfigWrite(filePath); + const parsed = schema.parse(value); + mkdirSync(dirname(filePath), { recursive: true }); + const tmpPath = tempPathFor(filePath); + try { + writeFileSync(tmpPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); + renameSync(tmpPath, filePath); + } catch (error) { + try { + unlinkSync(tmpPath); + } catch {} + throw error; + } +} + /** * Create `filePath` with `content` only while the path is still free — * atomically, and throwing EEXIST when it is already taken. diff --git a/apps/pythinker-code/src/utils/survey-popup-config.ts b/apps/pythinker-code/src/utils/survey-popup-config.ts new file mode 100644 index 000000000..9bbec3b95 --- /dev/null +++ b/apps/pythinker-code/src/utils/survey-popup-config.ts @@ -0,0 +1,39 @@ +export interface SurveyPopupConfig { + probability: number; + on_for_models: string[]; + min_time_before_feedback_ms: number; + min_user_turns_before_feedback: number; + min_time_between_feedback_ms: number; + min_user_turns_between_feedback: number; + min_time_between_global_feedback_ms: number; + long_context_survey_threshold: number; + long_context_probability: number; + long_context_trigger_mode: 'cumulative' | 'virtual_context'; +} + +export const DEFAULT_SURVEY_POPUP_CONFIG: SurveyPopupConfig = { + probability: 0.005, + on_for_models: ['*'], + min_time_before_feedback_ms: 600_000, + min_user_turns_before_feedback: 5, + min_time_between_feedback_ms: 3_600_000, + min_user_turns_between_feedback: 10, + min_time_between_global_feedback_ms: 100_000_000, + long_context_survey_threshold: 200_000, + long_context_probability: 0.2, + long_context_trigger_mode: 'cumulative', +}; + +export function getSurveyPopupConfig(): SurveyPopupConfig { + return { ...DEFAULT_SURVEY_POPUP_CONFIG, on_for_models: [...DEFAULT_SURVEY_POPUP_CONFIG.on_for_models] }; +} + +export function peekSurveyPopupConfig(): SurveyPopupConfig { + return getSurveyPopupConfig(); +} + +export function peekSurveyPopupConfigFresh(): boolean { + return true; +} + +export function resetSurveyPopupConfigCache(): void {} diff --git a/apps/pythinker-code/src/utils/survey-state-store.ts b/apps/pythinker-code/src/utils/survey-state-store.ts new file mode 100644 index 000000000..d6acf1c77 --- /dev/null +++ b/apps/pythinker-code/src/utils/survey-state-store.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { getSurveyStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFileSync } from '#/utils/persistence'; + +const SurveyStateSchema = z.object({ + version: z.literal(1), + last_shown_time: z.number(), +}); + +export async function readSurveyLastShownTime( + filePath: string = getSurveyStateFile(), +): Promise { + try { + const state = await readJsonFile(filePath, SurveyStateSchema, { + version: 1, + last_shown_time: Number.NaN, + }); + return Number.isFinite(state.last_shown_time) ? state.last_shown_time : undefined; + } catch { + return undefined; + } +} + +export function writeSurveyLastShownTime( + lastShownTime: number, + filePath: string = getSurveyStateFile(), +): void { + writeJsonFileSync(filePath, SurveyStateSchema, { + version: 1, + last_shown_time: lastShownTime, + }); +} diff --git a/apps/pythinker-code/test/cli/run-v2-print.test.ts b/apps/pythinker-code/test/cli/run-v2-print.test.ts index 678b894ce..673f40aad 100644 --- a/apps/pythinker-code/test/cli/run-v2-print.test.ts +++ b/apps/pythinker-code/test/cli/run-v2-print.test.ts @@ -4,8 +4,10 @@ import { describe, expect, it, vi } from 'vitest'; import { applyPrintBackgroundPolicy, createPrintTurnEndings, + delayOrAbort, formatTrustGatedMcpWarning, PrintSteeredTurnFailedError, + settleOrAbort, type PrintTurnEnding, type PrintTurnEndings, type TrustGatedMcpServer, @@ -526,11 +528,40 @@ describe('formatTrustGatedMcpWarning', () => { it('encodes control characters in untrusted server names and targets', () => { const text = formatTrustGatedMcpWarning([ - { name: 'evil\u001b]0;pwned\u0007', target: 'stdio: node\u001b[6n server.js' }, + { name: 'evil\u001B]0;pwned\u0007', target: 'stdio: node\u001B[6n server.js' }, ]); expect(text).toContain('evil\\x1b]0;pwned\\x07'); expect(text).toContain('stdio: node\\x1b[6n server.js'); - expect(text).not.toContain('\u001b'); + expect(text).not.toContain('\u001B'); expect(text).not.toContain('\u0007'); }); }); + +describe('print-mode quiesce abort', () => { + it('clears the poll timer when the shutdown signal aborts', async () => { + const controller = new AbortController(); + const started = Date.now(); + const pending = delayOrAbort(controller.signal, 5_000); + controller.abort(); + await pending; + expect(Date.now() - started).toBeLessThan(1_000); + }); + + it('stops waiting on a hung drain when the shutdown signal aborts', async () => { + const controller = new AbortController(); + const hung = new Promise(() => {}); + const started = Date.now(); + const pending = settleOrAbort(controller.signal, hung); + controller.abort(); + await pending; + expect(Date.now() - started).toBeLessThan(1_000); + }); + + it('still waits out a short delay when the signal stays live', async () => { + const controller = new AbortController(); + const started = Date.now(); + await delayOrAbort(controller.signal, 30); + expect(Date.now() - started).toBeGreaterThanOrEqual(20); + expect(controller.signal.aborted).toBe(false); + }); +}); diff --git a/apps/pythinker-code/test/cli/v2-run-print.test.ts b/apps/pythinker-code/test/cli/v2-run-print.test.ts index f43a8b088..802923120 100644 --- a/apps/pythinker-code/test/cli/v2-run-print.test.ts +++ b/apps/pythinker-code/test/cli/v2-run-print.test.ts @@ -8,6 +8,7 @@ import { AgentCron, AgentGoal, IAgentLifecycleService, + IAgentLoopService, IAgentPermissionModeService, IAgentProfileService, IAgentPromptService, @@ -17,6 +18,7 @@ import { IBootstrapService, IConfigService, IEventBus, + IEventDispatcher, IFileSystemStorageService, ISessionIndex, ISessionManager, @@ -179,9 +181,21 @@ function makeFakeHarness() { }), }; }), + drain: vi.fn(async () => {}), + list: vi.fn(() => ({ launching: false, active: undefined, pending: [] })), + }, + ], + [IAgentTaskService, { list: vi.fn(() => []), stopAllOnExit: vi.fn(async () => []) }], + [IEventDispatcher, { flush: vi.fn(async () => {}) }], + [ + IAgentLoopService, + { + status: vi.fn(() => ({ state: 'idle', pendingTurnIds: [] })), + cancel: vi.fn(() => false), + settled: vi.fn(async () => {}), + tryAcquireQuiescence: vi.fn(() => ({ dispose: vi.fn() })), }, ], - [IAgentTaskService, { list: vi.fn(() => []) }], [ IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: 'agents/main' }), @@ -614,4 +628,196 @@ describe('runV2Print', () => { expect(initOrder).toBeDefined(); expect(reconcileOrder).toBeGreaterThan(initOrder!); }); + + it('flushes the wire journal before disposing the app', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agentServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue({ agentId: 'main', generation: 1 }); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const dispatcher = agentServices.get(IEventDispatcher) as { + flush: ReturnType; + }; + expect(dispatcher.flush).toHaveBeenCalled(); + const flushOrder = dispatcher.flush.mock.invocationCallOrder[0]; + const disposeOrder = app.dispose.mock.invocationCallOrder[0]; + expect(flushOrder).toBeDefined(); + expect(disposeOrder).toBeGreaterThan(flushOrder!); + }); + + it('flushes the wire journal when the turn fails', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agentServices } = makeFakeHarness(); + + const promptService = agentServices.get(IAgentPromptService) as { + enqueue: ReturnType; + }; + promptService.enqueue.mockResolvedValueOnce({ + launched: Promise.resolve({ + id: 1, + result: Promise.resolve({ + type: 'failed', + error: { code: 'provider.overloaded', message: 'llm request failed' }, + }), + }), + }); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue({ agentId: 'main', generation: 1 }); + + await expect(runV2Print(opts() as never, '1.2.3-test', { stdout, stderr })).rejects.toThrow( + 'provider.overloaded: llm request failed', + ); + + const dispatcher = agentServices.get(IEventDispatcher) as { + flush: ReturnType; + }; + expect(dispatcher.flush).toHaveBeenCalled(); + const flushOrder = dispatcher.flush.mock.invocationCallOrder[0]; + const disposeOrder = app.dispose.mock.invocationCallOrder[0]; + expect(disposeOrder).toBeGreaterThan(flushOrder!); + }); + + it('does not let a wire flush failure mask the turn outcome', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agentServices } = makeFakeHarness(); + + const promptService = agentServices.get(IAgentPromptService) as { + enqueue: ReturnType; + }; + promptService.enqueue.mockResolvedValueOnce({ + launched: Promise.resolve({ + id: 1, + result: Promise.resolve({ + type: 'failed', + error: { code: 'provider.overloaded', message: 'llm request failed' }, + }), + }), + }); + const dispatcher = agentServices.get(IEventDispatcher) as { + flush: ReturnType; + }; + dispatcher.flush.mockRejectedValueOnce(new Error('disk full')); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue({ agentId: 'main', generation: 1 }); + + await expect(runV2Print(opts() as never, '1.2.3-test', { stdout, stderr })).rejects.toThrow( + 'provider.overloaded: llm request failed', + ); + expect(app.dispose).toHaveBeenCalled(); + }); + + it('cancels and settles the active turn before flushing on a termination signal', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agentServices } = makeFakeHarness(); + + const order: string[] = []; + const loop = agentServices.get(IAgentLoopService) as { + status: ReturnType; + cancel: ReturnType; + settled: ReturnType; + tryAcquireQuiescence: ReturnType; + }; + loop.status.mockReturnValue({ state: 'running', pendingTurnIds: [] }); + loop.cancel.mockImplementation(() => { + if (!order.includes('cancel')) order.push('cancel'); + return true; + }); + loop.settled = vi.fn(async () => { + if (!order.includes('settled')) order.push('settled'); + }); + const guardDispose = vi.fn(); + loop.tryAcquireQuiescence = vi.fn(() => ({ dispose: guardDispose })); + const taskService = agentServices.get(IAgentTaskService) as { + stopAllOnExit: ReturnType; + }; + taskService.stopAllOnExit = vi.fn(async () => { + if (!order.includes('stop')) order.push('stop'); + return []; + }); + const dispatcher = agentServices.get(IEventDispatcher) as { + flush: ReturnType; + }; + dispatcher.flush = vi.fn(async () => { + order.push('flush'); + }); + + const promptService = agentServices.get(IAgentPromptService) as { + enqueue: ReturnType; + drain: ReturnType; + list: ReturnType; + }; + let settleTurn!: (result: unknown) => void; + promptService.enqueue.mockResolvedValueOnce({ + launched: Promise.resolve({ + id: 1, + result: new Promise((resolve) => { + settleTurn = resolve; + }), + }), + }); + let promptPhase: 'launching' | 'active' | 'empty' = 'launching'; + promptService.list = vi.fn(() => { + if (promptPhase === 'launching') { + return { launching: true, active: undefined, pending: [] }; + } + if (promptPhase === 'active') { + return { launching: false, active: { id: 'p1' }, pending: [] }; + } + return { launching: false, active: undefined, pending: [] }; + }); + + const handlers = new Map Promise>(); + const fakeProcess = { + once: (signal: string, handler: () => Promise) => { + handlers.set(signal, handler); + }, + off: () => {}, + exit: vi.fn((code?: number) => { + order.push(`exit:${code}`); + }), + }; + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue({ agentId: 'main', generation: 1 }); + + const run = runV2Print(opts() as never, '1.2.3-test', { + stdout, + stderr, + process: fakeProcess as never, + }); + const outcome = run.catch((error: unknown) => error); + for (let i = 0; i < 100 && !handlers.has('SIGINT'); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + const onSigint = handlers.get('SIGINT')!; + settleTurn({ type: 'cancelled', steps: 0, reason: new Error('aborted') }); + const sigintRun = onSigint(); + for (let i = 0; i < 100 && !order.includes('settled'); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(promptService.drain).toHaveBeenCalled(); + expect(order).toEqual(['stop', 'cancel', 'settled']); + promptPhase = 'active'; + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(order).toEqual(['stop', 'cancel', 'settled']); + promptPhase = 'empty'; + await sigintRun; + + expect(order).toEqual(['stop', 'cancel', 'settled', 'flush', 'exit:130']); + expect(loop.tryAcquireQuiescence).toHaveBeenCalled(); + const lastGuardRelease = guardDispose.mock.invocationCallOrder.at(-1); + const appDisposeOrder = app.dispose.mock.invocationCallOrder[0]; + expect(lastGuardRelease).toBeGreaterThan(appDisposeOrder!); + expect(await outcome).toBeInstanceOf(Error); + }); }); diff --git a/apps/pythinker-code/test/tui/commands/experiments.test.ts b/apps/pythinker-code/test/tui/commands/experiments.test.ts index 89db5f285..7cbd3d411 100644 --- a/apps/pythinker-code/test/tui/commands/experiments.test.ts +++ b/apps/pythinker-code/test/tui/commands/experiments.test.ts @@ -109,6 +109,34 @@ describe('experimental feature command handlers', () => { ); }); + it.each([true, false])( + 'toggles notification display without reloading the session: %s', + async (enabled) => { + const host = makeHost(); + host.harness.getExperimentalFeatures.mockResolvedValue([ + feature({ id: 'notify_user', enabled }), + ]); + await applyExperimentalFeatureChanges(host, [{ id: 'notify_user', enabled }]); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + experimental: { notify_user: enabled }, + }); + expect(isExperimentalFlagEnabled('notify_user')).toBe(enabled); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + expect(host.harness.reloadSession).not.toHaveBeenCalled(); + expect(host.reloadCurrentSessionView).not.toHaveBeenCalled(); + expect(host.showError).not.toHaveBeenCalled(); + }, + ); + + it('still reloads when another experimental feature changes alongside notifications', async () => { + const host = makeHost(); + await applyExperimentalFeatureChanges(host, [ + { id: 'notify_user', enabled: false }, + { id: 'micro_compaction', enabled: false }, + ]); + expect(host.harness.reloadSession).toHaveBeenCalledOnce(); + }); + it('reports the post-apply enabled flag set in telemetry', async () => { const host = makeHost(); host.harness.getExperimentalFeatures.mockResolvedValue([ diff --git a/apps/pythinker-code/test/tui/commands/survey-preferences.test.ts b/apps/pythinker-code/test/tui/commands/survey-preferences.test.ts new file mode 100644 index 000000000..b8b807302 --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/survey-preferences.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { applySurveyPreferenceChoice } from '#/tui/commands/config'; +import { darkColors } from '#/tui/theme/colors'; + +const mocks = vi.hoisted(() => ({ + saveTuiConfig: vi.fn(), +})); + +vi.mock('../../../src/tui/config', async () => { + const actual = await vi.importActual( + '../../../src/tui/config.js', + ); + return { + ...actual, + saveTuiConfig: mocks.saveTuiConfig, + }; +}); + +function makeHost(disableFeedbackSurvey: boolean) { + return { + state: { + appState: { + theme: 'auto' as const, + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' as const }, + upgrade: { autoInstall: true }, + disableFeedbackSurvey, + }, + theme: { palette: darkColors }, + }, + setAppState: vi.fn(), + showStatus: vi.fn(), + }; +} + +describe('survey preference commands', () => { + it('saves the opt-out to tui.toml and mirrors it into appState', async () => { + mocks.saveTuiConfig.mockClear(); + const host = makeHost(false); + + await applySurveyPreferenceChoice(host, false); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ disableFeedbackSurvey: true }), + ); + expect(host.setAppState).toHaveBeenCalledWith({ disableFeedbackSurvey: true }); + expect(host.showStatus).toHaveBeenCalledWith('Feedback survey disabled.'); + }); + + it('re-enables the survey from an opt-out state', async () => { + mocks.saveTuiConfig.mockClear(); + const host = makeHost(true); + + await applySurveyPreferenceChoice(host, true); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ disableFeedbackSurvey: false }), + ); + expect(host.setAppState).toHaveBeenCalledWith({ disableFeedbackSurvey: false }); + expect(host.showStatus).toHaveBeenCalledWith('Feedback survey enabled.'); + }); + + it('does not rewrite the config when the value is unchanged', async () => { + mocks.saveTuiConfig.mockClear(); + const host = makeHost(false); + + await applySurveyPreferenceChoice(host, true); + + expect(mocks.saveTuiConfig).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Feedback survey already enabled.'); + }); + + it('reports a save failure without touching appState', async () => { + mocks.saveTuiConfig.mockRejectedValueOnce(new Error('disk full')); + const host = makeHost(false); + + await applySurveyPreferenceChoice(host, false); + + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith( + 'Failed to save session rating setting: disk full', + 'error', + ); + }); +}); diff --git a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts index 8e79bfe91..23e6a24fc 100644 --- a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts +++ b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts @@ -45,6 +45,7 @@ describe('update preference commands', () => { disablePasteBurst: false, renderLatex: true, cacheExpiryHint: true, + disableFeedbackSurvey: false, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, statusLine: { items: null, command: null }, diff --git a/apps/pythinker-code/test/tui/components/dialogs/survey-preference-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/survey-preference-selector.test.ts new file mode 100644 index 000000000..6538d9390 --- /dev/null +++ b/apps/pythinker-code/test/tui/components/dialogs/survey-preference-selector.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { SurveyPreferenceSelectorComponent } from '#/tui/components/dialogs/survey-preference-selector'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); + +describe('SurveyPreferenceSelectorComponent', () => { + it('maps the current preference onto the picker options', () => { + const selected: boolean[] = []; + const enabledPicker = new SurveyPreferenceSelectorComponent({ + currentValue: true, + onSelect: (value) => selected.push(value), + onCancel: () => {}, + }); + const disabledPicker = new SurveyPreferenceSelectorComponent({ + currentValue: false, + onSelect: (value) => selected.push(value), + onCancel: () => {}, + }); + + expect(strip(enabledPicker.render(60).join('\n'))).toContain('Feedback survey'); + expect(strip(disabledPicker.render(60).join('\n'))).toContain('Off'); + + enabledPicker.handleInput('\r'); + expect(selected).toEqual([true]); + disabledPicker.handleInput('\r'); + expect(selected).toEqual([true, false]); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/panels/notify-panel.test.ts b/apps/pythinker-code/test/tui/components/panels/notify-panel.test.ts new file mode 100644 index 000000000..4f47abbef --- /dev/null +++ b/apps/pythinker-code/test/tui/components/panels/notify-panel.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; + +import { + NotifyPanelComponent, + type NotifyEntry, +} from '#/tui/components/chrome/notify-panel'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function render(panel: NotifyPanelComponent, width = 80): string[] { + return panel.render(width).map(strip); +} + +/** Line 0 is a blank spacer separating the box from the scrolling transcript. */ +function titleOf(panel: NotifyPanelComponent, width = 80): string { + return render(panel, width)[1]!; +} + +function entry(id: string, text: string, agentId = 'main', agentName?: string): NotifyEntry { + return { id, agentId, agentName, time: 0, text }; +} + +function listRows(count: number, prefix = 'row'): string { + return Array.from({ length: count }, (_, i) => `- ${prefix} ${String(i + 1)}`).join('\n'); +} + +describe('NotifyPanelComponent', () => { + it('returns no lines when empty (so the layout slot collapses)', () => { + const panel = new NotifyPanelComponent(); + expect(panel.render(80)).toEqual([]); + expect(panel.isEmpty()).toBe(true); + expect(panel.focus()).toBe(false); + }); + + it('renders the update in a padded, bordered box with the channel tab', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'Login module is clean; the bug is in **session expiry**.')); + const lines = render(panel); + + expect(lines[0]).toBe(''); + expect(lines[1]).toMatch(/^╭/); + expect(lines[1]).toContain('main'); + expect(lines[1]).toContain('Updates 1/1'); + expect(lines[1]).toContain('ctrl+n page'); + expect(lines[1]).toMatch(/─╮$/); + expect(lines[2]).toMatch(/^│\s*│$/); + expect(lines[3]).toMatch(/^│ /); + expect(lines[3]).toContain('Login module is clean; the bug is in session expiry.'); + expect(lines.at(-2)).toMatch(/^│\s*│$/); + expect(lines.at(-1)).toMatch(/^╰─+╯$/); + }); + + it('updates an entry in place and flattens entries in display order', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'Reading the')); + panel.upsert(entry('tc-1', 'Reading the parser first.')); + expect(panel.getEntries().map((item) => item.text)).toEqual(['Reading the parser first.']); + expect(render(panel).join('\n')).toContain('Reading the parser first.'); + }); + + it('follows the latest activity across channels while unfocused', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'main phase one')); + panel.upsert(entry('tc-2', 'main phase two')); + expect(titleOf(panel)).toContain('Updates 2/2'); + + panel.upsert(entry('a0:c1', 'explore finding', 'a0', 'explore')); + const lines = render(panel); + expect(lines[1]).toContain('main'); + expect(lines[1]).toContain('explore'); + expect(lines[1]).toContain('Updates 1/1'); + expect(lines.join('\n')).toContain('explore finding'); + expect(lines.join('\n')).not.toContain('main phase'); + }); + + it('switches channels with prev/nextChannel and pages inside a channel', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'main one')); + panel.upsert(entry('tc-2', 'main two')); + panel.upsert(entry('a0:c1', 'explore one', 'a0', 'explore')); + + expect(panel.focus()).toBe(true); + expect(panel.nextChannel()).toBe(false); + + expect(panel.prevChannel()).toBe(true); + expect(titleOf(panel)).toContain('Updates 2/2'); + expect(render(panel).join('\n')).toContain('main two'); + + expect(panel.prevPage()).toBe(true); + expect(titleOf(panel)).toContain('Updates 1/2'); + expect(render(panel).join('\n')).toContain('main one'); + expect(panel.prevPage()).toBe(false); + + expect(panel.nextPage()).toBe(true); + expect(render(panel).join('\n')).toContain('main two'); + }); + + it('collects an unread dot on background channels while focused, cleared on visit', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'main one')); + panel.upsert(entry('a0:c1', 'explore one', 'a0', 'explore')); + + panel.focus(); + panel.prevChannel(); + expect(titleOf(panel)).not.toContain('●'); + + panel.upsert(entry('a0:c2', 'explore two', 'a0', 'explore')); + expect(titleOf(panel)).toContain('explore●'); + expect(render(panel).join('\n')).toContain('main one'); + + expect(panel.nextChannel()).toBe(true); + expect(titleOf(panel)).not.toContain('●'); + expect(titleOf(panel)).toContain('Updates 2/2'); + expect(render(panel).join('\n')).toContain('explore two'); + }); + + it('dedups repeated channel labels with a counter', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('a1:c1', 'first explore', 'a1', 'explore')); + panel.upsert(entry('a2:c1', 'second explore', 'a2', 'explore')); + + expect(panel.getChannels().map((ch) => ch.label)).toEqual(['explore', 'explore(2)']); + }); + + it('labels channels by raw agent id when no name is known', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('a9:c1', 'mystery worker', 'agent-9')); + expect(panel.getChannels().map((ch) => ch.label)).toEqual(['agent-9']); + expect(titleOf(panel)).toContain('agent-9'); + }); + + it('renders every row of a long entry — adaptive height, no truncation', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', listRows(30))); + const text = render(panel).join('\n'); + expect(text).toContain('row 1'); + expect(text).toContain('row 30'); + expect(text).not.toContain('later lines'); + expect(text).not.toContain('more lines'); + }); + + it('renders focus state: highlighted hints, blur restores', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'phase one')); + + expect(titleOf(panel)).toContain('ctrl+n page'); + panel.focus(); + expect(titleOf(panel)).toContain('← → agent · ↑ ↓ update · esc close'); + panel.blur(); + expect(panel.blur()).toBe(false); + expect(titleOf(panel)).toContain('ctrl+n page'); + }); + + it('folds to a one-line preview stub when the turn ends, expands on focus', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'phase one intro\n\n- detail one\n- detail two')); + panel.setEnded(true); + + const collapsed = render(panel); + expect(collapsed).toHaveLength(2); + expect(collapsed[0]).toBe(''); + expect(collapsed[1]).toContain('▸'); + expect(collapsed[1]).toContain('main'); + expect(collapsed[1]).toContain('1 update'); + expect(collapsed[1]).toContain('phase one intro'); + expect(collapsed[1]).toContain('ctrl+n'); + expect(collapsed[1]).not.toContain('detail one'); + expect(collapsed[1]).not.toMatch(/[╭╮╰╯│]/); + + panel.focus(); + const expanded = render(panel); + expect(expanded.join('\n')).toContain('detail one'); + expect(expanded.length).toBeGreaterThan(2); + + panel.blur(); + expect(render(panel)).toHaveLength(2); + }); + + it('stays expanded when the turn ends while the user is reading, folds on blur', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'phase one')); + panel.focus(); + panel.setEnded(true); + + expect(render(panel).join('\n')).toContain('phase one'); + + panel.blur(); + expect(render(panel)).toHaveLength(2); + }); + + it('unfolds and undims when a fresh update arrives after the turn ended', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'done with phase one')); + panel.setEnded(true); + expect(render(panel)).toHaveLength(2); + + panel.upsert(entry('tc-2', 'fresh turn update')); + const lines = render(panel); + expect(lines.join('\n')).toContain('fresh turn update'); + expect(lines[1]).not.toContain('turn ended'); + }); + + it('removes an entry and drops empty channels', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'main one')); + panel.upsert(entry('a0:c1', 'explore one', 'a0', 'explore')); + + expect(panel.remove('a0:c1')).toBe(true); + expect(panel.getChannels().map((ch) => ch.label)).toEqual(['main']); + expect(panel.remove('missing')).toBe(false); + }); + + it('clamps the page when the newest entry is removed', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'first')); + panel.upsert(entry('tc-2', 'second')); + expect(titleOf(panel)).toContain('Updates 2/2'); + + expect(panel.remove('tc-2')).toBe(true); + expect(titleOf(panel)).toContain('Updates 1/1'); + expect(panel.nextPage()).toBe(false); + }); + + it('releases focus when removal empties the panel', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'only')); + expect(panel.focus()).toBe(true); + + expect(panel.remove('tc-1')).toBe(true); + expect(panel.isEmpty()).toBe(true); + expect(panel.isFocused()).toBe(false); + expect(panel.prevPage()).toBe(false); + expect(panel.nextPage()).toBe(false); + }); + + it('clears the unread count when a retracted entry was unread', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'main one')); + panel.upsert(entry('a0:c1', 'explore one', 'a0', 'explore')); + expect(panel.focus()).toBe(true); + + panel.upsert(entry('tc-2', 'main two')); + const before = panel.getChannels().find((ch) => ch.label === 'main'); + expect(before?.unread).toBe(1); + + expect(panel.remove('tc-2')).toBe(true); + const after = panel.getChannels().find((ch) => ch.label === 'main'); + expect(after?.unread).toBe(0); + }); + + it('keeps the unread count when a retracted entry was already read', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'main one')); + panel.upsert(entry('a0:c1', 'explore one', 'a0', 'explore')); + expect(panel.focus()).toBe(true); + + panel.upsert(entry('tc-2', 'delegation')); + expect(panel.getChannels().find((ch) => ch.label === 'main')?.unread).toBe(1); + + expect(panel.prevChannel()).toBe(true); + expect(panel.nextChannel()).toBe(true); + panel.upsert(entry('tc-3', 'main three')); + expect(panel.getChannels().find((ch) => ch.label === 'main')?.unread).toBe(1); + + expect(panel.remove('tc-2')).toBe(true); + expect(panel.getChannels().find((ch) => ch.label === 'main')?.unread).toBe(1); + }); + + it('dims to a stub and notes the ended turn, and clears wholesale', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', 'done with phase one')); + panel.setEnded(true); + expect(render(panel)).toHaveLength(2); + expect(titleOf(panel)).toContain('ctrl+n'); + + panel.clear(); + expect(panel.isEmpty()).toBe(true); + expect(panel.render(80)).toEqual([]); + + panel.upsert(entry('tc-2', 'fresh turn')); + expect(titleOf(panel)).not.toContain('turn ended'); + }); + + it('never renders wider than the requested width', () => { + const panel = new NotifyPanelComponent(); + panel.upsert(entry('tc-1', `${'word '.repeat(60)}\n\n- a very long bullet ${'x'.repeat(120)}`)); + panel.upsert(entry('a1:c1', listRows(12, 'later'), 'a1', 'explore')); + for (const width of [24, 40, 80]) { + for (const line of panel.render(width)) { + expect(strip(line).length).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/pythinker-code/test/tui/components/panes/survey-panel.test.ts b/apps/pythinker-code/test/tui/components/panes/survey-panel.test.ts new file mode 100644 index 000000000..5b0cbbe82 --- /dev/null +++ b/apps/pythinker-code/test/tui/components/panes/survey-panel.test.ts @@ -0,0 +1,76 @@ +import chalk from 'chalk'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { visibleWidth } from '@pymodel/pi-tui'; + +import { + SurveyPanelComponent, + type SurveyPanelView, +} from '#/tui/components/panes/survey-panel'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function render(view: SurveyPanelView, width: number): string[] { + return new SurveyPanelComponent(view).render(width).map(stripAnsi); +} + +describe('SurveyPanelComponent', () => { + const previousChalkLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + }); + + it('renders the question and the four options on one line', () => { + const lines = render({ phase: 'open' }, 80); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain('●'); + expect(lines[0]).toContain('How is Pythinker doing this session? (optional)'); + expect(lines[1]).toBe(' 1: Bad 2: Fine 3: Good 0: Dismiss'); + }); + + it('folds the options onto their own lines when narrow', () => { + const lines = render({ phase: 'open' }, 30); + expect(lines[0]).toContain('How is Pythinker doing this'); + expect(lines[1]).toContain('session? (optional)'); + expect(lines.slice(2)).toEqual([' 1: Bad', ' 2: Fine', ' 3: Good', ' 0: Dismiss']); + }); + + it('keeps only the title line at extreme widths, without hard-breaking', () => { + const width = 10; + const lines = render({ phase: 'open' }, width); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines.join('\n')).not.toContain('1: Bad'); + expect(lines[0]).toContain('●'); + }); + + it('renders the pending line with the chosen rating and the undo hint', () => { + const lines = render({ phase: 'pending', response: 'bad' }, 80); + expect(lines).toEqual(['● Feedback: Bad · [escape: undo]']); + }); + + it('renders the thanks line', () => { + const lines = render({ phase: 'thanks' }, 80); + expect(lines).toEqual(['● Thanks for your feedback!']); + }); + + it('highlights the hovered option', () => { + const plain = new SurveyPanelComponent({ phase: 'open' }).render(80)[1]!; + const hovered = new SurveyPanelComponent({ phase: 'open', hoverIndex: 2 }).render(80)[1]!; + expect(stripAnsi(hovered)).toBe(stripAnsi(plain)); + expect(hovered).not.toBe(plain); + expect(hovered).toContain('3: Good'); + }); + + it('renders a single blank line when the terminal cannot hold a column', () => { + expect(render({ phase: 'open' }, 0)).toEqual(['']); + }); +}); diff --git a/apps/pythinker-code/test/tui/config.test.ts b/apps/pythinker-code/test/tui/config.test.ts index 2024e3abd..210cb967d 100644 --- a/apps/pythinker-code/test/tui/config.test.ts +++ b/apps/pythinker-code/test/tui/config.test.ts @@ -63,6 +63,7 @@ auto_install = false renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + disableFeedbackSurvey: false, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -109,6 +110,7 @@ command = " " renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + disableFeedbackSurvey: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -143,6 +145,7 @@ command = " " theme: 'light', disablePasteBurst: false, cacheExpiryHint: true, + disableFeedbackSurvey: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -156,6 +159,7 @@ command = " " renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + disableFeedbackSurvey: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -170,6 +174,7 @@ command = " " theme, disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, cacheExpiryHint: DEFAULT_TUI_CONFIG.cacheExpiryHint, + disableFeedbackSurvey: DEFAULT_TUI_CONFIG.disableFeedbackSurvey, editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, diff --git a/apps/pythinker-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/pythinker-code/test/tui/controllers/editor-keyboard-image-paste.test.ts index 89c027fa4..79ded08ae 100644 --- a/apps/pythinker-code/test/tui/controllers/editor-keyboard-image-paste.test.ts +++ b/apps/pythinker-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -79,6 +79,12 @@ function createPasteHarness( ? undefined : { summary: { sessionDir: options.sessionDir } }, btwPanelController: { closeOrCancel: vi.fn(() => false) }, + surveyController: { + handleSubmit: vi.fn(() => false), + handlePreInput: vi.fn(() => false), + handleEditorChange: vi.fn(), + closeSilently: vi.fn(), + }, engineV2: options.engineV2, track, showError: vi.fn(), diff --git a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts index e2ea47391..c1a56a3b7 100644 --- a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts @@ -51,6 +51,12 @@ function createHarness(options: { }, session, btwPanelController: { cancelRunning: btwCancelRunning, closeOrCancel: btwCloseOrCancel }, + surveyController: { + handleSubmit: vi.fn(() => false), + handlePreInput: vi.fn(() => false), + handleEditorChange: vi.fn(), + closeSilently: vi.fn(), + }, openUndoSelector, cancelRunningShellCommand, updateEditorBorderHighlight: vi.fn(), @@ -472,6 +478,12 @@ describe('EditorKeyboardController Shift-Tab effort cycle', () => { showError, showNotice, btwPanelController: { cancelRunning: vi.fn(), closeOrCancel: vi.fn() }, + surveyController: { + handleSubmit: vi.fn(() => false), + handlePreInput: vi.fn(() => false), + handleEditorChange: vi.fn(), + closeSilently: vi.fn(), + }, } as unknown as EditorKeyboardHost; new EditorKeyboardController(host, undefined as unknown as ImageAttachmentStore).install(); @@ -645,6 +657,12 @@ describe('EditorKeyboardController Ctrl-S steering', () => { cancelRunning: vi.fn(() => false), closeOrCancel: vi.fn(() => false), }, + surveyController: { + handleSubmit: vi.fn(() => false), + handlePreInput: vi.fn(() => false), + handleEditorChange: vi.fn(), + closeSilently: vi.fn(), + }, } as unknown as EditorKeyboardHost; const controller = new EditorKeyboardController( host, diff --git a/apps/pythinker-code/test/tui/controllers/notify.test.ts b/apps/pythinker-code/test/tui/controllers/notify.test.ts new file mode 100644 index 000000000..e0fa4cfd6 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/notify.test.ts @@ -0,0 +1,752 @@ +import type { Event, ResumedSessionState } from '@pymodel/pythinker-code-sdk'; +import { + Container, + Text, + TuiMainScreen, + TuiAltScreen, + VStack, + ScrollView, + type Terminal, +} from '@pymodel/pi-tui'; +import { CustomEditor } from '#/tui/components/editor/custom-editor'; +import { describe, expect, it, vi } from 'vitest'; + +import { NotifyPanelComponent } from '#/tui/components/chrome/notify-panel'; +import { NotifyController } from '#/tui/controllers/notify'; + +function makeHarness(enabled = true, fullscreen = false) { + let input: ((data: string) => void) | undefined; + const terminal: Terminal = { + start: (onInput) => { + input = onInput; + }, + stop: () => {}, + drainInput: async () => {}, + write: () => {}, + columns: 100, + rows: 24, + kittyProtocolActive: false, + moveBy: () => {}, + hideCursor: () => {}, + showCursor: () => {}, + clearLine: () => {}, + clearFromCursor: () => {}, + clearScreen: () => {}, + setTitle: () => {}, + setProgress: () => {}, + }; + const ui = fullscreen ? new TuiAltScreen(terminal) : new TuiMainScreen(terminal); + const editor = new CustomEditor(ui); + editor.setText('unsent draft'); + const transcript = new Text('earlier output\n'.repeat(80), 0, 0); + const notifyPanel = new NotifyPanelComponent(); + const notifyPanelContainer = new Container(); + const requestRender = vi.spyOn(ui, 'requestRender').mockImplementation(() => {}); + const root = new VStack(); + root.addChild(new ScrollView(transcript, { primary: true })); + root.addChild(notifyPanelContainer); + root.addChild(editor); + if (ui instanceof TuiAltScreen) ui.setLayoutRoot(root); + else { + ui.addChild(transcript); + ui.addChild(notifyPanelContainer); + ui.addChild(editor); + } + ui.setFocus(editor); + const controller = new NotifyController({ + notifyPanel, + notifyPanelContainer, + ui, + editor, + } as never); + controller.setEnabled(enabled); + editor.onPageNotify = () => controller.toggleFocus(); + editor.onNotifyPanelKey = (key) => controller.handlePanelKey(key); + const emit = ( + type: string, + fields: Record = {}, + agentId = 'main', + turnId = 1, + ) => { + controller.handleEvent({ type, sessionId: 's1', agentId, turnId, ...fields } as Event); + }; + if (enabled) emit('turn.started'); + requestRender.mockClear(); + const send = (id: string, message: string, agentId = 'main', turnId = 1) => { + emit( + 'tool.call.started', + { toolCallId: id, name: 'NotifyUser', args: { message } }, + agentId, + turnId, + ); + emit('tool.result', { toolCallId: id, output: 'Update shown to the user.' }, agentId, turnId); + }; + const texts = () => notifyPanel.getEntries().map((entry) => entry.text); + const rendered = () => notifyPanel.render(150).join('\n'); + return { + controller, + ui, + editor, + root, + input: (data: string) => input?.(data), + emit, + send, + texts, + rendered, + notifyPanel, + notifyPanelContainer, + requestRender, + }; +} + +function snapshot(): ResumedSessionState { + return { + sessionMetadata: { agents: {} }, + agents: { + main: { config: { profileName: 'agent' }, background: [], replay: [] }, + 'agent-1': { config: { profileName: 'coder' }, background: [], replay: [] }, + }, + } as unknown as ResumedSessionState; +} + +describe('NotifyController', () => { + it('does nothing when disabled, including replay, layout and keyboard', () => { + const h = makeHarness(false); + h.emit('turn.started'); + h.emit('subagent.spawned', { subagentId: 'agent-1', subagentName: 'coder' }); + h.send('n1', 'invisible'); + h.controller.restore(snapshot()); + expect(h.texts()).toEqual([]); + expect(h.notifyPanelContainer.children).toEqual([]); + expect(h.controller.toggleFocus()).toBe(false); + expect(h.requestRender).not.toHaveBeenCalled(); + }); + + it('waits for the successful result before displaying authoritative arguments', () => { + const h = makeHarness(); + h.emit('tool.call.delta', { + toolCallId: 'n1', + name: 'NotifyUser', + argumentsPart: '{"message":"Reading the', + }); + expect(h.texts()).toEqual([]); + h.emit('tool.call.delta', { toolCallId: 'n1', argumentsPart: ' parser."}' }); + expect(h.texts()).toEqual([]); + h.emit('tool.call.started', { + toolCallId: 'n1', + name: 'NotifyUser', + args: { message: 'Parser reviewed.' }, + }); + expect(h.texts()).toEqual([]); + expect(h.notifyPanelContainer.children).toEqual([]); + h.emit('tool.result', { toolCallId: 'n1', output: 'Update shown to the user.' }); + expect(h.texts()).toEqual(['Parser reviewed.']); + expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); + }); + + it.each([ + { output: 'Permission denied', isError: true }, + { output: 'Update shown to the user.', synthetic: true }, + { output: 'Notifications are disabled; the update was not displayed.' }, + { output: 'Unrecognized success' }, + ])('never displays rejected, suppressed or unconfirmed updates: %j', (result) => { + const h = makeHarness(); + h.emit('tool.call.delta', { + toolCallId: 'blocked', + name: 'NotifyUser', + argumentsPart: '{"message":"Private finding', + }); + expect(h.notifyPanelContainer.children).toEqual([]); + h.emit('tool.call.started', { + toolCallId: 'blocked', + name: 'NotifyUser', + args: { message: 'Private finding' }, + }); + expect(h.notifyPanelContainer.children).toEqual([]); + h.emit('tool.result', { toolCallId: 'blocked', ...result }); + expect(h.texts()).toEqual([]); + expect(h.notifyPanelContainer.children).toEqual([]); + }); + + it.each([false, true])( + 'keeps a foreground descendant alive under a background parent, detached later: %s', + (detachLater) => { + const h = makeHarness(); + h.emit('subagent.spawned', { + subagentId: 'agent-7', + parentAgentId: 'main', + subagentName: 'coder', + runInBackground: !detachLater, + }); + h.emit('turn.started', {}, 'agent-7'); + h.emit( + 'subagent.spawned', + { + subagentId: 'agent-29', + parentAgentId: 'agent-7', + subagentName: 'coder', + runInBackground: false, + }, + 'agent-7', + ); + h.emit('turn.started', {}, 'agent-29'); + h.emit( + 'tool.call.started', + { toolCallId: 'pending-child', name: 'NotifyUser', args: { message: 'Child finding' } }, + 'agent-29', + ); + if (detachLater) + h.emit('background.task.started', { + info: { kind: 'agent', agentId: 'agent-7', status: 'running' }, + }); + h.emit('turn.ended', { reason: 'completed' }); + h.emit( + 'tool.result', + { toolCallId: 'pending-child', output: 'Update shown to the user.' }, + 'agent-29', + ); + h.send('later-child', 'More child findings', 'agent-29'); + expect(h.texts()).toEqual(['Child finding', 'More child findings']); + h.emit('turn.ended', { reason: 'completed' }, 'agent-29'); + h.send('late-event', 'Stale finding', 'agent-29'); + expect(h.texts()).toEqual(['Child finding', 'More child findings']); + }, + ); + + it('clears updates at each new main turn but not at child turn boundaries', () => { + const h = makeHarness(); + h.emit('turn.started'); + h.send('same', 'first turn'); + h.emit('turn.ended', { reason: 'completed' }); + for (const [i, kind] of ['user', 'cron_job', 'background_task'].entries()) { + h.emit('turn.started', { origin: { kind } }, 'main', i + 2); + expect(h.texts()).toEqual([]); + h.send('same', kind, 'main', i + 2); + h.emit('turn.started', {}, 'agent-1', i + 2); + expect(h.texts()).toEqual([kind]); + h.emit('turn.ended', { reason: 'completed' }, 'main', i + 2); + } + expect(h.texts()).toEqual(['background_task']); + expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); + }); + + it('isolates identical call ids by agent and labels channels by agent name', () => { + const h = makeHarness(); + h.emit('subagent.spawned', { + subagentId: 'agent-1', + subagentName: 'explore', + description: 'Authentication checks', + runInBackground: false, + }); + h.send('same', 'main findings'); + h.send('same', 'child findings', 'agent-1'); + expect(h.texts()).toEqual(['main findings', 'child findings']); + expect(h.notifyPanel.getEntries()[0]!.agentId).toBe('main'); + expect(h.notifyPanel.getEntries()[1]!.agentId).toBe('agent-1'); + expect(h.rendered()).toContain('explore'); + expect(h.rendered()).not.toContain('Authentication checks'); + h.emit('subagent.completed', { subagentId: 'agent-1' }); + expect(h.rendered()).toContain('explore'); + }); + + it('keeps background agents working after the main agent ends', () => { + const h = makeHarness(); + h.emit('turn.started'); + h.emit('subagent.spawned', { + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Run tests', + runInBackground: true, + }); + h.emit('turn.started', {}, 'agent-1'); + h.send('n1', 'main done'); + h.emit('turn.ended', { reason: 'completed' }); + expect(h.rendered()).toContain('ctrl+n'); + h.controller.toggleFocus(); + expect(h.rendered()).toContain('main done'); + h.send('n2', 'background finding', 'agent-1'); + expect(h.texts()).toEqual(['main done', 'background finding']); + expect(h.rendered()).toContain('coder●'); + h.controller.handlePanelKey('right'); + expect(h.rendered()).toContain('background finding'); + h.emit('subagent.completed', { subagentId: 'agent-1' }); + expect(h.rendered()).toContain('background finding'); + }); + + it('keeps child turns active until their own end events, including detached tasks', () => { + const h = makeHarness(); + for (const subagentId of ['agent-7', 'agent-29']) { + h.emit('subagent.spawned', { subagentId, subagentName: 'coder', runInBackground: false }); + h.emit('turn.started', {}, subagentId); + h.emit( + 'tool.call.started', + { + toolCallId: 'pending', + name: 'NotifyUser', + args: { message: 'Working' }, + }, + subagentId, + ); + } + h.emit('background.task.started', { + info: { kind: 'agent', agentId: 'agent-29', status: 'running' }, + }); + h.emit('turn.ended', { reason: 'completed' }); + expect(h.texts()).toEqual([]); + h.emit('turn.ended', { reason: 'cancelled' }, 'agent-7'); + h.emit( + 'tool.result', + { toolCallId: 'pending', output: 'Update shown to the user.' }, + 'agent-7', + ); + expect(h.texts()).toEqual([]); + h.emit( + 'tool.result', + { toolCallId: 'pending', output: 'Update shown to the user.' }, + 'agent-29', + ); + expect(h.texts()).toEqual(['Working']); + expect(h.rendered()).toContain('coder'); + }); + + it('uses real agent ids regardless of creation order, updates or turn boundaries', () => { + const h = makeHarness(); + for (const subagentId of ['agent-7', 'agent-29', 'agent-105']) { + h.emit('subagent.spawned', { + subagentId, + subagentName: 'coder', + description: 'The same long task description for every worker', + runInBackground: true, + }); + } + h.send('b', 'Second worker reports first', 'agent-29'); + h.send('c', 'Third worker reports next', 'agent-105'); + h.send('a', 'First worker reports last', 'agent-7'); + expect(h.notifyPanel.getChannels().map((ch) => ch.label)).toEqual([ + 'coder', + 'coder(2)', + 'coder(3)', + ]); + h.emit('background.task.started', { + info: { kind: 'agent', agentId: 'agent-29', description: 'Changed task', status: 'running' }, + }); + h.emit('subagent.started', { subagentId: 'agent-29' }); + h.emit('turn.started', {}, 'main', 2); + h.send('again', 'Second worker continues', 'agent-29'); + expect(h.rendered()).toContain('coder'); + expect(h.rendered()).not.toContain('Changed task'); + h.controller.clear(); + h.send('c-again', 'Third worker continues', 'agent-105'); + expect(h.notifyPanel.getEntries()[0]!.agentId).toBe('agent-105'); + expect(h.rendered()).toContain('coder'); + h.controller.reset(); + h.send('new-session', 'A new session', 'agent-105'); + expect(h.notifyPanel.getEntries()[0]!.agentId).toBe('agent-105'); + expect(h.rendered()).toContain('agent-105'); + }); + + it('uses the same agent id before lifecycle metadata arrives and after toggling the feature', () => { + const h = makeHarness(); + h.send('first', 'Early update', 'agent-29'); + h.emit('subagent.spawned', { subagentId: 'agent-29', subagentName: 'coder' }); + h.send('second', 'Later update', 'agent-29'); + expect(h.rendered()).toContain('agent-29'); + h.controller.setEnabled(false); + h.emit('subagent.spawned', { subagentId: 'hidden', subagentName: 'coder' }); + h.controller.setEnabled(true); + h.send('third', 'After enabling', 'agent-29'); + expect(h.rendered()).toContain('agent-29'); + }); + + it.each([ + 'tool.result', + 'turn.step.interrupted', + 'turn.step.retrying', + 'turn.step.completed', + 'turn.ended', + 'subagent.failed', + ])('retracts only unfinished child updates on %s', (type) => { + const h = makeHarness(); + h.send('kept', 'delivered', 'agent-1'); + h.emit('turn.step.started', { step: 2 }, 'agent-1'); + h.emit( + 'tool.call.started', + { toolCallId: 'failed', name: 'NotifyUser', args: { message: 'unfinished' } }, + 'agent-1', + ); + h.emit( + type, + { + toolCallId: 'failed', + isError: true, + reason: 'failed', + step: 2, + finishReason: 'max_tokens', + subagentId: 'agent-1', + }, + 'agent-1', + ); + h.emit('tool.result', { toolCallId: 'failed', output: 'Update shown to the user.' }, 'agent-1'); + expect(h.texts()).toEqual(['delivered']); + }); + + it('ignores duplicate and late events for completed or withdrawn calls', () => { + const h = makeHarness(); + h.send('done', 'Confirmed update'); + h.emit('tool.call.delta', { + toolCallId: 'done', + name: 'NotifyUser', + argumentsPart: '{"message":"stale partial', + }); + expect(h.texts()).toEqual(['Confirmed update']); + h.emit('tool.call.delta', { + toolCallId: 'cut', + name: 'NotifyUser', + argumentsPart: '{"message":"unfinished', + }); + h.emit('turn.step.completed', { step: 0, finishReason: 'max_tokens' }); + h.emit('tool.call.started', { + toolCallId: 'cut', + name: 'NotifyUser', + args: { message: 'late start' }, + }); + expect(h.texts()).toEqual(['Confirmed update']); + }); + + it('retains independent agent status when the main turn ends', () => { + const h = makeHarness(); + h.emit('turn.started'); + h.emit('turn.started', {}, 'independent-1'); + h.send('note', 'Checking another question', 'independent-1'); + h.emit('turn.ended', { reason: 'completed' }); + expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); + }); + + it('clears only entries on clear, and all session state on reset', () => { + const h = makeHarness(); + h.emit('subagent.spawned', { + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Tests', + runInBackground: true, + }); + h.send('n1', 'earlier'); + h.controller.clear(); + h.send('n2', 'later', 'agent-1'); + expect(h.texts()).toEqual(['later']); + expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); + h.controller.reset(); + h.send('n3', 'another session'); + expect(h.texts()).toEqual(['another session']); + expect(h.rendered()).toContain('another session'); + }); + + it('disabling removes state and re-enabling does not replay buffered events', () => { + const h = makeHarness(); + h.send('n1', 'enabled'); + h.controller.setEnabled(false); + h.requestRender.mockClear(); + h.send('n2', 'disabled'); + h.controller.restore(snapshot()); + expect(h.notifyPanelContainer.children).toEqual([]); + expect(h.requestRender).not.toHaveBeenCalled(); + h.controller.setEnabled(true); + expect(h.texts()).toEqual([]); + h.send('n3', 'enabled again'); + expect(h.texts()).toEqual(['enabled again']); + }); + + it('starts with an empty panel when a session is restored', () => { + const h = makeHarness(); + h.send('old', 'previous session'); + h.controller.restore(snapshot()); + expect(h.texts()).toEqual([]); + expect(h.notifyPanelContainer.children).toEqual([]); + }); + + it('restores background metadata and status independently of agent enumeration order', () => { + const state = snapshot(); + Object.assign(state.agents['main']!, { + background: [ + { kind: 'agent', agentId: 'agent-1', description: 'Background tests', status: 'running' }, + ], + }); + const h = makeHarness(); + h.controller.restore(state); + expect(h.texts()).toEqual([]); + h.send('fresh', 'New background progress', 'agent-1'); + expect(h.rendered()).toContain('agent-1'); + h.emit('turn.ended', { reason: 'completed' }); + h.send('later', 'Still working', 'agent-1'); + expect(h.texts()).toEqual(['New background progress', 'Still working']); + expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); + }); + it('labels channels of subagents restored from a snapshot by subagent type', () => { + const state = snapshot(); + Object.assign(state.agents['main']!, { + background: [ + { + kind: 'agent', + agentId: 'agent-1', + subagentType: 'explore', + description: 'Search the codebase', + status: 'running', + }, + { + kind: 'agent', + agentId: 'agent-2', + subagentType: 'coder', + description: 'Finished task', + status: 'completed', + }, + ], + }); + const h = makeHarness(); + h.controller.restore(state); + h.send('fresh', 'Progress from a resumed subagent', 'agent-1'); + expect(h.notifyPanel.getChannels().map((ch) => ch.label)).toEqual(['explore']); + h.send('late', 'Settled before resume', 'agent-2'); + expect(h.notifyPanel.getChannels().map((ch) => ch.label)).toEqual(['explore', 'agent-2']); + }); + it.each([false, true])( + 'focuses and pages with the keyboard in regular/fullscreen mode without touching the editor: %s', + (fullscreen) => { + const h = makeHarness(true, fullscreen); + h.send('first', 'update one'); + h.send('second', 'update two'); + h.send('third', 'update three'); + h.ui.start(); + try { + h.ui.renderNow(); + const editorCursor = h.editor.getCursor(); + const root = h.ui instanceof TuiAltScreen ? h.ui.getLayoutRoot() : [...h.ui.children]; + expect(h.rendered()).toContain('Updates 3/3'); + h.input('\u000E'); + expect(h.rendered()).toContain('esc close'); + h.input('\u001B[A'); + expect(h.rendered()).toContain('Updates 2/3'); + h.input('\u001B[A'); + expect(h.rendered()).toContain('Updates 1/3'); + h.input('\u001B[B'); + expect(h.rendered()).toContain('Updates 2/3'); + h.rendered(); + h.emit('turn.ended', { reason: 'completed' }); + expect(h.rendered()).toContain('Updates 2/3'); + expect(h.rendered()).toContain('update two'); + expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); + expect(h.ui.getFocusedComponent()).toBe(h.editor); + expect(h.editor.getText()).toBe('unsent draft'); + expect(h.editor.getCursor()).toEqual(editorCursor); + if (h.ui instanceof TuiAltScreen) expect(h.ui.getLayoutRoot()).toBe(root); + else expect(h.ui.children).toEqual(root); + h.input('\u001B'); + expect(h.rendered()).toContain('ctrl+n'); + expect(h.rendered()).toContain('update two'); + expect(h.rendered()).not.toContain('update three'); + h.emit('turn.started', {}, 'main', 2); + expect(h.texts()).toEqual([]); + expect(h.notifyPanelContainer.children).toEqual([]); + } finally { + h.ui.stop(); + } + }, + ); + + it('ignores delayed turn boundaries without erasing the new turn messages', () => { + const h = makeHarness(); + h.send('old', 'old turn'); + h.emit('turn.started', {}, 'main', 2); + h.send('new', 'new turn', 'main', 2); + h.emit('turn.started', {}, 'main', 1); + h.emit('turn.ended', { reason: 'completed' }, 'main', 1); + expect(h.texts()).toEqual(['new turn']); + h.emit('turn.ended', { reason: 'completed' }, 'main', 2); + expect(h.texts()).toEqual(['new turn']); + expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); + }); + + it('retains more than 100 calls and the complete body, including concurrent notifications', () => { + const h = makeHarness(); + for (let i = 0; i < 150; i++) + h.emit('tool.call.started', { + toolCallId: String(i), + name: 'NotifyUser', + args: { message: `update ${i}` }, + }); + expect(h.texts()).toHaveLength(0); + for (let i = 0; i < 150; i++) + h.emit('tool.result', { toolCallId: String(i), output: 'Update shown to the user.' }); + const long = 'complete body '.repeat(2000); + h.send('long', long); + expect(h.texts()).toHaveLength(151); + expect(h.texts()[0]).toBe('update 0'); + expect(h.texts().at(-1)).toBe(long); + h.emit('turn.ended', { reason: 'completed' }); + expect(h.texts()).toHaveLength(151); + h.emit('tool.call.delta', { + toolCallId: '0', + name: 'NotifyUser', + argumentsPart: '{"message":"late fragment', + }); + expect(h.texts()[0]).toBe('update 0'); + }); + + it('disabling clears retained messages and focus without touching input focus', () => { + const h = makeHarness(); + h.send('done', 'done update'); + h.controller.toggleFocus(); + h.emit('turn.ended', { reason: 'completed' }); + h.controller.setEnabled(false); + expect(h.ui.getFocusedComponent()).toBe(h.editor); + expect(h.texts()).toEqual([]); + expect(h.controller.toggleFocus()).toBe(false); + expect(h.controller.handlePanelKey('left')).toBe(false); + }); + + it('mirrors Agent and AgentDynamicWorkflow delegations into the panel immediately', () => { + const h = makeHarness(); + h.emit('tool.call.started', { + toolCallId: 'a1', + name: 'Agent', + args: { description: 'survey the example project', subagent_type: 'explore', prompt: '…' }, + }); + expect(h.texts()).toEqual(['▸ Delegated to explore: **survey the example project**']); + expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); + + h.emit('tool.call.started', { + toolCallId: 'a2', + name: 'AgentDynamicWorkflow', + args: { items: [{ prompt: 'x' }, { prompt: 'y' }, { prompt: 'z' }] }, + }); + expect(h.texts()).toEqual([ + '▸ Delegated to explore: **survey the example project**', + '▸ Delegated to a dynamic workflow of 3 subagents', + ]); + }); + + it('counts resumed subagents in AgentDynamicWorkflow delegation entries', () => { + const h = makeHarness(); + h.emit('tool.call.started', { + toolCallId: 'a1', + name: 'AgentDynamicWorkflow', + args: { resume_agent_ids: { 'agent-1': 'continue the review', 'agent-2': 'keep going' } }, + }); + expect(h.texts()).toEqual(['▸ Delegated to a dynamic workflow of 2 subagents']); + + h.emit('tool.call.started', { + toolCallId: 'a2', + name: 'AgentDynamicWorkflow', + args: { items: ['x', 'y'], resume_agent_ids: { 'agent-3': 'resume' } }, + }); + expect(h.texts()).toEqual([ + '▸ Delegated to a dynamic workflow of 2 subagents', + '▸ Delegated to a dynamic workflow of 3 subagents', + ]); + }); + + it('retracts delegation entries when the launch fails or is interrupted', () => { + const h = makeHarness(); + h.emit('tool.call.started', { + toolCallId: 'a1', + name: 'Agent', + args: { description: 'keep me', subagent_type: 'explore', prompt: '…' }, + }); + h.emit('tool.call.started', { + toolCallId: 'a2', + name: 'AgentDynamicWorkflow', + args: { items: ['x', 'y'] }, + }); + h.emit('tool.call.started', { + toolCallId: 'a3', + name: 'Agent', + args: { description: 'interrupted', prompt: '…' }, + }); + expect(h.texts()).toHaveLength(3); + + h.emit('tool.result', { toolCallId: 'a2', isError: true, output: 'workflow validation failed' }); + expect(h.texts()).toEqual([ + '▸ Delegated to explore: **keep me**', + '▸ Delegated to subagent: **interrupted**', + ]); + + h.emit('tool.result', { toolCallId: 'a3', synthetic: true, output: '' }); + expect(h.texts()).toEqual(['▸ Delegated to explore: **keep me**']); + + h.emit('tool.result', { toolCallId: 'a1', output: 'subagent finished' }); + expect(h.texts()).toEqual(['▸ Delegated to explore: **keep me**']); + expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); + }); + + it('releases panel focus when a retraction empties the panel', () => { + const h = makeHarness(); + h.emit('tool.call.started', { + toolCallId: 'a1', + name: 'Agent', + args: { description: 'doomed launch', prompt: '…' }, + }); + expect(h.controller.toggleFocus()).toBe(true); + + h.emit('tool.result', { toolCallId: 'a1', isError: true, output: 'unknown profile' }); + expect(h.notifyPanel.isEmpty()).toBe(true); + expect(h.notifyPanel.isFocused()).toBe(false); + expect(h.notifyPanelContainer.children).toEqual([]); + expect(h.controller.handlePanelKey('up')).toBe(false); + expect(h.controller.handlePanelKey('down')).toBe(false); + }); + + it('keeps the delegation entry when another agent errors with the same tool call id', () => { + const h = makeHarness(); + h.emit('tool.call.started', { + toolCallId: 'a1', + name: 'Agent', + args: { description: 'keep me', subagent_type: 'explore', prompt: '…' }, + }); + h.emit('subagent.spawned', { subagentId: 'agent-9', subagentName: 'coder' }); + + h.emit('tool.result', { toolCallId: 'a1', isError: true, output: 'subagent tool failed' }, 'agent-9'); + expect(h.texts()).toEqual(['▸ Delegated to explore: **keep me**']); + }); + + it('folds the panel to a one-line preview stub when the main turn ends', () => { + const h = makeHarness(); + h.send('n1', 'phase report intro\n\n- detail A'); + h.emit('turn.ended', { reason: 'completed' }); + expect(h.rendered()).toContain('ctrl+n'); + expect(h.rendered()).toContain('phase report intro'); + expect(h.rendered()).not.toContain('detail A'); + + h.controller.toggleFocus(); + expect(h.rendered()).toContain('detail A'); + h.controller.handlePanelKey('escape'); + expect(h.rendered()).toContain('ctrl+n'); + expect(h.rendered()).not.toContain('detail A'); + }); + + it('re-collapses the panel once the last background agent completes', () => { + const h = makeHarness(); + h.emit('subagent.spawned', { subagentId: 'agent-1', subagentName: 'explore' }); + h.emit('turn.ended', { reason: 'completed' }); + + h.send('s1', 'bg intro\n\n- bg detail', 'agent-1'); + expect(h.rendered()).toContain('bg detail'); + + h.emit('subagent.completed', { subagentId: 'agent-1' }); + expect(h.rendered()).toContain('ctrl+n'); + expect(h.rendered()).toContain('bg intro'); + expect(h.rendered()).not.toContain('bg detail'); + }); + + it('keeps the panel expanded while a background agent is still running', () => { + const h = makeHarness(); + h.emit('subagent.spawned', { subagentId: 'agent-1', subagentName: 'explore' }); + h.emit('subagent.spawned', { subagentId: 'agent-2', subagentName: 'coder' }); + h.emit('turn.ended', { reason: 'completed' }); + + h.send('s1', 'bg intro\n\n- bg detail', 'agent-1'); + h.emit('subagent.completed', { subagentId: 'agent-1' }); + expect(h.rendered()).toContain('bg detail'); + + h.emit('subagent.completed', { subagentId: 'agent-2' }); + expect(h.rendered()).not.toContain('bg detail'); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts index 06f1941fe..8e38a670d 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts @@ -36,6 +36,7 @@ function makeSubagentHandler() { streamingUI: makeStreamingUIStub(), appendTranscriptEntry: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyToolCallStarted: vi.fn(), notifyCompactionFinished: vi.fn() }, updateActivityPane: vi.fn(), }; const handler = new SubAgentEventHandler(host as never, { @@ -147,6 +148,7 @@ function makeSessionEventHost() { sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyToolCallStarted: vi.fn(), notifyCompactionFinished: vi.fn() }, tasksBrowserController: { repaint: vi.fn(), refreshOutputViewer: vi.fn() }, }; return host as never; diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 1cfc29bea..b92f2fb7f 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -95,6 +95,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyToolCallStarted: vi.fn(), notifyCompactionFinished: vi.fn() }, tasksBrowserController: {}, }; host.setAppState.mockImplementation((patch: Record) => { diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-notify.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-notify.test.ts new file mode 100644 index 000000000..840051db6 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-notify.test.ts @@ -0,0 +1,119 @@ +import { Container } from '@pymodel/pi-tui'; +import { NotifyPanelComponent } from '#/tui/components/chrome/notify-panel'; +import type { Event } from '@pymodel/pythinker-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeHost() { + const host = { + state: { + footer: { setStreamSpeed: vi.fn() }, + notifyPanel: new NotifyPanelComponent(), + notifyPanelContainer: new Container(), + appState: { + sessionId: 's1', + streamingPhase: 'idle', + isCompacting: false, + model: 'test-model', + permissionMode: 'auto', + stepRetry: null, + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + setStep: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + setTodoList: vi.fn(), + clearNotifyPanel: vi.fn(), + markNotifyPanelEnded: vi.fn(), + finalizeTurn: vi.fn(), + finalizeLiveTextBuffers: vi.fn(), + completeToolResult: vi.fn(), + getTurnContext: vi.fn(() => ({ turnId: '1', step: 0 })), + }, + requireSession: vi.fn(), + setAppState: vi.fn((patch: Record) => + Object.assign(host.state.appState, patch), + ), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + updateActivityPane: vi.fn(), + updateQueueDisplay: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyToolCallStarted: vi.fn(), notifyCompactionFinished: vi.fn() }, + tasksBrowserController: {}, + }; + return { host: host as any }; +} + +function turnStarted(origin: Record): Event { + return { + sessionId: 's1', + agentId: 'main', + type: 'turn.started', + turnId: 1, + origin, + } as unknown as Event; +} + +function turnEnded(): Event { + return { + sessionId: 's1', + agentId: 'main', + type: 'turn.ended', + turnId: 1, + reason: 'completed', + } as unknown as Event; +} + +describe('SessionEventHandler — update panel lifecycle', () => { + it.each(['user', 'cron_job', 'background_task'])('clears old updates at a new main %s turn', (kind) => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.notifications.setEnabled(true); + handler.notifications.handleEvent({ type: 'tool.call.started', sessionId: 's1', agentId: 'main', turnId: 0, toolCallId: 'n1', name: 'NotifyUser', args: { message: 'Earlier finding' } } as Event); + handler.notifications.handleEvent({ type: 'tool.result', sessionId: 's1', agentId: 'main', turnId: 0, toolCallId: 'n1', output: 'Update shown to the user.' } as Event); + handler.handleEvent(turnStarted({ kind }), vi.fn()); + handler.handleEvent(turnEnded(), vi.fn()); + expect(host.state.notifyPanel.getEntries().map((entry: { text: string }) => entry.text)).toEqual([]); + expect(host.streamingUI.clearNotifyPanel).not.toHaveBeenCalled(); + }); + + it('collects child notifications before child routing without changing the main turn', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.notifications.setEnabled(true); + handler.handleEvent({ type: 'tool.call.started', sessionId: 's1', agentId: 'worker-1', turnId: 9, toolCallId: 'n1', name: 'NotifyUser', args: { message: 'Child finding' } } as Event, vi.fn()); + handler.handleEvent({ type: 'tool.result', sessionId: 's1', agentId: 'worker-1', turnId: 9, toolCallId: 'n1', output: 'Update shown to the user.' } as Event, vi.fn()); + expect(host.state.notifyPanel.getEntries()[0]).toMatchObject({ agentId: 'worker-1', text: 'Child finding' }); + expect(host.streamingUI.setTurnId).not.toHaveBeenCalled(); + expect(host.streamingUI.completeToolResult).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts index 2e0d8c98b..15a572e1a 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -56,6 +56,7 @@ function makeHost() { sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyToolCallStarted: vi.fn(), notifyCompactionFinished: vi.fn() }, tasksBrowserController: {}, }; return { host: host as never, streamingUI }; diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts index 266afba1d..388e7139b 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -53,6 +53,7 @@ function makeHost() { sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyToolCallStarted: vi.fn(), notifyCompactionFinished: vi.fn() }, tasksBrowserController: {}, }; return { host: host as any }; diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-todo.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-todo.test.ts index cef697f4a..c91f75e9b 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-todo.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-todo.test.ts @@ -34,6 +34,7 @@ function makeHarness() { patchLivePane: vi.fn(), setAppState: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, + surveyController: { notifyToolCallStarted: vi.fn(), notifyCompactionFinished: vi.fn() }, updateActivityPane: vi.fn(), showStatus: vi.fn(), }; diff --git a/apps/pythinker-code/test/tui/controllers/survey-controller.test.ts b/apps/pythinker-code/test/tui/controllers/survey-controller.test.ts new file mode 100644 index 000000000..695c8d2d8 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/survey-controller.test.ts @@ -0,0 +1,1813 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { + SurveyController, + type SurveyControllerDeps, + type SurveyHost, +} from '#/tui/controllers/survey-controller'; +import type { TranscriptEntry } from '#/tui/types'; +import { DEFAULT_SURVEY_POPUP_CONFIG } from '#/utils/survey-popup-config'; + +const ESC = '\u001B'; +const CSI_LEFT = '\u001B[D'; +const CSI_RIGHT = '\u001B[C'; +const CSI_UP = '\u001B[A'; + +interface TimerDriver { + readonly setTimer: (fn: () => void, ms: number) => unknown; + readonly clearTimer: (handle: unknown) => void; + fire(ms: number): void; + pending(): number[]; +} + +function createTimerDriver(): TimerDriver { + interface Entry { + readonly id: number; + readonly fn: () => void; + readonly ms: number; + cleared: boolean; + } + const entries: Entry[] = []; + let nextId = 0; + return { + setTimer: (fn, ms) => { + nextId += 1; + entries.push({ id: nextId, fn, ms, cleared: false }); + return nextId; + }, + clearTimer: (handle) => { + const entry = entries.find((candidate) => candidate.id === handle); + if (entry !== undefined) entry.cleared = true; + }, + fire: (ms) => { + for (const entry of entries.splice(0)) { + if (!entry.cleared && entry.ms === ms) entry.fn(); + } + }, + pending: () => entries.filter((entry) => !entry.cleared).map((entry) => entry.ms), + }; +} + +interface Harness { + readonly controller: SurveyController; + readonly host: SurveyHost; + readonly container: GutterContainer; + readonly timers: TimerDriver; + readonly clock: { mono: number; wall: number }; + elapse(ms: number): void; + readonly track: ReturnType; + readonly editor: { + inputMode: 'prompt' | 'bash'; + autocompleteActive: boolean; + getText(): string; + setText(text: string): void; + }; + typeDigit(digit: string): void; + readonly state: SurveyHost['state']; + readonly writes: number[]; + readonly randomCalls: () => number; + flush(): Promise; + runTurns(count: number): void; + appear(): void; + renderSurvey(): string; +} + +function createHarness(deps: Partial = {}): Harness { + const clock = { mono: 0, wall: 1_700_000_000_000 }; + const timers = createTimerDriver(); + const track = vi.fn(); + const writes: number[] = []; + const container = new GutterContainer(1, 1); + let editorText = ''; + const editor = { + inputMode: 'prompt' as 'prompt' | 'bash', + autocompleteActive: false, + getText: () => editorText, + setText: (text: string) => { + editorText = text; + }, + hasAutocompleteActivity: () => editor.autocompleteActive, + }; + const state = { + surveyContainer: container, + transcriptEntries: [] as TranscriptEntry[], + editorReplacementMounted: false, + activeDialog: null, + livePane: { mode: 'idle', pendingApproval: null, pendingQuestion: null }, + externalEditorRunning: false, + tasksBrowser: undefined, + editor, + appState: { + model: 'test-model', + streamingPhase: 'idle', + isCompacting: false, + contextTokens: 640, + cumulativeTokens: 1234, + permissionMode: 'manual', + thinkingEffort: 'high', + disableFeedbackSurvey: false, + }, + ui: { requestRender: vi.fn() }, + }; + let randomCallCount = 0; + let appearanceCounter = 0; + const { random: randomOverride, ...restDeps } = deps; + const host = { + state, + btwPanelController: { isActive: () => false }, + track, + } as unknown as SurveyHost; + const controller = new SurveyController(host, { + monotonicNow: () => clock.mono, + wallNow: () => clock.wall, + random: () => { + randomCallCount += 1; + return (randomOverride ?? (() => 0))(); + }, + appearanceId: () => { + appearanceCounter += 1; + return `appearance-${String(appearanceCounter)}`; + }, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + terminalWidth: () => 120, + terminalHeight: () => 24, + configFresh: () => true, + readGlobalLastShown: async () => undefined, + writeGlobalLastShown: (wallTime) => { + writes.push(wallTime); + }, + ...restDeps, + }); + + const harness: Harness = { + controller, + host, + container, + timers, + clock, + track, + editor, + state: state as unknown as SurveyHost['state'], + writes, + randomCalls: () => randomCallCount, + elapse: (ms) => { + clock.mono += ms; + timers.fire(ms); + }, + flush: async () => { + await new Promise((resolve) => { + setImmediate(resolve); + }); + }, + runTurns: (count) => { + for (let turn = 1; turn <= count; turn++) { + controller.notifyTurnStarted(true); + controller.notifyTurnEnded(); + } + }, + appear: () => { + clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + clock.mono += 600; + }, + typeDigit: (digit: string) => { + controller.handlePreInput(digit); + controller.handleEditorChange(digit); + }, + renderSurvey: () => + container + .render(120) + .join('\n') + .replaceAll(/\u001B\[[0-9;]*m/g, ''), + }; + return harness; +} + +function userEntry(content: string): TranscriptEntry { + return { id: content, kind: 'user', turnId: undefined, renderMode: 'plain', content }; +} + +const HARNESS_ENVIRONMENT = { + current_model: 'test-model', + user_turn_count: 5, + cumulative_tokens: 1234, + virtual_context_tokens: 640, + tool_call_count: 0, + compaction_count: 0, + permission_mode: 'manual', + thinking_effort: 'high', +}; + +const DEFAULT_SNAPSHOT = { + config_probability: 0.005, + config_on_for_models: '*', + config_min_time_before_feedback_ms: 600_000, + config_min_user_turns_before_feedback: 5, + config_min_time_between_feedback_ms: 3_600_000, + config_min_user_turns_between_feedback: 10, + config_min_time_between_global_feedback_ms: 100_000_000, + config_long_context_survey_threshold: 200_000, + config_long_context_probability: 0.2, + config_long_context_trigger_mode: 'cumulative', +}; + +describe('SurveyController gating', () => { + it('appears once the session clears warmup and reports appeared', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.renderSurvey()).toContain('How is Pythinker doing this session? (optional)'); + expect(harness.renderSurvey()).toContain('1: Bad'); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith('feedback_survey', { + event_type: 'appeared', + appearance_id: 'appearance-1', + appearance_index: 1, + response: undefined, + ...HARNESS_ENVIRONMENT, + ...DEFAULT_SNAPSHOT, + }); + expect(harness.writes).toEqual([1_700_000_000_000]); + }); + + it('stays hidden during warmup and appears once it completes', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 597_999; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('stays hidden without enough user turns', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden when sampled out', async () => { + const harness = createHarness({ random: () => 0.9 }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('stays hidden when telemetry is disabled', async () => { + const harness = createHarness({ telemetryDisabled: () => true }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden when the user disabled the survey', async () => { + const harness = createHarness({ feedbackSurveyDisabled: () => true }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden inside the persisted global cooldown', async () => { + const harness = createHarness({ + readGlobalLastShown: async () => 1_700_000_000_000 - 1000, + }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden while the latest user message opens an ordered list', async () => { + const harness = createHarness(); + await harness.flush(); + harness.state.transcriptEntries.push(userEntry('1. first item')); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden while a btw panel is active', async () => { + const harness = createHarness(); + (harness.host.btwPanelController as { isActive: () => boolean }).isActive = () => true; + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden while an editor replacement is mounted', async () => { + const harness = createHarness(); + harness.state.editorReplacementMounted = true; + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); + + it('cancels a pending evaluation when a new turn starts', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.controller.notifyTurnStarted(true); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + }); + + it('does not arm the idle evaluation when a cron turn ends on its own', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.controller.notifyTurnStarted(false); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + }); + + it('re-arms the pending evaluation after non-user continuation turns end', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + + harness.controller.notifyTurnStarted(false); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('keeps one sample per user turn across evaluations', async () => { + const harness = createHarness({ random: () => 0.9 }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + harness.controller.notifyTurnStarted(false); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.randomCalls()).toBe(1); + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.randomCalls()).toBe(2); + }); + + it('paces the second appearance by time and turns, and bumps appearance_index', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.controller.handlePreInput(ESC); + expect(harness.container.children).toHaveLength(0); + + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.clock.mono += 3_600_000; + harness.clock.wall += 100_000_000; + harness.runTurns(10); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenLastCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared', appearance_index: 2 }), + ); + }); +}); + +describe('SurveyController long-context arm', () => { + it('shows the long-context survey over the cumulative threshold without any warmup', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.renderSurvey()).toContain('How is Pythinker doing this session? (optional)'); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith('long_context_survey', { + event_type: 'appeared', + appearance_id: 'appearance-1', + appearance_index: 1, + response: undefined, + current_model: 'test-model', + user_turn_count: 1, + cumulative_tokens: 250_000, + virtual_context_tokens: 640, + tool_call_count: 0, + compaction_count: 0, + permission_mode: 'manual', + thinking_effort: 'high', + ...DEFAULT_SNAPSHOT, + }); + expect(harness.writes).toEqual([]); + }); + + it('reports the responded and abandoned states under the long_context_survey name', async () => { + const responded = createHarness(); + responded.state.appState.cumulativeTokens = 250_000; + await responded.flush(); + responded.controller.notifyTurnStarted(true); + responded.controller.notifyTurnEnded(); + responded.elapse(2000); + responded.clock.mono += 600; + + responded.typeDigit('2'); + responded.elapse(400); + responded.elapse(3000); + expect(responded.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ + event_type: 'responded', + response: 'fine', + appearance_id: 'appearance-1', + }), + ); + + const abandoned = createHarness(); + abandoned.state.appState.cumulativeTokens = 250_000; + await abandoned.flush(); + abandoned.controller.notifyTurnStarted(true); + abandoned.controller.notifyTurnEnded(); + abandoned.elapse(2000); + abandoned.clock.mono += 600; + + abandoned.controller.handleEditorChange('hello'); + expect(abandoned.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'abandoned', appearance_id: 'appearance-1' }), + ); + }); + + it('prefers the long-context survey when the session arm is also eligible', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.appear(); + + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared', appearance_index: 1 }), + ); + }); + + it('compares the cumulative counter by default and ignores the window occupancy', async () => { + const harness = createHarness(); + harness.state.appState.contextTokens = 500_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('compares the window occupancy when the trigger mode is virtual_context', async () => { + const harness = createHarness({ + config: () => ({ + ...DEFAULT_SURVEY_POPUP_CONFIG, + long_context_trigger_mode: 'virtual_context', + }), + }); + harness.state.appState.contextTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ + event_type: 'appeared', + cumulative_tokens: 1234, + virtual_context_tokens: 250_000, + config_long_context_trigger_mode: 'virtual_context', + }), + ); + }); + + it('closes the arm on a non-positive effective threshold and produces no events', async () => { + const harness = createHarness({ + config: () => ({ ...DEFAULT_SURVEY_POPUP_CONFIG, long_context_survey_threshold: 0 }), + }); + harness.state.appState.cumulativeTokens = 500_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('leaves the session arm running when the threshold closes the long-context arm', async () => { + const harness = createHarness({ + config: () => ({ ...DEFAULT_SURVEY_POPUP_CONFIG, long_context_survey_threshold: 0 }), + }); + harness.state.appState.cumulativeTokens = 500_000; + await harness.flush(); + + harness.appear(); + + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared', config_long_context_survey_threshold: 0 }), + ); + }); + + it('stays hidden when the long-context roll misses, and never rolls again this mount', async () => { + const harness = createHarness({ random: () => 0.9 }); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.randomCalls()).toBe(2); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.randomCalls()).toBe(3); + }); + + it('shows at most once per mount even after the first appearance closes', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + + harness.clock.mono += 600; + harness.controller.handlePreInput(ESC); + expect(harness.container.children).toHaveLength(0); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + const appeared = harness.track.mock.calls.filter( + (call) => (call[1] as { event_type?: string }).event_type === 'appeared', + ); + expect(appeared).toHaveLength(1); + }); + + it('regains its one chance after a session reset', async () => { + let roll = 0.9; + const harness = createHarness({ random: () => roll }); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + roll = 0.1; + harness.controller.reset(); + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared', appearance_index: 1 }), + ); + expect(harness.writes).toEqual([]); + }); + + it('does not spend the roll while an active prompt suppresses the evaluation', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + (harness.host.btwPanelController as { isActive: () => boolean }).isActive = () => true; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.randomCalls()).toBe(1); + + (harness.host.btwPanelController as { isActive: () => boolean }).isActive = () => false; + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + }); + + it('evaluates only after the mount produced a user turn, even with tokens past the threshold', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.controller.notifyTurnStarted(false); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + }); + + it('ignores the persisted global cooldown that gates the session arm', async () => { + const harness = createHarness({ + readGlobalLastShown: async () => 1_700_000_000_000 - 1000, + }); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + expect(harness.writes).toEqual([]); + }); + + it('does not suppress the session arm through the persisted cooldown after a long-context appearance', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + + harness.controller.notifyTurnStarted(true); + harness.controller.notifyTurnEnded(); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + harness.clock.mono += 600; + harness.controller.handlePreInput(ESC); + expect(harness.writes).toEqual([]); + + harness.clock.mono += 3_600_000; + harness.runTurns(10); + harness.elapse(2000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + expect(harness.writes).toEqual([1_700_000_000_000]); + }); +}); + +describe('SurveyController interaction', () => { + it('selects a rating after the digit debounce and walks pending → thanks → closed', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.editor.setText('2'); + harness.typeDigit('2'); + harness.typeDigit('2'); + harness.elapse(400); + + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.editor.getText()).toBe(''); + expect(harness.renderSurvey()).toContain('Feedback: Fine · [escape: undo]'); + + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith('feedback_survey', { + event_type: 'responded', + appearance_id: 'appearance-1', + appearance_index: 1, + response: 'fine', + ...HARNESS_ENVIRONMENT, + ...DEFAULT_SNAPSHOT, + }); + expect(harness.renderSurvey()).toContain('Thanks for your feedback!'); + + harness.elapse(5000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending digit selection when the edit continues', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('1'); + harness.controller.handleEditorChange(''); + harness.elapse(400); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('undo cancels the pending report, and a re-choice reports only the final rating', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.typeDigit('1'); + harness.elapse(400); + expect(harness.controller.handlePreInput(ESC)).toBe(true); + expect(harness.renderSurvey()).toContain('How is Pythinker doing this session? (optional)'); + harness.elapse(3000); + expect(harness.track).not.toHaveBeenCalled(); + + harness.typeDigit('3'); + harness.elapse(400); + harness.elapse(3000); + + const responses = harness.track.mock.calls.map( + (call) => (call[1] as { response?: string }).response, + ); + expect(responses).toEqual(['good']); + expect(harness.track.mock.calls[0]![1]).toMatchObject({ appearance_id: 'appearance-1' }); + }); + + it('Esc dismisses without thanks', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput(ESC)).toBe(true); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'dismissed' }), + ); + expect(harness.timers.pending()).toHaveLength(0); + }); + + it('digit 0 dismisses without thanks and clears the editor', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('0'); + harness.typeDigit('0'); + harness.elapse(400); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'dismissed' }), + ); + expect(harness.editor.getText()).toBe(''); + expect(harness.container.children).toHaveLength(0); + }); + + it('abandons on non-option input', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.controller.handleEditorChange('hello'); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('preserves the pre-existing draft snapshot through the editor pre-submit clear', async () => { + const harness = createHarness(); + await harness.flush(); + harness.editor.setText('2'); + harness.appear(); + harness.track.mockClear(); + + harness.controller.handleEditorChange(''); + expect(harness.controller.handleSubmit('2')).toBe(false); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('treats a digit retyped after deleting the snapshot draft as fresh input', async () => { + const harness = createHarness(); + await harness.flush(); + harness.editor.setText('2'); + harness.appear(); + + harness.controller.handleEditorChange(''); + harness.controller.handlePreInput('2'); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + expect(harness.timers.pending()).toHaveLength(1); + }); + + it('lets a pre-existing digit draft submit instead of treating it as a rating', async () => { + const harness = createHarness(); + await harness.flush(); + harness.editor.setText('2'); + harness.appear(); + harness.track.mockClear(); + + harness.controller.handleEditorChange('2'); + harness.elapse(400); + expect(harness.track).not.toHaveBeenCalled(); + + expect(harness.controller.handleSubmit('2')).toBe(false); + expect(harness.editor.getText()).toBe('2'); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('intercepts a lone digit submit as a selection instead of sending', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('1'); + + expect(harness.controller.handleSubmit('1')).toBe(true); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'bad' }), + ); + expect(harness.editor.getText()).toBe(''); + }); + + it('lets other submissions through and abandons the survey', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.controller.handleSubmit('hello')).toBe(false); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('ignores all input during the mount protection window', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + + expect(harness.controller.handlePreInput('1')).toBe(false); + harness.editor.setText('1'); + harness.controller.handleEditorChange('1'); + expect(harness.timers.pending()).toHaveLength(0); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + expect(harness.controller.handleSubmit('1')).toBe(false); + + harness.clock.mono += 600; + harness.typeDigit('1'); + harness.elapse(400); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'bad' }), + ); + }); + + it('abandons when a yank injects a lone digit during the mount window', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput('\u0019')).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('abandons when history recall injects a digit during the mount window', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput(CSI_UP)).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('moves the hover with arrow keys and confirms with Enter', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(true); + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(true); + expect(harness.controller.handleSubmit('')).toBe(true); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'fine' }), + ); + }); + + it('wraps the hover backwards from nothing to Dismiss', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.controller.handlePreInput(CSI_LEFT)).toBe(true); + expect(harness.controller.handleSubmit('')).toBe(true); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'dismissed' }), + ); + }); + + it('abandons instead of rating when history recall replaces a draft at column zero', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('draft'); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput(CSI_UP)).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('debounces a digit that arrives as a CSI-u sequence', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + + expect(harness.controller.handlePreInput('\u001B[49u')).toBe(false); + harness.editor.setText('1'); + harness.controller.handleEditorChange('1'); + expect(harness.timers.pending()).toHaveLength(1); + harness.elapse(400); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'bad' }), + ); + }); + + it('debounces a typed digit even right after an Up cursor move', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('draft'); + + expect(harness.controller.handlePreInput(CSI_UP)).toBe(false); + expect(harness.controller.handlePreInput('2')).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + expect(harness.timers.pending()).toHaveLength(1); + harness.elapse(400); + harness.elapse(3000); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'fine' }), + ); + }); + + it('abandons instead of rating when history recall injects a lone digit', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput(CSI_UP)).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + harness.elapse(400); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('abandons instead of rating when a kill-ring yank injects a lone digit', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput('\u0019')).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('abandons instead of rating when a paste injects a lone digit', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + expect(harness.controller.handlePreInput('\u001B[200~2\u001B[201~')).toBe(false); + harness.editor.setText('2'); + harness.controller.handleEditorChange('2'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + harness.elapse(400); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('lets arrow keys through to the editor when a draft exists', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('draft'); + + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(false); + expect(harness.controller.handlePreInput(CSI_LEFT)).toBe(false); + expect(harness.controller.handleSubmit('')).toBe(false); + expect(harness.track).toHaveBeenCalledTimes(1); + }); + + it('clears the hover when the survey closes so a later appearance starts clean', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(true); + harness.controller.handlePreInput(ESC); + expect(harness.container.children).toHaveLength(0); + + harness.clock.mono += 3_600_000; + harness.clock.wall += 100_000_000; + harness.runTurns(10); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + + harness.track.mockClear(); + expect(harness.controller.handleSubmit('')).toBe(false); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('reports the settled rating with the turn count of the turn it was made in', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('1'); + harness.elapse(400); + harness.track.mockClear(); + + harness.controller.notifyTurnStarted(true); + + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', user_turn_count: 5 }), + ); + }); + + it('settles the pending rating when a turn starts inside the undo window', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('1'); + harness.elapse(400); + harness.track.mockClear(); + + harness.controller.notifyTurnStarted(true); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'bad' }), + ); + expect(harness.timers.pending()).toHaveLength(0); + }); + + it('closes silently when a turn starts while open', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.controller.notifyTurnStarted(true); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('closes silently on a gate flip (editor replacement) without reporting', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.controller.closeSilently(); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('treats bash-mode input as non-option input', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.inputMode = 'bash'; + + harness.typeDigit('1'); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + }); + + it('sweeps the survey away as abandoned when the editor enters bash mode', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + harness.editor.inputMode = 'bash'; + harness.controller.notifyInputModeChanged('bash'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'abandoned' }), + ); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + }); + + it('does not open while the external editor is running, on either arm', async () => { + const harness = createHarness(); + harness.state.appState.cumulativeTokens = 250_000; + await harness.flush(); + harness.clock.mono += 600_000; + harness.state.externalEditorRunning = true; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.state.externalEditorRunning = false; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared' }), + ); + }); + + it('does not open while the tasks browser takeover is active', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.state.tasksBrowser = {} as never; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.state.tasksBrowser = undefined; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('does not select a hovered option with Enter after the terminal narrows', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.appear(); + expect(harness.controller.handlePreInput(CSI_RIGHT)).toBe(true); + harness.track.mockClear(); + + width = 8; + expect(harness.controller.handleSubmit('')).toBe(false); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('does not fire a pending digit selection after the terminal narrows', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.appear(); + harness.editor.setText('2'); + harness.typeDigit('2'); + harness.track.mockClear(); + + width = 8; + harness.elapse(400); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.editor.getText()).toBe('2'); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('lets Escape undo a pending rating even after the terminal narrows', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.appear(); + harness.typeDigit('2'); + harness.elapse(400); + harness.track.mockClear(); + + width = 8; + expect(harness.controller.handlePreInput(ESC)).toBe(true); + harness.elapse(3000); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.renderSurvey()).toContain('How is Pythinker doing this session? (optional)'); + }); + + it('does not open when the terminal is shorter than the survey needs', async () => { + let height = 24; + const harness = createHarness({ terminalHeight: () => height }); + await harness.flush(); + harness.clock.mono += 600_000; + height = 7; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + height = 8; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('requires more height when a narrow terminal wraps the survey title', async () => { + let height = 24; + const harness = createHarness({ terminalWidth: () => 14, terminalHeight: () => height }); + await harness.flush(); + harness.clock.mono += 600_000; + height = 14; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + height = 15; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('counts word-wrapped title rows instead of dividing characters', async () => { + let height = 24; + const harness = createHarness({ terminalWidth: () => 18, terminalHeight: () => height }); + await harness.flush(); + harness.clock.mono += 600_000; + height = 14; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + height = 15; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('passes keys through when the terminal shrinks in height while open', async () => { + let height = 24; + const harness = createHarness({ terminalHeight: () => height }); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + height = 6; + expect(harness.controller.handlePreInput(ESC)).toBe(false); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('does not open when the terminal is narrower than the option legend', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.clock.mono += 600_000; + width = 13; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + width = 14; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('passes keys through when the terminal is narrowed below the legend width while open', async () => { + let width = 120; + const harness = createHarness({ terminalWidth: () => width }); + await harness.flush(); + harness.appear(); + harness.track.mockClear(); + + width = 8; + expect(harness.controller.handlePreInput(ESC)).toBe(false); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('does not open while autocomplete is active, and Esc passes through to it', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.editor.autocompleteActive = true; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.editor.autocompleteActive = false; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + + harness.editor.autocompleteActive = true; + harness.track.mockClear(); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('does not open while the editor is in bash mode', async () => { + const harness = createHarness(); + await harness.flush(); + harness.clock.mono += 600_000; + harness.editor.inputMode = 'bash'; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.editor.inputMode = 'prompt'; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('settles and closes a pending rating when the editor enters bash mode', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('2'); + harness.elapse(400); + harness.track.mockClear(); + + harness.editor.inputMode = 'bash'; + harness.controller.notifyInputModeChanged('bash'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', response: 'fine' }), + ); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + }); + + it('closes the thanks state silently when the editor enters bash mode', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.typeDigit('2'); + harness.elapse(400); + harness.elapse(3000); + expect(harness.renderSurvey()).toContain('Thanks for your feedback!'); + harness.track.mockClear(); + + harness.editor.inputMode = 'bash'; + harness.controller.notifyInputModeChanged('bash'); + + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + expect(harness.controller.handlePreInput(ESC)).toBe(false); + }); + + it('defers evaluation until the persisted cooldown read settles, then honors it', async () => { + let settleRead!: () => void; + const harness = createHarness({ + readGlobalLastShown: () => + new Promise((resolve) => { + settleRead = () => { + resolve(1_700_000_000_000); + }; + }), + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + settleRead(); + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('keeps the just-written global cooldown across a reset', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + expect(harness.writes).toEqual([1_700_000_000_000]); + harness.track.mockClear(); + + harness.controller.reset(); + await harness.flush(); + + harness.clock.wall += 1; + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('takes the newer of the in-memory and reloaded cooldown timestamps', async () => { + let diskValue: number | undefined = 1_600_000_000_000; + const harness = createHarness({ readGlobalLastShown: async () => diskValue }); + await harness.flush(); + harness.appear(); + expect(harness.writes).toEqual([1_700_000_000_000]); + harness.track.mockClear(); + + diskValue = 1_650_000_000_000; + harness.controller.reset(); + await harness.flush(); + + harness.clock.wall += 1; + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('resets in-session pacing on session reset', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.controller.reset(); + await harness.flush(); + + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + }); + + it('stays hidden when the appState mirror disables the survey', async () => { + const harness = createHarness(); + harness.state.appState.disableFeedbackSurvey = true; + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); +}); + +describe('SurveyController event payload', () => { + it('reports the live session statistics and the current model', async () => { + const harness = createHarness(); + await harness.flush(); + harness.state.appState.model = 'k3'; + harness.clock.mono += 600_000; + for (let turn = 1; turn <= 5; turn++) { + harness.controller.notifyTurnStarted(true); + harness.controller.notifyToolCallStarted(); + harness.controller.notifyToolCallStarted(); + harness.controller.notifyTurnEnded(); + } + harness.controller.notifyToolCallStarted(); + harness.elapse(2000); + + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ + current_model: 'k3', + user_turn_count: 5, + cumulative_tokens: 1234, + virtual_context_tokens: 640, + tool_call_count: 11, + permission_mode: 'manual', + }), + ); + }); + + it('counts finished compactions since mount and resets on remount', async () => { + const harness = createHarness(); + await harness.flush(); + harness.controller.notifyCompactionFinished(); + harness.controller.notifyCompactionFinished(); + harness.appear(); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ compaction_count: 2, thinking_effort: 'high' }), + ); + + harness.controller.reset(); + await harness.flush(); + harness.clock.wall += 100_000_000; + harness.appear(); + expect(harness.track).toHaveBeenLastCalledWith( + 'feedback_survey', + expect.objectContaining({ compaction_count: 0 }), + ); + }); + + it('snapshots the config that produced the appearance, not a later refresh', async () => { + let liveConfig = { ...DEFAULT_SURVEY_POPUP_CONFIG, probability: 0.5 }; + const harness = createHarness({ config: () => liveConfig }); + await harness.flush(); + harness.appear(); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared', config_probability: 0.5 }), + ); + + liveConfig = { ...liveConfig, probability: 0.9 }; + harness.typeDigit('1'); + harness.elapse(400); + harness.elapse(3000); + expect(harness.track).toHaveBeenLastCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'responded', config_probability: 0.5 }), + ); + }); + + it('cancels the pending digit selection when a cursor key passes through to the draft', async () => { + const harness = createHarness(); + await harness.flush(); + harness.appear(); + harness.editor.setText('2'); + harness.typeDigit('2'); + + expect(harness.controller.handlePreInput(CSI_LEFT)).toBe(false); + harness.elapse(400); + expect(harness.track).toHaveBeenCalledTimes(1); + expect(harness.editor.getText()).toBe('2'); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('keeps deferring while a region-change refresh is still in flight', async () => { + let region = 'region-a'; + let settle!: () => void; + let first = true; + const harness = createHarness({ + configRegion: () => region, + refreshConfig: () => { + if (first) { + first = false; + return undefined; + } + return new Promise((resolve) => { + settle = resolve; + }); + }, + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + + region = 'region-b'; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + settle(); + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('defers evaluation until the startup refresh settles', async () => { + let settle!: () => void; + const harness = createHarness({ + refreshConfig: () => + new Promise((resolve) => { + settle = resolve; + }), + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(5); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + settle(); + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('refreshes and defers when the config cache has gone stale', async () => { + let fresh = true; + const refreshConfig = vi.fn(() => { + fresh = true; + }); + const harness = createHarness({ configFresh: () => fresh, refreshConfig }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + expect(harness.container.children).toHaveLength(0); + + fresh = false; + harness.clock.mono += 3_600_000; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(2); + expect(harness.container.children).toHaveLength(0); + + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + }); + + it('defers the evaluation that triggers a refresh so a stale policy cannot open the survey', async () => { + let region = 'region-a'; + let config = { ...DEFAULT_SURVEY_POPUP_CONFIG, probability: 1 }; + const refreshConfig = vi.fn(() => { + config = { ...config, probability: 0 }; + }); + const harness = createHarness({ + configRegion: () => region, + config: () => config, + refreshConfig, + random: () => 0.9, + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + + region = 'region-b'; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(2); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + expect(harness.track).not.toHaveBeenCalled(); + }); + + it('evaluates with the refreshed policy on the next turn after the deferred evaluation', async () => { + let region = 'region-a'; + let config = { ...DEFAULT_SURVEY_POPUP_CONFIG, probability: 1, on_for_models: ['other-model'] }; + const refreshConfig = vi.fn(() => { + config = { ...config, on_for_models: ['*'] }; + }); + const harness = createHarness({ + configRegion: () => region, + config: () => config, + refreshConfig, + }); + await harness.flush(); + harness.clock.mono += 600_000; + harness.runTurns(4); + + region = 'region-b'; + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).toHaveLength(0); + + await harness.flush(); + harness.runTurns(1); + harness.elapse(2000); + expect(harness.container.children).not.toHaveLength(0); + expect(harness.track).toHaveBeenCalledWith( + 'feedback_survey', + expect.objectContaining({ event_type: 'appeared', config_on_for_models: '*' }), + ); + }); + + it('re-refreshes the survey config when the region changes', async () => { + let region = 'region-a'; + const refreshConfig = vi.fn(); + const harness = createHarness({ refreshConfig, configRegion: () => region }); + await harness.flush(); + expect(refreshConfig).toHaveBeenCalledTimes(1); + + region = 'region-b'; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(2); + }); + + it('re-refreshes the survey config once the previous refresh is over an hour old', async () => { + const refreshConfig = vi.fn(); + const harness = createHarness({ refreshConfig }); + await harness.flush(); + expect(refreshConfig).toHaveBeenCalledTimes(1); + + harness.clock.mono += 3_597_000; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(1); + + harness.clock.mono += 2_000; + harness.runTurns(1); + harness.elapse(2000); + expect(refreshConfig).toHaveBeenCalledTimes(2); + }); + + it('applies the model gate at evaluation time', async () => { + const harness = createHarness({ + config: () => ({ ...DEFAULT_SURVEY_POPUP_CONFIG, on_for_models: ['other-model'] }), + }); + await harness.flush(); + harness.appear(); + expect(harness.container.children).toHaveLength(0); + }); +}); diff --git a/apps/pythinker-code/test/tui/create-tui-state.test.ts b/apps/pythinker-code/test/tui/create-tui-state.test.ts index 7120ee04f..6c25b4ce8 100644 --- a/apps/pythinker-code/test/tui/create-tui-state.test.ts +++ b/apps/pythinker-code/test/tui/create-tui-state.test.ts @@ -153,8 +153,10 @@ describe('createTUIState', () => { expect(dock?.children).toEqual([ state.activityContainer, state.todoPanelContainer, + state.notifyPanelContainer, state.queueContainer, state.btwPanelContainer, + state.surveyContainer, state.editorContainer, ]); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index bb7deb5a7..64666a0a2 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -4632,9 +4632,12 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); const panel = stripSgr(renderBtwPanel(driver)); const rootChildren = driver.state.ui.children; - expect(rootChildren.indexOf(driver.state.btwPanelContainer)).toBe( + expect(rootChildren.indexOf(driver.state.surveyContainer)).toBe( rootChildren.indexOf(driver.state.editorContainer) - 1, ); + expect(rootChildren.indexOf(driver.state.btwPanelContainer)).toBe( + rootChildren.indexOf(driver.state.surveyContainer) - 1, + ); expect(transcript).toContain('main answer after btw'); expect(transcript).not.toContain('side answer'); expect(panel).toContain('BTW'); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index 6f2023da9..075ad9cd9 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -335,8 +335,8 @@ describe('PythinkerTUI startup', () => { await expect(driver.init()).resolves.toBe(false); (driver as unknown as { mountFooter(): void }).mountFooter(); - // Dock = 5 chrome containers + footer wrap, below the transcript viewport. - expect(driver.state.dockContainer?.children).toHaveLength(6); + // Dock = activity, todo, notify, queue, btw, survey, editor, footer wrap. + expect(driver.state.dockContainer?.children).toHaveLength(8); }); it('shows a session-less notice on v2 startup', async () => { diff --git a/apps/pythinker-code/test/tui/utils/survey-policy.test.ts b/apps/pythinker-code/test/tui/utils/survey-policy.test.ts new file mode 100644 index 000000000..b86ef3f42 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/survey-policy.test.ts @@ -0,0 +1,663 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_SURVEY_POPUP_CONFIG, + type SurveyPopupConfig, +} from '#/utils/survey-popup-config'; +import { + buildSurveyEventProperties, + evaluateLongContextArm, + evaluateSurveyGate, + SURVEY_EVENT_NAMES, + SURVEY_MACHINE_CLOSED, + surveyMachineReduce, + type LongContextArmGateInput, + type SessionArmGateInput, + type SurveyEventEnvironmentFields, + type SurveyGateInput, + type SurveyMachineState, +} from '#/tui/utils/survey-policy'; + +const CONFIG = DEFAULT_SURVEY_POPUP_CONFIG; + +function passingSession(overrides: Partial = {}): SessionArmGateInput { + return { + phase: 'closed', + turnInProgress: false, + idleForMs: 2000, + externalEditorActive: false, + terminalWidth: 120, + terminalHeight: 24, + promptActive: false, + editorBashActive: false, + editorAutocompleteActive: false, + feedbackSurveyDisabled: false, + telemetryDisabled: false, + currentModel: 'k2', + lastUserMessageStartsOrderedList: false, + mountedForMs: 600_000, + userTurnsSinceMount: 5, + msSinceLastShown: undefined, + userTurnsSinceLastShown: undefined, + sample: 0, + msSinceGlobalLastShown: undefined, + ...overrides, + }; +} + +function passingLongContext( + overrides: Partial = {}, +): LongContextArmGateInput { + return { + phase: 'closed', + turnInProgress: false, + idleForMs: 2000, + promptActive: false, + editorBashActive: false, + editorAutocompleteActive: false, + externalEditorActive: false, + terminalWidth: 120, + terminalHeight: 24, + feedbackSurveyDisabled: false, + telemetryDisabled: false, + currentModel: 'k2', + lastUserMessageStartsOrderedList: false, + cumulativeTokens: 0, + virtualContextTokens: 0, + mountRollConsumed: false, + drawMountRoll: () => 0, + ...overrides, + }; +} + +function gate( + sessionOverrides: Partial = {}, + config: SurveyPopupConfig = CONFIG, + longContextOverrides: Partial = {}, +): SurveyGateInput { + return { + session: passingSession(sessionOverrides), + longContext: passingLongContext(longContextOverrides), + config, + }; +} + +describe('evaluateSurveyGate (session arm)', () => { + it('shows the session survey when every gate passes', () => { + expect(evaluateSurveyGate(gate())).toEqual({ show: true, survey: 'session' }); + }); + + it.each<[Partial, string]>([ + [{ phase: 'open' }, 'survey-active'], + [{ phase: 'pending' }, 'survey-active'], + [{ phase: 'thanks' }, 'survey-active'], + [{ turnInProgress: true }, 'turn-in-progress'], + [{ idleForMs: 1999 }, 'idle-too-short'], + [{ lastUserMessageStartsOrderedList: true }, 'ordered-list-ambiguity'], + [{ promptActive: true }, 'prompt-active'], + [{ editorBashActive: true }, 'editor-bash-active'], + [{ editorAutocompleteActive: true }, 'editor-autocomplete-active'], + [{ externalEditorActive: true }, 'external-editor-active'], + [{ terminalWidth: 10 }, 'terminal-too-narrow'], + [{ terminalHeight: 7 }, 'terminal-too-short'], + [{ terminalWidth: 12, terminalHeight: 14 }, 'terminal-too-short'], + [{ terminalWidth: 16, terminalHeight: 13 }, 'terminal-too-short'], + [{ feedbackSurveyDisabled: true }, 'feature-disabled'], + [{ telemetryDisabled: true }, 'telemetry-disabled'], + [{ mountedForMs: 599_999 }, 'warmup'], + [{ userTurnsSinceMount: 4 }, 'warmup'], + [{ sample: 0.006 }, 'sampled-out'], + [{ msSinceGlobalLastShown: 99_999_999 }, 'global-cooldown'], + ])('skips with %j → %s', (overrides, reason) => { + expect(evaluateSurveyGate(gate(overrides))).toEqual({ show: false, reason }); + }); + + it('applies the chain in order: an open survey reports survey-active, not later reasons', () => { + expect( + evaluateSurveyGate(gate({ phase: 'open', turnInProgress: true, telemetryDisabled: true })), + ).toEqual({ show: false, reason: 'survey-active' }); + }); + + it('samples with a half-open test: sample below probability shows, at-or-above is out', () => { + expect(evaluateSurveyGate(gate({ sample: 0.004_999 }))).toEqual({ + show: true, + survey: 'session', + }); + expect(evaluateSurveyGate(gate({ sample: 0.005 }))).toEqual({ + show: false, + reason: 'sampled-out', + }); + expect(evaluateSurveyGate(gate({ sample: 0 }, { ...CONFIG, probability: 0 })).show).toBe( + false, + ); + }); + + describe('model gate', () => { + it('opens for every model with "*"', () => { + expect(evaluateSurveyGate(gate({ currentModel: 'anything' })).show).toBe(true); + }); + + it('closes both arms with an empty list', () => { + expect(evaluateSurveyGate(gate({}, { ...CONFIG, on_for_models: [] }))).toEqual({ + show: false, + reason: 'model-gated', + }); + }); + + it('requires an exact match otherwise', () => { + const config = { ...CONFIG, on_for_models: ['k3'] }; + expect(evaluateSurveyGate(gate({ currentModel: 'k3' }, config)).show).toBe(true); + expect(evaluateSurveyGate(gate({ currentModel: 'k2' }, config))).toEqual({ + show: false, + reason: 'model-gated', + }); + expect(evaluateSurveyGate(gate({ currentModel: 'k3-fictional' }, config))).toEqual({ + show: false, + reason: 'model-gated', + }); + }); + }); + + describe('in-session pacing', () => { + const shown = { + msSinceLastShown: 3_600_000, + userTurnsSinceLastShown: 10, + } as const; + + it('requires the gap and the new turns once shown before', () => { + expect(evaluateSurveyGate(gate(shown)).show).toBe(true); + }); + + it('skips inside the time gap', () => { + expect( + evaluateSurveyGate(gate({ ...shown, msSinceLastShown: 3_599_999 })), + ).toEqual({ show: false, reason: 'pacing' }); + }); + + it('skips without enough new turns', () => { + expect(evaluateSurveyGate(gate({ ...shown, userTurnsSinceLastShown: 9 }))).toEqual({ + show: false, + reason: 'pacing', + }); + }); + }); + + it('honours the global cooldown exactly at the boundary', () => { + expect(evaluateSurveyGate(gate({ msSinceGlobalLastShown: 100_000_000 })).show).toBe(true); + }); +}); + +describe('evaluateLongContextArm', () => { + const ELIGIBLE: Partial = { cumulativeTokens: 250_000 }; + + function arm( + overrides: Partial = {}, + config: SurveyPopupConfig = CONFIG, + ): ReturnType { + return evaluateLongContextArm(gate({}, config, { ...ELIGIBLE, ...overrides })); + } + + it('shows the long-context survey when the whole chain passes', () => { + expect(arm()).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it.each<[Partial, string]>([ + [{ mountRollConsumed: true }, 'mount-roll-consumed'], + [{ phase: 'open' }, 'survey-active'], + [{ phase: 'pending' }, 'survey-active'], + [{ phase: 'thanks' }, 'survey-active'], + [{ turnInProgress: true }, 'turn-in-progress'], + [{ idleForMs: 1999 }, 'idle-too-short'], + [{ promptActive: true }, 'prompt-active'], + [{ editorBashActive: true }, 'editor-bash-active'], + [{ editorAutocompleteActive: true }, 'editor-autocomplete-active'], + [{ externalEditorActive: true }, 'external-editor-active'], + [{ terminalWidth: 10 }, 'terminal-too-narrow'], + [{ terminalHeight: 7 }, 'terminal-too-short'], + [{ terminalWidth: 12, terminalHeight: 14 }, 'terminal-too-short'], + [{ terminalWidth: 16, terminalHeight: 13 }, 'terminal-too-short'], + [{ feedbackSurveyDisabled: true }, 'feature-disabled'], + [{ telemetryDisabled: true }, 'telemetry-disabled'], + [{ lastUserMessageStartsOrderedList: true }, 'ordered-list-ambiguity'], + [{ cumulativeTokens: 199_999 }, 'below-threshold'], + ])('skips with %j → %s', (overrides, reason) => { + expect(arm(overrides)).toEqual({ show: false, reason }); + }); + + it('checks the mount latch first: a spent roll reports mount-roll-consumed, not later reasons', () => { + expect(arm({ mountRollConsumed: true, phase: 'open', telemetryDisabled: true })).toEqual({ + show: false, + reason: 'mount-roll-consumed', + }); + }); + + it('applies the chain in order: an open survey reports survey-active, not later reasons', () => { + expect(arm({ phase: 'open', telemetryDisabled: true })).toEqual({ + show: false, + reason: 'survey-active', + }); + }); + + it('applies the model gate before the threshold', () => { + expect(arm({}, { ...CONFIG, on_for_models: [], long_context_survey_threshold: 0 })).toEqual({ + show: false, + reason: 'model-gated', + }); + expect(arm({}, { ...CONFIG, on_for_models: ['k3'] })).toEqual({ + show: false, + reason: 'model-gated', + }); + }); + + it('shows at the counter boundary (counter == threshold)', () => { + expect(arm({ cumulativeTokens: 200_000 })).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('samples with a half-open test: roll below long_context_probability shows, at-or-above is out', () => { + expect(arm({ drawMountRoll: () => 0.199_999 })).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + expect(arm({ drawMountRoll: () => 0.2 })).toEqual({ + show: false, + reason: 'sampled-out', + longContextRollConsumed: true, + }); + }); + + describe('mount one-shot', () => { + it('does not draw the dice below the threshold, so the roll stays unspent', () => { + const drawMountRoll = vi.fn(() => 0); + expect(arm({ cumulativeTokens: 199_999, drawMountRoll })).toEqual({ + show: false, + reason: 'below-threshold', + }); + expect(drawMountRoll).not.toHaveBeenCalled(); + }); + + it('does not draw the dice while transiently suppressed by an active prompt', () => { + const drawMountRoll = vi.fn(() => 0); + expect(arm({ promptActive: true, drawMountRoll })).toEqual({ + show: false, + reason: 'prompt-active', + }); + expect(drawMountRoll).not.toHaveBeenCalled(); + }); + + it('spends the roll on a miss: the arm stays silent for the rest of the mount', () => { + expect(arm({ drawMountRoll: () => 0.9 })).toEqual({ + show: false, + reason: 'sampled-out', + longContextRollConsumed: true, + }); + }); + + it('spends the roll on a hit and shows once', () => { + expect(arm({ drawMountRoll: () => 0.1 })).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('never draws again once the roll is spent', () => { + const drawMountRoll = vi.fn(() => 0); + expect(arm({ mountRollConsumed: true, drawMountRoll })).toEqual({ + show: false, + reason: 'mount-roll-consumed', + }); + expect(drawMountRoll).not.toHaveBeenCalled(); + }); + }); + + describe('threshold validity', () => { + it.each([0, -1, Number.NaN])('closes the arm on a non-positive threshold (%s)', (threshold) => { + expect(arm({}, { ...CONFIG, long_context_survey_threshold: threshold })).toEqual({ + show: false, + reason: 'threshold-invalid', + }); + }); + + it('runs on the built-in 200k default when the field never took a cloud value', () => { + expect(arm({ cumulativeTokens: 199_999 })).toEqual({ show: false, reason: 'below-threshold' }); + expect(arm({ cumulativeTokens: 200_000 })).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + }); + + describe('counter mode', () => { + it('compares the cumulative counter by default and ignores the window occupancy', () => { + expect(arm({ cumulativeTokens: 199_999, virtualContextTokens: 500_000 })).toEqual({ + show: false, + reason: 'below-threshold', + }); + }); + + it('compares the window occupancy when the trigger mode is virtual_context', () => { + const config = { ...CONFIG, long_context_trigger_mode: 'virtual_context' as const }; + expect(arm({ cumulativeTokens: 0, virtualContextTokens: 250_000 }, config)).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + expect(arm({ cumulativeTokens: 500_000, virtualContextTokens: 100 }, config)).toEqual({ + show: false, + reason: 'below-threshold', + }); + }); + }); +}); + +describe('evaluateSurveyGate (arbitration)', () => { + const ELIGIBLE: Partial = { cumulativeTokens: 250_000 }; + + it('prefers the long-context survey when both arms pass', () => { + expect(evaluateSurveyGate(gate({}, CONFIG, ELIGIBLE))).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('falls back to the session arm when the long-context arm is below the threshold', () => { + expect(evaluateSurveyGate(gate())).toEqual({ show: true, survey: 'session' }); + }); + + it('falls back to the session arm when the long-context arm misses its roll, latching it spent', () => { + expect( + evaluateSurveyGate(gate({ sample: 0 }, CONFIG, { ...ELIGIBLE, drawMountRoll: () => 0.9 })), + ).toEqual({ + show: true, + survey: 'session', + longContextRollConsumed: true, + }); + }); + + it('falls back to the session arm when the threshold closes the long-context arm', () => { + expect( + evaluateSurveyGate(gate({}, { ...CONFIG, long_context_survey_threshold: 0 }, ELIGIBLE)), + ).toEqual({ show: true, survey: 'session' }); + }); + + it('shows the long-context survey even when the session arm is sampled out', () => { + expect(evaluateSurveyGate(gate({ sample: 0.9 }, CONFIG, ELIGIBLE))).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('shows the long-context survey inside the persisted cooldown that still gates the session arm', () => { + expect( + evaluateSurveyGate(gate({ msSinceGlobalLastShown: 1000 }, CONFIG, ELIGIBLE)), + ).toEqual({ + show: true, + survey: 'long_context', + longContextRollConsumed: true, + }); + }); + + it('shows nothing when the long-context arm is ineligible and the session arm is sampled out', () => { + expect(evaluateSurveyGate(gate({ sample: 0.9 }))).toEqual({ + show: false, + reason: 'sampled-out', + }); + }); + + it('reports the spent roll even when both arms lose', () => { + expect( + evaluateSurveyGate( + gate({ sample: 0.9 }, CONFIG, { ...ELIGIBLE, drawMountRoll: () => 0.9 }), + ), + ).toEqual({ + show: false, + reason: 'sampled-out', + longContextRollConsumed: true, + }); + }); +}); + +describe('surveyMachineReduce', () => { + const appearance = { survey: 'session' as const, appearanceId: 'a1', appearanceIndex: 1 }; + const openState: SurveyMachineState = { phase: 'open', appearance, response: undefined }; + + it('walks the happy path: closed → open → pending → thanks → closed', () => { + const opened = surveyMachineReduce(SURVEY_MACHINE_CLOSED, { type: 'open', appearance }); + expect(opened.state.phase).toBe('open'); + expect(opened.effects).toEqual([{ type: 'report', eventType: 'appeared' }]); + + const selected = surveyMachineReduce(opened.state, { type: 'select', response: 'bad' }); + expect(selected.state).toEqual({ phase: 'pending', appearance, response: 'bad' }); + expect(selected.effects).toEqual([{ type: 'schedule', timer: 'pending-settle' }]); + + const settled = surveyMachineReduce(selected.state, { type: 'settle' }); + expect(settled.state.phase).toBe('thanks'); + expect(settled.effects).toEqual([ + { type: 'report', eventType: 'responded', response: 'bad' }, + { type: 'schedule', timer: 'thanks-close' }, + ]); + + const closed = surveyMachineReduce(settled.state, { type: 'thanks-elapsed' }); + expect(closed.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(closed.effects).toEqual([]); + }); + + it('undo returns pending to open without reporting, and settle after an undo reports only the final choice', () => { + const selected = surveyMachineReduce(openState, { type: 'select', response: 'fine' }); + const undone = surveyMachineReduce(selected.state, { type: 'undo' }); + expect(undone.state).toEqual(openState); + expect(undone.effects).toEqual([]); + + const reselected = surveyMachineReduce(undone.state, { type: 'select', response: 'good' }); + const settled = surveyMachineReduce(reselected.state, { type: 'settle' }); + expect(settled.effects).toEqual([ + { type: 'report', eventType: 'responded', response: 'good' }, + { type: 'schedule', timer: 'thanks-close' }, + ]); + }); + + it('dismiss reports responded dismissed and closes without thanks', () => { + const dismissed = surveyMachineReduce(openState, { type: 'dismiss' }); + expect(dismissed.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(dismissed.effects).toEqual([ + { type: 'report', eventType: 'responded', response: 'dismissed' }, + ]); + }); + + it('abandon reports abandoned and closes', () => { + const abandoned = surveyMachineReduce(openState, { type: 'abandon' }); + expect(abandoned.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(abandoned.effects).toEqual([{ type: 'report', eventType: 'abandoned' }]); + }); + + it.each(['open', 'thanks'] as const)('close-silently from %s reports nothing', (phase) => { + const state: SurveyMachineState = { phase, appearance, response: 'good' }; + const closed = surveyMachineReduce(state, { type: 'close-silently' }); + expect(closed.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(closed.effects).toEqual([]); + }); + + it('close-silently from pending settles the un-undone choice before closing', () => { + const state: SurveyMachineState = { phase: 'pending', appearance, response: 'bad' }; + const closed = surveyMachineReduce(state, { type: 'close-silently' }); + expect(closed.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(closed.effects).toEqual([ + { type: 'report', eventType: 'responded', response: 'bad' }, + ]); + }); + + it.each([ + [{ type: 'select', response: 'good' } as const], + [{ type: 'dismiss' } as const], + [{ type: 'abandon' } as const], + [{ type: 'undo' } as const], + [{ type: 'settle' } as const], + [{ type: 'thanks-elapsed' } as const], + ])('ignores %s while closed', (action) => { + const result = surveyMachineReduce(SURVEY_MACHINE_CLOSED, action); + expect(result.state).toEqual(SURVEY_MACHINE_CLOSED); + expect(result.effects).toEqual([]); + }); + + describe('takeover', () => { + const longContextAppearance = { + survey: 'long_context' as const, + appearanceId: 'a2', + appearanceIndex: 2, + }; + + it('a long-context survey takes over an open session survey silently', () => { + const result = surveyMachineReduce(openState, { + type: 'open', + appearance: longContextAppearance, + }); + expect(result.state).toEqual({ + phase: 'open', + appearance: longContextAppearance, + response: undefined, + }); + expect(result.effects).toEqual([{ type: 'report', eventType: 'appeared' }]); + }); + + it('a session survey never takes over an open long-context survey', () => { + const longOpen: SurveyMachineState = { + phase: 'open', + appearance: longContextAppearance, + response: undefined, + }; + const result = surveyMachineReduce(longOpen, { type: 'open', appearance }); + expect(result.state).toEqual(longOpen); + expect(result.effects).toEqual([]); + }); + + it('ignores a re-open of the same survey while open (no double appearance)', () => { + const other = { survey: 'session' as const, appearanceId: 'a9', appearanceIndex: 3 }; + const result = surveyMachineReduce(openState, { type: 'open', appearance: other }); + expect(result.state).toEqual(openState); + expect(result.effects).toEqual([]); + }); + + it.each(['pending', 'thanks'] as const)('ignores a takeover while %s', (phase) => { + const state: SurveyMachineState = { phase, appearance, response: 'good' }; + const result = surveyMachineReduce(state, { + type: 'open', + appearance: longContextAppearance, + }); + expect(result.state).toEqual(state); + expect(result.effects).toEqual([]); + }); + }); +}); + +describe('buildSurveyEventProperties', () => { + it('maps survey kinds to the wire event names', () => { + expect(SURVEY_EVENT_NAMES).toEqual({ + session: 'feedback_survey', + long_context: 'long_context_survey', + }); + }); + + const ENVIRONMENT: SurveyEventEnvironmentFields = { + current_model: 'k2', + user_turn_count: 9, + cumulative_tokens: 123, + virtual_context_tokens: 45, + tool_call_count: 6, + compaction_count: 2, + permission_mode: 'manual', + thinking_effort: 'high', + }; + + it('builds the core three-state fields', () => { + expect( + buildSurveyEventProperties( + { + event_type: 'responded', + appearance_id: 'a1', + appearance_index: 2, + response: 'fine', + }, + ENVIRONMENT, + CONFIG, + ), + ).toEqual({ + event_type: 'responded', + appearance_id: 'a1', + appearance_index: 2, + response: 'fine', + ...ENVIRONMENT, + config_probability: 0.005, + config_on_for_models: '*', + config_min_time_before_feedback_ms: 600_000, + config_min_user_turns_before_feedback: 5, + config_min_time_between_feedback_ms: 3_600_000, + config_min_user_turns_between_feedback: 10, + config_min_time_between_global_feedback_ms: 100_000_000, + config_long_context_survey_threshold: 200_000, + config_long_context_probability: 0.2, + config_long_context_trigger_mode: 'cumulative', + }); + }); + + it('leaves response undefined for appeared / abandoned', () => { + const properties = buildSurveyEventProperties( + { + event_type: 'appeared', + appearance_id: 'a1', + appearance_index: 1, + }, + ENVIRONMENT, + CONFIG, + ); + expect(properties['response']).toBeUndefined(); + }); + + it('flattens the effective config into primitive config_* properties', () => { + const properties = buildSurveyEventProperties( + { + event_type: 'appeared', + appearance_id: 'a1', + appearance_index: 1, + }, + ENVIRONMENT, + { ...CONFIG, probability: 0.5, on_for_models: ['k3', 'k2'] }, + ); + expect(properties['config_probability']).toBe(0.5); + expect(properties['config_on_for_models']).toBe('k3,k2'); + }); + + it('keeps every property a telemetry primitive so sanitize drops nothing', () => { + const properties = buildSurveyEventProperties( + { + event_type: 'responded', + appearance_id: 'a1', + appearance_index: 1, + response: 'bad', + }, + ENVIRONMENT, + { ...CONFIG, on_for_models: [] }, + ); + for (const [key, value] of Object.entries(properties)) { + const isPrimitive = + value === undefined || + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string'; + expect(isPrimitive, `property ${key} is not a primitive`).toBe(true); + } + expect(properties['config_on_for_models']).toBe(''); + }); +}); diff --git a/apps/pythinker-code/test/utils/survey-popup-config.test.ts b/apps/pythinker-code/test/utils/survey-popup-config.test.ts new file mode 100644 index 000000000..1badff63d --- /dev/null +++ b/apps/pythinker-code/test/utils/survey-popup-config.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_SURVEY_POPUP_CONFIG, + getSurveyPopupConfig, + peekSurveyPopupConfig, + peekSurveyPopupConfigFresh, +} from '#/utils/survey-popup-config'; + +describe('survey popup config', () => { + it('returns the built-in defaults', () => { + expect(getSurveyPopupConfig()).toEqual(DEFAULT_SURVEY_POPUP_CONFIG); + expect(peekSurveyPopupConfig()).toEqual(DEFAULT_SURVEY_POPUP_CONFIG); + expect(peekSurveyPopupConfigFresh()).toBe(true); + }); + + it('copies the model list so callers cannot mutate the defaults', () => { + const first = getSurveyPopupConfig(); + first.on_for_models.push('other'); + expect(DEFAULT_SURVEY_POPUP_CONFIG.on_for_models).toEqual(['*']); + }); +}); diff --git a/apps/pythinker-code/test/utils/survey-state-store.test.ts b/apps/pythinker-code/test/utils/survey-state-store.test.ts new file mode 100644 index 000000000..19d31418e --- /dev/null +++ b/apps/pythinker-code/test/utils/survey-state-store.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + readSurveyLastShownTime, + writeSurveyLastShownTime, +} from '#/utils/survey-state-store'; + +describe('survey-state-store', () => { + let dir: string; + let file: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'pythinker-survey-state-')); + file = join(dir, 'feedback-survey-state.json'); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns undefined when the file is missing', async () => { + await expect(readSurveyLastShownTime(file)).resolves.toBeUndefined(); + }); + + it('round-trips last_shown_time', async () => { + writeSurveyLastShownTime(1_700_000_000_000, file); + await expect(readSurveyLastShownTime(file)).resolves.toBe(1_700_000_000_000); + }); + + it('returns undefined when the file is corrupt', async () => { + await writeFile(file, 'not json', 'utf-8'); + await expect(readSurveyLastShownTime(file)).resolves.toBeUndefined(); + }); + + it('returns undefined when the schema does not match', async () => { + await writeFile(file, JSON.stringify({ version: 2, last_shown_time: 1 }), 'utf-8'); + await expect(readSurveyLastShownTime(file)).resolves.toBeUndefined(); + }); + + it('overwrites a previous timestamp atomically', async () => { + writeSurveyLastShownTime(1, file); + writeSurveyLastShownTime(2, file); + await expect(readSurveyLastShownTime(file)).resolves.toBe(2); + }); +}); diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index 1b9fa8d8b..3d233a269 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -1,6 +1,8 @@ import { COMPACT_USER_MESSAGE_MAX_TOKENS, + COMPACTION_CONTINUATION_VARIANT, COMPACTION_ELISION_VARIANT, + buildCompactionContinuationText, buildCompactionElisionText, collectCompactableUserMessages, isRealUserInput, @@ -346,7 +348,7 @@ export function projectContext( const original = realUserEntries[suffixStart + i]!; return original.message === message ? original : { ...original, message }; }); - messages = [...keptEntries, modelSummaryBubble]; + messages = [...keptEntries, modelSummaryBubble, continuationBubble(entry, rec)]; } else { // Head/tail record: mirror `selectCompactionUserMessages` and the // elision marker `ContextMemory.applyCompaction` inserts between the @@ -389,7 +391,13 @@ export function projectContext( } as ContextMessage, toolStepUuids: [], }; - messages = [...headEntries, markerBubble, ...tailEntries, modelSummaryBubble]; + messages = [ + ...headEntries, + markerBubble, + ...tailEntries, + modelSummaryBubble, + continuationBubble(entry, rec), + ]; } } else { // Full history: keep ALL preceding messages, just append the summary @@ -692,6 +700,24 @@ function estimateContentTokens(content: readonly ContentPart[]): number { return total; } +function continuationBubble( + entry: { readonly lineNo: number }, + rec: { readonly time?: number }, +): ProjectedMessage { + return { + lineNo: entry.lineNo + 0.25, + time: rec.time, + source: 'append_message', + message: { + role: 'user', + content: [{ type: 'text', text: buildCompactionContinuationText() }], + toolCalls: [], + origin: { kind: 'injection', variant: COMPACTION_CONTINUATION_VARIANT }, + } as ContextMessage, + toolStepUuids: [], + }; +} + /** True for messages that correspond to a real `_history` entry — * i.e. `append_message` and `compaction_summary` (the summary IS in `_history`). * The synthetic UI-only markers (`undo` / `clear`) are NOT in `_history`, so diff --git a/apps/vis/server/test/lib/context-projector.test.ts b/apps/vis/server/test/lib/context-projector.test.ts index b4b7c1f1e..032e1d02f 100644 --- a/apps/vis/server/test/lib/context-projector.test.ts +++ b/apps/vis/server/test/lib/context-projector.test.ts @@ -1,6 +1,7 @@ // apps/vis/server/test/lib/context-projector.test.ts import { describe, it, expect, afterEach } from 'vitest'; import { estimateTokensForMessages } from '@pymodel/agent-core-v2/kosong/contract/tokens'; +import { buildCompactionContinuationText } from '@pymodel/agent-core-v2/agent/contextMemory/compactionHandoff'; import { buildSessionFixture } from '../fixtures/build'; import { projectContext } from '../../src/lib/context-projector'; import { readAgentWire } from '../../src/lib/wire-reader'; @@ -327,16 +328,23 @@ describe('context-projector', () => { keptUserMessageCount: 2 }, raw: {} }, ]; const proj = projectContext(entries as any); - // [m0, m1, summary] — real user prompts are kept verbatim, the assistant - // tail is dropped. - expect(proj.messages).toHaveLength(3); + // [m0, m1, summary, anchor] — real user prompts are kept verbatim, the + // assistant tail is dropped, and the continuation anchor follows the summary. + expect(proj.messages).toHaveLength(4); expect(proj.messages.map((m) => m.source)).toEqual([ - 'append_message', 'append_message', 'compaction_summary', + 'append_message', 'append_message', 'compaction_summary', 'append_message', ]); expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'm0' }); expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'm1' }); expect(proj.messages[2]!.compaction).toEqual({ compactedCount: 3, tokensBefore: 100, tokensAfter: 10 }); expect(proj.messages[2]!.message.content[0]).toMatchObject({ text: 'sum' }); + expect(proj.messages[3]!.message.origin).toEqual({ + kind: 'injection', + variant: 'compaction_continuation', + }); + expect(proj.messages[3]!.message.content[0]).toMatchObject({ + text: buildCompactionContinuationText(), + }); }); it('apply_compaction mirrors the legacy verbatim tail for records without keptUserMessageCount (model)', () => { @@ -386,9 +394,10 @@ describe('context-projector', () => { ]; const proj = projectContext(entries as any); - // [FIRST, head slice of middle, marker, tail slice of middle, LAST, summary] - // — mirrors the engine's selectCompactionUserMessages + elision marker. - expect(proj.messages).toHaveLength(6); + // [FIRST, head slice of middle, marker, tail slice of middle, LAST, summary, anchor] + // — mirrors the engine's selectCompactionUserMessages + elision marker, with + // the continuation anchor after the summary. + expect(proj.messages).toHaveLength(7); const texts = proj.messages.map((m) => m.message.content.map((p: any) => (p.type === 'text' ? p.text : '')).join(''), ); @@ -404,9 +413,14 @@ describe('context-projector', () => { expect(middle.endsWith(texts[3]!)).toBe(true); expect(texts[4]).toBe(last); expect(proj.messages[5]!.source).toBe('compaction_summary'); + expect(proj.messages[6]!.message.origin).toEqual({ + kind: 'injection', + variant: 'compaction_continuation', + }); + expect(texts[6]).toBe(buildCompactionContinuationText()); // Synthesized entries (the head slice of the same message that anchors the // tail, and the marker) get fractional lineNos so keys stay unique. - expect(new Set(proj.messages.map((m) => m.lineNo)).size).toBe(6); + expect(new Set(proj.messages.map((m) => m.lineNo)).size).toBe(7); }); it('apply_compaction drops shell/local-command/background messages in model mode only', () => { @@ -430,10 +444,10 @@ describe('context-projector', () => { const model = projectContext(entries as any); expect(model.messages.map((m) => m.source)).toEqual([ - 'append_message', 'compaction_summary', 'append_message', + 'append_message', 'compaction_summary', 'append_message', 'append_message', ]); expect(model.messages.map((m) => m.message.content[0])).toMatchObject([ - { text: 'real user' }, { text: 'sum' }, { text: 'new' }, + { text: 'real user' }, { text: 'sum' }, { text: buildCompactionContinuationText() }, { text: 'new' }, ]); const full = projectContext(entries as any, 'full'); @@ -478,12 +492,14 @@ describe('context-projector', () => { keptUserMessageCount: 3 }, raw: {} }, ]; const proj = projectContext(entries as any); - // Correct: [u1, u3, u4, summary]. The marker is gone, all real prompts kept. + // Correct: [u1, u3, u4, summary, anchor]. The marker is gone, all real + // prompts kept, and the continuation anchor follows the summary. expect(proj.messages.map((m) => m.source)).toEqual([ - 'append_message', 'append_message', 'append_message', 'compaction_summary', + 'append_message', 'append_message', 'append_message', 'compaction_summary', 'append_message', ]); expect(proj.messages.map((m) => m.message.content[0])).toMatchObject([ { text: 'u1' }, { text: 'u3' }, { text: 'u4' }, { text: 'sum' }, + { text: buildCompactionContinuationText() }, ]); }); @@ -854,10 +870,10 @@ describe('context-projector', () => { keptUserMessageCount: 2 }, raw: {} }, ]; // No 2nd arg → 'model' default: the real user prompts are kept verbatim and - // the summary is appended after them. + // the summary is appended after them, followed by the continuation anchor. const proj = projectContext(entries as any); expect(proj.messages.map((m) => m.source)).toEqual([ - 'append_message', 'append_message', 'compaction_summary', + 'append_message', 'append_message', 'compaction_summary', 'append_message', ]); expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: 'm0' }); expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: 'm1' }); diff --git a/apps/vis/server/test/routes/context.test.ts b/apps/vis/server/test/routes/context.test.ts index b7bcd8614..a101194ed 100644 --- a/apps/vis/server/test/routes/context.test.ts +++ b/apps/vis/server/test/routes/context.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; +import { buildCompactionContinuationText } from '@pymodel/agent-core-v2/agent/contextMemory/compactionHandoff'; import { buildSessionFixture } from '../fixtures/build'; import { contextRoute } from '../../src/routes/context'; @@ -77,10 +78,11 @@ describe('context route', () => { messages: { source: string; message: { content: { type: string; text?: string }[] } }[]; }; expect(modelBody.messages.map((m) => m.source)).toEqual([ - 'append_message', 'compaction_summary', 'append_message', + 'append_message', 'compaction_summary', 'append_message', 'append_message', ]); expect(modelBody.messages[0]!.message.content[0]).toMatchObject({ text: 'before compaction' }); - expect(modelBody.messages[2]!.message.content[0]).toMatchObject({ text: 'after compaction' }); + expect(modelBody.messages[2]!.message.content[0]).toMatchObject({ text: buildCompactionContinuationText() }); + expect(modelBody.messages[3]!.message.content[0]).toMatchObject({ text: 'after compaction' }); // Full history: every pre-compaction message (user prompt + assistant reply) // is KEPT, then the summary marker, then the post-compaction tail. diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 7af29eb00..fc9c1c305 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -454,6 +454,23 @@ disabled = ["EnterPlanMode", "ExitPlanMode", "mcp__github__*"] Like the `tools` / `disallowedTools` fields of an agent file, this section shapes the tools shown to the model and is enforced again before execution. [Permission rules](#permission) remain a separate control for operations that require approval. ::: +## `read` + +`read` controls the character limits for the [`Read` tool](../reference/tools.md). The limit includes file content, line numbers, and the status block; it does not impose a separate line-count or UTF-8 byte limit. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `default_max_chars` | `integer` | `100000` | Character budget when the tool call omits `max_chars` | +| `max_chars` | `integer` | `500000` | Maximum character budget a tool call may request | + +```toml +[read] +default_max_chars = 100000 +max_chars = 500000 +``` + +Both values must be positive integers. A call's `max_chars` overrides the default, but is capped at the configured maximum; the result reports the effective budget. If the configured default exceeds the maximum, the maximum also limits default reads. Raise `default_max_chars` when you want larger documents to be returned in one call without the agent requesting a larger budget. + ## `image` `image` controls how images are compressed before being sent to the model, across every ingestion point (pasted images, `ReadMediaFile` reads, images in MCP tool results, and so on). @@ -561,6 +578,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | `render_latex` | `boolean` | `true` | Render LaTeX math expressions (`$…$`, `$$…$$`) in Markdown messages as Unicode text; `false` keeps the raw source | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | | `cache_expiry_hint` | `boolean` | `true` | Show a dialog when resuming a long-idle session or submitting after a long idle stretch, warning that the context cache has likely expired and offering to compact or start a new session (v2 engine only) | +| `disable_feedback_survey` | `boolean` | `false` | Disable the occasional session rating prompt above the input box | | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent | | `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` | @@ -574,6 +592,7 @@ theme = "auto" # "auto" | "dark" | "light" | custom theme name render_latex = true # false keeps LaTeX math in messages as raw source disable_paste_burst = false # true disables non-bracketed paste-burst fallback cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit +disable_feedback_survey = false # true hides the occasional session rating prompt [editor] command = "" # empty uses $VISUAL / $EDITOR diff --git a/docs/reference/keyboard.md b/docs/reference/keyboard.md index 1605d5eae..78ff7e477 100644 --- a/docs/reference/keyboard.md +++ b/docs/reference/keyboard.md @@ -15,6 +15,7 @@ The following keys are always available in the input box: | `Ctrl-C` | Interrupt the current streaming output, or clear the input box | | `Ctrl-D` | Exit Pythinker Code CLI when the input box is empty | | `Ctrl-T` | Expand or collapse the todo list when it is truncated | +| `Ctrl-N` | Focus the experimental Updates panel; then `←`/`→` switch channels and `↑`/`↓` page messages | Pressing `Ctrl-C` **during streaming** cancels immediately — no second confirmation needed. diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 499c644b1..3440cdc1d 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -17,7 +17,11 @@ File tools handle reading, writing, and searching the local filesystem — the f | `Glob` | Auto-allow | Find files by glob pattern | | `ReadMediaFile` | Auto-allow | Read an image or video file | -**`Read`** accepts a file path (`path`) plus optional `line_offset` (starting line number; negative values count from the end) and `n_lines` (maximum number of lines to read). Returns at most 1000 lines or 100 KB per call; content beyond that limit is accompanied by a truncation notice. If the file is an image or video, the tool suggests using `ReadMediaFile` instead. +**`Read`** accepts a file path (`path`) plus optional `line_offset` (starting line number; negative values count from the end), `column_offset` (zero-based position within the first line of a forward read), `n_lines` (requested number of source lines), and `max_chars` (maximum characters in the result, including line numbers and status). Omitting `n_lines` reads toward the end of the file. The default is 100,000 characters, and calls can request up to 500,000; both values can be changed in the [`read` configuration](../configuration/config-files.md#read). Characters and column offsets use JavaScript string length in the displayed text, excluding the line-number prefix for column offsets: common letters count as one, while many emoji count as two. + +`Read` prefers complete lines and its results are not shortened again by the general tool-output limit. A line that cannot fit on its own page is returned in fragments; the status reports the column range and `Next Read` arguments to retrieve the rest without raising the budget. Join fragments of the same line without adding a newline. A partial line remains in the requested `n_lines` range until its ending is returned. Invalid column positions return an error rather than skipping content. + +Tail reads return the newest complete lines in the requested range first. If no complete line fits, the result includes forward `Next Read` arguments for the unread range; `column_offset` cannot be combined with a negative `line_offset`. Continuation positions refer to the current file contents, so start a new read if the file changes. If a tail read reports that the file changed during reading, retry against the updated file. UTF-16 LE/BE files up to 10 MiB are checked with strict decoding first. If decoding fails, `Read` returns readable text with malformed sequences replaced by U+FFFD, and every page warns that decoding was lossy and the text may differ from the original. The warning counts toward the character budget; a literal U+FFFD in a valid file does not trigger it. Use `ReadMediaFile` for images or videos. **`Write`** accepts `path`, `content`, and an optional `mode` (`overwrite` or `append`; defaults to overwrite). Missing parent directories are created automatically; `append` mode appends content to the end of the file without automatically adding a newline. Writing to an existing file — in either `overwrite` or `append` mode — requires a prior `Read` of that file in the session; the write is rejected if the file changed on disk since the last read, while creating a new file is exempt. @@ -89,6 +93,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `Agent` | Auto-allow | Spawn a sub-Agent to execute a subtask | | `AgentDynamicWorkflow` | Auto-allow in dynamic_workflow mode; otherwise requires approval | Launch item-based subagents or resume existing subagents | | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | +| `NotifyUser` | Auto-allow | Show the user a short progress update mid-turn | | `Skill` | Auto-allow | Invoke a registered inline Skill | **`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (available when [secondary-model routing](../configuration/config-files.md#subagent-model-pool) is enabled and a pool is configured — a `[secondary_model.models]` table or a lone `default_model`: a pool alias, or `"primary"` for the model the caller itself is running; ignored when resuming). Without it, the subagent binds the pool's `default_model`; without a configured pool, subagents always inherit the caller's model. Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `PYTHINKER_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`pythinker -p`). The same limit governs tower workers and reviewers. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. @@ -97,6 +102,10 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately; the question stays open after the turn ends, and the answer is delivered to the Agent as a notification once the user responds. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. +**`NotifyUser`** lets the main agent and subagents send a short progress update using a single `message` parameter with light Markdown. The TUI Updates panel groups messages by agent. Press `Ctrl-N` to focus the panel, then `←`/`→` to switch channels and `↑`/`↓` to page messages. The next main-agent turn clears the panel. Failed, interrupted, or suppressed notifications do not enter the panel. + +The feature is experimental and off by default. Enable it with `PYTHINKER_CODE_EXPERIMENTAL_NOTIFY_USER=1`, `[experimental] notify_user = true` in `config.toml`, or `/experiments` before creating a TUI session. Sessions created while it is disabled have neither the tool nor its prompt guidance. If a session was created with the feature disabled, start a new session to use Updates. + **`Skill`** allows the Agent to actively invoke a registered inline-type Skill. Accepts `skill` (the Skill name) and optional `args` (additional argument text). Only `type = "inline"` Skills can be called via this tool; Skills with `disableModelInvocation: true` are rejected. Maximum nesting depth is 3 levels. See [Agent Skills](../customization/skills.md) for details. ## Background Tasks diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 5b12d8a77..44ee3f153 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -8,7 +8,7 @@ # commented "# field: type" lines describe the remaining schema fields. # Values resolve as: default -> config.toml -> env overlay -> memory. -# Index (30 sections · 2 overlay(s)) +# Index (31 sections · 2 overlay(s)) # advisor src/session/advisor/configSection.ts # background src/agent/task/configSection.ts # builtinProductSkills src/features/skill/catalog/configSection.ts @@ -32,6 +32,7 @@ # models src/app/kosongConfig/configSection.ts # permission src/agent/permissionRules/configSection.ts # providers src/app/kosongConfig/configSection.ts +# read src/agent/tools/os/read/configSection.ts # secondaryModel src/session/subagent/configSection.ts # services src/app/auth/configSection.ts # subagent src/session/subagent/configSection.ts @@ -385,6 +386,16 @@ merge_all_available_skills = true # env: record # source: record +# ########################################################################## +# read +# owner: src/agent/tools/os/read/configSection.ts +# scope: core +# ########################################################################## + +[read] +# default_max_chars: integer +# max_chars: integer + # ########################################################################## # secondaryModel (config.toml: secondary_model) # owner: src/session/subagent/configSection.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 8bbf05b3b..6a227e007 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 10 keys · Agent: 85 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 10 keys · Agent: 87 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -61,12 +61,14 @@ // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts // dynamic_workflow src/features/dynamic_workflow/dynamicWorkflowOps.ts // externalHooks.stopHookContinuationUsed src/features/externalHooks/agent/agentExternalHooksService.ts +// fileHistory src/features/fileHistory/fileHistoryOps.ts // fullCompaction src/agent/fullCompaction/compactionOps.ts // fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.compactionCountInTurn src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.consecutiveOverflowCompactions src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.lastCompactedTokenCount src/agent/fullCompaction/fullCompactionService.ts // fullCompaction.observedMaxContextTokensByModel src/agent/fullCompaction/fullCompactionService.ts +// fullCompaction.wireRanges src/agent/fullCompaction/compactionOps.ts // interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts // llm.requestTrace src/agent/llmRequester/llmRequestOps.ts // llmRequester.emittedThinkingEffortWarnings src/agent/llmRequester/llmRequesterService.ts @@ -1439,6 +1441,11 @@ export interface AgentStateSnapshot { 'fullCompaction': /* CompactionState — packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts */ { readonly phase: /* CompactionPhase — packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts */ 'completed' | 'cancelled' | 'running' | 'idle'; }; + // replayable · durable — folds: ContextApplyCompaction, ContextClear + 'fullCompaction.wireRanges': readonly /* WireLineRange — packages/agent-core-v2/src/wire/record.ts */ { + readonly start: number; + readonly end: number; + }[]; // src/agent/fullCompaction/fullCompactionService.ts 'fullCompaction.activeTurnId': number | undefined; 'fullCompaction.compactionCountInTurn': number; @@ -1792,6 +1799,23 @@ export interface AgentStateSnapshot { 'dynamic_workflow': 'task' | 'tool' | 'manual' | null; // src/features/externalHooks/agent/agentExternalHooksService.ts 'externalHooks.stopHookContinuationUsed': boolean; + // src/features/fileHistory/fileHistoryOps.ts + // replayable · durable — folds: FileHistoryCheckpointed, FileHistoryTracked + 'fileHistory': /* FileHistoryState — packages/agent-core-v2/src/features/fileHistory/fileHistory.ts */ { + readonly checkpoints: readonly /* FileHistoryCheckpointRecord — packages/agent-core-v2/src/features/fileHistory/fileHistory.ts */ { + readonly turnId: number; + readonly phase?: 'start' | 'end'; + readonly entries: Readonly>; + }[]; + readonly tracked: readonly string[]; + }; // src/features/plan/injection/planModeInjection.ts 'plan.wasActive': boolean; // src/features/plan/planOps.ts diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 835a6cc77..66882ab17 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -24,7 +24,7 @@ // cross-reducers), blobs (the folding states whose blob codec offloads inline // media to blob storage), owner (the source file declaring the class). -// Index (59 record types) +// Index (61 record types) // config.update profile src/agent/profile/profileOps.ts // context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts // context.append_message contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts @@ -36,6 +36,8 @@ // cron.delete (none) src/features/cron/cronOps.ts // dynamic_workflow_mode.enter dynamic_workflow src/features/dynamic_workflow/dynamicWorkflowOps.ts // dynamic_workflow_mode.exit contextMemory, dynamic_workflow src/features/dynamic_workflow/dynamicWorkflowOps.ts +// file_history.checkpoint fileHistory src/features/fileHistory/fileHistoryOps.ts +// file_history.tracked fileHistory src/features/fileHistory/fileHistoryOps.ts // forked (none) src/features/goal/goalOps.ts // full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts // full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts @@ -237,6 +239,37 @@ interface DynamicWorkflowModeExitPayload { agentId: string; } +/** + * states: fileHistory + * owner: src/features/fileHistory/fileHistoryOps.ts + */ +interface FileHistoryCheckpointPayload { + _name: 'file_history.checkpoint'; + agentId: string; + turnId: number; + phase?: 'start' | 'end'; + entries: Record; +} + +/** + * states: fileHistory + * owner: src/features/fileHistory/fileHistoryOps.ts + */ +interface FileHistoryTrackedPayload { + _name: 'file_history.tracked'; + agentId: string; + turnId: number; + path: string; + entry: { + key: string | null; + version: number; + contentHash?: string; + size?: number; + oversize?: boolean; + mtimeMs?: number; + }; +} + /** * states: (none) * owner: src/features/goal/goalOps.ts @@ -913,6 +946,8 @@ interface WirePayloadMap { "cron.delete": CronDeletePayload; "dynamic_workflow_mode.enter": DynamicWorkflowModeEnterPayload; "dynamic_workflow_mode.exit": DynamicWorkflowModeExitPayload; + "file_history.checkpoint": FileHistoryCheckpointPayload; + "file_history.tracked": FileHistoryTrackedPayload; "forked": ForkedPayload; "full_compaction.begin": FullCompactionBeginPayload; "full_compaction.cancel": FullCompactionCancelPayload; diff --git a/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md b/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md index f814a9f84..3b8345bf3 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md +++ b/packages/agent-core-v2/src/agent/contextMemory/compaction-summary-prefix.md @@ -1 +1 @@ -The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. +The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed. diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 11a6a9f73..673ac18cf 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -8,6 +8,7 @@ export const COMPACTION_SUMMARY_PREFIX = summaryPrefixTemplate.trimEnd(); export const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000; export const COMPACT_USER_MESSAGE_HEAD_TOKENS = 2_000; export const COMPACTION_ELISION_VARIANT = 'compaction_elision'; +export const COMPACTION_CONTINUATION_VARIANT = 'compaction_continuation'; type MessageLike = ContextMessage; @@ -94,11 +95,12 @@ export function buildContextCompactionShape( ? [...selection.head, ...selection.tail] : [...selection.head, elisionMessage, ...selection.tail]; const contextSummary = input.contextSummary ?? input.summary; + const continuationMessage = createCompactionContinuationMessage(); const tokensAfter = input.tokensAfter ?? (input.requestOverheadTokens ?? 0) + (input.summaryOutputTokens ?? estimate.text(contextSummary)) + - estimate.messages(keptMessages); + estimate.messages([...keptMessages, continuationMessage]); const keptUserMessageCount = input.keptUserMessageCount ?? selection.head.length + selection.tail.length; const keptHeadUserMessageCount = @@ -113,7 +115,11 @@ export function buildContextCompactionShape( keptUserMessageCount, keptHeadUserMessageCount, droppedCount: input.droppedCount, - messages: [...keptMessages, createCompactionSummaryMessage(contextSummary)], + messages: [ + ...keptMessages, + createCompactionSummaryMessage(contextSummary), + continuationMessage, + ], }; } @@ -146,6 +152,21 @@ export function buildCompactionElisionText(omittedTokens: number): string { ); } +export function createCompactionContinuationMessage(): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text: buildCompactionContinuationText() }], + toolCalls: [], + origin: { kind: 'injection', variant: COMPACTION_CONTINUATION_VARIANT }, + }; +} + +export function buildCompactionContinuationText(): string { + return wrapSystemReminder( + 'Context compaction is complete — continue the work that was in progress when it began.', + ); +} + export function collectCompactableUserMessages(messages: readonly T[]): T[] { return messages.filter( (message) => isRealUserInput(message) && !isCompactionSummaryMessage(message), @@ -313,9 +334,9 @@ function truncateTextToTokensFromEnd(text: string, maxTokens: number): string { let start = text.length; for (let i = text.length - 1; i >= 0; i--) { let isAscii = false; - const code = text.charCodeAt(i); + const code = text.charCodeAt(i); // oxlint-disable-line unicorn/prefer-code-point if (code >= 0xdc00 && code <= 0xdfff && i > 0) { - const high = text.charCodeAt(i - 1); + const high = text.charCodeAt(i - 1); // oxlint-disable-line unicorn/prefer-code-point if (high >= 0xd800 && high <= 0xdbff) { i--; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts index 2021c8bd7..3300d4ce6 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts @@ -63,6 +63,9 @@ const contextCompactionBaseShape = { keptHeadUserMessageCount: z.number().optional(), droppedCount: z.number().optional(), legacyTail: z.boolean().optional(), + wireLines: z + .object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() }) + .optional(), }; const contextApplyCompactionSchema = z.union([ diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index 1e7162b96..9a490ec9a 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -1,4 +1,5 @@ import { createDecorator } from "#/_base/di/instantiation"; +import type { WireLineRange } from '#/wire/record'; import type { UndoCut } from './contextOps'; import type { LoopRecordedEvent } from './loopEventFold'; @@ -15,6 +16,7 @@ export interface ContextCompactionInput { readonly keptUserMessageCount?: number; readonly keptHeadUserMessageCount?: number; readonly droppedCount?: number; + readonly wireLines?: WireLineRange; } export interface ContextCompactionResult { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index a2ccddc31..f29cc89e8 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -131,6 +131,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte keptUserMessageCount: result.keptUserMessageCount, keptHeadUserMessageCount: result.keptHeadUserMessageCount, droppedCount: result.droppedCount, + wireLines: input.wireLines, }), ); this.tokenCounting.rebase(this.scopeContext.agentContext, { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index aeb36bd1a..6eb4bdb1c 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -207,7 +207,7 @@ function recoverFoldedLength( const keptHeadUserMessageCount = readNumber(record, 'keptHeadUserMessageCount'); const compactedCount = readNumber(record, 'compactedCount'); if (keptUserMessageCount !== undefined) { - return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 1 : 2); + return keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 2 : 3); } if (compactedCount !== undefined && compactedCount < foldedLength) { return 1 + (foldedLength - compactedCount); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md b/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md index 4f0b4279c..90742b820 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md +++ b/packages/agent-core-v2/src/agent/fullCompaction/compaction-instruction.md @@ -1,24 +1,20 @@ -You are about to run out of context. Write a first-person handoff note to -yourself so you can seamlessly continue this task after the earlier -conversation is cleared. +You are about to run out of context. Create a handoff summary for the +model that will resume this task after the earlier conversation is cleared. --- This message is a direct task, not part of the above conversation --- -Write the note as your own continuing train of thought — first person, present -tense, the way you would reason through the next move. Do not write a -third-party report about someone else's work, and do not impose rigid section -headings; let the shape follow the task. Write the note in the same language the -conversation has been using — do not switch to English just because these -instructions happen to be in English. +Do not impose rigid section headings; let the shape follow the task. Write it +in the same language the conversation has been using — do not switch to English +just because these instructions happen to be in English. -Make the note self-sufficient: the next turn will see only your most recent user -messages and this note — every assistant message, tool call, and tool result -above will be gone. In your own words, preserve what you genuinely need to -continue: +Make the summary self-sufficient: the next turn will see only the preserved +messages and this summary — every other assistant message, tool call, and tool +result above will be gone. In your own words, preserve what you genuinely need +to continue: - What the latest request is actually asking for: your reading of its intent and any ambiguity you have already resolved — not a re-transcription, since what - fits is kept verbatim in your most recent messages. But those kept messages are + fits is kept verbatim in the preserved messages. But those kept messages are size-capped, so a long request is truncated there: if the latest request is large (a big paste or file), preserve the parts at risk of being dropped — above all the actual ask. If several requests are in play, say which one governs @@ -52,7 +48,9 @@ continue: here is one less thing the next turn must rediscover. Include any required format for the final answer. -Your TODO list is re-attached automatically below this note from its live +This conversation's event log stays on disk and a recovery pointer is appended below this summary automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up. + +Your TODO list is re-attached automatically below this summary from its live source, so do not transcribe it — copying it wastes space and can contradict the live version. What that list cannot hold is the reasoning between tasks — why one was reordered or dropped, or a decision on one that constrains another — so @@ -63,9 +61,9 @@ was never verified (tests "passing", a fix "working", a file "created"), say so plainly and treat it as unverified rather than fact — re-check before relying on it. -Be concise, and keep the note proportional to the task: a long multi-step task -warrants detail, but a trivial or nearly finished exchange needs only a sentence -or two — do not pad it out. Include the critical data, identifiers, and +Be concise, and keep the summary proportional to the task: a long multi-step +task warrants detail, but a trivial or nearly finished exchange needs only a +sentence or two — do not pad it out. Include the critical data, identifiers, and references needed to continue, and omit anything that does not change the next move. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compactionInstruction.ts b/packages/agent-core-v2/src/agent/fullCompaction/compactionInstruction.ts new file mode 100644 index 000000000..cd3a10f91 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/compactionInstruction.ts @@ -0,0 +1,15 @@ +import { renderPrompt } from '#/_base/utils/render-prompt'; + +import compactionInstructionTemplate from './compaction-instruction.md?raw'; + +export interface CompactionInstructionInput { + readonly customInstruction?: string; +} + +export function renderCompactionInstruction(input: CompactionInstructionInput): string { + const customInstruction = input.customInstruction?.trim() ?? ''; + return renderPrompt(compactionInstructionTemplate, { + custom_instruction_block: + customInstruction.length > 0 ? `\nOptional user instruction:\n${customInstruction}\n` : '', + }).trimEnd(); +} diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts index c58165945..a1b2bb25a 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts @@ -3,6 +3,12 @@ import { z } from 'zod'; import { AgentEvent2, type AgentDomainTrait } from '#/app/event/event2'; import { defineState } from '#/state/state'; +import { + ContextApplyCompaction, + ContextClear, + type ContextApplyCompactionPayload, +} from '#/agent/contextMemory/contextEvents'; +import type { WireLineRange } from '#/wire/record'; import type { CompactionBeginData, CompactionResult, CompactionSource } from './types'; @@ -123,3 +129,14 @@ export const fullCompactionKey = defineState( s.phase = 'idle'; } }); + +export const fullCompactionWireRangesKey = defineState( + 'fullCompaction.wireRanges', + () => [], +) + .replayable({ schema: z.custom() }) + .on(ContextApplyCompaction, (s, e) => { + const wireLines = (e as unknown as ContextApplyCompactionPayload).wireLines; + return wireLines === undefined ? undefined : [...s, wireLines]; + }) + .on(ContextClear, (s) => (s.length === 0 ? undefined : [])); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/context-recovery-footer.md b/packages/agent-core-v2/src/agent/fullCompaction/context-recovery-footer.md new file mode 100644 index 000000000..f3ba114f7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/context-recovery-footer.md @@ -0,0 +1,10 @@ +## Context Recovery +Everything before this note is still on disk in this agent's event log (read-only, append-only): + ${wire_path} +${window_lines} +If you need exact command output, file contents, error text, or the wording of an earlier request, look it up there instead of guessing. How to read it: +- Layout: one file per agent. agents/main/ is the main agent; each subagent has its own agents//wire.jsonl. A parent's log holds only the Agent tool call and the subagent's returned result — the subagent's own steps are in its own file. +- Format: one JSON record per line, append-only; `type` says what it is. The conversation is in `context.append_message` (user prompts) and `context.append_loop_event` (event.type: step.begin | content.part [text|think] | tool.call | tool.result | step.end). Every other type (llm.request, usage.record, token_counting.measured, metadata, profile.bind, …) is bookkeeping — skip it. +- Boundaries: `context.apply_compaction` marks a compaction (older lines stay in the file; grep for it to find exact boundaries). `context.undo` count=N retracts the previous N messages — treat retracted content as never having happened. `context.clear` resets the conversation. +- Externalized content: tool results over 50k chars may contain an `output_path` pointing to saved output; check the result's preservation notice before assuming the file contains everything. Read results are bounded by their own character budget and are not spilled again. Media parts are blob references, not inline. +- Reading: lines are long JSON (often 10k+ chars). Grep the file for a keyword to get line numbers, then Read exactly that line (line_offset=N, n_lines=1). Long records can span several Read results: follow Next Read with its column_offset until the line is complete, joining fragments without inserting newlines. To pull one field with real newlines when Bash is available: sed -n 'Np' wire.jsonl | jq -r '.event.result.output'. Prefer individual records over large ranges. diff --git a/packages/agent-core-v2/src/agent/fullCompaction/contextRecovery.ts b/packages/agent-core-v2/src/agent/fullCompaction/contextRecovery.ts new file mode 100644 index 000000000..430f821c4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/fullCompaction/contextRecovery.ts @@ -0,0 +1,28 @@ +import { renderPrompt } from '#/_base/utils/render-prompt'; +import type { WireLineRange } from '#/wire/record'; + +import contextRecoveryTemplate from './context-recovery-footer.md?raw'; + +export const CONTEXT_RECOVERY_HEADING = '## Context Recovery'; + +export interface ContextRecoveryPointer { + readonly journalPath: string; + readonly windows: readonly WireLineRange[]; +} + +export function renderContextRecoveryPointer(pointer: ContextRecoveryPointer): string { + const windows = pointer.windows; + const summarized = windows.length - 1; + const lines = windows.map((range, index) => { + const label = `window ${String(index + 1)}: lines ${String(range.start)}–${String(range.end)}`; + return index === summarized ? `${label} ← the conversation this note summarizes` : label; + }); + const nextStart = windows[summarized]!.end + 1; + lines.push( + `window ${String(windows.length + 1)} (the one you are in now) starts at line ${String(nextStart)} with the \`context.apply_compaction\` record that carries this note — it is already in your context; no need to read it.`, + ); + return renderPrompt(contextRecoveryTemplate, { + wire_path: pointer.journalPath, + window_lines: lines.map((line) => ` ${line}`).join('\n'), + }).trimEnd(); +} diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 4bed2888b..4ec1d300d 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -3,7 +3,6 @@ import { Service } from "#/_base/di/service"; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/state/state'; -import { renderPrompt } from "#/_base/utils/render-prompt"; import { estimateTokensForMessage } from "#/kosong/contract/tokens"; import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -43,7 +42,11 @@ import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isCodedError, isError2, toPythinkerErrorPayload, unwrapErrorCause } from "#/errors"; import { AgentErrorEvent } from '#/agent/mcp/mcpEvents'; import { IEventDispatcher } from '#/state/eventDispatcher'; -import compactionInstructionTemplate from './compaction-instruction.md?raw'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import type { WireLineRange } from '#/wire/record'; +import { IWireService } from '#/wire/wire'; +import { renderCompactionInstruction } from './compactionInstruction'; +import { renderContextRecoveryPointer } from './contextRecovery'; import { IAgentFullCompactionService, type FullCompactionInput, @@ -58,6 +61,7 @@ import { CompactionCancelled, CompactionCompleted, fullCompactionKey, + fullCompactionWireRangesKey, FullCompactionBegin, FullCompactionCancel, FullCompactionComplete, @@ -152,10 +156,12 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom @IEventBus private readonly eventBus: IEventBus, @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentStateService private readonly states: IAgentStateService, + @IWireService private readonly wire: IWireService, ) { super(); this.todo = manager.resolve(agent.agentContext, AgentTodo); this.states.contributeState(fullCompactionKey); + this.states.contributeState(fullCompactionWireRangesKey); this.states.contributeState(fullCompactionCompactionCountInTurnKey); this.states.contributeState(fullCompactionObservedMaxContextTokensByModelKey); this.states.contributeState(fullCompactionLastCompactedTokenCountKey); @@ -636,11 +642,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom : undefined; const compactionMaxOutputSize = resolvedModel.maxOutputSize ?? defaultCompactionCap; - const customInstruction = data.instruction?.trim() ?? ''; - const instruction = renderPrompt(compactionInstructionTemplate, { - custom_instruction_block: - customInstruction.length > 0 ? `\nOptional user instruction:\n${customInstruction}\n` : '', - }).trimEnd(); + const instruction = renderCompactionInstruction({ customInstruction: data.instruction }); const delays = retryBackoffDelays(MAX_COMPACTION_RETRY_ATTEMPTS); let attempt: CompactionAttemptResult | undefined; @@ -691,6 +693,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom overflowShrinkCount, (message) => this.tokenCounting.estimateMessage(message), ); + if (historyForModel.length === 0) throw error; droppedCount += before - historyForModel.length; retryCount = 0; continue; @@ -738,14 +741,23 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom } const summary = await this.postProcessSummary(attempt.summary); + const wireLines = await this.captureWireLines(); + const recoveryFooter = this.renderRecoveryFooter(wireLines); + const summaryText = buildCompactionSummaryText(summary); const result = this.context.applyCompaction({ summary, - contextSummary: buildCompactionSummaryText(summary), + contextSummary: + recoveryFooter === undefined ? summaryText : `${summaryText}\n\n${recoveryFooter}`, compactedCount: originalHistory.length, tokensBefore, - summaryOutputTokens: attempt.usage?.output, + summaryOutputTokens: + attempt.usage === null || attempt.usage === undefined + ? undefined + : attempt.usage.output + + (recoveryFooter === undefined ? 0 : this.tokenCounting.estimateText(recoveryFooter)), requestOverheadTokens: this.requestTokens([]), droppedCount: droppedCount === 0 ? undefined : droppedCount, + wireLines, }); const properties: CompactionFinishedEvent = { @@ -789,6 +801,28 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom } } + private async captureWireLines(): Promise { + try { + await this.wire.flush(); + } catch (error) { + onUnexpectedError(error); + return undefined; + } + const end = this.wire.lineCount(); + const previous = this.states.get(fullCompactionWireRangesKey).at(-1); + const start = Math.max(previous?.end ?? 0, this.wire.lastContextClearLine() ?? 0) + 1; + if (end < start) return undefined; + return { start, end }; + } + + private renderRecoveryFooter(wireLines: WireLineRange | undefined): string | undefined { + if (wireLines === undefined) return undefined; + const journalPath = this.wire.journalPath(); + if (journalPath === undefined) return undefined; + const windows = [...this.states.get(fullCompactionWireRangesKey), wireLines]; + return renderContextRecoveryPointer({ journalPath, windows }); + } + private async postProcessSummary(summary: string): Promise { const todos = this.todo.get(); if (todos.length === 0) { diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts index 1862ba49e..9a38ed9d9 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts @@ -20,6 +20,7 @@ const DEFAULT_APPROVE_TOOLS = new Set([ 'Agent', 'AgentDynamicWorkflow', 'AskUserQuestion', + 'NotifyUser', 'Skill', 'EnterPlanMode', 'ExitPlanMode', diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index b43945b1d..e875eb133 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -25,6 +25,9 @@ import { IModelService } from '#/kosong/model/model'; import { IProviderService } from '#/kosong/provider/provider'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { renderAgentProfilePrompt } from '#/app/agentProfileCatalog/profile-shared'; +import { ISessionNotify } from '#/features/notify/sessionNotify'; +import { NOTIFY_USER_TOOL_NAME } from '#/features/notify/tools/notify-user/notify-user'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { ErrorCodes, Error2 } from "#/errors"; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; @@ -162,6 +165,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @ISessionContext private readonly sessionContext: ISessionContext, @IBootstrapService private readonly bootstrap: IBootstrapService, + @ISessionNotify private readonly notify: ISessionNotify, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @@ -325,7 +329,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const context = await this.buildSystemPromptContext(profile); this.assertBindable(profile.name); const currentProfileName = this.profileName; - const rendered = profile.renderSystemPrompt(context); + const rendered = renderAgentProfilePrompt(profile, context); this.activeProfile = profile; this.cacheAgentsMdWarning(context); @@ -488,7 +492,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ useProfile(profile: ResolvedAgentProfile, context: SystemPromptContext): void { this.activeProfile = profile; - const rendered = profile.renderSystemPrompt(context); + const rendered = renderAgentProfilePrompt(profile, context); this.update({ profileName: profile.name, systemPrompt: rendered.text, @@ -901,6 +905,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ profile: ResolvedAgentProfile, options?: ApplyProfileOptions, ): Promise { + await this.notify.ready; const preloadedAgentsMd = await this.workspaceInstructionsSnapshot(); const fsAvailable = this.runtime.isAvailable(['fs']); const lease = this.runtime.acquire(fsAvailable ? ['fs'] : []); @@ -938,6 +943,8 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ skillActive: this.isToolActiveForProfile(profile, 'Skill'), productName: (await this.identity.resolved()).displayName, replyStyleGuide: this.bootstrap.args.replyStyleGuide, + notifyUserActive: + this.notify.enabled && this.isToolActiveForProfile(profile, NOTIFY_USER_TOOL_NAME), }; } diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index 5a9c51ff3..d4ff315d9 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -49,6 +49,7 @@ export interface PromptHandle extends PromptSnapshot { export interface PromptQueueSnapshot { readonly active: PromptSnapshot | undefined; readonly pending: readonly PromptSnapshot[]; + readonly launching: boolean; } export interface PromptPayload { diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 4716f68c7..41f916c8c 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -401,7 +401,11 @@ export class AgentPromptService implements IAgentPromptService { } list(): PromptQueueSnapshot { - return { active: this.active === undefined ? undefined : snapshot(this.active), pending: this.pending.map(snapshot) }; + return { + active: this.active === undefined ? undefined : snapshot(this.active), + pending: this.pending.map(snapshot), + launching: this.launching, + }; } async steer(promptIds: readonly string[]): Promise { diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 385db169a..a9413edb9 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -808,15 +808,22 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { async stopAllOnExit(reason: string): Promise { if (this.keepAliveOnExit()) return []; const active = this.list(true); - await Promise.all( + await Promise.allSettled( active .filter((task) => task.detached === true) .map(async (task) => { const entry = this.tasks.get(task.taskId); if (entry === undefined) return; - entry.stoppedOnExit = true; - entry.terminalNotificationSuppressed = true; - await this.persistLive(entry); + try { + entry.stoppedOnExit = true; + entry.terminalNotificationSuppressed = true; + await this.persistLive(entry); + } catch (error: unknown) { + this.log.error('terminal notification suppression failed', { + taskId: task.taskId, + error, + }); + } }), ); return this.stopAll(reason); diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts index 2bf2743a0..6aa87c155 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts @@ -17,6 +17,8 @@ export interface IAgentToolResultTruncationService { ): Promise; isSpillFilePath(path: string): boolean; + + isWireJournalPath(path: string): boolean; } export const IAgentToolResultTruncationService: ServiceIdentifier< diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts index 6841df602..efdf8c897 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts @@ -8,9 +8,10 @@ import { type ExecutableToolResult, } from '#/tool/toolContract'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { AGENT_WIRE_RECORD_KEY } from '#/wire/record'; import type { ContentPart } from '#/kosong/contract/message'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; -import { join, normalize } from 'pathe'; +import { basename, join, normalize } from 'pathe'; import { IAgentToolResultTruncationService, type ToolResultTruncationInput, @@ -117,6 +118,12 @@ export class ToolResultTruncationService implements IAgentToolResultTruncationSe return normalized === dir || normalized.startsWith(`${dir}/`); } + isWireJournalPath(path: string): boolean { + const sessionsDir = normalize(join(this.bootstrap.homeDir, this.bootstrap.scope('sessions'))); + const normalized = normalize(path); + return normalized.startsWith(`${sessionsDir}/`) && basename(normalized) === AGENT_WIRE_RECORD_KEY; + } + private async saveToolResult( toolName: string, toolCallId: string, diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 2fe6f8888..03f7dcb3f 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -44,6 +44,8 @@ import { import { ILogService } from '#/_base/log/log'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; +import { ISessionNotify } from '#/features/notify/sessionNotify'; +import { NOTIFY_USER_TOOL_NAME } from '#/features/notify/tools/notify-user/notify-user'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { hasPinnedPermissionMode } from '#/features/tower/tower'; import { @@ -121,6 +123,7 @@ export class SubagentTool implements ISubagentTool { @ILogService private readonly log: ILogService, @IConfigService private readonly config: IConfigService, @IFlagService private readonly flags: IFlagService, + @ISessionNotify private readonly notify: ISessionNotify, @AgentToolContribution private readonly contributions: CollectionView, @ISubagentRoutingService private readonly routing: ISubagentRoutingService, ) { @@ -149,8 +152,12 @@ export class SubagentTool implements ISubagentTool { allowlist === undefined ? catalogProfiles : catalogProfiles.filter((profile) => allowlist.includes(profile.name)); + const notifyAvailable = this.notify.enabled; const typeLines = buildProfileDescriptions( - profiles, + profiles.map((profile) => ({ + ...profile, + tools: profile.tools?.filter((name) => name !== NOTIFY_USER_TOOL_NAME || notifyAvailable), + })), this.knownToolReferences(), (profile, name, source) => this.toolPolicy.isToolActiveForProfile(profile, name, source), diff --git a/packages/agent-core-v2/src/agent/tools/os/read/configSection.ts b/packages/agent-core-v2/src/agent/tools/os/read/configSection.ts new file mode 100644 index 000000000..d8431d26e --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/read/configSection.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const READ_SECTION = 'read'; + +export const ReadConfigSchema = z.object({ + defaultMaxChars: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), +}); + +export type ReadConfig = z.infer; + +registerConfigSection(READ_SECTION, ReadConfigSchema, { defaultValue: {} }); diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.md b/packages/agent-core-v2/src/agent/tools/os/read/read.md index 8597a6647..247b98475 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/read.md +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.md @@ -5,13 +5,17 @@ If the user provides a concrete file path to a text file, call Read directly. Do When you need several files, prefer to read them in parallel: emit multiple `Read` calls in a single response instead of reading one file per turn. - Relative paths resolve against the working directory; a path outside the working directory must be absolute. -- Returns up to ${MAX_LINES} lines or ${MAX_BYTES_KB} KB per call, whichever comes first; lines longer than ${MAX_LINE_LENGTH} chars are truncated mid-line. -- Page larger files with `line_offset` (1-based start line) and `n_lines`. Omit `n_lines` to read up to the ${MAX_LINES}-line cap. +- Returns text within `max_chars`, including line numbers and the status block, preferring complete lines. The configured default is ${DEFAULT_MAX_CHARS} characters; calls can request up to ${MAX_CHARS}. Characters use JavaScript string length, not UTF-8 bytes or tokens. Read results are not spilled or shortened again by the general tool-output limit. +- Omit `n_lines` to read toward the end of the file. There is no fixed line-count cap. When the task requires the full text of a large file, request a larger `max_chars`, up to ${MAX_CHARS}, in the first call. +- Page larger files with `line_offset` (1-based start line) and `n_lines`. If the result is incomplete, copy the `Next Read` arguments in the status block to continue without gaps or overlaps. Do not answer from a partial page when the task requires the remaining content. +- If a single line cannot fit on its own page, Read returns a fragment and reports its column range. Continue on the same line with the supplied `column_offset`; do not insert a newline between fragments of one source line. A partial line still counts toward the remaining `n_lines` until its ending is returned. +- `column_offset` is a zero-based position in the first line's displayed text, excluding its line-number prefix. It is supported only for forward reads. Offsets past the line or inside a Unicode surrogate pair return an error. Continuation refers to the current file contents; start a new read if the file changed. +- Pythinker Code agent event logs (`wire.jsonl` under the sessions directory) follow the same character budget; locate a record with Grep, read it with `n_lines=1`, and follow `Next Read` to retrieve every fragment of a long record. - Sensitive files (`.env` files, credential stores, SSH private keys, and similar secrets) are refused to protect secrets; do not attempt to read them. Templates and public keys are exempt: `.env.example` / `.env.sample` / `.env.template` and public SSH keys such as `id_rsa.pub` read normally. -- UTF-8 text files are read directly. UTF-16 LE/BE text files (with or without a BOM) are detected automatically and transcoded to UTF-8 for display; the status block notes the detected encoding, and Edit/Write on such a file still expect UTF-8 — convert its encoding first (e.g. with `iconv`). Other encodings (e.g. GBK), binary files, and files containing NUL bytes are refused. -- Negative line_offset reads from the end of the file (for example, -100 reads the last 100 lines); the absolute value cannot exceed ${MAX_LINES}. +- UTF-8 text files are read directly. UTF-16 LE/BE text files (with or without a BOM) are detected automatically and checked with strict decoding first. If malformed sequences are found, Read returns readable text with U+FFFD replacements and a lossy-decoding warning on every page; do not treat this view as exact original text. The status block notes the detected encoding, and Edit/Write on such a file still expect UTF-8 — convert its encoding first (e.g. with `iconv`). Other encodings (e.g. GBK), binary files, and files containing NUL bytes are refused. +- Negative `line_offset` reads from the end of the file (for example, -100 reads the last 100 lines). If the requested tail range exceeds the character budget, the newest complete lines in that range are returned first; `Next Read` covers the omitted earlier range. If no complete line fits, Read reports this and supplies forward `Next Read` arguments for the entire unread range. Omit `column_offset` when using a negative `line_offset`. - Output format: `\t` per line. -- A `...` status block is appended after the file content; it summarizes how much was read (line and byte counts, truncation, line-ending notes) and is not part of the file itself. +- A `...` status block is appended after the file content. It reports the actual returned range, total lines, effective character budget, whether the requested range is complete, and whether EOF was reached. The block is not part of the file itself. - Pure CRLF files are displayed with LF line endings; `Edit` matches this output and preserves CRLF when writing back. - Mixed or lone carriage-return line endings are shown as `\r` and require exact `Edit.old_string` escapes. - After a successful `Edit`/`Write`, do not re-read solely to prove the write landed. When the task depends on an exact file, API, or output shape, inspect the final external contract before finishing. diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.ts b/packages/agent-core-v2/src/agent/tools/os/read/read.ts index 881b20182..05262569a 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/read.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.ts @@ -3,14 +3,13 @@ import { z } from 'zod'; import { createDecorator } from '#/_base/di/instantiation'; import { type AgentTool } from '#/tool/toolContract'; -export const MAX_LINES: number = 1000; -export const MAX_LINE_LENGTH: number = 2000; -export const MAX_BYTES: number = 100 * 1024; +export const DEFAULT_MAX_CHARS = 100_000; +export const DEFAULT_MAX_CHARS_LIMIT = 500_000; export const TRANSCODE_MAX_BYTES: number = 10 * 1024 * 1024; const PositiveLineOffsetSchema = z.number().int().min(1); -const TailLineOffsetSchema = z.number().int().min(-MAX_LINES).max(-1); +const TailLineOffsetSchema = z.number().int().negative(); export const ReadInputSchema = z.object({ path: z @@ -22,16 +21,22 @@ export const ReadInputSchema = z.object({ .union([PositiveLineOffsetSchema, TailLineOffsetSchema]) .optional() .describe( - `The line number to start reading from. Omit to start at line 1. Negative values read from the end of the file; the absolute value cannot exceed ${String(MAX_LINES)}.`, + 'The line number to start reading from. Omit to start at line 1. Negative values read from the end of the file (for example, -100 reads the last 100 lines).', ), + column_offset: z.number().int().nonnegative().optional().describe( + 'Zero-based character offset within the first line of a forward read, excluding its line-number prefix. Uses JavaScript string length in the displayed text. Copy continuation arguments from the previous result to resume a long line.', + ), n_lines: z .number() .int() .positive() .optional() .describe( - `The number of lines to read; the tool also applies its internal cap. Omit to read up to the internal cap of ${String(MAX_LINES)} lines.`, + 'The number of lines to read. Omit to read toward the end of the file. Results are bounded by max_chars, with continuation arguments when the requested range is incomplete.', ), + max_chars: z.number().int().positive().optional().describe( + 'Maximum characters in the returned text, including line numbers and status. Omit for the configured default; requests above the configured maximum are capped.', + ), }); export const ReadOutputSchema = z.object({ diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 95d8aa2e8..726215b94 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -4,6 +4,8 @@ import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IConfigService } from '#/app/config/config'; +import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; import { ToolAccesses, type ExecutableToolResult, @@ -19,18 +21,18 @@ import { MEDIA_SNIFF_BYTES, detectFileType } from '#/agent/media/file-type'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; import { makeCarriageReturnsVisible, splitLinesKeepingTerminator, type LineEndingStyle } from '#/_base/text/line-endings'; -import { decodeUtfText, detectTextEncoding, type UtfTextEncoding } from '#/_base/text/encoding'; +import { detectTextEncoding, type UtfTextEncoding } from '#/_base/text/encoding'; import { renderPrompt } from '#/_base/utils/render-prompt'; import { + DEFAULT_MAX_CHARS, + DEFAULT_MAX_CHARS_LIMIT, IReadTool, - MAX_BYTES, - MAX_LINE_LENGTH, - MAX_LINES, ReadInputSchema, TRANSCODE_MAX_BYTES, type ReadInput, } from './read'; import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; +import { READ_SECTION, type ReadConfig } from './configSection'; import readDescriptionTemplate from './read.md?raw'; interface LineEndingFlags { @@ -44,34 +46,40 @@ interface ReadLineEntry { readonly rawContent: string; } -interface RenderedLine { - readonly line: string; - readonly wasTruncated: boolean; +interface ReadTailEntry extends ReadLineEntry { + readonly minChars: number; } -interface FinishReadResultInput { - readonly renderedLines: readonly string[]; - readonly truncatedLineNumbers: readonly number[]; - readonly maxLinesReached: boolean; - readonly maxBytesReached: boolean; - readonly lineEndingStyle: LineEndingStyle; - readonly startLine: number; - readonly totalLines: number; - readonly requestedLines: number; +interface ReadRequest { + readonly args: ReadInput; + readonly maxChars: number; + readonly maxCharsLimit: number; readonly detectedEncoding?: UtfTextEncoding; + readonly lossyDecoding: boolean; + readonly eventLog: boolean; } -function truncateLine(line: string, maxLength: number): string { - if (line.length <= maxLength) return line; - const marker = '...'; - const target = Math.max(maxLength, marker.length); - return line.slice(0, target - marker.length) + marker; +interface ReadPage { + readonly request: ReadRequest; + readonly renderedLines: readonly string[]; + readonly startLine: number; + readonly rangeStart: number; + readonly rangeEnd: number; + readonly totalLines: number; + readonly fromTail: boolean; + readonly lineEndingStyle: LineEndingStyle; } function stripTrailingLf(line: string): string { return line.endsWith('\n') ? line.slice(0, -1) : line; } +function splitsSurrogatePair(text: string, offset: number): boolean { + const previous = text.charCodeAt(offset - 1); // oxlint-disable-line unicorn/prefer-code-point + const next = text.charCodeAt(offset); // oxlint-disable-line unicorn/prefer-code-point + return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff; +} + function updateLineEndingFlags(flags: LineEndingFlags, text: string): void { for (let i = 0; i < text.length; i += 1) { const code = text.codePointAt(i); @@ -94,57 +102,14 @@ function lineEndingStyleFromFlags(flags: LineEndingFlags): LineEndingStyle { return 'lf'; } -function renderLine(entry: ReadLineEntry, lineEndingStyle: LineEndingStyle): RenderedLine { +function renderLine(entry: ReadLineEntry, lineEndingStyle: LineEndingStyle): string { const modelContent = lineEndingStyle === 'crlf' && entry.rawContent.endsWith('\r') ? entry.rawContent.slice(0, -1) : entry.rawContent; - const truncated = truncateLine(modelContent, MAX_LINE_LENGTH); const renderedContent = - lineEndingStyle === 'mixed' ? makeCarriageReturnsVisible(truncated) : truncated; - return { - line: `${String(entry.lineNo)}\t${renderedContent}`, - wasTruncated: truncated !== modelContent, - }; -} - -function renderedLineBytes(renderedLine: string, isFirst: boolean): number { - return (isFirst ? 0 : 1) + Buffer.byteLength(renderedLine, 'utf8'); -} - -function renderEntries( - entries: readonly ReadLineEntry[], - lineEndingStyle: LineEndingStyle, -): { - renderedLines: string[]; - truncatedLineNumbers: number[]; - maxBytesReached: boolean; -} { - const renderedLines: string[] = []; - const truncatedLineNumbers: number[] = []; - let bytes = 0; - let maxBytesReached = false; - - for (const entry of entries) { - const rendered = renderLine(entry, lineEndingStyle); - const lineBytes = renderedLineBytes(rendered.line, renderedLines.length === 0); - if (renderedLines.length > 0 && bytes + lineBytes > MAX_BYTES) { - maxBytesReached = true; - break; - } - - if (rendered.wasTruncated) { - truncatedLineNumbers.push(entry.lineNo); - } - renderedLines.push(rendered.line); - bytes += lineBytes; - if (bytes >= MAX_BYTES) { - maxBytesReached = true; - break; - } - } - - return { renderedLines, truncatedLineNumbers, maxBytesReached }; + lineEndingStyle === 'mixed' ? makeCarriageReturnsVisible(modelContent) : modelContent; + return `${String(entry.lineNo)}\t${renderedContent}`; } function isFileNotFoundError(error: unknown): boolean { @@ -194,29 +159,42 @@ function notUtf8DecodableFileOutput(path: string): string { ); } -const READ_DESCRIPTION = renderPrompt(readDescriptionTemplate, { - MAX_LINES, - MAX_BYTES_KB: MAX_BYTES / 1024, - MAX_LINE_LENGTH, -}); - export class ReadTool implements IReadTool { declare readonly _serviceBrand: undefined; readonly name = 'Read' as const; - readonly description = READ_DESCRIPTION; + get description(): string { + const limits = this.limits(); + return renderPrompt(readDescriptionTemplate, { + DEFAULT_MAX_CHARS: limits.defaultMaxChars, + MAX_CHARS: limits.maxChars, + }); + } readonly parameters: Record = toInputJsonSchema(ReadInputSchema); constructor( @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @IAgentToolResultTruncationService private readonly resultTruncation: IAgentToolResultTruncationService, + @IConfigService private readonly config: IConfigService, ) {} + private limits(): { defaultMaxChars: number; maxChars: number } { + const section = this.config.get(READ_SECTION); + const maxChars = section?.maxChars ?? DEFAULT_MAX_CHARS_LIMIT; + return { + defaultMaxChars: Math.min(section?.defaultMaxChars ?? DEFAULT_MAX_CHARS, maxChars), + maxChars, + }; + } + private workspaceConfig(view: RuntimeWorkspaceView): WorkspaceConfig { return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs }; } resolveExecution(args: ReadInput): ToolExecution { + if (args.column_offset !== undefined && (args.line_offset ?? 1) < 0) { + return { isError: true, output: 'column_offset is only supported for forward reads. Use a positive line_offset or the forward Next Read arguments.' }; + } const inspected = inspectAgentRuntime(this.runtime); const view = new RuntimeWorkspaceView(inspected, { workDir: this.workspaceCtx.workDir, @@ -248,10 +226,9 @@ export class ReadTool implements IReadTool { } const denied = await sensitiveTargetError(lease.runtime.fs!, args.path, path); if (denied !== undefined) return { isError: true, output: denied }; - const result = await this.execution(lease.runtime.fs!, args, path); - return this.resultTruncation.isSpillFilePath(path) - ? { ...result, spillExempt: true as const } - : result; + const eventLog = this.resultTruncation.isWireJournalPath(path); + const result = await this.execution(lease.runtime.fs!, args, path, eventLog); + return { ...result, spillExempt: true }; } finally { lease.dispose(); } @@ -259,7 +236,12 @@ export class ReadTool implements IReadTool { }; } - private async execution(fs: IHostFileSystem, args: ReadInput, safePath: string): Promise { + private async execution( + fs: IHostFileSystem, + args: ReadInput, + safePath: string, + eventLog: boolean, + ): Promise { try { let stat: Awaited>; try { @@ -284,8 +266,9 @@ export class ReadTool implements IReadTool { } const detection = detectTextEncoding(header); - let lines: AsyncIterable; + let readLines: () => AsyncIterable; let detectedEncoding: UtfTextEncoding | undefined; + let lossyDecoding = false; if (!detection.seemsBinary && detection.encoding !== 'utf-8') { if (stat.size > TRANSCODE_MAX_BYTES) { return { @@ -296,40 +279,48 @@ export class ReadTool implements IReadTool { 'Convert it to UTF-8 first (e.g. with `iconv`).', }; } - const decoded = decodeUtfText(await fs.readBytes(safePath), detection.encoding); + const bytes = await fs.readBytes(safePath); + let decoded: string; + try { + decoded = new TextDecoder(detection.encoding, { fatal: true }).decode(bytes); + } catch (error) { + if (!isTextDecodeError(error)) throw error; + decoded = new TextDecoder(detection.encoding, { fatal: false }).decode(bytes); + lossyDecoding = true; + } detectedEncoding = detection.encoding; - lines = decodedLines(splitLinesKeepingTerminator(decoded)); + const decodedContent = splitLinesKeepingTerminator(decoded); + readLines = () => decodedLines(decodedContent); } else if (fileType.kind === 'unknown') { return { isError: true, output: notReadableFileOutput(args.path), }; } else { - lines = fs.readLines(safePath, { errors: 'strict', maxLineBytes: MAX_LINE_LENGTH * 4 }); + readLines = () => fs.readLines(safePath, { errors: 'strict' }); } + const limits = this.limits(); + const request: ReadRequest = { + args, + maxChars: Math.min(args.max_chars ?? limits.defaultMaxChars, limits.maxChars), + maxCharsLimit: limits.maxChars, + detectedEncoding, + lossyDecoding, + eventLog, + }; const lineOffset = args.line_offset ?? 1; - const requestedLines = args.n_lines ?? MAX_LINES; - const effectiveLimit = Math.min(requestedLines, MAX_LINES); - - if (lineOffset < 0) { - return await this.readTail( - args.path, - lines, - lineOffset, - effectiveLimit, - requestedLines, - detectedEncoding, - ); + if (lineOffset >= 0) return await this.readForward(readLines(), request); + const rereadsFile = detectedEncoding === undefined && (args.n_lines ?? Infinity) < -lineOffset; + const result = await this.readTail(readLines, request); + if (!result.isError && rereadsFile) { + const currentStat = await fs.stat(safePath); + if (!currentStat.isFile || currentStat.size !== stat.size || + currentStat.mtimeMs !== stat.mtimeMs || currentStat.ino !== stat.ino) { + return { isError: true, output: 'File changed while reading its tail. Retry Read with the updated file.' }; + } } - return await this.readForward( - args.path, - lines, - lineOffset, - effectiveLimit, - requestedLines, - detectedEncoding, - ); + return result; } catch (error) { if (isTextDecodeError(error)) { return { isError: true, output: notUtf8DecodableFileOutput(args.path) }; @@ -342,200 +333,250 @@ export class ReadTool implements IReadTool { } private async readForward( - displayPath: string, lines: AsyncIterable, - lineOffset: number, - effectiveLimit: number, - requestedLines: number, - detectedEncoding?: UtfTextEncoding, + request: ReadRequest, ): Promise { + const { args, maxChars } = request; + const lineOffset = args.line_offset ?? 1; + const columnOffset = args.column_offset ?? 0; + const requestedLines = args.n_lines ?? Infinity; const selectedEntries: ReadLineEntry[] = []; const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; let currentLineNo = 0; - let maxLinesReached = false; let collectionClosed = false; + let minimumChars = 0; for await (const rawLine of lines) { if (containsNulByte(rawLine)) { - return { isError: true, output: notReadableFileOutput(displayPath) }; + return { isError: true, output: notReadableFileOutput(args.path) }; } currentLineNo += 1; updateLineEndingFlags(flags, rawLine); - if (collectionClosed) { - if (effectiveLimit >= MAX_LINES && currentLineNo >= lineOffset) { - maxLinesReached = true; - } + if (collectionClosed) continue; + if (currentLineNo < lineOffset) continue; + if (selectedEntries.length >= requestedLines) { + collectionClosed = true; continue; } - if (currentLineNo < lineOffset) continue; - if (selectedEntries.length >= effectiveLimit) { - if (effectiveLimit >= MAX_LINES) { - maxLinesReached = true; - } + const rawContent = stripTrailingLf(rawLine); + const lineChars = String(currentLineNo).length + 1 + Math.max( + 0, + rawContent.length - (rawContent.endsWith('\r') ? 1 : 0) - + (currentLineNo === lineOffset ? columnOffset : 0), + ) + (selectedEntries.length === 0 ? 0 : 1); + if (minimumChars + lineChars > maxChars && selectedEntries.length > 0) { collectionClosed = true; continue; } selectedEntries.push({ lineNo: currentLineNo, - rawContent: stripTrailingLf(rawLine), + rawContent, }); - if (selectedEntries.length >= effectiveLimit) { + minimumChars += lineChars; + if (selectedEntries.length >= requestedLines || minimumChars >= maxChars) { collectionClosed = true; } } const lineEndingStyle = lineEndingStyleFromFlags(flags); - const rendered = renderEntries(selectedEntries, lineEndingStyle); - - return this.finishReadResult({ - renderedLines: rendered.renderedLines, - truncatedLineNumbers: rendered.truncatedLineNumbers, - maxLinesReached, - maxBytesReached: rendered.maxBytesReached, - lineEndingStyle, - startLine: selectedEntries.length > 0 ? lineOffset : 0, + const renderedLines = selectedEntries.map((entry) => renderLine(entry, lineEndingStyle)); + const firstLine = renderedLines[0]; + if (columnOffset > 0) { + const prefix = `${String(lineOffset)}\t`; + const text = firstLine?.slice(prefix.length); + if (text === undefined || columnOffset > text.length) { + return { isError: true, output: `column_offset=${String(columnOffset)} is past the end of the starting line ${String(lineOffset)}. Read the line from column 0 to inspect its current contents.` }; + } + if (splitsSurrogatePair(text, columnOffset)) { + return { isError: true, output: `column_offset=${String(columnOffset)} splits a Unicode character in line ${String(lineOffset)}. Use a character boundary or the Next Read arguments.` }; + } + renderedLines[0] = prefix + text.slice(columnOffset); + } + return this.finishPage({ + request, + renderedLines, + startLine: lineOffset, + rangeStart: lineOffset, + rangeEnd: Math.min(currentLineNo, lineOffset + requestedLines - 1), totalLines: currentLineNo, - requestedLines, - detectedEncoding, + fromTail: false, + lineEndingStyle, }); } + private finishPage(page: ReadPage): ExecutableToolResult { + const { args, maxChars, maxCharsLimit, eventLog, detectedEncoding, lossyDecoding } = page.request; + let first = 0; + let end = page.renderedLines.length; + let contentChars = page.renderedLines.reduce((sum, line) => sum + line.length + 1, -1); + const firstColumn = page.fromTail ? 0 : args.column_offset ?? 0; + const firstPrefix = `${String(page.startLine)}\t`; + const firstText = page.renderedLines[0]?.slice(firstPrefix.length) ?? ''; + let fragmentEnd: number | undefined; + + while (true) { + const count = end - first; + const startLine = page.startLine + first; + const endLine = page.startLine + end - 1; + const lineIncomplete = fragmentEnd !== undefined; + const complete = page.rangeStart > page.rangeEnd || + (count > 0 && startLine === page.rangeStart && endLine === page.rangeEnd && !lineIncomplete); + const parts = [ + count > 0 + ? `${String(count)} ${count === 1 ? 'line' : 'lines'} read from file starting from line ${String(startLine)}.` + : 'No lines read from file.', + `Total lines in file: ${String(page.totalLines)}.`, + complete ? 'Requested range complete.' : 'Character limit reached.', + `Effective max_chars: ${String(maxChars)}.`, + ]; + if (!lineIncomplete && ((count > 0 && endLine === page.totalLines) || page.rangeStart > page.totalLines)) { + parts.push('End of file reached.'); + } + if (count > 0 && !page.fromTail && (firstColumn > 0 || fragmentEnd !== undefined)) { + parts.push(`Line ${String(startLine)} fragment: columns [${String(firstColumn)}, ${String(firstColumn + (fragmentEnd ?? firstText.length))}) of ${String(firstColumn + firstText.length)}. ${lineIncomplete ? 'Line continues.' : 'Line complete.'}`); + } + if (args.max_chars !== undefined && args.max_chars > maxCharsLimit) { + parts.push(`Requested max_chars=${String(args.max_chars)} was capped at the configured maximum ${String(maxCharsLimit)}.`); + } + if (!complete && (count > 0 || page.fromTail)) { + const nextStart = page.fromTail ? page.rangeStart : lineIncomplete ? startLine : endLine + 1; + const nextEnd = page.fromTail && count > 0 ? startLine - 1 : page.rangeEnd; + const next = { + path: args.path, + line_offset: nextStart, + column_offset: fragmentEnd !== undefined ? firstColumn + fragmentEnd : undefined, + n_lines: page.fromTail || args.n_lines !== undefined ? nextEnd - nextStart + 1 : undefined, + max_chars: maxChars, + }; + parts.push(`Next Read: ${JSON.stringify(next)}`); + } + if (eventLog) { + parts.push('Pythinker Code agent event log: read one record at a time (n_lines=1); increase max_chars for a longer record or extract fields with Bash.'); + } + if (page.lineEndingStyle === 'mixed') { + parts.push('Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.'); + } + if (detectedEncoding !== undefined) { + parts.push(`Detected file encoding: ${encodingDisplayName(detectedEncoding)}; content transcoded to UTF-8 for display. Edit and Write expect UTF-8 — convert the file's encoding first (e.g. \`iconv\` via Bash).`); + } + if (lossyDecoding) { + parts.push('Lossy UTF-16 decoding: malformed sequences were replaced with U+FFFD. The decoded text may differ from the original file.'); + } + const note = `${parts.join(' ')}`; + const renderedChars = count === 0 + ? renderToolResultForModel({ output: '', note }).reduce( + (sum, part) => sum + (part.type === 'text' ? part.text.length : 0), + 0, + ) + : contentChars + 1 + note.length; + if (renderedChars <= maxChars && (complete || count > 0)) { + return { + output: fragmentEnd !== undefined + ? firstPrefix + firstText.slice(0, fragmentEnd) + : page.renderedLines.slice(first, end).join('\n'), + note, + truncated: complete ? undefined : true, + }; + } + if (count === 1 && !page.fromTail) { + const previousEnd = fragmentEnd ?? firstText.length; + fragmentEnd = Math.min(previousEnd - 1, previousEnd - (renderedChars - maxChars)); + if (splitsSurrogatePair(firstText, fragmentEnd)) fragmentEnd -= 1; + if (fragmentEnd <= 0) { + return { isError: true, output: `max_chars=${String(maxChars)} is too small for file text and the Read status. Increase max_chars.` }; + } + contentChars = firstPrefix.length + fragmentEnd; + continue; + } + if (count === 0) { + if (!complete) { + const recovery: ExecutableToolResult = { + isError: true, + output: 'No complete line fits. Continue with the forward Next Read.', + note, + truncated: true, + }; + const recoveryChars = renderToolResultForModel(recovery).reduce( + (sum, part) => sum + (part.type === 'text' ? part.text.length : 0), + 0, + ); + if (recoveryChars <= maxChars) return recovery; + } + return { isError: true, output: `max_chars=${String(maxChars)} is too small for the Read status. Increase max_chars.` }; + } + const dropped = page.fromTail ? first++ : --end; + contentChars -= page.renderedLines[dropped]!.length + 1; + } + } + private async readTail( - displayPath: string, - lines: AsyncIterable, - lineOffset: number, - effectiveLimit: number, - requestedLines: number, - detectedEncoding?: UtfTextEncoding, + readLines: () => AsyncIterable, + request: ReadRequest, ): Promise { - const tailCount = Math.abs(lineOffset); - const entries: ReadLineEntry[] = []; + const { args, maxChars } = request; + const lineOffset = args.line_offset ?? 1; + const requestedLines = args.n_lines ?? Infinity; + const tailCount = -lineOffset; + const singlePass = requestedLines >= tailCount; + let entries: (ReadTailEntry | undefined)[] = []; + let first = 0; + let chars = 0; + const retainLine = (rawLine: string, lineNo: number): void => { + const rawContent = stripTrailingLf(rawLine); + const minChars = String(lineNo).length + 2 + rawContent.length - (rawContent.endsWith('\r') ? 1 : 0); + entries.push({ lineNo, rawContent, minChars }); + chars += minChars; + while (first < entries.length && (chars - 1 > maxChars || entries.length - first > tailCount)) { + chars -= entries[first]!.minChars; + entries[first++] = undefined; + } + if (first > 1024 && first >= entries.length / 2) { + entries = entries.slice(first); + first = 0; + } + }; const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false }; - let currentLineNo = 0; - - for await (const rawLine of lines) { + let totalLines = 0; + for await (const rawLine of readLines()) { if (containsNulByte(rawLine)) { - return { isError: true, output: notReadableFileOutput(displayPath) }; + return { isError: true, output: notReadableFileOutput(args.path) }; } - currentLineNo += 1; + totalLines += 1; updateLineEndingFlags(flags, rawLine); - entries.push({ - lineNo: currentLineNo, - rawContent: stripTrailingLf(rawLine), - }); - if (entries.length > tailCount) { - entries.shift(); - } + if (singlePass) retainLine(rawLine, totalLines); } - return this.finishTailEntries({ - entries, - lineEndingFlags: flags, - effectiveLimit, - totalLines: currentLineNo, - requestedLines, - detectedEncoding, - }); - } - - private finishTailEntries(input: { - entries: readonly ReadLineEntry[]; - lineEndingFlags: LineEndingFlags; - effectiveLimit: number; - totalLines: number; - requestedLines: number; - detectedEncoding?: UtfTextEncoding; - }): ExecutableToolResult { - const lineEndingStyle = lineEndingStyleFromFlags(input.lineEndingFlags); - let renderedCandidates = input.entries.slice(0, input.effectiveLimit).map((entry) => { - return { entry, rendered: renderLine(entry, lineEndingStyle) }; - }); - - let totalBytes = 0; - for (const [index, candidate] of renderedCandidates.entries()) { - totalBytes += renderedLineBytes(candidate.rendered.line, index === 0); - } - - let maxBytesReached = false; - if (totalBytes > MAX_BYTES) { - maxBytesReached = true; - const kept: typeof renderedCandidates = []; - let bytes = 0; - for (let i = renderedCandidates.length - 1; i >= 0; i -= 1) { - const candidate = renderedCandidates[i]; - if (candidate === undefined) continue; - const lineBytes = renderedLineBytes(candidate.rendered.line, kept.length === 0); - if (bytes + lineBytes > MAX_BYTES) break; - kept.unshift(candidate); - bytes += lineBytes; + const rangeStart = Math.max(1, totalLines + lineOffset + 1); + const rangeEnd = Math.min(totalLines, rangeStart + requestedLines - 1); + const lineEndingStyle = lineEndingStyleFromFlags(flags); + if (!singlePass) { + let currentLine = 0; + for await (const rawLine of readLines()) { + currentLine += 1; + if (currentLine > rangeEnd) break; + if (currentLine < rangeStart) continue; + if (containsNulByte(rawLine)) { + return { isError: true, output: notReadableFileOutput(args.path) }; + } + retainLine(rawLine, currentLine); } - renderedCandidates = kept; - } - - const renderedLines: string[] = []; - const truncatedLineNumbers: number[] = []; - for (const candidate of renderedCandidates) { - renderedLines.push(candidate.rendered.line); - if (candidate.rendered.wasTruncated) { - truncatedLineNumbers.push(candidate.entry.lineNo); + if (currentLine < rangeEnd || currentLine > totalLines) { + return { isError: true, output: 'File changed while reading its tail. Retry Read with the updated file.' }; } } - - return this.finishReadResult({ - renderedLines, - truncatedLineNumbers, - maxLinesReached: false, - maxBytesReached, + const selected = entries.slice(first).map((entry) => renderLine(entry!, lineEndingStyle)); + return this.finishPage({ + request, + renderedLines: selected, + startLine: rangeEnd - selected.length + 1, + rangeStart, + rangeEnd, + totalLines, + fromTail: true, lineEndingStyle, - startLine: renderedCandidates[0]?.entry.lineNo ?? 0, - totalLines: input.totalLines, - requestedLines: input.requestedLines, - detectedEncoding: input.detectedEncoding, }); } - private finishReadResult(input: FinishReadResultInput): ExecutableToolResult { - return { - output: input.renderedLines.join('\n'), - note: `${this.finishMessage(input)}`, - }; - } - - private finishMessage(input: FinishReadResultInput): string { - const lineCount = input.renderedLines.length; - const lineWord = lineCount === 1 ? 'line' : 'lines'; - const parts = - lineCount > 0 - ? [ - `${String(lineCount)} ${lineWord} read from file starting from line ${String(input.startLine)}.`, - ] - : ['No lines read from file.']; - - parts.push(`Total lines in file: ${String(input.totalLines)}.`); - if (input.maxLinesReached) { - parts.push(`Max ${String(MAX_LINES)} lines reached.`); - } else if (input.maxBytesReached) { - parts.push(`Max ${String(MAX_BYTES)} bytes reached.`); - } else if (lineCount < input.requestedLines) { - parts.push('End of file reached.'); - } - if (input.truncatedLineNumbers.length > 0) { - parts.push( - `Lines [${input.truncatedLineNumbers.join(', ')}] were truncated to ${String(MAX_LINE_LENGTH)} characters; use Bash (e.g. cut or sed) to read the elided content of those lines.`, - ); - } - if (input.lineEndingStyle === 'mixed') { - parts.push( - 'Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.', - ); - } - if (input.detectedEncoding !== undefined) { - parts.push( - `Detected file encoding: ${encodingDisplayName(input.detectedEncoding)}; content transcoded to UTF-8 for display. Edit and Write expect UTF-8 — convert the file's encoding first (e.g. \`iconv\` via Bash).`, - ); - } - return parts.join(' '); - } } registerAgentToolService(IReadTool, ReadTool, { diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 6ff7be2ba..8cd97f2a6 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -28,6 +28,7 @@ export interface AgentProfileContext { readonly pluginSections?: string; readonly productName?: string; readonly replyStyleGuide?: string; + readonly notifyUserActive?: boolean; readonly [key: string]: unknown; } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts index 3efe25feb..38cdf7949 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts @@ -109,6 +109,20 @@ export const DEFAULT_PRODUCT_NAME = 'Pythinker Code CLI'; export const DEFAULT_REPLY_STYLE_GUIDE = "Your text replies render as Markdown in the user's terminal. Keep structure light and shallow — deep nesting, large tables, and heavy headings read poorly there. Cite code locations as `path/to/file.ts:42` so the user can navigate to them. Do not use emoji unless the user does first or asks for it."; +export const NOTIFY_USER_GUIDANCE = + 'When `NotifyUser` is available, use it proactively to keep the end user informed while you work. For a multi-step task, send an early update describing your approach, then report meaningful findings, phase conclusions, long waits, and blockers. Keep each update to one or two sentences in the end user\'s language; avoid repeating unchanged status. The UI adds the source label automatically. If you are working as a subagent, report only your own subtask\'s progress, do not present its completion as completion of the whole task, and do not ask the end user questions or request decisions. Updates do not automatically reach your parent agent: include every important finding in your final handoff. Updates remain visible until the main agent starts its next turn, so your final reply must still stand on its own.'; + +export function renderAgentProfilePrompt( + profile: AgentProfile, + context: AgentProfileContext, +): SystemPromptRenderResult { + const rendered = profile.renderSystemPrompt(context); + if (context.notifyUserActive !== true || rendered.text.includes(NOTIFY_USER_GUIDANCE)) { + return rendered; + } + return { ...rendered, text: `${rendered.text}\n\n${NOTIFY_USER_GUIDANCE}` }; +} + const ADDITIONAL_DIRS_SECTION_PROSE = 'The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope.'; @@ -135,6 +149,7 @@ export function systemPromptVars( role_additional: '', product_name: context.productName ?? DEFAULT_PRODUCT_NAME, reply_style_guide: context.replyStyleGuide ?? DEFAULT_REPLY_STYLE_GUIDE, + notify_user_guidance: context.notifyUserActive === true ? ` ${NOTIFY_USER_GUIDANCE}` : '', os: context.osKind ?? '', windows_notes: context.osKind === 'Windows' ? `\n\n${WINDOWS_NOTES}\n\n` : '', shell: shellName.length > 0 ? `${shellName} (\`${shellPath}\`)` : '', diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md index 8d8c65075..fba00f823 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md @@ -10,7 +10,7 @@ Match the user's language. ${reply_style_guide} -Text between tool calls may not be shown to the user, so keep it to brief status notes. Everything the user needs from this turn — answers, findings, deliverables — must appear in your final message, which should stand on its own. +Text between tool calls may not be shown to the user, so keep it to brief status notes.${notify_user_guidance} Everything the user needs from this turn — answers, findings, deliverables — must appear in your final message, which should stand on its own. In your final answer, focus on the most important information. Use structure — headings, lists, tables — only when the content calls for it, and keep explanations as brief as the subject allows. Prefer plain language over jargon: spell out terms the reader may not know. diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index b66e075c6..7d1348b54 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -15,6 +15,8 @@ import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageSe import { FileSkillDiscovery } from '#/features/skill/catalog/fileSkillDiscovery'; import { ISkillDiscovery } from '#/features/skill/catalog/skillDiscovery'; +export type HostUiCapability = 'update_panel'; + export interface HostArgs { readonly agentFiles?: readonly string[]; readonly skillDirs?: readonly string[]; @@ -22,6 +24,7 @@ export interface HostArgs { readonly displayName?: string; readonly replyStyleGuide?: string; readonly nonInteractive?: boolean; + readonly uiCapabilities?: readonly HostUiCapability[]; } export interface HostArgsInput { @@ -31,6 +34,7 @@ export interface HostArgsInput { readonly displayName?: string; readonly replyStyleGuide?: string; readonly nonInteractive?: boolean; + readonly uiCapabilities?: readonly HostUiCapability[]; } export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs { @@ -41,6 +45,7 @@ export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs { displayName: input?.displayName, replyStyleGuide: input?.replyStyleGuide, nonInteractive: input?.nonInteractive, + uiCapabilities: input?.uiCapabilities, }; } diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index fad95c806..f232ad472 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -1,4 +1,4 @@ -import { join, resolve } from 'pathe'; +import { join, relative, resolve } from 'pathe'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import type { ISessionScopeHandle } from '#/_base/di/scope'; @@ -14,6 +14,7 @@ import { workspacePersistenceScope, } from '#/workspace/sessionLifecycle/internal/addressing'; import { ErrorCodes, Error2 } from '#/errors'; +import { FILE_HISTORY_BLOB_PREFIX } from '#/features/fileHistory/fileHistory'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -202,9 +203,11 @@ export async function exportSessionDirectory(input: { const sessionScan = await scanSessionWire(sessionDir, input.signal); const stableSessionLog = sessionLogSource; - const selectedSessionFiles: SessionZipEntry[] = sessionFiles.filter( - (file) => file !== sessionLogPath, - ); + const selectedSessionFiles: SessionZipEntry[] = sessionFiles.filter((file) => { + if (file === sessionLogPath) return false; + const parts = relative(sessionDir, file).split(/[\\/]/); + return parts[0] !== FILE_HISTORY_BLOB_PREFIX && parts[0] !== 'notify'; + }); if (stableSessionLog !== undefined) { selectedSessionFiles.push({ path: sessionLogPath, source: stableSessionLog }); selectedSessionFiles.sort((left, right) => diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts new file mode 100644 index 000000000..f38289ce7 --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistory.ts @@ -0,0 +1,60 @@ +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface FileBackupEntry { + readonly key: string | null; + readonly version: number; + readonly contentHash?: string; + readonly size?: number; + readonly oversize?: boolean; + readonly mtimeMs?: number; +} + +export type FileHistoryCheckpointPhase = 'start' | 'end'; + +export interface FileHistoryCheckpointRecord { + readonly turnId: number; + readonly phase?: FileHistoryCheckpointPhase; + readonly entries: Readonly>; +} + +export interface FileHistoryState { + readonly checkpoints: readonly FileHistoryCheckpointRecord[]; + readonly tracked: readonly string[]; +} + +export type FileHistoryChangeStatus = 'added' | 'modified' | 'deleted'; + +export interface FileHistoryChange { + readonly path: string; + readonly status: FileHistoryChangeStatus; + readonly additions: number; + readonly deletions: number; + readonly binary?: boolean; + readonly oversize?: boolean; +} + +export interface FileHistoryContent { + readonly version: number; + readonly content?: string; + readonly binary?: boolean; +} + +export interface IAgentFileHistoryService { + readonly _serviceBrand: undefined; + + history(): FileHistoryState; + settled(): Promise; + captureForActiveTurn(path: string): Promise; + changes(turnId: number): Promise; + turnRecorded(turnId: number): Promise; + contentAt( + turnId: number, + path: string, + phase?: FileHistoryCheckpointPhase, + ): Promise; +} + +export const IAgentFileHistoryService: ServiceIdentifier = + createDecorator('agentFileHistoryService'); + +export const FILE_HISTORY_BLOB_PREFIX = 'file-history'; diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts new file mode 100644 index 000000000..2b7c840f1 --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryFeature.ts @@ -0,0 +1,16 @@ +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { IAgentFileHistoryService } from './fileHistory'; +import { AgentFileHistoryService } from './fileHistoryService'; + +export class FileHistoryFeature extends Feature { + static override readonly name = 'fileHistory'; + + constructor() { + super(); + this.contributeAgentService(IAgentFileHistoryService, AgentFileHistoryService); + } +} + +registerFeature(FileHistoryFeature); diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts new file mode 100644 index 000000000..6b1ef3545 --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryOps.ts @@ -0,0 +1,133 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; +import { defineState } from '#/state/state'; + +import type { + FileBackupEntry, + FileHistoryCheckpointPhase, + FileHistoryState, +} from './fileHistory'; + +export const FILE_HISTORY_TURN_WINDOW = 5; +export const FILE_HISTORY_RECORD_PREFIX = 'file_history.'; + +const backupEntrySchema = z.object({ + key: z.string().nullable(), + version: z.number(), + contentHash: z.string().optional(), + size: z.number().optional(), + oversize: z.boolean().optional(), + mtimeMs: z.number().optional(), +}); + +const fileHistoryTrackedSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + path: z.string(), + entry: backupEntrySchema, +}); + +export class FileHistoryTracked extends AgentEvent2> { + static override readonly type = 'file_history.tracked'; + static override readonly durable = true; + static override readonly schema = fileHistoryTrackedSchema; +} +export interface FileHistoryTracked { + readonly agentId: string; + readonly turnId: number; + readonly path: string; + readonly entry: FileBackupEntry; +} + +const fileHistoryCheckpointedSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + phase: z.enum(['start', 'end']).optional(), + entries: z.record(z.string(), backupEntrySchema), +}); + +export class FileHistoryCheckpointed extends AgentEvent2< + z.infer +> { + static override readonly type = 'file_history.checkpoint'; + static override readonly durable = true; + static override readonly schema = fileHistoryCheckpointedSchema; +} +export interface FileHistoryCheckpointed { + readonly agentId: string; + readonly turnId: number; + readonly phase?: FileHistoryCheckpointPhase; + readonly entries: Readonly>; +} + +export function checkpointPhaseOf(record: { + readonly phase?: FileHistoryCheckpointPhase; +}): FileHistoryCheckpointPhase { + return record.phase ?? 'start'; +} + +export function displacedCheckpoints< + T extends { readonly turnId: number; readonly phase?: FileHistoryCheckpointPhase }, +>(checkpoints: readonly T[], completingTurnId?: number): readonly T[] { + const completedIds = [ + ...new Set([ + ...checkpoints.filter((c) => checkpointPhaseOf(c) === 'end').map((c) => c.turnId), + ...(completingTurnId === undefined ? [] : [completingTurnId]), + ]), + ].toSorted((a, b) => b - a); + if (completedIds.length <= FILE_HISTORY_TURN_WINDOW) return []; + const keep = new Set(completedIds.slice(0, FILE_HISTORY_TURN_WINDOW)); + return checkpoints.filter((c) => !keep.has(c.turnId) && c.turnId <= completedIds[0]!); +} + +function cloneEntries( + entries: Readonly>, +): Record { + const clone: Record = Object.create(null) as Record< + string, + FileBackupEntry + >; + for (const [path, entry] of Object.entries(entries)) clone[path] = entry; + return clone; +} + +export const fileHistoryKey = defineState( + 'fileHistory', + (): FileHistoryState => ({ checkpoints: [], tracked: [] }), +) + .replayable({ schema: z.custom() }) + .on(FileHistoryCheckpointed, (s, e) => { + const phase = checkpointPhaseOf(e); + const existing = s.checkpoints.find( + (c) => c.turnId === e.turnId && checkpointPhaseOf(c) === phase, + ); + if (existing !== undefined) { + existing.entries = cloneEntries(e.entries); + return; + } + s.checkpoints.push({ turnId: e.turnId, phase, entries: cloneEntries(e.entries) }); + const displaced = new Set(displacedCheckpoints(s.checkpoints)); + if (displaced.size > 0) { + s.checkpoints = s.checkpoints.filter((c) => !displaced.has(c)); + const kept = new Set(); + for (const checkpoint of s.checkpoints) { + for (const path of Object.keys(checkpoint.entries)) kept.add(path); + } + s.tracked = s.tracked.filter((path) => kept.has(path)); + } + }) + .on(FileHistoryTracked, (s, e) => { + if (!s.tracked.includes(e.path)) s.tracked.push(e.path); + let checkpoint = s.checkpoints.find( + (c) => c.turnId === e.turnId && checkpointPhaseOf(c) === 'start', + ); + if (checkpoint === undefined) { + s.checkpoints.push({ turnId: e.turnId, phase: 'start', entries: {} }); + checkpoint = s.checkpoints.at(-1); + } + if (checkpoint !== undefined && !Object.hasOwn(checkpoint.entries, e.path)) { + checkpoint.entries[e.path] = { ...e.entry }; + } + }); diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryRetention.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryRetention.ts new file mode 100644 index 000000000..36b1bd478 --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryRetention.ts @@ -0,0 +1,127 @@ +import { dirname, join } from 'pathe'; + +import { unwrapErrorCause } from '#/_base/errors/errors'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; + +import { FILE_HISTORY_BLOB_PREFIX } from './fileHistory'; + +export const FILE_HISTORY_SESSION_WINDOW = 30; + +const RETENTION_DOC_SCOPE = 'file-history'; + +interface RetentionEntry { + readonly id: string; + readonly touchedAt: number; +} + +interface RetentionDoc { + readonly sessions: readonly RetentionEntry[]; +} + +export interface FileHistoryRetentionInput { + readonly docs: IAtomicDocumentStore; + readonly hostFs: IHostFileSystem; + readonly workspaceId: string; + readonly sessionDir: string; + readonly sessionId: string; +} + +const touchQueues = new Map>(); + +export function touchFileHistorySession(input: FileHistoryRetentionInput): Promise { + const previous = touchQueues.get(input.workspaceId) ?? Promise.resolve(); + const run = previous.then(() => applyTouch(input)).catch(onUnexpectedError); + touchQueues.set(input.workspaceId, run); + return run; +} + +async function applyTouch(input: FileHistoryRetentionInput): Promise { + const doc = + (await input.docs.get(RETENTION_DOC_SCOPE, input.workspaceId)) ?? + { sessions: [] }; + const sessions = doc.sessions.filter((entry) => entry.id !== input.sessionId); + sessions.push({ id: input.sessionId, touchedAt: Date.now() }); + sessions.sort((a, b) => a.touchedAt - b.touchedAt); + const evicted = sessions.splice(0, Math.max(0, sessions.length - FILE_HISTORY_SESSION_WINDOW)); + const sessionsDir = dirname(input.sessionDir); + const stuck: RetentionEntry[] = []; + for (const victim of evicted) { + const removed = await removeSessionBlobs(input.hostFs, join(sessionsDir, victim.id, 'agents')); + if (!removed) stuck.push(victim); + } + if (stuck.length > 0) sessions.unshift(...stuck); + await input.docs.set(RETENTION_DOC_SCOPE, input.workspaceId, { sessions }); +} + +function isMissingPathError(error: unknown): boolean { + const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; + return code === 'ENOENT'; +} + +async function removeSessionBlobs(hostFs: IHostFileSystem, agentsDir: string): Promise { + let agentNames: readonly string[]; + try { + const entries = await hostFs.readdir(agentsDir); + agentNames = entries.filter((entry) => entry.isDirectory).map((entry) => entry.name); + } catch (error) { + if (isMissingPathError(error)) return true; + onUnexpectedError(error); + return false; + } + let removed = true; + for (const name of agentNames) { + try { + await hostFs.remove(join(agentsDir, name, FILE_HISTORY_BLOB_PREFIX)); + } catch (error) { + if (isMissingPathError(error)) continue; + onUnexpectedError(error); + removed = false; + } + } + return removed; +} + +export function dropFileHistorySession(input: { + readonly docs: IAtomicDocumentStore; + readonly workspaceId: string; + readonly sessionId: string; +}): Promise { + const previous = touchQueues.get(input.workspaceId) ?? Promise.resolve(); + const run = previous + .then(async () => { + const doc = await input.docs.get(RETENTION_DOC_SCOPE, input.workspaceId); + if (doc === undefined) return; + const sessions = doc.sessions.filter((entry) => entry.id !== input.sessionId); + if (sessions.length === doc.sessions.length) return; + await input.docs.set(RETENTION_DOC_SCOPE, input.workspaceId, { sessions }); + }) + .catch(onUnexpectedError); + touchQueues.set(input.workspaceId, run); + return run; +} + +export async function touchForkedFileHistory(input: FileHistoryRetentionInput): Promise { + const agentsDir = join(input.sessionDir, 'agents'); + let agentNames: readonly string[]; + try { + const entries = await input.hostFs.readdir(agentsDir); + agentNames = entries.filter((entry) => entry.isDirectory).map((entry) => entry.name); + } catch (error) { + if (isMissingPathError(error)) return; + throw error; + } + for (const name of agentNames) { + try { + const blobs = await input.hostFs.readdir(join(agentsDir, name, FILE_HISTORY_BLOB_PREFIX)); + if (blobs.length > 0) { + await touchFileHistorySession(input); + return; + } + } catch (error) { + if (isMissingPathError(error)) continue; + throw error; + } + } +} diff --git a/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts new file mode 100644 index 000000000..6f1ac7eed --- /dev/null +++ b/packages/agent-core-v2/src/features/fileHistory/fileHistoryService.ts @@ -0,0 +1,688 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute, relative, resolve } from 'pathe'; + +import { Service } from '#/_base/di/service'; +import { unwrapErrorCause } from '#/_base/errors/errors'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { WillExecuteToolEvent } from '#/agent/toolExecutor/toolHooks'; +import { TurnStarted } from '#/agent/loop/turnEvents'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { TurnEnded } from '#/agent/loop/turnOps'; +import { IEventBus } from '#/app/event/eventBus'; +import { IBlobStore } from '#/persistence/interface/blobStore'; +import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IEventDispatcher } from '#/state/eventDispatcher'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +import { + IAgentFileHistoryService, + FILE_HISTORY_BLOB_PREFIX, + type FileBackupEntry, + type FileHistoryChange, + type FileHistoryCheckpointPhase, + type FileHistoryCheckpointRecord, + type FileHistoryContent, + type FileHistoryState, +} from './fileHistory'; +import { + displacedCheckpoints, + FileHistoryCheckpointed, + FileHistoryTracked, + checkpointPhaseOf, + fileHistoryKey, +} from './fileHistoryOps'; +import { touchFileHistorySession } from './fileHistoryRetention'; + +export const FILE_HISTORY_MAX_FILE_BYTES = 4 * 1024 * 1024; +export { FILE_HISTORY_BLOB_PREFIX } from './fileHistory'; + +export class AgentFileHistoryService extends Service implements IAgentFileHistoryService { + declare readonly _serviceBrand: undefined; + + private queue: Promise = Promise.resolve(); + private activeTurnId: number | undefined; + private orphanSweepDone = false; + + constructor( + @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, + @IAgentStateService private readonly agentState: IAgentStateService, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IEventBus eventBus: IEventBus, + @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, + @IBlobStore private readonly blobs: IBlobStore, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + @ISessionContext private readonly sessionCtx: ISessionContext, + @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, + @IHostFileSystem private readonly hostFs: IHostFileSystem, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + ) { + super(); + this.agentState.contributeState(fileHistoryKey); + if (this.agentCtx.agentId !== MAIN_AGENT_ID) { + this._register( + toolExecutor.onWillExecuteTool((event) => this.onSubagentWillExecuteTool(event)), + ); + return; + } + + this._register( + toolExecutor.onWillExecuteTool((event) => this.onWillExecuteTool(event)), + ); + this._register( + eventBus.subscribe(TurnStarted, (event) => { + if (event.agentId !== this.agentCtx.agentId) return; + this.activeTurnId = event.turnId; + }), + ); + this._register( + eventBus.subscribe(TurnEnded, (event) => { + if (event.agentId !== this.agentCtx.agentId) return; + if (this.activeTurnId === event.turnId) this.activeTurnId = undefined; + void this.enqueue(() => this.endCheckpoint(event.turnId)); + }), + ); + this.effect(() => () => this.queue, 'fileHistory:drain'); + } + + history(): FileHistoryState { + return this.agentState.get(fileHistoryKey); + } + + settled(): Promise { + return this.queue; + } + + changes(turnId: number): Promise { + return this.enqueueValue(() => this.readChanges(turnId)); + } + + turnRecorded(turnId: number): Promise { + return this.enqueueValue(async () => { + const state = this.history(); + const index = state.checkpoints.findIndex( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', + ); + if (index < 0) return false; + const end = state.checkpoints.find( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'end', + ); + const live = + end === undefined && + index === state.checkpoints.length - 1 && + this.activeTurnId === turnId; + if (end === undefined && !live) return false; + const keys = new Set(); + for (const entry of [ + ...Object.values(state.checkpoints[index]!.entries), + ...Object.values(end?.entries ?? {}), + ]) { + if (entry.key !== null) keys.add(entry.key); + } + for (const key of keys) { + if (!(await this.blobs.has(this.agentCtx.scope(), key))) return false; + } + return true; + }); + } + + private async readChanges(turnId: number): Promise { + const state = this.history(); + const index = state.checkpoints.findIndex( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', + ); + if (index < 0) return []; + const end = state.checkpoints.find( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'end', + ); + const live = + end === undefined && + index === state.checkpoints.length - 1 && + this.activeTurnId === turnId; + if (end === undefined && !live) return []; + + const start = state.checkpoints[index]!; + const paths = Object.keys(end !== undefined ? end.entries : start.entries); + + const changes: FileHistoryChange[] = []; + const lcsBudget = { remaining: LCS_AGGREGATE_CELL_BUDGET }; + for (const path of paths.toSorted()) { + const before = Object.hasOwn(start.entries, path) ? start.entries[path] : undefined; + const after = end !== undefined ? end.entries[path] : undefined; + if (end !== undefined && before?.version === after?.version) continue; + if (before?.oversize === true && after?.oversize === true) { + if (before.version !== after.version) { + changes.push({ path, status: 'modified', additions: 0, deletions: 0, oversize: true }); + } + continue; + } + const beforeMissing = before === undefined || (before.key === null && before.oversize !== true); + let liveOversize: { size: number; mtimeMs?: number } | undefined; + let liveMissing = false; + let afterBytes: Uint8Array | undefined; + if (end !== undefined) { + if (before?.oversize !== true && after?.oversize !== true) { + afterBytes = await this.entryBytes(after); + } + } else { + const current = await this.readCurrent(path); + if (current === 'unreadable') continue; + if (current instanceof Uint8Array) afterBytes = current; + else if (current === 'missing') liveMissing = true; + else liveOversize = { size: current.oversizeBytes, mtimeMs: current.mtimeMs }; + } + const afterMissing = + end !== undefined + ? after === undefined || (after.key === null && after.oversize !== true) + : liveMissing; + if (before?.oversize === true || after?.oversize === true || liveOversize !== undefined) { + if ( + before?.oversize === true && + liveOversize !== undefined && + before.size === liveOversize.size && + before.mtimeMs === liveOversize.mtimeMs + ) { + continue; + } + const status = beforeMissing ? 'added' : afterMissing ? 'deleted' : 'modified'; + changes.push({ path, status, additions: 0, deletions: 0, oversize: true }); + continue; + } + const beforeBytes = await this.entryBytes(before); + const beforeLost = + before !== undefined && before.key !== null && beforeBytes === undefined; + const afterLost = + end !== undefined && after !== undefined && after.key !== null && afterBytes === undefined; + if (beforeLost || afterLost) { + const status = beforeMissing ? 'added' : afterMissing ? 'deleted' : 'modified'; + changes.push({ path, status, additions: 0, deletions: 0, binary: true }); + continue; + } + const change = diffChange(path, beforeBytes, afterBytes, lcsBudget); + if (change !== undefined) changes.push(change); + } + return changes; + } + + contentAt( + turnId: number, + path: string, + phase: FileHistoryCheckpointPhase = 'start', + ): Promise { + return this.enqueueValue(() => this.readContentAt(turnId, path, phase)); + } + + private async readContentAt( + turnId: number, + path: string, + phase: FileHistoryCheckpointPhase, + ): Promise { + const state = this.history(); + const index = state.checkpoints.findIndex( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === phase, + ); + if (index < 0) return undefined; + const record = state.checkpoints[index]!; + const pathKey = this.pathKey(path); + let entry = Object.hasOwn(record.entries, pathKey) ? record.entries[pathKey] : undefined; + if (entry === undefined && phase === 'end') { + const start = state.checkpoints.find( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', + ); + entry = + start !== undefined && Object.hasOwn(start.entries, pathKey) + ? start.entries[pathKey] + : undefined; + } + if (entry === undefined || entry.oversize === true) return undefined; + if (entry.key === null) return { version: entry.version }; + const bytes = await this.blobs.get(this.agentCtx.scope(), entry.key); + if (bytes === undefined) return undefined; + const content = decodeText(bytes); + if (content === undefined) return { version: entry.version, binary: true }; + return { version: entry.version, content }; + } + + private onWillExecuteTool(event: WillExecuteToolEvent): void { + const path = editTargetPath(event.execution.display); + if (path === undefined) return; + event.waitUntil(this.enqueue(() => this.capture(path, event.turnId))); + } + + private onSubagentWillExecuteTool(event: WillExecuteToolEvent): void { + const path = editTargetPath(event.execution.display); + if (path === undefined) return; + const main = this.agentLifecycle.handleOf(MAIN_AGENT_ID); + if (main === undefined) return; + event.waitUntil(main.accessor.get(IAgentFileHistoryService).captureForActiveTurn(path)); + } + + captureForActiveTurn(path: string): Promise { + const turnId = this.activeTurnId; + if (turnId === undefined) return Promise.resolve(); + return this.enqueue(() => this.capture(path, turnId)); + } + + private enqueue(op: () => Promise): Promise { + return this.enqueueValue(op); + } + + private enqueueValue(op: () => Promise): Promise { + const run = this.queue.then(op); + this.queue = run.then( + () => undefined, + (error) => { + onUnexpectedError(error); + }, + ); + return run; + } + + private async capture(path: string, turnId: number): Promise { + const pathKey = this.pathKey(path); + const state = this.history(); + const startCheckpoint = state.checkpoints.find( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', + ); + if (startCheckpoint !== undefined && Object.hasOwn(startCheckpoint.entries, pathKey)) return; + + const current = await this.readCurrent(pathKey); + if (current === 'unreadable') return; + const latest = latestEntry(state.checkpoints, pathKey); + const nextVersion = maxVersion(state.checkpoints, pathKey) + 1; + let entry: FileBackupEntry; + if (current === 'missing') { + entry = + latest !== undefined && latest.key === null && latest.oversize !== true + ? { ...latest } + : { key: null, version: nextVersion }; + } else if (current instanceof Uint8Array) { + const contentHash = sha256(current); + entry = + latest !== undefined && latest.contentHash === contentHash + ? { ...latest } + : await this.backup(pathKey, nextVersion, current, contentHash); + } else { + entry = + latest?.oversize === true && + latest.size === current.oversizeBytes && + latest.mtimeMs === current.mtimeMs + ? { ...latest } + : { + key: null, + version: nextVersion, + oversize: true, + size: current.oversizeBytes, + mtimeMs: current.mtimeMs, + }; + } + await this.dispatcher.dispatch( + new FileHistoryTracked({ agentId: this.agentCtx.agentId, turnId, path: pathKey, entry }), + ); + } + + private async endCheckpoint(turnId: number): Promise { + const state = this.history(); + if (state.checkpoints.some((c) => c.turnId === turnId && checkpointPhaseOf(c) === 'end')) { + return; + } + const start = state.checkpoints.find( + (c) => c.turnId === turnId && checkpointPhaseOf(c) === 'start', + ); + if (start === undefined) return; + await this.sweepOrphanBlobs(); + void touchFileHistorySession({ + docs: this.docs, + hostFs: this.hostFs, + workspaceId: this.sessionCtx.workspaceId, + sessionDir: this.sessionCtx.sessionDir, + sessionId: this.sessionCtx.sessionId, + }); + + const entries: Record = Object.create(null) as Record< + string, + FileBackupEntry + >; + for (const [pathKey, before] of Object.entries(start.entries)) { + const nextVersion = maxVersion(state.checkpoints, pathKey) + 1; + const current = await this.readCurrent(pathKey); + if (current === 'unreadable') continue; + if (current === 'missing') { + if (before.key !== null || before.oversize === true) { + entries[pathKey] = { key: null, version: nextVersion }; + } + continue; + } + if (!(current instanceof Uint8Array)) { + if ( + before.oversize !== true || + before.size !== current.oversizeBytes || + before.mtimeMs !== current.mtimeMs + ) { + entries[pathKey] = { + key: null, + version: nextVersion, + oversize: true, + size: current.oversizeBytes, + mtimeMs: current.mtimeMs, + }; + } + continue; + } + const contentHash = sha256(current); + if (before.contentHash === contentHash) continue; + entries[pathKey] = await this.backup(pathKey, nextVersion, current, contentHash); + } + + const evictable = displacedCheckpoints(state.checkpoints, turnId); + await this.dispatcher.dispatch( + new FileHistoryCheckpointed({ agentId: this.agentCtx.agentId, turnId, phase: 'end', entries }), + ); + if (evictable.length > 0) { + await this.dispatcher.flush(); + await this.evictBlobs(evictable, this.history().checkpoints); + } + } + + private async backup( + pathKey: string, + version: number, + content: Uint8Array, + contentHash?: string, + ): Promise { + const hash = contentHash ?? sha256(content); + const key = blobKey(pathKey, version); + await this.blobs.put(this.agentCtx.scope(), key, content); + return { key, version, contentHash: hash, size: content.byteLength }; + } + + private async sweepOrphanBlobs(): Promise { + if (this.orphanSweepDone) return; + this.orphanSweepDone = true; + const referenced = new Set(); + for (const checkpoint of this.history().checkpoints) { + for (const entry of Object.values(checkpoint.entries)) { + if (entry.key !== null) referenced.add(entry.key); + } + } + let names: readonly string[]; + try { + names = await this.blobs.list(`${this.agentCtx.scope()}/${FILE_HISTORY_BLOB_PREFIX}`); + } catch (error) { + onUnexpectedError(error); + return; + } + for (const name of names) { + const key = `${FILE_HISTORY_BLOB_PREFIX}/${name}`; + if (referenced.has(key)) continue; + try { + await this.blobs.delete(this.agentCtx.scope(), key); + } catch (error) { + onUnexpectedError(error); + } + } + } + + private async evictBlobs( + evicted: readonly FileHistoryCheckpointRecord[], + retained: readonly FileHistoryCheckpointRecord[], + ): Promise { + if (evicted.length === 0) return; + const retainedKeys = new Set(); + for (const checkpoint of retained) { + for (const entry of Object.values(checkpoint.entries)) { + if (entry.key !== null) retainedKeys.add(entry.key); + } + } + for (const checkpoint of evicted) { + for (const entry of Object.values(checkpoint.entries)) { + if (entry.key === null || retainedKeys.has(entry.key)) continue; + try { + await this.blobs.delete(this.agentCtx.scope(), entry.key); + } catch (error) { + onUnexpectedError(error); + } + } + } + } + + private async entryBytes(entry: FileBackupEntry | undefined): Promise { + if (entry === undefined || entry.key === null) return undefined; + return this.blobs.get(this.agentCtx.scope(), entry.key); + } + + private async readCurrent( + pathKey: string, + ): Promise< + Uint8Array | 'missing' | 'unreadable' | { oversizeBytes: number; mtimeMs?: number } + > { + const absolute = isAbsolute(pathKey) ? pathKey : resolve(this.workspaceCtx.workDir, pathKey); + const lease = this.runtime.acquire(['fs']); + try { + const fs = lease.runtime.fs; + if (fs === undefined) return 'unreadable'; + let info; + try { + info = await fs.stat(absolute); + } catch (error) { + const code = (unwrapErrorCause(error) as { code?: unknown } | null)?.code; + return code === 'ENOENT' ? 'missing' : 'unreadable'; + } + if (!info.isFile) return 'unreadable'; + if (info.size > FILE_HISTORY_MAX_FILE_BYTES) { + return { oversizeBytes: info.size, mtimeMs: info.mtimeMs }; + } + try { + const bytes = await fs.readBytes(absolute, info.size + 1); + if (bytes.byteLength > FILE_HISTORY_MAX_FILE_BYTES) { + const grown = await fs.stat(absolute).catch(() => undefined); + return { + oversizeBytes: grown?.size ?? bytes.byteLength, + mtimeMs: grown?.mtimeMs, + }; + } + if (bytes.byteLength !== info.size) return 'unreadable'; + return bytes; + } catch { + return 'unreadable'; + } + } finally { + lease.dispose(); + } + } + + private pathKey(path: string): string { + let raw = path; + if (isAbsolute(path)) { + const relativePath = relative(this.workspaceCtx.workDir, path); + if (relativePath !== '' && relativePath !== '..' && !relativePath.startsWith('../')) { + raw = relativePath; + } + } + const key = this.comparisonKey(raw); + const existing = this.history().tracked.find( + (tracked) => this.comparisonKey(tracked) === key, + ); + return existing ?? raw; + } + + private comparisonKey(pathKey: string): string { + return isWindowsPath(this.workspaceCtx.workDir) ? pathKey.toLowerCase() : pathKey; + } +} + +function isWindowsPath(value: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value); +} + +function editTargetPath(display: ToolInputDisplay | undefined): string | undefined { + if (display === undefined || display.kind !== 'file_io') return undefined; + if (display.operation !== 'edit' && display.operation !== 'write') return undefined; + return display.path; +} + +function latestEntry( + checkpoints: readonly FileHistoryCheckpointRecord[], + path: string, +): FileBackupEntry | undefined { + for (let i = checkpoints.length - 1; i >= 0; i -= 1) { + const record = checkpoints[i]!.entries; + if (Object.hasOwn(record, path)) return record[path]; + } + return undefined; +} + +function maxVersion( + checkpoints: readonly FileHistoryCheckpointRecord[], + path: string, +): number { + let max = 0; + for (const checkpoint of checkpoints) { + const entry = Object.hasOwn(checkpoint.entries, path) ? checkpoint.entries[path] : undefined; + if (entry !== undefined && entry.version > max) max = entry.version; + } + return max; +} + +function blobKey(pathKey: string, version: number): string { + const hash = createHash('sha256').update(pathKey, 'utf8').digest('hex'); + return `${FILE_HISTORY_BLOB_PREFIX}/${hash}@v${String(version)}`; +} + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function decodeText(bytes: Uint8Array): string | undefined { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return undefined; + } +} + +function diffChange( + path: string, + beforeBytes: Uint8Array | undefined, + afterBytes: Uint8Array | undefined, + budget?: LcsCellBudget, +): FileHistoryChange | undefined { + if (beforeBytes === undefined && afterBytes === undefined) return undefined; + const before = beforeBytes === undefined ? undefined : decodeText(beforeBytes); + const after = afterBytes === undefined ? undefined : decodeText(afterBytes); + const binary = + (beforeBytes !== undefined && before === undefined) || + (afterBytes !== undefined && after === undefined); + + if (beforeBytes === undefined) { + return binary + ? { path, status: 'added', additions: 0, deletions: 0, binary } + : { path, status: 'added', additions: countLines(after ?? ''), deletions: 0 }; + } + if (afterBytes === undefined) { + return binary + ? { path, status: 'deleted', additions: 0, deletions: 0, binary } + : { path, status: 'deleted', additions: 0, deletions: countLines(before ?? '') }; + } + if (binary) { + return bytesEqual(beforeBytes, afterBytes) + ? undefined + : { path, status: 'modified', additions: 0, deletions: 0, binary }; + } + if (before === after) return undefined; + const counted = countLineDiff(before ?? '', after ?? '', budget); + if (counted === undefined) { + return { path, status: 'modified', additions: 0, deletions: 0, oversize: true }; + } + return { path, status: 'modified', additions: counted.additions, deletions: counted.deletions }; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.byteLength !== b.byteLength) return false; + for (let i = 0; i < a.byteLength; i += 1) if (a[i] !== b[i]) return false; + return true; +} + +function splitLines(content: string): string[] { + if (content === '') return []; + const lines = content.split('\n'); + if (lines.at(-1) === '') lines.pop(); + else lines[lines.length - 1] = `${lines.at(-1)!}\u0000`; + return lines; +} + +function countLines(content: string): number { + return splitLines(content).length; +} + +export function countLineDiff( + before: string, + after: string, + budget?: LcsCellBudget, +): { additions: number; deletions: number } | undefined { + const beforeLines = splitLines(before); + const afterLines = splitLines(after); + + let start = 0; + while ( + start < beforeLines.length && + start < afterLines.length && + beforeLines[start] === afterLines[start] + ) { + start += 1; + } + let beforeEnd = beforeLines.length; + let afterEnd = afterLines.length; + while ( + beforeEnd > start && + afterEnd > start && + beforeLines[beforeEnd - 1] === afterLines[afterEnd - 1] + ) { + beforeEnd -= 1; + afterEnd -= 1; + } + + const oldSlice = beforeLines.slice(start, beforeEnd); + const newSlice = afterLines.slice(start, afterEnd); + const cells = oldSlice.length * newSlice.length; + if (cells > LCS_CELL_BUDGET) return undefined; + if (budget !== undefined) { + if (cells > budget.remaining) return undefined; + budget.remaining -= cells; + } + const common = lcsLength(oldSlice, newSlice); + return { + additions: newSlice.length - common, + deletions: oldSlice.length - common, + }; +} + +interface LcsCellBudget { + remaining: number; +} + +const LCS_CELL_BUDGET = 4_000_000; +const LCS_AGGREGATE_CELL_BUDGET = 16_000_000; + +function lcsLength(a: readonly string[], b: readonly string[]): number { + if (a.length === 0 || b.length === 0) return 0; + let previous = new Uint32Array(b.length + 1); + let current = new Uint32Array(b.length + 1); + for (let i = 1; i <= a.length; i += 1) { + for (let j = 1; j <= b.length; j += 1) { + current[j] = + a[i - 1] === b[j - 1] + ? previous[j - 1]! + 1 + : Math.max(previous[j]!, current[j - 1]!); + } + [previous, current] = [current, previous]; + } + return previous[b.length]!; +} diff --git a/packages/agent-core-v2/src/features/notify/flag.ts b/packages/agent-core-v2/src/features/notify/flag.ts new file mode 100644 index 000000000..a6ef54f89 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/flag.ts @@ -0,0 +1,16 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const NOTIFY_USER_FLAG_ID = 'notify_user'; +export const NOTIFY_USER_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_NOTIFY_USER'; + +export const notifyUserFlag: FlagDefinitionInput = { + id: NOTIFY_USER_FLAG_ID, + title: 'NotifyUser tool', + description: + 'Show live progress updates from the main agent and subagents in the TUI Updates panel.', + env: NOTIFY_USER_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(notifyUserFlag); diff --git a/packages/agent-core-v2/src/features/notify/notifyFeature.ts b/packages/agent-core-v2/src/features/notify/notifyFeature.ts new file mode 100644 index 000000000..02bdd4cb8 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/notifyFeature.ts @@ -0,0 +1,26 @@ +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { notifyUserNudgeAgentRuntimeProvider } from './notifyUserNudgeAgentRuntime'; +import { ISessionNotify } from './sessionNotify'; +import { INotifyUserTool, NOTIFY_USER_TOOL_NAME } from './tools/notify-user/notify-user'; +import { NotifyUserTool } from './tools/notify-user/notifyUserTool'; + +import './flag'; +import './sessionNotify'; + +export class NotifyFeature extends Feature { + static override readonly name = 'notify'; + + constructor() { + super(); + this.contributeTool(INotifyUserTool, NotifyUserTool, { + name: NOTIFY_USER_TOOL_NAME, + domain: 'notify', + when: (accessor) => accessor.get(ISessionNotify).enabled, + }); + this.contributeAgentRuntime(notifyUserNudgeAgentRuntimeProvider); + } +} + +registerFeature(NotifyFeature); diff --git a/packages/agent-core-v2/src/features/notify/notifyUserAvailability.ts b/packages/agent-core-v2/src/features/notify/notifyUserAvailability.ts new file mode 100644 index 000000000..8084ecc62 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/notifyUserAvailability.ts @@ -0,0 +1,11 @@ +import type { HostUiCapability, IBootstrapService } from '#/app/bootstrap/bootstrap'; +import type { IFlagService } from '#/app/flag/flag'; + +import { NOTIFY_USER_FLAG_ID } from './flag'; + +export const NOTIFY_USER_UI_CAPABILITY: HostUiCapability = 'update_panel'; + +export function notifyUserAvailable(flags: IFlagService, bootstrap: IBootstrapService): boolean { + if (!flags.enabled(NOTIFY_USER_FLAG_ID)) return false; + return (bootstrap.args.uiCapabilities ?? []).includes(NOTIFY_USER_UI_CAPABILITY); +} diff --git a/packages/agent-core-v2/src/features/notify/notifyUserNudge.ts b/packages/agent-core-v2/src/features/notify/notifyUserNudge.ts new file mode 100644 index 000000000..80fcb6549 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/notifyUserNudge.ts @@ -0,0 +1,89 @@ +import type { ContextMessage } from '#/agent/contextMemory/types'; + +import { NOTIFY_USER_TOOL_NAME } from './tools/notify-user/notify-user'; + +export const NOTIFY_USER_NUDGE_VARIANT = 'notify_user_nudge'; +export const NOTIFY_USER_NUDGE_THRESHOLD = 8; + +function startsNewTurn(message: ContextMessage): boolean { + const origin = message.origin; + if (origin === undefined) return false; + switch (origin.kind) { + case 'user': + case 'cron_job': + case 'cron_missed': + case 'task': + case 'retry': + return true; + case 'system_trigger': + return origin.name !== 'stop_hook'; + case 'skill_activation': + return origin.trigger === 'user-slash'; + case 'plugin_command': + return true; + default: + return false; + } +} + +export function toolCallsSinceLastNotify(history: readonly ContextMessage[]): number { + let count = 0; + for (let index = history.length - 1; index >= 0; index -= 1) { + const message = history[index]!; + if (startsNewTurn(message)) break; + if (message.role !== 'assistant') continue; + if (message.toolCalls.some((call) => call.name === NOTIFY_USER_TOOL_NAME)) break; + count += message.toolCalls.length; + } + return count; +} + +export function toolCallsSincePosition( + history: readonly ContextMessage[], + position: number, +): number { + let count = 0; + for (const message of history.slice(position + 1)) { + if (message.role !== 'assistant') continue; + count += message.toolCalls.length; + } + return count; +} + +export function lastMidResponsePosition(history: readonly ContextMessage[]): number { + for (let index = history.length - 1; index >= 0; index -= 1) { + const message = history[index]!; + if (startsNewTurn(message)) break; + if (message.role !== 'assistant') continue; + if (message.toolCalls.some((call) => call.name === NOTIFY_USER_TOOL_NAME)) break; + const hasVisibleText = message.content.some( + (part) => part.type === 'text' && part.text.trim().length > 0, + ); + if (hasVisibleText) return index; + } + return -1; +} + +export function shouldNudgeNotifyUser( + streak: number, + callsSinceLastNudge: number | null, +): boolean { + if (streak < NOTIFY_USER_NUDGE_THRESHOLD) return false; + return callsSinceLastNudge === null || callsSinceLastNudge >= NOTIFY_USER_NUDGE_THRESHOLD; +} + +export function shouldNudgeMidResponse( + midResponsePosition: number, + lastInjectedAt: number | null, +): boolean { + if (midResponsePosition < 0) return false; + return lastInjectedAt === null || lastInjectedAt < midResponsePosition; +} + +export function renderNotifyUserNudge(count: number): string { + return `You have gone ${String(count)} tool calls without a NotifyUser update — the user has seen nothing in the meantime. Send one now: a structured, chat-style update (a well structured paragraph with a few bullet points, under ~1000 characters) covering what you have concluded so far and what you will do next, batched with your next tool calls.`; +} + +export function renderMidResponseHint(): string { + return 'You just sent a mid-turn text reply — easy for the user to miss between tool calls. Repost its substance as a NotifyUser update (a short intro plus a few bullet points), batched with your next tool calls, then continue.'; +} diff --git a/packages/agent-core-v2/src/features/notify/notifyUserNudgeAgentRuntime.ts b/packages/agent-core-v2/src/features/notify/notifyUserNudgeAgentRuntime.ts new file mode 100644 index 000000000..5d626c058 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/notifyUserNudgeAgentRuntime.ts @@ -0,0 +1,103 @@ +import { fromCallback, setup } from 'xstate'; + +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { + defineAgentRuntimeContract, + defineAgentRuntimeProvider, + type AgentRuntimeContext, + type AgentRuntimeRestoreEvent, +} from '#/agent/runtime/agentRuntime'; +import { IFlagService } from '#/app/flag/flag'; +import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; +import type { ContextInjectionContext } from '#/features/reminder/types'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; + +import { NOTIFY_USER_FLAG_ID } from './flag'; +import { + NOTIFY_USER_NUDGE_VARIANT, + lastMidResponsePosition, + renderMidResponseHint, + renderNotifyUserNudge, + shouldNudgeMidResponse, + shouldNudgeNotifyUser, + toolCallsSinceLastNotify, + toolCallsSincePosition, +} from './notifyUserNudge'; +import { NOTIFY_USER_TOOL_NAME } from './tools/notify-user/notify-user'; + +interface NotifyUserNudgeActorContext { + readonly runtime: AgentRuntimeContext; +} + +const notifyUserNudgeReminders = fromCallback(({ + input, +}: { + input: { + readonly runtime: AgentRuntimeContext; + }; +}) => { + const runtime = input.runtime; + if (!runtime.get(IFlagService).enabled(NOTIFY_USER_FLAG_ID)) return () => {}; + const reminder = runtime.get(IAgentLifecycleService).resolve(runtime.agent, AgentReminder); + const registration = reminder.register( + NOTIFY_USER_NUDGE_VARIANT, + ({ lastInjectedAt }: ContextInjectionContext): string | undefined => { + if (!runtime.get(IFlagService).enabled(NOTIFY_USER_FLAG_ID)) return undefined; + if (runtime.get(IAgentToolRegistryService).resolve(NOTIFY_USER_TOOL_NAME) === undefined) { + return undefined; + } + const history = runtime.get(IAgentContextMemoryService).get(); + const streak = toolCallsSinceLastNotify(history); + const callsSinceLastNudge = + lastInjectedAt === null ? null : toolCallsSincePosition(history, lastInjectedAt); + if (shouldNudgeNotifyUser(streak, callsSinceLastNudge)) return renderNotifyUserNudge(streak); + if (shouldNudgeMidResponse(lastMidResponsePosition(history), lastInjectedAt)) { + return renderMidResponseHint(); + } + return undefined; + }, + ); + return () => { + registration.dispose(); + }; +}); + +const notifyUserNudgeActorLogic = setup({ + types: {} as { + context: NotifyUserNudgeActorContext; + input: AgentRuntimeContext; + events: AgentRuntimeRestoreEvent; + }, + actors: { notifyUserNudgeReminders }, +}).createMachine({ + context: ({ input }) => ({ runtime: input }), + initial: 'beforeRestore', + states: { + beforeRestore: { + on: { 'runtime.restore': 'active' }, + }, + active: { + invoke: { + src: 'notifyUserNudgeReminders', + input: ({ context }) => ({ runtime: context.runtime }), + }, + }, + }, +}); + +export class NotifyUserNudgeRuntime {} + +export const AgentNotifyUserNudge = defineAgentRuntimeContract( + 'notifyUserNudge', +); + +export const notifyUserNudgeAgentRuntimeProvider = defineAgentRuntimeProvider< + null, + NotifyUserNudgeRuntime +>(AgentNotifyUserNudge, { + id: 'notifyUserNudge', + logic: notifyUserNudgeActorLogic, + eager: true, + createApi: () => new NotifyUserNudgeRuntime(), +}); diff --git a/packages/agent-core-v2/src/features/notify/sessionNotify.ts b/packages/agent-core-v2/src/features/notify/sessionNotify.ts new file mode 100644 index 000000000..44605efe9 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/sessionNotify.ts @@ -0,0 +1,66 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IFlagService } from '#/app/flag/flag'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; +import { notifyUserAvailable } from './notifyUserAvailability'; + +export interface ISessionNotify { + readonly _serviceBrand: undefined; + readonly ready: Promise; + readonly enabled: boolean; +} + +export const ISessionNotify = createDecorator('sessionNotify'); + +export class SessionNotify implements ISessionNotify { + declare readonly _serviceBrand: undefined; + readonly ready: Promise; + enabled = false; + + constructor( + @ISessionContext private readonly context: ISessionContext, + @IAtomicDocumentStore private readonly store: IAtomicDocumentStore, + @IAppendLogStore private readonly journal: IAppendLogStore, + @IFlagService flags: IFlagService, + @IBootstrapService bootstrap: IBootstrapService, + ) { + this.ready = this.load(notifyUserAvailable(flags, bootstrap)); + } + + private async load(initial: boolean): Promise { + const scope = this.context.scope('notify'); + const stored = await this.store.get<{ enabled: boolean }>(scope, 'state.json'); + if (stored !== undefined && typeof stored.enabled === 'boolean') { + this.enabled = stored.enabled; + return; + } + let enabled = initial; + for await (const record of this.journal.read( + this.context.scope('agents/main'), + AGENT_WIRE_RECORD_KEY, + )) { + if (record.type !== 'profile.bind' || typeof record['systemPrompt'] !== 'string') continue; + enabled = + record['systemPrompt'].includes('When `NotifyUser` is available, use it proactively') || + record['systemPrompt'].includes( + 'The `NotifyUser` tool is your channel to the user while you work', + ); + break; + } + await this.store.set(scope, 'state.json', { enabled }); + this.enabled = enabled; + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionNotify, + SessionNotify, + ScopeActivation.OnDemand, + 'notify', +); diff --git a/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.md b/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.md new file mode 100644 index 000000000..0c8fcf214 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.md @@ -0,0 +1,15 @@ +Show the end user a progress update without ending your turn. Main-agent and subagent updates appear together in the TUI's Updates panel, with source labels added automatically. The panel keeps every update in order until the main agent starts a new turn. It remains visible when work ends, and the user can page through the complete messages. + +**When to use:** +1. Early in a multi-step task, describe your approach so the user can follow your work. +2. Report meaningful findings and phase conclusions, distinguishing confirmed results from hypotheses. +3. Before a long-running step, say what you are waiting for and why. +4. When blocked, explain the blocker and your next step. + +**How to use:** +- Write the way you would update a colleague in chat, in the end user's language, in light Markdown. Give each update a two-part layout: open with a short flowing paragraph (two to four sentences — the conclusion and the evidence behind it), then put the details into structure — a few bullet points, short separated paragraphs, or key-value lines with file paths and error excerpts. Avoid tables and wide code blocks; the panel is narrower than the chat. Keep the whole update under roughly 1000 characters, self-contained enough to read on its own page. Avoid repeating unchanged status or narrating individual tool calls. +- When working as a subagent, describe only your own subtask. Its completion does not mean the whole task is complete. +- Do not add an agent name or source prefix; the UI supplies it. +- Batch the update with your next tool calls when possible. +- This tool informs the end user; it does not automatically send a message to your parent agent. Keep all important findings in your final reply or final handoff to the parent. +- Do not use an update to ask questions, request decisions, or deliver the final answer. Subagents must leave questions for the parent agent in their handoff. diff --git a/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.ts b/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.ts new file mode 100644 index 000000000..a1e5b73c1 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const NOTIFY_USER_TOOL_NAME = 'NotifyUser' as const; + +export interface NotifyUserInput { + message: string; +} + +export const NotifyUserInputSchema: z.ZodType = z.object({ + message: z + .string() + .min(1) + .describe( + "The update to show the user: a short intro paragraph followed by a few bullet points of light Markdown, in the user's language, under ~1000 characters.", + ), +}); + +export interface INotifyUserTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const INotifyUserTool = createDecorator('notifyUserTool'); diff --git a/packages/agent-core-v2/src/features/notify/tools/notify-user/notifyUserTool.ts b/packages/agent-core-v2/src/features/notify/tools/notify-user/notifyUserTool.ts new file mode 100644 index 000000000..1bbd7d96f --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/tools/notify-user/notifyUserTool.ts @@ -0,0 +1,44 @@ +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IFlagService } from '#/app/flag/flag'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { ToolAccesses, type ToolExecution } from '#/tool/toolContract'; +import { notifyUserAvailable } from '../../notifyUserAvailability'; + +import { + INotifyUserTool, + NOTIFY_USER_TOOL_NAME, + NotifyUserInputSchema, + type NotifyUserInput, +} from './notify-user'; +import DESCRIPTION from './notify-user.md?raw'; + +export const NOTIFY_USER_DELIVERED_OUTPUT = 'Update shown to the user.'; +export const NOTIFY_USER_EMPTY_MESSAGE = 'message must not be empty.'; +export const NOTIFY_USER_SUPPRESSED_OUTPUT = 'Notifications are disabled; the update was not displayed.'; + +export class NotifyUserTool implements INotifyUserTool { + declare readonly _serviceBrand: undefined; + readonly name = NOTIFY_USER_TOOL_NAME; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(NotifyUserInputSchema); + + constructor( + @IFlagService private readonly flags: IFlagService, + @IBootstrapService private readonly bootstrap: IBootstrapService, + ) {} + + resolveExecution(args: NotifyUserInput): ToolExecution { + if (args.message.trim().length === 0) { + return { isError: true, output: NOTIFY_USER_EMPTY_MESSAGE }; + } + return { + description: 'Notifying the user', + accesses: ToolAccesses.none(), + approvalRule: this.name, + execute: async () => + notifyUserAvailable(this.flags, this.bootstrap) + ? { isError: false, output: NOTIFY_USER_DELIVERED_OUTPUT } + : { isError: false, output: NOTIFY_USER_SUPPRESSED_OUTPUT }, + }; + } +} diff --git a/packages/agent-core-v2/src/features/plan/profile/plan.ts b/packages/agent-core-v2/src/features/plan/profile/plan.ts index 1c29497bf..68931f0bd 100644 --- a/packages/agent-core-v2/src/features/plan/profile/plan.ts +++ b/packages/agent-core-v2/src/features/plan/profile/plan.ts @@ -6,6 +6,7 @@ import { } from '#/app/agentProfileCatalog/profile-shared'; const PLAN_TOOLS = [ + 'NotifyUser', 'Read', 'ReadMediaFile', 'Glob', diff --git a/packages/agent-core-v2/src/features/tower/workerProfile.ts b/packages/agent-core-v2/src/features/tower/workerProfile.ts index d5e35bb69..10882ae2f 100644 --- a/packages/agent-core-v2/src/features/tower/workerProfile.ts +++ b/packages/agent-core-v2/src/features/tower/workerProfile.ts @@ -36,6 +36,7 @@ const TOWER_WORKER_TOOLS = [ 'TaskOutput', 'TaskStop', 'TodoList', + 'NotifyUser', 'WaitFor', 'WebSearch', 'FetchURL', diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 10e5e62c6..f0c1f7c4a 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -343,6 +343,17 @@ export * from '#/features/plan/planOps'; export * from '#/features/plan/planService'; import '#/features/dateChange/dateChangeFeature'; import '#/features/plan/planFeature'; +export * from '#/features/fileHistory/fileHistory'; +export * from '#/features/fileHistory/fileHistoryOps'; +export * from '#/features/fileHistory/fileHistoryService'; +import '#/features/fileHistory/fileHistoryFeature'; +export * from '#/features/notify/flag'; +export * from '#/features/notify/notifyUserAvailability'; +export * from '#/features/notify/sessionNotify'; +export * from '#/features/notify/notifyUserNudge'; +export * from '#/features/notify/tools/notify-user/notify-user'; +export * from '#/features/notify/tools/notify-user/notifyUserTool'; +import '#/features/notify/notifyFeature'; export * from '#/features/externalHooks/configSection'; export * from '#/features/externalHooks/app/externalHooksRunner'; export * from '#/features/externalHooks/app/externalHooksRunnerService'; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts index 4820de925..d93700c8c 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/blobStoreService.ts @@ -26,8 +26,7 @@ export class BlobStoreService implements IBlobStore { } async has(scope: string, key: string): Promise { - const keys = await this.storage.list(scope, key); - return keys.includes(key); + return (await this.storage.size(scope, key)) !== undefined; } async delete(scope: string, key: string): Promise { diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index a9fe0d158..56d383fa6 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -68,6 +68,9 @@ import { let nextAgentId = 0; +const REMOVE_PROMPT_QUIESCE_TIMEOUT_MS = 3_000; +const REMOVE_PROMPT_QUIESCE_POLL_MS = 10; + export class AgentLifecycleService extends Disposable implements IAgentLifecycleService { declare readonly _serviceBrand: undefined; private readonly roster = new Map(); @@ -472,18 +475,58 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle const compactionSettled = compaction?.promise.catch(() => undefined) ?? Promise.resolve(); const prompt = handle.accessor.get(IAgentPromptService); await phase(async () => { - await prompt.drain(reason); - for (const turnId of loop.status().pendingTurnIds) { - loop.cancel(turnId, reason); - } - loop.cancel(undefined, reason); if (compaction !== null && !compaction.abortController.signal.aborted) { compaction.abortController.abort(reason); } - await Promise.all([loop.settled(), compactionSettled]); - }); - await phase(() => { - quiescence = loop.tryAcquireQuiescence(); + const deadline = Date.now() + REMOVE_PROMPT_QUIESCE_TIMEOUT_MS; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + let drainTimedOut = false; + let drainTimer: ReturnType | undefined; + await Promise.race([ + (async () => { + await prompt.drain(reason); + for (const turnId of loop.status().pendingTurnIds) { + loop.cancel(turnId, reason); + } + loop.cancel(undefined, reason); + await Promise.all([loop.settled(), compactionSettled]); + })().finally(() => { + if (drainTimer !== undefined) clearTimeout(drainTimer); + }), + new Promise((resolve) => { + drainTimer = setTimeout(() => { + drainTimedOut = true; + resolve(); + }, remaining); + }), + ]); + if (drainTimedOut) break; + let idle = true; + try { + const snapshot = prompt.list(); + idle = + !snapshot.launching && snapshot.active === undefined && snapshot.pending.length === 0; + } catch { + idle = true; + } + if (idle) { + try { + const guard = loop.tryAcquireQuiescence(); + if (guard !== undefined) { + quiescence = guard; + break; + } + } catch { + break; + } + } + if (Date.now() >= deadline) break; + await new Promise((resolve) => { + setTimeout(resolve, REMOVE_PROMPT_QUIESCE_POLL_MS); + }); + } }); await phase(() => handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed')); await phase(() => handle.accessor.get(IEventDispatcher).flush()); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 77337a25e..5e04bfe50 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -31,6 +31,7 @@ const AGENT_TOOLS = [ 'AgentDynamicWorkflow', 'FetchURL', 'AskUserQuestion', + 'NotifyUser', 'EnterPlanMode', 'ExitPlanMode', 'CreateGoal', @@ -44,6 +45,7 @@ const AGENT_TOOLS = [ ] as const; const CODER_TOOLS = [ + 'NotifyUser', 'Bash', 'CronCreate', 'CronDelete', @@ -68,6 +70,7 @@ const CODER_TOOLS = [ ] as const; const EXPLORE_TOOLS = [ + 'NotifyUser', 'Bash', 'Read', 'ReadMediaFile', diff --git a/packages/agent-core-v2/src/wire/record.ts b/packages/agent-core-v2/src/wire/record.ts index 758f68bb1..f5fbd16d7 100644 --- a/packages/agent-core-v2/src/wire/record.ts +++ b/packages/agent-core-v2/src/wire/record.ts @@ -9,6 +9,11 @@ export type RecordDehydrator = ( transform: PartsTransformer, ) => WireRecord | Promise; +export interface WireLineRange { + readonly start: number; + readonly end: number; +} + export interface WireRecord { readonly type: string; readonly time?: number; diff --git a/packages/agent-core-v2/src/wire/wire.ts b/packages/agent-core-v2/src/wire/wire.ts index 13d052786..215ad8b75 100644 --- a/packages/agent-core-v2/src/wire/wire.ts +++ b/packages/agent-core-v2/src/wire/wire.ts @@ -9,6 +9,9 @@ export interface IWireService { appendRecord(record: WireRecord, dehydrate?: RecordDehydrator): void; readJournal(): AsyncIterable; flush(): Promise; + lineCount(): number; + lastContextClearLine(): number | undefined; + journalPath(): string | undefined; } export const IWireService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index 5ae82baf4..b01528557 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -38,6 +38,8 @@ export class WireService extends Service implements IWireService { declare readonly _serviceBrand: undefined; private readonly wireScope: string; + private lines = 0; + private lastClearLine: number | undefined; private readonly agentId: string; private persistQueue: Promise | undefined; private pendingRepair: @@ -111,16 +113,20 @@ export class WireService extends Service implements IWireService { let rewrittenRecords: WireRecord[] | undefined; let newerWireVersion = false; let recordIndex = 0; + let lineCount = 0; let hasRecords = false; let legacyPlanRevisionMigrated = false; for await (const candidate of source) { + lineCount++; + this.lines = lineCount; const sourceRecord: unknown = candidate; if (!isWireRecord(sourceRecord)) { this.reportSkippedRecord(undefined, recordIndex, true); recordIndex++; continue; } + if (sourceRecord.type === 'context.clear') this.lastClearLine = lineCount; if (!hasRecords) { hasRecords = true; if (sourceRecord.type !== 'metadata') { @@ -179,9 +185,23 @@ export class WireService extends Service implements IWireService { await this.repairJournal(truncation, rewrittenRecords); } else if (rewrittenRecords !== undefined) { await this.log.rewrite(this.wireScope, AGENT_WIRE_RECORD_KEY, rewrittenRecords); + this.lines = rewrittenRecords.length; + this.lastClearLine = lastContextClearLineOf(rewrittenRecords); } } + lineCount(): number { + return this.lines; + } + + lastContextClearLine(): number | undefined { + return this.lastClearLine; + } + + journalPath(): string | undefined { + return this.storage.pathFor(this.wireScope, AGENT_WIRE_RECORD_KEY); + } + private async repairJournal( truncation: AppendLogTruncation, rewrittenRecords: WireRecord[] | undefined, @@ -210,6 +230,10 @@ export class WireService extends Service implements IWireService { truncation, ); this.pendingRepair = outcome === 'failed' ? { records, truncation } : undefined; + if (outcome !== 'failed') { + this.lines = records.length; + this.lastClearLine = lastContextClearLineOf(records); + } } private async repairPendingJournal(): Promise { @@ -320,7 +344,16 @@ export class WireService extends Service implements IWireService { this.log.append(this.wireScope, AGENT_WIRE_RECORD_KEY, record, { onError: onUnexpectedError, }); + this.lines += 1; + if (record.type === 'context.clear') this.lastClearLine = this.lines; + } +} + +function lastContextClearLineOf(records: readonly WireRecord[]): number | undefined { + for (let index = records.length - 1; index >= 0; index -= 1) { + if (records[index]!.type === 'context.clear') return index + 1; } + return undefined; } function extractLegacyPlanRevisionKey(path: string, agentId: string): string | undefined { diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts index bcc48d971..ee3f28ffd 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -1,4 +1,5 @@ import { Error2, ErrorCodes } from '#/errors'; +import { FILE_HISTORY_RECORD_PREFIX } from '#/features/fileHistory/fileHistoryOps'; import type { ContentPart } from '#/kosong/contract/message'; import { promptMetadataTextFromContentParts, @@ -42,18 +43,19 @@ export function sliceMainRecordsAtTurn( const end = turnStarts[turnIndex + 1] ?? records.length; const retainedTurnInputs = turnInputIndicesThrough(records, turnIndex); - const retained = records + const window = records .slice(0, end) .filter( - (record, index) => !isUserVisibleTurnInputRecord(record) || retainedTurnInputs.has(index), + (record, index) => + !isUserVisibleTurnInputRecord(record) || retainedTurnInputs.has(index), ); - const cutoffTimes = retained - .map(recordTime) - .filter((time): time is number => time !== undefined); + const retained = window.filter( + (record) => !record.type.startsWith(FILE_HISTORY_RECORD_PREFIX), + ); const lastPrompt = promptMetadataFromTurnRecord(records[start]!); return { records: retained, - cutoffTime: cutoffTimes.length === 0 ? undefined : Math.max(...cutoffTimes), + cutoffTime: cutoffFrom(window), lastPrompt, }; } @@ -82,15 +84,15 @@ export function sliceMainRecordsBeforePrompt( return sliceMainRecordsAtTurn(records, sourceSessionId, activeTurnIndex - 1); } const activeStart = turnStarts[0]!; - const retained = records + const window = records .slice(0, activeStart) .filter((record) => !isUserVisibleTurnInputRecord(record)); - const cutoffTimes = retained - .map(recordTime) - .filter((time): time is number => time !== undefined); + const retained = window.filter( + (record) => !record.type.startsWith(FILE_HISTORY_RECORD_PREFIX), + ); return { records: retained, - cutoffTime: cutoffTimes.length === 0 ? undefined : Math.max(...cutoffTimes), + cutoffTime: cutoffFrom(window), }; } @@ -110,6 +112,13 @@ export function sliceSubagentRecordsAtTime( return records.slice(0, end); } +function cutoffFrom(records: readonly WireRecord[]): number | undefined { + const times = records + .map(recordTime) + .filter((time): time is number => time !== undefined); + return times.length === 0 ? undefined : Math.max(...times); +} + function isUserVisibleTurnRecord(record: WireRecord): boolean { if (record.type !== 'context.append_message') return false; const message = asRecord(record['message']); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 4c4f6b61e..f7a8d2f87 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { join } from 'pathe'; import type { IInstantiationService } from '#/_base/di/instantiation'; -import { Disposable } from '#/_base/di/lifecycle'; +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { createScopedChildHandle, type ISessionScopeHandle, @@ -13,6 +13,11 @@ import { AsyncEmitter, Emitter, type Event, type IWaitUntil } from '#/_base/even import { ILogService } from '#/_base/log/log'; import { drainLogCloses } from '#/_base/log/logService'; import { DEFAULT_PLAN_MODE_SECTION } from '#/features/plan/configSection'; +import { FILE_HISTORY_BLOB_PREFIX, IAgentFileHistoryService } from '#/features/fileHistory/fileHistory'; +import { + dropFileHistorySession, + touchForkedFileHistory, +} from '#/features/fileHistory/fileHistoryRetention'; import { IAgentPlanService } from '#/features/plan/plan'; import { LifecycleScope } from '#/app/scopes'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -60,6 +65,7 @@ import { encodeSessionMeta, } from '#/session/sessionMetadata/sessionMetadataService'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { ISessionNotify } from '#/features/notify/sessionNotify'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { AGENT_WIRE_RECORD_KEY, @@ -82,7 +88,7 @@ import { IWorkspaceAgentProfileLoader, } from '#/workspace/workspaceAgentProfileLoader/workspaceAgentProfileLoader'; import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { IAgentActivityView } from '#/agent/activityView/activityView'; +import { IAgentLoopService } from '#/agent/loop/loop'; import { IWorkspaceSkillCatalog } from '#/features/skill/workspace/workspaceSkillCatalog'; import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions'; import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp'; @@ -283,6 +289,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ) as ISessionScopeHandle; try { await handle.accessor.get(ISessionMetadata).ready; + await handle.accessor.get(ISessionNotify).ready; await handle.accessor.get(ISessionToolPolicy).ready; await Promise.all([ this.workspaceAgentProfileLoader.ready, @@ -452,6 +459,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } await this.hostFs.remove(sessionDirOf(this.bootstrap.homeDir, this.handlerScope, sessionId)); await this.index.remove(sessionId); + await dropFileHistorySession({ docs: this.docs, workspaceId: this.workspaceId, sessionId }); this.appendLogStore.append('', 'session_index.jsonl', { sessionId, deleted: true }); await this.appendLogStore.flush(); } @@ -490,22 +498,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec activeExpertTalkAgentPrefix = activeExpertTalk === undefined ? undefined : `expert-talk-${activeExpertTalk.runId.slice(0, 8)}-`; - for (const agent of sourceAgents.list()) { - const agentHandle = sourceAgents.handleOf(agent.agentId); - if (agentHandle === undefined) continue; - if (agentHandle.accessor.get(IAgentActivityView).state().turn === undefined) continue; - const isExpertTalkParticipant = activeExpertTalkAgentPrefix !== undefined && - agent.agentId.startsWith(activeExpertTalkAgentPrefix); - if ( - activeExpertTalkPromptId !== undefined && - (agent.agentId === MAIN_AGENT_ID || isExpertTalkParticipant) - ) continue; - throw new Error2( - ErrorCodes.SESSION_FORK_ACTIVE_TURN, - `Session "${sourceId}" cannot be forked while a turn is running`, - { details: { sessionId: sourceId } }, - ); - } for (const agent of sourceAgents.list()) { const agentHandle = sourceAgents.handleOf(agent.agentId); if (agentHandle === undefined) continue; @@ -517,8 +509,42 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let targetId: string | undefined; let targetSessionDir: string | undefined; + const quiescenceHolds: IDisposable[] = []; try { + if (sourceHandle !== undefined) { + const sourceAgents = sourceHandle.accessor.get(IAgentLifecycleService); + for (const agent of sourceAgents.list()) { + const agentHandle = sourceAgents.handleOf(agent.agentId); + if (agentHandle === undefined) continue; + const isExpertTalkParticipant = + activeExpertTalkAgentPrefix !== undefined && + agent.agentId.startsWith(activeExpertTalkAgentPrefix); + if ( + activeExpertTalkPromptId !== undefined && + (agent.agentId === MAIN_AGENT_ID || isExpertTalkParticipant) + ) { + continue; + } + const hold = agentHandle.accessor.get(IAgentLoopService).tryAcquireQuiescence(); + if (hold === undefined) { + throw new Error2( + ErrorCodes.SESSION_FORK_ACTIVE_TURN, + `Session "${sourceId}" cannot be forked while a turn is running or queued, or while another fork is copying it`, + { details: { sessionId: sourceId, agentId: agent.agentId } }, + ); + } + quiescenceHolds.push(hold); + } + } await drainSessionMetadataWrites(); + if (sourceHandle !== undefined) { + const sourceAgents = sourceHandle.accessor.get(IAgentLifecycleService); + for (const agent of sourceAgents.list()) { + const agentHandle = sourceAgents.handleOf(agent.agentId); + if (agentHandle === undefined) continue; + await agentHandle.accessor.get(IAgentFileHistoryService).settled(); + } + } const sourceMeta = sourceHandle !== undefined ? await sourceHandle.accessor.get(ISessionMetadata).read() @@ -584,6 +610,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } else { await Promise.all(agentIds.map((agentId) => this.appendForkedMarker(targetId!, agentId))); await this.appendLogStore.flush(); + await touchForkedFileHistory({ + docs: this.docs, + hostFs: this.hostFs, + workspaceId: this.workspaceId, + sessionDir: targetSessionDir, + sessionId: targetId!, + }); } const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`; @@ -645,6 +678,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.hostFs.remove(targetSessionDir).catch(() => {}); } throw error; + } finally { + for (const hold of quiescenceHolds) hold.dispose(); } } @@ -785,6 +820,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const agentDir = join(targetSessionDir, 'agents', agentId); removals.push(this.hostFs.remove(join(agentDir, 'tasks'))); removals.push(this.hostFs.remove(join(agentDir, 'cron'))); + removals.push(this.hostFs.remove(join(agentDir, FILE_HISTORY_BLOB_PREFIX))); } await Promise.all(removals); } diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 9e5860081..3fe653472 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -787,8 +787,9 @@ describe('Agent context', () => { ); expect(shape.tokensAfter).toBe(0); - expect(shape.messages.map((m) => m.role)).toEqual(['user', 'user']); + expect(shape.messages.map((m) => m.role)).toEqual(['user', 'user', 'user']); expect(shape.messages[1]?.origin?.kind).toBe('compaction_summary'); + expect(shape.messages[2]?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' }); }); it('prefers the measured summary output tokens over the text estimate', () => { diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index e9501d05a..21845e648 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -109,7 +109,7 @@ describe('reduceContextTranscript', () => { compaction('SUM', 3, 1), appendMessage(userMessage('u4')), ]); - expect(result.foldedLength).toBe(3); + expect(result.foldedLength).toBe(4); }); it('accounts for the elision marker when the record kept a head segment', () => { @@ -119,7 +119,7 @@ describe('reduceContextTranscript', () => { ...assistantStep('s1', 'a1'), compaction('SUM', 3, 2, 1), ]); - expect(result.foldedLength).toBe(4); + expect(result.foldedLength).toBe(5); }); it('carries the originating wire record time per entry', () => { @@ -160,7 +160,7 @@ describe('reduceContextTranscript', () => { ]); expect(texts(result)).toEqual(['message A', 'reply A', 'summary text']); expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant', 'user']); - expect(result.foldedLength).toBe(2); + expect(result.foldedLength).toBe(3); }); it('undo without compaction keeps the earlier exchange intact', () => { @@ -389,9 +389,10 @@ describe('live fold parity', () => { ]; const live = foldLive(records); const transcript = reduceContextTranscript(records); - expect(live).toHaveLength(5); + expect(live).toHaveLength(6); expect(transcript.foldedLength).toBe(live.length); expect(live[2]!.origin).toEqual({ kind: 'compaction_summary' }); + expect(live[3]!.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' }); }); it('settles a frame left open by a failed attempt when compaction lands mid-fold', () => { @@ -404,7 +405,7 @@ describe('live fold parity', () => { ]; const live = foldLive(records); const transcript = reduceContextTranscript(records); - expect(live.map((m) => m.role)).toEqual(['user', 'user', 'assistant']); + expect(live.map((m) => m.role)).toEqual(['user', 'user', 'user', 'assistant']); expect(texts(transcript)).toEqual(['u1', 'a1', 'SUM', 'a3']); expect(transcript.foldedLength).toBe(live.length); }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts index 0d9b03672..3af862bf8 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts @@ -15,6 +15,7 @@ import { ContextUndo, } from '#/agent/contextMemory/contextEvents'; import { contextMemoryKey } from '#/agent/contextMemory/contextOps'; +import { buildCompactionContinuationText } from '#/agent/contextMemory/compactionHandoff'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IEventBus } from '#/app/event/eventBus'; @@ -376,11 +377,19 @@ describe('AgentContextMemoryService (wire-backed)', () => { ); const model = replay.agentState.get(contextMemoryKey); - expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user']); - expect(model.map(textOf)).toEqual(['old user', 'recent user', 'model-facing summary']); + expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user', 'user']); + expect(model.map(textOf)).toEqual([ + 'old user', + 'recent user', + 'model-facing summary', + buildCompactionContinuationText(), + ]); expect(model[2]).toMatchObject({ origin: { kind: 'compaction_summary' }, }); + expect(model[3]).toMatchObject({ + origin: { kind: 'injection', variant: 'compaction_continuation' }, + }); }); it('replays pre-contextSummary kept-user records without adding a new prefix', async () => { @@ -406,7 +415,12 @@ describe('AgentContextMemoryService (wire-backed)', () => { ); const model = replay.agentState.get(contextMemoryKey); - expect(model.map(textOf)).toEqual(['old user', 'recent user', 'OLD SUMMARY']); + expect(model.map(textOf)).toEqual([ + 'old user', + 'recent user', + 'OLD SUMMARY', + buildCompactionContinuationText(), + ]); expect(model[2]).toMatchObject({ role: 'user', origin: { kind: 'compaction_summary' }, diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 24608b4c8..7f8355cd2 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -17,14 +17,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { DefaultCompactionStrategy, } from '#/agent/fullCompaction/strategy'; -import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHandoff'; +import { + COMPACTION_SUMMARY_PREFIX, + buildCompactionContinuationText, +} from '#/agent/contextMemory/compactionHandoff'; import { makeHookRunner } from '../../features/externalHooks/runner-stub'; import type { IExternalHooksRunnerService } from '#/features/externalHooks/app/externalHooksRunner'; import { MASTER_ENV } from '#/app/flag/flagService'; import { estimateTokensForMessages } from '#/kosong/contract/tokens'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../../harness'; -import { agentService, appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent as createTestAgent } from '../../harness'; +import { agentService, appService, appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent as createTestAgent } from '../../harness'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; +import { renderCompactionInstruction } from '#/agent/fullCompaction/compactionInstruction'; +import { IWireService } from '#/wire/wire'; import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; import { IAgentFullCompactionService, @@ -288,11 +296,16 @@ describe('FullCompaction', () => { role: 'user', text: expect.stringContaining('Compacted summary.'), }, + { role: 'user', text: buildCompactionContinuationText() }, ]); - expect(ctx.context.get().at(-1)?.content[0]).toMatchObject({ + expect(ctx.context.get().at(-2)?.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining('The conversation so far has been compacted'), }); + expect(ctx.context.get().at(-1)).toMatchObject({ + role: 'user', + origin: { kind: 'injection', variant: 'compaction_continuation' }, + }); expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ @@ -304,7 +317,7 @@ describe('FullCompaction', () => { compacted_count: 6, retry_count: 0, thinking_effort: 'off', - input_tokens: 1181, + input_tokens: 1192, output_tokens: 8, input_cache_read: 0, input_cache_creation: 0, @@ -529,6 +542,7 @@ describe('FullCompaction', () => { role: 'user', text: expect.stringContaining('Recovered compacted summary.'), }, + { role: 'user', text: buildCompactionContinuationText() }, ]); await ctx.expectResumeMatches(); }); @@ -831,6 +845,7 @@ describe('FullCompaction', () => { { role: 'user', text: 'old user one' }, { role: 'user', text: 'recent user two' }, { role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nRecovered compacted summary.` }, + { role: 'user', text: buildCompactionContinuationText() }, ]); expect( ctx.allEvents.filter((event) => event.event === 'compaction.completed'), @@ -883,6 +898,7 @@ describe('FullCompaction', () => { { role: 'user', text: 'old user one' }, { role: 'user', text: 'recent user two' }, { role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nRecovered compacted summary.` }, + { role: 'user', text: buildCompactionContinuationText() }, ]); vi.useRealTimers(); await ctx.expectResumeMatches(); @@ -1416,6 +1432,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); await ctx.dispatch({ type: 'context.append_loop_event', @@ -1430,6 +1447,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); await ctx.expectResumeMatches(); }); @@ -1488,9 +1506,15 @@ describe('FullCompaction', () => { }, { "role": "user", - "text": "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. + "text": "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed. Compacted prefix.", }, + { + "role": "user", + "text": " + Context compaction is complete — continue the work that was in progress when it began. + ", + }, ] `); await ctx.expectResumeMatches(); @@ -1718,14 +1742,15 @@ describe('FullCompaction', () => { call 2: messages: user: text "old user one\\n\\nold user two\\n\\nrecent user three\\n\\nAnswer after compacting" - user: text "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary.\\nAuto compacted summary." + user: text "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed.\\nAuto compacted summary." + user: text "\\nContext compaction is complete — continue the work that was in progress when it began.\\n" `); expect(records).toContainEqual({ event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', tokens_before: 6_455, - tokens_after: 6_439, + tokens_after: 6_472, compacted_count: 7, retry_count: 0, }), @@ -1827,8 +1852,10 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); - expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'compaction_summary' }); + expect(ctx.context.get().at(-2)?.origin).toEqual({ kind: 'compaction_summary' }); + expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' }); await ctx.dispatch({ type: 'context.append_loop_event', @@ -1852,6 +1879,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); }); @@ -1895,8 +1923,10 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); - expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'compaction_summary' }); + expect(ctx.context.get().at(-2)?.origin).toEqual({ kind: 'compaction_summary' }); + expect(ctx.context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'compaction_continuation' }); await ctx.dispatch({ type: 'context.append_loop_event', @@ -1911,6 +1941,7 @@ describe('FullCompaction', () => { 'user', 'user', 'user', + 'user', ]); }); @@ -1936,6 +1967,7 @@ describe('FullCompaction', () => { role: 'user', text: `${COMPACTION_SUMMARY_PREFIX}\nSingle message summary.`, }, + { role: 'user', text: buildCompactionContinuationText() }, ]); await ctx.expectResumeMatches(); }); @@ -1971,6 +2003,7 @@ describe('FullCompaction', () => { role: 'user', text: expect.stringContaining('Compacted after single-message compact.'), }, + { role: 'user', text: buildCompactionContinuationText() }, ]); await ctx.expectResumeMatches(); }); @@ -2099,7 +2132,7 @@ describe('FullCompaction', () => { expect(ctx.llmCalls).toHaveLength(2); const [compactionCall, answerCall] = ctx.llmCalls; - expect(messageText(compactionCall?.history.at(-1))).toContain('first-person handoff note'); + expect(messageText(compactionCall?.history.at(-1))).toContain('You are about to run out of context.'); expect( answerCall?.history.map(messageText).some((text) => text.includes('Reserved compacted summary.')), ).toBe(true); @@ -2179,6 +2212,35 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('fails the compaction instead of compacting an empty history when overflow shrink drops everything', async () => { + let calls = 0; + const generate: GenerateFn = async () => { + calls += 1; + if (calls === 1) { + throw new APIContextOverflowError(400, 'Context length exceeded', 'req-shrink-empty'); + } + return textResult('Groundless summary.'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + ctx.appendExchange(1, 'small user one', 'small assistant one', 20); + ctx.context.append({ + role: 'user', + content: [{ type: 'text', text: 'X'.repeat(400_000) }], + toolCalls: [], + }); + const failed = ctx.once('error'); + + await ctx.rpc.beginCompaction({}); + await failed; + + expect(calls).toBe(1); + expect(ctx.context.get()).toHaveLength(3); + }); + it('compacts and retries when the provider reports context overflow', async () => { let callCount = 0; const inputs: string[][] = []; @@ -2252,8 +2314,11 @@ describe('FullCompaction', () => { "user: old user one Retry after provider overflow", - "user: The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. + "user: The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed. Overflow compacted summary.", + "user: + Context compaction is complete — continue the work that was in progress when it began. + ", ], ] `); @@ -2977,8 +3042,11 @@ describe('FullCompaction', () => { "user: old user one xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "user: The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. + "user: The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed. Placeholder compacted summary.", + "user: + Context compaction is complete — continue the work that was in progress when it began. + ", ], ] `); @@ -3011,7 +3079,7 @@ describe('FullCompaction', () => { await completed; const history = ctx.compactHistory(); - expect(history).toHaveLength(3); + expect(history).toHaveLength(4); expect(history[0]).toMatchObject({ role: 'user', text: 'old user one', @@ -3026,12 +3094,194 @@ describe('FullCompaction', () => { 'Compacted summary.\n\n## TODO List\n [in_progress] Fix the auth bug\n [pending] Add tests', ), }); - expect(ctx.context.get().at(-1)?.content[0]).toMatchObject({ + expect(history[3]).toMatchObject({ + role: 'user', + text: buildCompactionContinuationText(), + }); + expect(ctx.context.get().at(-2)?.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining('The conversation so far has been compacted'), }); + expect(ctx.context.get().at(-1)).toMatchObject({ + role: 'user', + origin: { kind: 'injection', variant: 'compaction_continuation' }, + }); + await ctx.expectResumeMatches(); + }); +}); + +describe('FullCompaction context recovery pointer', () => { + const JOURNAL_HOME = '/home/user/.pythinker'; + + interface ApplyCompactionArgs { + readonly summary?: string; + readonly contextSummary?: string; + readonly wireLines?: { readonly start: number; readonly end: number }; + } + + function locatedStorage(base: string): IFileSystemStorageService { + const memory = new InMemoryStorageService(); + return new Proxy(memory, { + get(target, property, receiver) { + if (property === 'pathFor') { + return (scope: string, key: string) => `${base}/${scope}/${key}`; + } + const value = Reflect.get(target, property, receiver) as unknown; + return typeof value === 'function' + ? (value as (...args: unknown[]) => unknown).bind(target) + : value; + }, + }) as unknown as IFileSystemStorageService; + } + + function recoveryAgent( + ...inputs: readonly (TestAgentServiceOverride | TestAgentOptions)[] + ): TestAgentContext { + const ctx = testAgent(...inputs); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + tools: SNAPSHOT_VISIBLE_TOOLS, + }); + return ctx; + } + + async function compactOnce(ctx: TestAgentContext, summary: string): Promise { + const completed = ctx.once('compaction.completed'); + ctx.mockNextResponse({ type: 'text', text: summary }); + await ctx.rpc.beginCompaction({}); + await completed; + } + + function noteText(ctx: TestAgentContext): string { + const part = ctx.context.get().at(-2)?.content[0]; + return part?.type === 'text' ? part.text : ''; + } + + function applyCompactionRecords(ctx: TestAgentContext): ApplyCompactionArgs[] { + return ctx.newEvents().flatMap((event) => { + if (event === null || typeof event !== 'object') return []; + const candidate = event as { type?: unknown; event?: unknown; args?: unknown }; + if (candidate.type !== '[wire]' || candidate.event !== 'context.apply_compaction') return []; + return [candidate.args as ApplyCompactionArgs]; + }); + } + + it('appends the journal location and window line ranges to the model-facing note', async () => { + const ctx = recoveryAgent(appService(IFileSystemStorageService, locatedStorage(JOURNAL_HOME))); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 40); + + await compactOnce(ctx, 'Compacted summary.'); + + const [record] = applyCompactionRecords(ctx); + expect(record?.wireLines).toEqual({ start: 1, end: expect.any(Number) }); + const end = record!.wireLines!.end; + expect(end).toBeGreaterThan(1); + const note = noteText(ctx); + expect(note).toContain('Compacted summary.'); + expect(note).toContain('## Context Recovery'); + expect(note).toContain(`${JOURNAL_HOME}/`); + expect(note).toContain('/wire.jsonl'); + expect(note).toContain(`window 1: lines 1–${String(end)} ← the conversation this note summarizes`); + expect(note).toContain(`window 2 (the one you are in now) starts at line ${String(end + 1)}`); + expect(note).toContain('context.append_loop_event'); + expect(record?.summary).not.toContain('Context Recovery'); + expect(record?.contextSummary).toContain('Context Recovery'); await ctx.expectResumeMatches(); }); + + it('lists every earlier window after repeated compactions', async () => { + const ctx = recoveryAgent(appService(IFileSystemStorageService, locatedStorage(JOURNAL_HOME))); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + await compactOnce(ctx, 'First summary.'); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 40); + await compactOnce(ctx, 'Second summary.'); + + const [first, second] = applyCompactionRecords(ctx); + const firstLines = first!.wireLines!; + const secondLines = second!.wireLines!; + expect(secondLines.start).toBe(firstLines.end + 1); + expect(secondLines.end).toBeGreaterThan(secondLines.start); + const note = noteText(ctx); + expect(note).toContain(`window 1: lines 1–${String(firstLines.end)}\n`); + expect(note).not.toContain(`window 1: lines 1–${String(firstLines.end)} ←`); + expect(note).toContain( + `window 2: lines ${String(secondLines.start)}–${String(secondLines.end)} ← the conversation this note summarizes`, + ); + expect(note).toContain(`window 3 (the one you are in now) starts at line ${String(secondLines.end + 1)}`); + await ctx.expectResumeMatches(); + }); + + it('records window line ranges but omits the pointer when the journal has no on-disk path', async () => { + const ctx = recoveryAgent(); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 40); + + await compactOnce(ctx, 'Compacted summary.'); + + const [record] = applyCompactionRecords(ctx); + expect(record?.wireLines).toEqual({ start: 1, end: expect.any(Number) }); + expect(noteText(ctx)).not.toContain('Context Recovery'); + expect(record?.contextSummary).not.toContain('Context Recovery'); + }); + + it('starts the window after the latest context.clear record', async () => { + const ctx = recoveryAgent(appService(IFileSystemStorageService, locatedStorage(JOURNAL_HOME))); + ctx.appendExchange(1, 'discarded user one', 'discarded assistant one', 20); + ctx.context.clear(); + ctx.appendExchange(2, 'post-clear user two', 'post-clear assistant two', 40); + + await compactOnce(ctx, 'Post-clear summary.'); + + const wire = ctx.get(IWireService); + await wire.flush(); + let line = 0; + let clearLine = 0; + for await (const record of wire.readJournal()) { + line += 1; + if (record.type === 'context.clear') clearLine = line; + } + expect(clearLine).toBeGreaterThan(1); + const [record] = applyCompactionRecords(ctx); + expect(record?.wireLines?.start).toBe(clearLine + 1); + expect(record!.wireLines!.end).toBeGreaterThan(clearLine); + const note = noteText(ctx); + expect(note).toContain(`window 1: lines ${String(clearLine + 1)}–`); + expect(note).not.toContain('window 1: lines 1–'); + }); + + it('counts the appended recovery footer into the compacted token floor', async () => { + const withFooter = recoveryAgent( + appService(IFileSystemStorageService, locatedStorage(JOURNAL_HOME)), + ); + const bare = recoveryAgent(); + for (const ctx of [withFooter, bare]) { + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 40); + await compactOnce(ctx, 'Compacted summary.'); + } + + const [footerRecord] = applyCompactionRecords(withFooter); + const contextSummary = footerRecord!.contextSummary!; + const footer = contextSummary.slice(contextSummary.indexOf('## Context Recovery')); + expect(footer.length).toBeGreaterThan(0); + const withFooterTokens = withFooter.tokenCounting.get().size; + const bareTokens = bare.tokenCounting.get().size; + expect(withFooterTokens - bareTokens).toBe( + withFooter.get(ISessionTokenCountingService).estimateText(footer), + ); + }); + + it('tells the summarizer a recovery pointer follows the summary', () => { + const withPointer = renderCompactionInstruction({}); + const withCustom = renderCompactionInstruction({ customInstruction: ' keep the API facts ' }); + + expect(withPointer).toContain('a recovery pointer is appended below this summary automatically'); + expect(withPointer).toContain('format for the final answer.\n\nThis conversation'); + expect(withPointer).not.toContain('${'); + expect(withCustom).toContain('Optional user instruction:\nkeep the API facts'); + }); }); afterEach(() => { @@ -3272,7 +3522,7 @@ function inputHistorySnapshot(history: readonly Message[]): string[] { } function normalizeInputText(text: string): string { - return text.includes('first-person handoff note') ? '' : text; + return text.includes('You are about to run out of context.') ? '' : text; } describe('prompt deferral during full compaction', () => { diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 26ce39b75..aae843beb 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -162,8 +162,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "time": "