From 9aebaeea1cb1622d3f16a2667db41b26fe900ed1 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 5 Nov 2026 03:30:44 +1300 Subject: [PATCH 1/5] Keep sync opt-in, resume real conversations and bound model command output --- src/commands/login.ts | 1 + src/commands/sessions.ts | 4 +- src/commands/settings.ts | 7 ++ src/commands/sync.ts | 30 +++++-- src/config.ts | 7 ++ src/core/actionExecutor.ts | 28 ++++-- src/core/agent/AgentDependencyComposer.ts | 1 + src/core/agent/AgentToolOutputRuntime.ts | 4 + src/core/toolManager.ts | 4 + src/index.ts | 11 +-- src/runtime/CliRuntimeResourceOwner.ts | 8 +- src/session/SessionManager.ts | 9 +- src/sync/SyncService.ts | 21 +++-- src/sync/runtimeSyncService.ts | 10 +++ src/sync/types.ts | 2 +- src/types.ts | 11 ++- src/ui/shellCommand.ts | 2 + src/utils/commandModelOutput.ts | 90 +++++++++++++++++++ tests/commands/sync.test.ts | 23 ++++- .../core/actionExecutor.commandOutput.test.ts | 76 ++++++++++++++++ tests/runtime/CliRuntimeResourceOwner.test.ts | 11 +++ tests/session/SessionManager.test.ts | 15 ++++ tests/sync/syncOptIn.test.ts | 48 ++++++++++ .../continuation-output.tuistory.test.ts | 42 +++++++++ tests/tuistory/resume.tuistory.test.ts | 2 +- tests/utils/commandModelOutput.test.ts | 38 ++++++++ 26 files changed, 472 insertions(+), 33 deletions(-) create mode 100644 src/utils/commandModelOutput.ts create mode 100644 tests/core/actionExecutor.commandOutput.test.ts create mode 100644 tests/sync/syncOptIn.test.ts create mode 100644 tests/tuistory/continuation-output.tuistory.test.ts create mode 100644 tests/utils/commandModelOutput.test.ts diff --git a/src/commands/login.ts b/src/commands/login.ts index 31eee0e53..8e57c3ccb 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -370,6 +370,7 @@ async function checkAndRestoreSyncData( userId: string, config: LoadedConfig ): Promise { + if (config.sync?.enabled !== true) return; try { // Create sync service to check for cloud data const syncService = createSyncService({ diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index ac7430081..71cb588e2 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -23,10 +23,12 @@ export async function sessions(ctx: { : undefined; try { - const allSessions = await ctx.sessionManager.listSessions( + const storedSessions = await ctx.sessionManager.listSessions( projectFilter ? { project: projectFilter } : undefined ); + const allSessions = storedSessions.filter(session => session.messageCount > 0); + if (allSessions.length === 0) { console.log(chalk.gray(t('commands.sessions.noSessions'))); return null; diff --git a/src/commands/settings.ts b/src/commands/settings.ts index f3da70bd2..9dbb43311 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -375,6 +375,13 @@ export function setConfigSetting(config: LoadedConfig, keyInput: string, rawValu return { key: 'provider', value: provider }; } + if (key === 'tools.maxCommandOutputChars') { + const value = Number(rawValue); + if (!Number.isSafeInteger(value) || value < 1024) throw new Error('tools.maxCommandOutputChars must be an integer of at least 1024.'); + config.tools = { ...config.tools, maxCommandOutputChars: value }; + return { key, value }; + } + const providerConfigKey = normalizeProviderConfigKey(key); if (providerConfigKey) { const current = config[providerConfigKey.provider]; diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 082e9200e..60c6360ab 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -8,12 +8,13 @@ import readline from 'node:readline'; import { StdinBuffer } from '../ui/StdinBuffer.js'; import type { SlashCommandContext } from '../core/slashCommandTypes.js'; import { loadConfig, saveConfig } from '../config.js'; -import type { AutohandConfig } from '../types.js'; +import type { AutohandConfig, LoadedConfig } from '../types.js'; import { formatSyncSkippedFiles } from '../sync/skipReport.js'; import type { SyncSkippedFiles } from '../sync/rootBudgets.js'; import type { SyncService } from '../sync/SyncService.js'; import { getSyncService as getRuntimeSyncService, + setBackgroundSyncEnabled, setSyncService as setRuntimeSyncService, } from '../sync/runtimeSyncService.js'; import { createCommandTheme } from './commandTheme.js'; @@ -56,6 +57,11 @@ export function getSyncService(): SyncService | null { export async function sync(ctx: SlashCommandContext, args: string[] = []): Promise { const config = await loadConfig(); const isLoggedIn = Boolean(config.auth?.token && config.auth?.user); + if (args[0] === 'off') { + await updateSyncEnabled(config, false); + console.log(createCommandTheme().accent(t('commands.sync.disabled'))); + return null; + } if (!isLoggedIn) { const theme = createCommandTheme(); @@ -64,6 +70,12 @@ export async function sync(ctx: SlashCommandContext, args: string[] = []): Promi return null; } + if (args[0] === 'on') { + await updateSyncEnabled(config, true); + console.log(createCommandTheme().accent(t('commands.sync.enabled'))); + return null; + } + // Gather sync data const data = await gatherSyncData(ctx, config); @@ -78,6 +90,14 @@ export async function sync(ctx: SlashCommandContext, args: string[] = []): Promi return null; } +async function updateSyncEnabled(config: LoadedConfig, enabled: boolean): Promise { + // Stop the timer and in-flight work before the asynchronous config write. + if (!enabled) setBackgroundSyncEnabled(false); + config.sync = { ...config.sync, enabled }; + await saveConfig(config); + if (enabled) setBackgroundSyncEnabled(true); +} + async function gatherSyncData(ctx: SlashCommandContext, config: AutohandConfig): Promise { const syncService = getRuntimeSyncService(); let status: Awaited> = { @@ -97,7 +117,7 @@ async function gatherSyncData(ctx: SlashCommandContext, config: AutohandConfig): } return { - enabled: config.sync?.enabled !== false, + enabled: syncService ? status.enabled : config.sync?.enabled === true, isRunning: syncService?.isRunning ?? false, lastSync: status.lastSync, lastError: status.lastError, @@ -198,11 +218,11 @@ function renderSyncUI(data: SyncData, ctx: SlashCommandContext): Promise { // Toggle sync enabled try { const config = await loadConfig(); - const newEnabled = config.sync?.enabled === false; - config.sync = { ...config.sync, enabled: newEnabled }; - await saveConfig(config); + const newEnabled = config.sync?.enabled !== true; + await updateSyncEnabled(config, newEnabled); if (closed) return; data.enabled = newEnabled; + data.isRunning = data.syncService?.isRunning ?? false; console.log(createCommandTheme().accent(`\n${newEnabled ? t('commands.sync.enabled') : t('commands.sync.disabled')}`)); await sleep(1000); render(); diff --git a/src/config.ts b/src/config.ts index b55627e16..15df872e8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1493,6 +1493,13 @@ function validateShellSettings(config: AutohandConfig, configPath: string): void function validateConfig(config: AutohandConfig, configPath: string): void { validateShellSettings(config, configPath); + if (config.tools !== undefined) { + if (!isPlainObject(config.tools)) throw new Error(`tools must be an object in ${configPath}`); + const limit = config.tools.maxCommandOutputChars; + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1024)) { + throw new Error(`tools.maxCommandOutputChars must be an integer of at least 1024 in ${configPath}`); + } + } if (config.traces !== undefined) { if (!isPlainObject(config.traces)) { throw new Error(`traces must be an object in ${configPath}`); diff --git a/src/core/actionExecutor.ts b/src/core/actionExecutor.ts index a246215a2..717bfc045 100644 --- a/src/core/actionExecutor.ts +++ b/src/core/actionExecutor.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ import chalk from 'chalk'; +import { CommandModelOutput, resolveMaxCommandOutputChars } from '../utils/commandModelOutput.js'; +import { formatToolObservation } from './agent/AgentToolOutputRuntime.js'; import { setBounded } from '../utils/bounded.js'; import path from 'node:path'; import fse from 'fs-extra'; @@ -176,6 +178,7 @@ export interface ActionExecutorOptions { projectManager?: ProjectManager; sessionId?: string; getCurrentSessionId?: () => string | undefined; + getCommandOutputDirectory?: () => string | undefined; onExploration?: (entry: ExplorationEvent) => void; toolsRegistry?: ToolsRegistry; metaToolService?: MetaToolService; @@ -260,6 +263,7 @@ const READ_FILE_MAX_BYTES = 128 * 1024; const READ_FILE_MAX_LINE_CHARACTERS = 2_000; interface ToolOutcomeCapture { + commandOutput?: CommandModelOutput; failure?: ToolFailureOutcome; imagePaths?: string[]; } @@ -922,9 +926,19 @@ export class ActionExecutor { } const capture: ToolOutcomeCapture = {}; - const withImages = (outcome: ToolActionOutcome): ToolActionOutcome => capture.imagePaths?.length - ? { ...outcome, imagePaths: capture.imagePaths } - : outcome; + if ((action.type === 'run_command' || action.type === 'shell') && !action.background && (action.type !== 'run_command' || !action.interactive)) { + capture.commandOutput = new CommandModelOutput( + resolveMaxCommandOutputChars(this.runtime.config.tools?.maxCommandOutputChars), + this.deps.getCommandOutputDirectory?.(), + ); + } + const withImages = (outcome: ToolActionOutcome): ToolActionOutcome => { + if (capture.commandOutput) { + const modelOutput = capture.commandOutput.finish(formatToolObservation({ tool: action.type, ...outcome })); + outcome = { ...outcome, modelOutput }; + } + return capture.imagePaths?.length ? { ...outcome, imagePaths: capture.imagePaths } : outcome; + }; try { const output = await this.withToolActivity( action, @@ -2016,10 +2030,12 @@ export class ActionExecutor { shell: true, signal: context?.signal, onStdout: (chunk) => { + capture?.commandOutput?.append(chunk); emitOutput('stdout', chunk); emitLiveOutput('stdout', chunk); }, onStderr: (chunk) => { + capture?.commandOutput?.append(chunk); emitOutput('stderr', chunk); emitLiveOutput('stderr', chunk); }, @@ -2123,8 +2139,8 @@ export class ActionExecutor { cmdStr, this.runtime.workspaceRoot, { - onStdout: (chunk) => this.onLiveCommandOutput!(liveId, 'stdout', chunk), - onStderr: (chunk) => this.onLiveCommandOutput!(liveId, 'stderr', chunk), + onStdout: (chunk) => { capture?.commandOutput?.append(chunk); this.onLiveCommandOutput!(liveId, 'stdout', chunk); }, + onStderr: (chunk) => { capture?.commandOutput?.append(chunk); this.onLiveCommandOutput!(liveId, 'stderr', chunk); }, preferPty: process.stdin.isTTY && process.stdout.isTTY, columns: process.stdout.columns, rows: process.stdout.rows, @@ -2195,6 +2211,8 @@ export class ActionExecutor { shell: true, background: action.background, signal: context?.signal, + onStdout: chunk => capture?.commandOutput?.append(chunk), + onStderr: chunk => capture?.commandOutput?.append(chunk), ...(action.background ? { onBackgroundExit: () => { if (fallbackBackgroundRegistryId !== undefined) { diff --git a/src/core/agent/AgentDependencyComposer.ts b/src/core/agent/AgentDependencyComposer.ts index a32916458..0a44e63e8 100644 --- a/src/core/agent/AgentDependencyComposer.ts +++ b/src/core/agent/AgentDependencyComposer.ts @@ -854,6 +854,7 @@ export function initializeAgentDependencies( toolsRegistry: host.toolsRegistry, getRegisteredTools: () => host.toolManager?.listDefinitions() ?? [], getCurrentSessionId: () => host.sessionManager?.getCurrentSession?.()?.metadata?.sessionId, + getCommandOutputDirectory: () => host.sessionManager?.getCurrentSession?.()?.getArtifactDirectory?.(), memoryManager: host.memoryManager, permissionManager: host.permissionManager, onFileModified: (filePath, changeType, toolCallId) => { diff --git a/src/core/agent/AgentToolOutputRuntime.ts b/src/core/agent/AgentToolOutputRuntime.ts index 3d0aa4ba6..97f0c39cd 100644 --- a/src/core/agent/AgentToolOutputRuntime.ts +++ b/src/core/agent/AgentToolOutputRuntime.ts @@ -24,6 +24,8 @@ export function handleAgentToolOutput( if (process.env.AUTOHAND_STREAM_TOOL_OUTPUT !== '1') { return; } + // Commands supply one bounded final observation; live chunks remain terminal-only. + if (chunk.tool === 'run_command' || chunk.tool === 'shell') return; if (!chunk.toolCallId || !chunk.data) { return; } @@ -37,6 +39,7 @@ export function queueAgentToolMessageChunk( toolCallId: string, stream?: 'stdout' | 'stderr' ): void { + if (name === 'run_command' || name === 'shell') return; const session = host.sessionManager.getCurrentSession(); if (!session) return; @@ -76,6 +79,7 @@ export async function saveAgentToolMessage( } export function formatToolObservation(result: ToolExecutionResult): string { + if (result.modelOutput !== undefined) return result.modelOutput; if (result.success) return result.output ?? '(no output)'; if (!result.output) return result.error ?? 'Tool failed without error message'; if (!result.error || result.output.includes(result.error)) return result.output; diff --git a/src/core/toolManager.ts b/src/core/toolManager.ts index 0548c4057..764db808f 100644 --- a/src/core/toolManager.ts +++ b/src/core/toolManager.ts @@ -3600,7 +3600,11 @@ export class ToolManager { || outcome.imageRefs.some(value => typeof value !== 'string' || !TOOL_IMAGE_REF.test(value)))) { throw new Error('Tool executor returned malformed runtime image references.'); } + if (outcome.modelOutput !== undefined && typeof outcome.modelOutput !== 'string') { + throw new Error('Tool executor returned malformed model output.'); + } const images = { + ...(outcome.modelOutput === undefined ? {} : { modelOutput: outcome.modelOutput }), ...(outcome.imagePaths === undefined ? {} : { imagePaths: [...outcome.imagePaths] }), ...(outcome.imageRefs === undefined ? {} : { imageRefs: [...outcome.imageRefs] }), }; diff --git a/src/index.ts b/src/index.ts index 764141cda..c36ceb793 100644 --- a/src/index.ts +++ b/src/index.ts @@ -465,7 +465,7 @@ program .conflicts('tracesOn')) .option('--login', 'Sign in to your Autohand account', false) .option('--logout', 'Sign out of your Autohand account', false) - .option('--sync-settings [bool]', 'Enable/disable settings sync (default: true for logged users)') + .option('--sync-settings [bool]', 'Enable/disable settings sync (default: false)') .option('--patch', 'Generate git patch without applying changes (requires --prompt)', false) .option('--output ', 'Output file for patch (default: stdout, used with --patch)') .option('--mode ', 'Run mode: interactive (default), rpc, or acp', 'interactive') @@ -2053,11 +2053,8 @@ async function runCLI(options: InternalCLIOptions): Promise { onVersionResult: (versionResult) => { agentHolder.current?.setVersionCheckResult(versionResult); }, - shouldStartSync: () => Boolean( - config.auth?.token - && options.syncSettings !== false - && config.sync?.enabled !== false - ), + shouldCreateSync: () => Boolean(config.auth?.token), + shouldStartSync: () => Boolean(config.auth?.token && (options.syncSettings ?? config.sync?.enabled ?? false)), createSyncService: async (authUser) => { const { createSyncService, DEFAULT_SYNC_CONFIG, SyncApiClient } = await import('./sync/index.js'); let lastUnavailableCount = 0; @@ -2077,7 +2074,7 @@ async function runCLI(options: InternalCLIOptions): Promise { config: { ...DEFAULT_SYNC_CONFIG, ...config.sync, - enabled: true, + enabled: options.syncSettings ?? config.sync?.enabled ?? false, }, onSyncFailure: async (reason) => { const oneLine = reason.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').slice(0, 300).replace(/[.\s]+$/u, ''); diff --git a/src/runtime/CliRuntimeResourceOwner.ts b/src/runtime/CliRuntimeResourceOwner.ts index 4f9fcf642..da90e8f45 100644 --- a/src/runtime/CliRuntimeResourceOwner.ts +++ b/src/runtime/CliRuntimeResourceOwner.ts @@ -25,6 +25,8 @@ export interface CliBackgroundStartup< versionResult: VersionResult | null; }>; onVersionResult(result: VersionResult): void; + /** Prepare a disabled service so the user can opt in during this process. */ + shouldCreateSync?(authUser: AuthUser): boolean; shouldStartSync(authUser: AuthUser): boolean; createSyncService(authUser: AuthUser): Promise; } @@ -168,7 +170,9 @@ export class CliRuntimeResourceOwner< if (versionResult) { startup.onVersionResult(versionResult); } - if (!authUser || !startup.shouldStartSync(authUser)) return; + if (!authUser) return; + const shouldStart = startup.shouldStartSync(authUser); + if (!shouldStart && !startup.shouldCreateSync?.(authUser)) return; const service = await startup.createSyncService(authUser); if (!this.isGenerationActive(generation)) { @@ -178,7 +182,7 @@ export class CliRuntimeResourceOwner< this.syncService = service; try { - service.start(); + if (shouldStart) service.start(); if (!this.isGenerationActive(generation)) { this.syncService = null; await this.stopService(service); diff --git a/src/session/SessionManager.ts b/src/session/SessionManager.ts index dca9af7d2..8cb16df64 100644 --- a/src/session/SessionManager.ts +++ b/src/session/SessionManager.ts @@ -372,7 +372,9 @@ export class SessionManager { const activeAt = Date.parse(metadata.lastActiveAt); return Number.isFinite(activeAt) ? activeAt : Date.parse(metadata.createdAt) || 0; }; - return sessions.reduce((latest, session) => { + const conversations = sessions.filter(session => session.messageCount > 0); + const candidates = conversations.length > 0 ? conversations : sessions; + return candidates.reduce((latest, session) => { return !latest || activityTime(session) > activityTime(latest) ? session : latest; }, null); } @@ -584,6 +586,11 @@ export class Session { return !this.persist; } + /** Artifact persistence follows the same boundary as the session transcript. */ + getArtifactDirectory(): string | undefined { + return this.persist ? this.sessionDir : undefined; + } + private async ensureSessionDir(): Promise { await fs.ensureDir(this.sessionDir); } diff --git a/src/sync/SyncService.ts b/src/sync/SyncService.ts index 15d2425e7..3b3b73a79 100644 --- a/src/sync/SyncService.ts +++ b/src/sync/SyncService.ts @@ -185,7 +185,7 @@ export class SyncService { * Start the background sync timer */ start(): void { - if (this.started || this.stopped) return; + if (this.started || this.stopped || !this.config.enabled) return; this.started = true; // Run initial sync with proper error handling @@ -210,11 +210,20 @@ export class SyncService { * Stop the background sync timer */ stop(): void { - if (!this.stopped) { - this.stopped = true; - this.generation++; - this.operationController?.abort(new SyncOperationStoppedError()); - } + this.stopped = true; + this.pause(); + } + + /** Pause immediately without making an explicit later opt-in impossible. */ + setEnabled(enabled: boolean): void { + this.config.enabled = enabled; + if (enabled) this.start(); + else this.pause(); + } + + private pause(): void { + this.generation++; + this.operationController?.abort(new SyncOperationStoppedError()); if (this.timer) { clearInterval(this.timer); this.timer = null; diff --git a/src/sync/runtimeSyncService.ts b/src/sync/runtimeSyncService.ts index d41b16aaf..16b447d13 100644 --- a/src/sync/runtimeSyncService.ts +++ b/src/sync/runtimeSyncService.ts @@ -7,9 +7,18 @@ import type { SyncService } from './SyncService.js'; let globalSyncService: SyncService | null = null; let pendingBackgroundSync = false; +let enabledOverride: boolean | undefined; export function setSyncService(service: SyncService | null): void { globalSyncService = service; + if (!service) enabledOverride = undefined; + else if (enabledOverride !== undefined) service.setEnabled(enabledOverride); +} + +/** Remember an explicit /sync choice even while startup authentication is pending. */ +export function setBackgroundSyncEnabled(enabled: boolean): void { + enabledOverride = enabled; + globalSyncService?.setEnabled(enabled); } export function getSyncService(): SyncService | null { @@ -23,6 +32,7 @@ export function scheduleBackgroundSync(): void { pendingBackgroundSync = true; setTimeout(() => { pendingBackgroundSync = false; + if (globalSyncService !== syncService || !syncService.isRunning) return; void syncService.sync().catch(() => { // Background sync is opportunistic; explicit /sync still reports errors. }); diff --git a/src/sync/types.ts b/src/sync/types.ts index 03c290b05..ee62494a3 100644 --- a/src/sync/types.ts +++ b/src/sync/types.ts @@ -30,7 +30,7 @@ export interface SyncConfig { * Default sync configuration */ export const DEFAULT_SYNC_CONFIG: SyncConfig = { - enabled: true, + enabled: false, interval: 5 * 60 * 1000, // 5 minutes includeTelemetry: false, includeFeedback: false, diff --git a/src/types.ts b/src/types.ts index db15893a3..11afc7183 100644 --- a/src/types.ts +++ b/src/types.ts @@ -838,7 +838,7 @@ export interface ShareSettings { /** Settings sync configuration */ export interface SyncSettings { - /** Enable/disable sync (default: true for logged users) */ + /** Enable/disable background sync (default: false; explicitly opt in). */ enabled?: boolean; /** Sync interval in milliseconds (default: 300000 = 5 min) */ interval?: number; @@ -1114,6 +1114,8 @@ export interface AutohandConfig { network?: NetworkSettings; /** Child-process controls for shell tools and `!` commands. */ shell?: ShellSettings; + /** Model-facing command output; terminal capture retains its independent limit. */ + tools?: { maxCommandOutputChars?: number }; /** Named run profiles selected with `--profile `; each is a partial config layered for that run only. */ profiles?: Record; externalAgents?: ExternalAgentsConfig; @@ -2155,7 +2157,10 @@ export interface ToolImageRegistrationResult { error?: string; } -export type ToolActionOutcome = +export type ToolActionOutcome = { + /** Bounded model observation, separate from terminal and diagnostic output. */ + modelOutput?: string; +} & ( | { success: true; output?: string; @@ -2172,7 +2177,7 @@ export type ToolActionOutcome = exitCode?: number | null; imagePaths?: string[]; imageRefs?: string[]; - }; + }); export type ToolExecutionResult = { tool: AgentAction['type']; diff --git a/src/ui/shellCommand.ts b/src/ui/shellCommand.ts index 4d698d205..24451b838 100644 --- a/src/ui/shellCommand.ts +++ b/src/ui/shellCommand.ts @@ -636,6 +636,7 @@ export async function executeShellCommandAsync( } finish({ success: false, + ...(stdout.length > 0 ? { output: stdout.toString() } : {}), error: stderr.toString() || error.stderr?.toString() || error.message || 'Unknown error' }); }); @@ -659,6 +660,7 @@ export async function executeShellCommandAsync( finish({ success: false, + ...(stdout.length > 0 ? { output: stdout.toString() } : {}), error: errorMessage }); }); diff --git a/src/utils/commandModelOutput.ts b/src/utils/commandModelOutput.ts new file mode 100644 index 000000000..ae9ad37e7 --- /dev/null +++ b/src/utils/commandModelOutput.ts @@ -0,0 +1,90 @@ +import { closeSync, mkdirSync, openSync, writeSync } from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { CommandOutputCapture } from './commandOutputCapture.js'; + +export const DEFAULT_MAX_COMMAND_OUTPUT_CHARS = 30_000; + +export function resolveMaxCommandOutputChars(configured?: number): number { + const environment = Number(process.env.AUTOHAND_MAX_COMMAND_OUTPUT_CHARS); + if (Number.isSafeInteger(environment) && environment >= 1024) return environment; + return configured ?? DEFAULT_MAX_COMMAND_OUTPUT_CHARS; +} + +/** Spools raw output without retaining it in memory; UI capture remains independent. */ +export class CommandModelOutput { + private pending = ''; + private totalLength = 0; + private descriptor: number | undefined; + private closed = false; + private archiveFailed = false; + private head = ''; + private tail = ''; + filePath: string | undefined; + + constructor(private readonly limit: number, private readonly sessionDirectory?: string) { + if (!Number.isSafeInteger(limit) || limit < 1024) throw new RangeError('Command model output limit must be an integer of at least 1024'); + } + + append(chunk: string): void { + if (this.closed) return; + this.totalLength += chunk.length; + this.head += chunk.slice(0, Math.max(0, this.limit - this.head.length)); + this.tail = (this.tail + chunk.slice(-this.limit)).slice(-this.limit); + if (this.archiveFailed) return; + if (this.totalLength <= this.limit) { this.pending += chunk; return; } + if (!this.sessionDirectory) { this.pending = ''; return; } + try { + if (this.descriptor === undefined) { + const directory = path.join(this.sessionDirectory, 'command-output'); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const filename = path.join(directory, `${randomUUID()}.log`); + this.descriptor = openSync(filename, 'wx', 0o600); + this.filePath = filename; + this.write(this.pending); + this.pending = ''; + } + this.write(chunk); + } catch { + this.archiveFailed = true; + this.filePath = undefined; + this.pending = ''; + this.close(); + } + } + + finish(observation: string): string { + this.closed = true; + this.close(); + if (this.totalLength <= this.limit && observation.length <= this.limit) return observation; + const location = this.filePath + ? `Full command output saved to ${this.filePath}. Use run_command with grep to inspect it.` + : 'Full command output was not saved (session persistence disabled or archive unavailable).'; + if (this.totalLength > this.limit) { + const prefix = observation.split('\n', 1)[0].slice(0, 512) + '\n'; + const room = Math.max(2, this.limit - prefix.length - location.length - 100); + let head = this.head.slice(0, Math.floor(room / 2)); + let tail = this.tail.slice(-Math.ceil(room / 2)); + if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1); + if (/^[\uDC00-\uDFFF]/.test(tail)) tail = tail.slice(1); + return `${prefix}${head}\n[output truncated: ${this.totalLength - head.length - tail.length} characters omitted. ${location}]\n${tail}`; + } + const suffix = `\n[${location}]`; + const capture = new CommandOutputCapture(Math.max(2, this.limit - suffix.length - 96)); + capture.append(observation); + return capture.toString() + suffix; + } + + private write(contents: string): void { + const bytes = Buffer.from(contents); + let offset = 0; + while (offset < bytes.length) offset += writeSync(this.descriptor!, bytes, offset); + } + + private close(): void { + if (this.descriptor === undefined) return; + const descriptor = this.descriptor; + this.descriptor = undefined; + try { closeSync(descriptor); } catch { this.filePath = undefined; } + } +} diff --git a/tests/commands/sync.test.ts b/tests/commands/sync.test.ts index 7f33902fa..7c97942a7 100644 --- a/tests/commands/sync.test.ts +++ b/tests/commands/sync.test.ts @@ -7,6 +7,7 @@ import { PassThrough } from 'node:stream'; import { describe, expect, it, vi } from 'vitest'; import { getSyncService } from '../../src/sync/runtimeSyncService.js'; import { SyncService } from '../../src/sync/SyncService.js'; +import { loadConfig, saveConfig } from '../../src/config.js'; import { sync } from '../../src/commands/sync.js'; import type { SlashCommandContext } from '../../src/core/slashCommandTypes.js'; @@ -14,7 +15,10 @@ vi.mock('../../src/config.js', () => ({ loadConfig: vi.fn().mockResolvedValue({ auth: { token: 'fixture-token', user: { id: 'fixture-user' } } }), saveConfig: vi.fn(), })); -vi.mock('../../src/sync/runtimeSyncService.js', () => ({ getSyncService: vi.fn(() => null), setSyncService: vi.fn() })); +vi.mock('../../src/sync/runtimeSyncService.js', () => { + const getSyncService = vi.fn<() => SyncService | null>(() => null); + return { getSyncService, setSyncService: vi.fn(), setBackgroundSyncEnabled: vi.fn((enabled: boolean) => getSyncService()?.setEnabled(enabled)) }; +}); describe('sync screen input ownership', () => { it('clears an incomplete extended-key timer when the panel closes', async () => { @@ -153,3 +157,20 @@ describe('/sync status', () => { } finally { vi.mocked(getSyncService).mockReturnValue(null); stdin.mockRestore(); log.mockRestore(); input.destroy(); } }); }); + + +describe('/sync on and off', () => { + it('stops the live service before saving off, even without valid authentication', async () => { + vi.mocked(loadConfig).mockResolvedValueOnce({ configPath: '/fixture/config.json', sync: { enabled: true } }); + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { enabled: true, interval: 300000 } }); + const toggle = vi.spyOn(service, 'setEnabled'); + vi.mocked(getSyncService).mockReturnValueOnce(service); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await sync({} as SlashCommandContext, ['off']); + expect(toggle).toHaveBeenCalledWith(false); + expect(saveConfig).toHaveBeenCalledWith(expect.objectContaining({ sync: { enabled: false } })); + expect(toggle.mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(saveConfig).mock.invocationCallOrder.at(-1)!); + } finally { service.stop(); log.mockRestore(); toggle.mockRestore(); } + }); +}); diff --git a/tests/core/actionExecutor.commandOutput.test.ts b/tests/core/actionExecutor.commandOutput.test.ts new file mode 100644 index 000000000..5792d1d43 --- /dev/null +++ b/tests/core/actionExecutor.commandOutput.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { ToolManager } from '../../src/core/toolManager.js'; +import { setConfigSetting } from '../../src/commands/settings.js'; +import { ActionExecutor } from '../../src/core/actionExecutor.js'; +import { FileActionManager } from '../../src/actions/filesystem.js'; +import { formatToolObservation, handleAgentToolOutput, queueAgentToolMessageChunk } from '../../src/core/agent/AgentToolOutputRuntime.js'; +const directories: string[] = []; +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); +describe('command output at the model boundary', () => { + it('preserves the model observation through tool-result normalization', async () => { + const manager = new ToolManager({ executor: async () => ({ success: true, output: 'terminal log', modelOutput: 'model log' }), + definitions: [{ name: 'run_command', description: 'Run a command' }], confirmApproval: async () => true }); + const [result] = await manager.execute([{ tool: 'run_command', args: { command: 'true' } }]); + expect(result.output).toBe('terminal log'); + expect(formatToolObservation(result)).toBe('model log'); + }); + it('accepts the public config key and rejects invalid limits', () => { + const config = { configPath: '/fixture/config.json' }; + expect(setConfigSetting(config, 'tools.maxCommandOutputChars', '4096').value).toBe(4096); + expect(() => setConfigSetting(config, 'tools.maxCommandOutputChars', '0')).toThrow('at least 1024'); + }); + + it.each([ + { type: 'run_command', live: false, exit: 0 }, + { type: 'run_command', live: false, exit: 7 }, + { type: 'shell', live: false, exit: 0 }, + { type: 'shell', live: false, exit: 7 }, + { type: 'shell', live: true, exit: 0 }, + { type: 'shell', live: true, exit: 7 }, + ] as const)('bounds $type live=$live exit=$exit and saves output beyond the UI capture limit', async ({ type, live, exit }) => { + const directory = await mkdtemp(path.join(tmpdir(), 'ah-command-model-')); directories.push(directory); + const script = path.join(directory, 'output.cjs'); + const stdout = 'HEAD\n' + 'x'.repeat(1024 * 1024 + 20000) + '\n400000\n'; + await writeFile(script, `process.stdout.write(${JSON.stringify(stdout)}, () => { process.stderr.write(${JSON.stringify('STDERR_FINAL\n')}, () => { process.exitCode = ${exit}; }); });`); + const actions = new ActionExecutor({ + runtime: { workspaceRoot: directory, config: { configPath: '' }, options: { yes: true } }, + files: new FileActionManager(directory), resolveWorkspacePath: file => path.resolve(directory, file), + confirmDangerousAction: async () => true, getCommandOutputDirectory: () => path.join(directory, 'session'), + ...(live ? { onLiveCommandStart: () => 'live', onLiveCommandOutput: vi.fn(), onLiveCommandRemove: vi.fn(), onLiveCommandFinish: vi.fn() } : {}), + }); + const outcome = await actions.executeForTool({ type, command: `"${process.execPath}" "${script}"` }, { approvalHandled: true }); + expect(outcome.success).toBe(exit === 0); + expect(outcome.output!.length).toBeGreaterThan(30000); + const model = formatToolObservation({ tool: type, ...outcome }); + expect(model.length).toBeLessThanOrEqual(30000); + expect(model).toContain('400000'); expect(model).toContain('STDERR_FINAL'); + const files = await readdir(path.join(directory, 'session', 'command-output')); + const filename = path.join(directory, 'session', 'command-output', files[0]); + const archived = await readFile(filename, 'utf8'); + expect(archived.replace('STDERR_FINAL\n', '')).toBe(stdout); + expect(model).toContain(filename); + }); + it('keeps a 20k log whole and does not create an unnecessary archive', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'ah-command-short-')); directories.push(directory); + const actions = new ActionExecutor({ runtime: { workspaceRoot: directory, config: { configPath: '' }, options: { yes: true } }, + files: new FileActionManager(directory), resolveWorkspacePath: file => path.resolve(directory, file), + confirmDangerousAction: async () => true, getCommandOutputDirectory: () => directory }); + const outcome = await actions.executeForTool({ type: 'run_command', command: `"${process.execPath}" -e 'process.stdout.write("Q".repeat(20000))'` }, { approvalHandled: true }); + expect(formatToolObservation({ tool: 'run_command', ...outcome })).toContain('Q'.repeat(20000)); + expect(await readdir(directory)).not.toContain('command-output'); + }); + it('keeps streamed command chunks out of the conversation even when streaming is enabled', async () => { + vi.stubEnv('AUTOHAND_STREAM_TOOL_OUTPUT', '1'); + const appendTransient = vi.fn(); const queueToolMessageChunk = vi.fn(); + const host = { sessionManager: { getCurrentSession: () => ({ append: vi.fn(), appendTransient }) }, toolOutputQueue: Promise.resolve(), queueToolMessageChunk }; + for (const tool of ['run_command', 'shell'] as const) { + handleAgentToolOutput(host, { tool, toolCallId: 'command', data: 'unbounded', stream: 'stdout' }); + queueAgentToolMessageChunk(host, tool, 'unbounded', 'command', 'stdout'); + } + await host.toolOutputQueue; + expect(queueToolMessageChunk).not.toHaveBeenCalled(); expect(appendTransient).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/runtime/CliRuntimeResourceOwner.test.ts b/tests/runtime/CliRuntimeResourceOwner.test.ts index efc616785..acce0fd6b 100644 --- a/tests/runtime/CliRuntimeResourceOwner.test.ts +++ b/tests/runtime/CliRuntimeResourceOwner.test.ts @@ -48,6 +48,17 @@ function makeService() { } describe('CliRuntimeResourceOwner', () => { + it('owns an opted-out service without starting it, so /sync can enable it later', async () => { + const service = makeService(); const register = vi.fn(); + const owner = new CliRuntimeResourceOwner({ process: new TestProcess(), stopPing: vi.fn(), setSyncService: register, onSignal: vi.fn() }); + owner.startBackgroundStartup({ resolveAuthAndVersion: async () => ({ authUser: { id: 'user' }, versionResult: null }), + onVersionResult: vi.fn(), shouldCreateSync: () => true, shouldStartSync: () => false, createSyncService: async () => service }); + await vi.waitFor(() => expect(register).toHaveBeenCalledWith(service)); + expect(service.start).not.toHaveBeenCalled(); + await owner.shutdown(); + expect(service.shutdown).toHaveBeenCalled(); + }); + it('never starts background work when disabled by the launch policy', async () => { const stopPing = vi.fn(); const startPing = vi.fn(); diff --git a/tests/session/SessionManager.test.ts b/tests/session/SessionManager.test.ts index 2c7f27d78..b718a58ca 100644 --- a/tests/session/SessionManager.test.ts +++ b/tests/session/SessionManager.test.ts @@ -391,6 +391,21 @@ describe('SessionManager', () => { expect(await manager.getLastSession('/empty')).toBeNull(); }); + it('continues a conversation instead of a newer empty session, within the current project', async () => { + const manager = new SessionManager(tmpDir); + const conversation = await manager.createSession('/project', 'test-model'); + await conversation.append({ role: 'user', content: 'Remember BANANA-42', timestamp: new Date().toISOString() }); + Object.assign(conversation.metadata, { lastActiveAt: '2026-01-01' }); + await conversation.save(); + const empty = await manager.createSession('/project', 'test-model'); + Object.assign(empty.metadata, { lastActiveAt: '2026-01-03' }); + await empty.save(); + const elsewhere = await manager.createSession('/other', 'test-model'); + await elsewhere.append({ role: 'user', content: 'Other conversation', timestamp: new Date().toISOString() }); + expect((await manager.getLastSession('/project'))?.sessionId).toBe(conversation.metadata.sessionId); + expect((await manager.getLastSession('/other'))?.sessionId).toBe(elsewhere.metadata.sessionId); + }); + it('loads only the newest indexed metadata page for the resume picker', async () => { const projectPath = '/workspace/recent-sessions'; const entries = [ diff --git a/tests/sync/syncOptIn.test.ts b/tests/sync/syncOptIn.test.ts new file mode 100644 index 000000000..4a5b42837 --- /dev/null +++ b/tests/sync/syncOptIn.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SyncService } from '../../src/sync/SyncService.js'; +import { DEFAULT_SYNC_CONFIG } from '../../src/sync/types.js'; +import { scheduleBackgroundSync, setBackgroundSyncEnabled, setSyncService } from '../../src/sync/runtimeSyncService.js'; + +afterEach(() => { setSyncService(null); vi.useRealTimers(); vi.restoreAllMocks(); }); +describe('opt-in background sync', () => { + it('applies an explicit choice made before startup has registered the service', () => { + vi.useFakeTimers(); + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { ...DEFAULT_SYNC_CONFIG } }); + vi.spyOn(service, 'sync').mockResolvedValue({ success: true, uploaded: 0, downloaded: 0, conflicts: 0 }); + try { + setBackgroundSyncEnabled(true); setSyncService(service); + expect(service.isRunning).toBe(true); + setBackgroundSyncEnabled(false); + expect(service.isRunning).toBe(false); + } finally { service.stop(); } + }); + + it('makes no sync request over ten minutes with the fresh-install defaults', async () => { + vi.useFakeTimers(); + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { ...DEFAULT_SYNC_CONFIG } }); + const sync = vi.spyOn(service, 'sync').mockResolvedValue({ success: true, uploaded: 0, downloaded: 0, conflicts: 0 }); + try { + service.start(); + await vi.advanceTimersByTimeAsync(600_000); + expect(service.isRunning).toBe(false); + expect(sync).not.toHaveBeenCalled(); + } finally { service.stop(); } + }); + it('disables a running timer and queued sync immediately, and can opt back in', async () => { + vi.useFakeTimers(); + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { ...DEFAULT_SYNC_CONFIG, enabled: true } }); + const sync = vi.spyOn(service, 'sync').mockResolvedValue({ success: true, uploaded: 0, downloaded: 0, conflicts: 0 }); + try { + service.start(); setSyncService(service); scheduleBackgroundSync(); + service.setEnabled(false); + expect(service.isRunning).toBe(false); + await vi.advanceTimersByTimeAsync(600_000); + expect(sync).toHaveBeenCalledTimes(1); + service.setEnabled(true); + expect(service.isRunning).toBe(true); + expect(sync).toHaveBeenCalledTimes(2); + service.stop(); service.setEnabled(true); + expect(service.isRunning).toBe(false); + } finally { service.stop(); } + }); +}); diff --git a/tests/tuistory/continuation-output.tuistory.test.ts b/tests/tuistory/continuation-output.tuistory.test.ts new file mode 100644 index 000000000..302b9e041 --- /dev/null +++ b/tests/tuistory/continuation-output.tuistory.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { createMockOpenRouterSequenceServer, createTempAutohandHome, exitInteractive, launchBuiltAutohand, waitForExit } from './helpers/autohandTuistory.js'; + +describe('continuation and command output release regressions', () => { + it('remembers BANANA-42 across an empty interactive launch and -c', async () => { + const server = await createMockOpenRouterSequenceServer([JSON.stringify({ finalResponse: 'BANANA-42' })]); + const state = await createTempAutohandHome({ config: { openrouter: { baseUrl: server.baseUrl }, + features: { automaticSpecialists: false }, agent: { autoMemory: false, maxIterations: 3, sessionRetryLimit: 0 }, ui: { promptSuggestions: false } } }); + try { + const first = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-p', 'Reply with exactly: BANANA-42'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + try { await waitForExit(first, 30000); expect(first.getRawOutput()).toContain('BANANA-42'); expect(first.exitInfo?.exitCode).toBe(0); } finally { first.close(); } + const empty = await launchBuiltAutohand(['--config', state.configPath, '--offline'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + try { await empty.waitForText('❯'); await exitInteractive(empty); } finally { empty.close(); } + const continued = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-c', '-p', 'What did I ask you to reply with?'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + try { await waitForExit(continued, 30000); expect(continued.getRawOutput()).toContain('BANANA-42'); expect(continued.exitInfo?.exitCode).toBe(0); } finally { continued.close(); } + expect(JSON.stringify(server.requests.at(-1))).toContain('Reply with exactly: BANANA-42'); + } finally { await server.close(); await state.cleanup(); } + }, 90000); + + it('shows a visible final answer after seq 1 400000 with a bounded provider observation', async () => { + const server = await createMockOpenRouterSequenceServer([ + JSON.stringify({ toolCalls: [{ tool: 'run_command', args: { command: 'seq 1 400000' } }] }), + JSON.stringify({ finalResponse: '400000' }), + ]); + const state = await createTempAutohandHome({ config: { openrouter: { baseUrl: server.baseUrl }, + features: { automaticSpecialists: false }, agent: { autoMemory: false, maxIterations: 3, sessionRetryLimit: 0 }, ui: { promptSuggestions: false } } }); + try { + const session = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-p', 'Run seq 1 400000 once and reply with the last number.'], + { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { AUTOHAND_STREAM_TOOL_OUTPUT: '1' } }); + try { + await waitForExit(session, 30000); + expect(session.getRawOutput()).toContain('400000'); expect(session.exitInfo?.exitCode).toBe(0); + const request = server.requests.at(-1)!; + const messages = request.messages as Array<{ role: string; content: string }>; + const observations = messages.filter(message => message.role === 'tool'); + expect(observations).toHaveLength(1); + expect(observations[0].content.length).toBeLessThanOrEqual(30000); + expect(observations[0].content).toContain('400000'); expect(observations[0].content).toContain('Full command output saved to'); + } finally { session.close(); } + } finally { await server.close(); await state.cleanup(); } + }, 60000); +}); diff --git a/tests/tuistory/resume.tuistory.test.ts b/tests/tuistory/resume.tuistory.test.ts index 0e93b0f18..8af18a4f4 100644 --- a/tests/tuistory/resume.tuistory.test.ts +++ b/tests/tuistory/resume.tuistory.test.ts @@ -95,7 +95,7 @@ describe('resume startup Tuistory', () => { }); it.each([ - { args: ['--last'], expected: 'resume-active' }, + { args: ['--last'], expected: 'resume-newer' }, { args: ['--last', '--all'], expected: 'elsewhere-session' }, ])('resumes the latest active session for $args', async ({ args, expected }) => { const { session, state } = await launch(args); diff --git a/tests/utils/commandModelOutput.test.ts b/tests/utils/commandModelOutput.test.ts new file mode 100644 index 000000000..b3e1bc33b --- /dev/null +++ b/tests/utils/commandModelOutput.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { CommandModelOutput, resolveMaxCommandOutputChars } from '../../src/utils/commandModelOutput.js'; +const directories: string[] = []; +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); +describe('model command output', () => { + it('keeps short output intact and respects the environment override', () => { + expect(resolveMaxCommandOutputChars()).toBe(30000); + vi.stubEnv('AUTOHAND_MAX_COMMAND_OUTPUT_CHARS', '4096'); + expect(resolveMaxCommandOutputChars(8000)).toBe(4096); + const capture = new CommandModelOutput(4096); + capture.append('a short log'); + expect(capture.finish('a short log')).toBe('a short log'); + }); + it('archives every raw chunk beyond the UI limit and bounds the model head and tail together', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'ah-model-output-')); directories.push(directory); + const capture = new CommandModelOutput(30000, directory); + const chunks = ['HEAD\n', 'x'.repeat(1024 * 1024 + 100000), '\nSTDERR\n', 'z'.repeat(50000), '\n400000\n']; + chunks.forEach(chunk => capture.append(chunk)); + const model = capture.finish(chunks.join('')); + expect(model.length).toBeLessThanOrEqual(30000); + expect(model).toContain('HEAD'); expect(model).toContain('400000'); expect(model).toContain('output truncated:'); + expect(capture.filePath).toBeDefined(); + expect(await readFile(capture.filePath!, 'utf8')).toBe(chunks.join('')); + expect((await stat(capture.filePath!)).mode & 0o777).toBe(0o600); + expect(model).toContain(capture.filePath); + }); + it('reports an unavailable archive without inventing a path and still caps output', () => { + const capture = new CommandModelOutput(2000); + capture.append('x'.repeat(10000)); + const output = capture.finish('x'.repeat(10000)); + expect(output.length).toBeLessThanOrEqual(2000); + expect(output).toContain('not saved'); + expect(capture.filePath).toBeUndefined(); + }); +}); From 04b4b8370a7c02e99e3ea97bc2b227a7313361a5 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 5 Nov 2026 03:47:20 +1300 Subject: [PATCH 2/5] Preserve bounded command observations through script dispatch --- src/core/agent/CodeModeRunner.ts | 7 +++++++ tests/core/agent/CodeModeRunner.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/core/agent/CodeModeRunner.ts b/src/core/agent/CodeModeRunner.ts index 8e0200f12..777baa746 100644 --- a/src/core/agent/CodeModeRunner.ts +++ b/src/core/agent/CodeModeRunner.ts @@ -95,6 +95,13 @@ function isArgumentObject(value: unknown): value is NonNullable { }); }); + + +describe('bounded command observations through code mode', () => { + it.each([true, false])('passes the bounded command observation to the guest, success=%s', async success => { + const { host } = createHost(call => success + ? { tool: call.tool, success: true, output: 'RAW'.repeat(100000), modelOutput: 'BOUNDED command output' } + : { tool: call.tool, success: false, kind: 'command', error: 'RAW'.repeat(100000), output: 'RAW'.repeat(100000), modelOutput: 'BOUNDED command failure' }); + await runCodeModeScript(host, INPUT, sandbox(async call => { + const [outcome] = await call([{ tool: 'run_command', args: { command: 'seq 1 400000' } }]); + expect(JSON.stringify(outcome)).not.toContain('RAW'); + expect(JSON.stringify(outcome)).toContain('BOUNDED'); + return { result: JSON.stringify(outcome) }; + })); + }); +}); From 432734e06a454605bc0858f6865474b4ac071344 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 5 Nov 2026 04:01:53 +1300 Subject: [PATCH 3/5] Keep short command results compatible and isolate sync configuration --- src/core/actionExecutor.ts | 5 +++-- src/sync/SyncService.ts | 2 +- tests/core/agent/CodeModeRunner.test.ts | 3 ++- tests/sync/syncOptIn.test.ts | 9 +++++++++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/core/actionExecutor.ts b/src/core/actionExecutor.ts index 717bfc045..5d8d5dc92 100644 --- a/src/core/actionExecutor.ts +++ b/src/core/actionExecutor.ts @@ -934,8 +934,9 @@ export class ActionExecutor { } const withImages = (outcome: ToolActionOutcome): ToolActionOutcome => { if (capture.commandOutput) { - const modelOutput = capture.commandOutput.finish(formatToolObservation({ tool: action.type, ...outcome })); - outcome = { ...outcome, modelOutput }; + const observation = formatToolObservation({ tool: action.type, ...outcome }); + const modelOutput = capture.commandOutput.finish(observation); + if (modelOutput !== observation) outcome = { ...outcome, modelOutput }; } return capture.imagePaths?.length ? { ...outcome, imagePaths: capture.imagePaths } : outcome; }; diff --git a/src/sync/SyncService.ts b/src/sync/SyncService.ts index 3b3b73a79..3af56d296 100644 --- a/src/sync/SyncService.ts +++ b/src/sync/SyncService.ts @@ -171,7 +171,7 @@ export class SyncService { constructor(options: SyncServiceOptions) { this.authToken = options.authToken; this.userId = options.userId; - this.config = options.config; + this.config = { ...options.config }; this.client = options.apiClient || getSyncApiClient(); this.onEvent = options.onEvent || (() => {}); this.onAuthFailure = options.onAuthFailure; diff --git a/tests/core/agent/CodeModeRunner.test.ts b/tests/core/agent/CodeModeRunner.test.ts index 9a5add2ad..f358fe34b 100644 --- a/tests/core/agent/CodeModeRunner.test.ts +++ b/tests/core/agent/CodeModeRunner.test.ts @@ -336,11 +336,12 @@ describe('bounded command observations through code mode', () => { const { host } = createHost(call => success ? { tool: call.tool, success: true, output: 'RAW'.repeat(100000), modelOutput: 'BOUNDED command output' } : { tool: call.tool, success: false, kind: 'command', error: 'RAW'.repeat(100000), output: 'RAW'.repeat(100000), modelOutput: 'BOUNDED command failure' }); - await runCodeModeScript(host, INPUT, sandbox(async call => { + const result = await runCodeModeScript(host, INPUT, sandbox(async call => { const [outcome] = await call([{ tool: 'run_command', args: { command: 'seq 1 400000' } }]); expect(JSON.stringify(outcome)).not.toContain('RAW'); expect(JSON.stringify(outcome)).toContain('BOUNDED'); return { result: JSON.stringify(outcome) }; })); + expect(result.success).toBe(true); }); }); diff --git a/tests/sync/syncOptIn.test.ts b/tests/sync/syncOptIn.test.ts index 4a5b42837..f585fad03 100644 --- a/tests/sync/syncOptIn.test.ts +++ b/tests/sync/syncOptIn.test.ts @@ -5,6 +5,15 @@ import { scheduleBackgroundSync, setBackgroundSyncEnabled, setSyncService } from afterEach(() => { setSyncService(null); vi.useRealTimers(); vi.restoreAllMocks(); }); describe('opt-in background sync', () => { + it('does not mutate the caller configuration when the running service is toggled', () => { + vi.useFakeTimers(); + const config = { ...DEFAULT_SYNC_CONFIG }; + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config }); + vi.spyOn(service, 'sync').mockResolvedValue({ success: true, uploaded: 0, downloaded: 0, conflicts: 0 }); + try { service.setEnabled(true); expect(config.enabled).toBe(false); } + finally { service.stop(); } + }); + it('applies an explicit choice made before startup has registered the service', () => { vi.useFakeTimers(); const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { ...DEFAULT_SYNC_CONFIG } }); From 1e7a42eb5ef52c6f08258d1b3a3f1829d39bc653 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 5 Nov 2026 04:12:40 +1300 Subject: [PATCH 4/5] Authenticate the continuation terminal fixture against its local mock --- .../tuistory/continuation-output.tuistory.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/tuistory/continuation-output.tuistory.test.ts b/tests/tuistory/continuation-output.tuistory.test.ts index 302b9e041..39f8e6ed8 100644 --- a/tests/tuistory/continuation-output.tuistory.test.ts +++ b/tests/tuistory/continuation-output.tuistory.test.ts @@ -1,20 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { createMockOpenRouterSequenceServer, createTempAutohandHome, exitInteractive, launchBuiltAutohand, waitForExit } from './helpers/autohandTuistory.js'; +import { createMockAuthServer, createMockOpenRouterSequenceServer, createTempAutohandHome, exitInteractive, launchBuiltAutohand, waitForExit } from './helpers/autohandTuistory.js'; describe('continuation and command output release regressions', () => { it('remembers BANANA-42 across an empty interactive launch and -c', async () => { const server = await createMockOpenRouterSequenceServer([JSON.stringify({ finalResponse: 'BANANA-42' })]); - const state = await createTempAutohandHome({ config: { openrouter: { baseUrl: server.baseUrl }, + const auth = await createMockAuthServer(); + const state = await createTempAutohandHome({ config: { api: { baseUrl: auth.baseUrl }, openrouter: { baseUrl: server.baseUrl }, features: { automaticSpecialists: false }, agent: { autoMemory: false, maxIterations: 3, sessionRetryLimit: 0 }, ui: { promptSuggestions: false } } }); + const launchOptions = { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { AUTOHAND_API_URL: auth.baseUrl, AUTOHAND_AUTH_URL: auth.baseUrl, AUTOHAND_AUTH_API_URL: `${auth.baseUrl}/api/auth` } }; try { - const first = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-p', 'Reply with exactly: BANANA-42'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + const first = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-p', 'Reply with exactly: BANANA-42'], launchOptions); try { await waitForExit(first, 30000); expect(first.getRawOutput()).toContain('BANANA-42'); expect(first.exitInfo?.exitCode).toBe(0); } finally { first.close(); } - const empty = await launchBuiltAutohand(['--config', state.configPath, '--offline'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + const empty = await launchBuiltAutohand(['--config', state.configPath, '--offline'], launchOptions); try { await empty.waitForText('❯'); await exitInteractive(empty); } finally { empty.close(); } - const continued = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-c', '-p', 'What did I ask you to reply with?'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + const continued = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-c', '-p', 'What did I ask you to reply with?'], launchOptions); try { await waitForExit(continued, 30000); expect(continued.getRawOutput()).toContain('BANANA-42'); expect(continued.exitInfo?.exitCode).toBe(0); } finally { continued.close(); } expect(JSON.stringify(server.requests.at(-1))).toContain('Reply with exactly: BANANA-42'); - } finally { await server.close(); await state.cleanup(); } + } finally { await server.close(); await auth.close(); await state.cleanup(); } }, 90000); it('shows a visible final answer after seq 1 400000 with a bounded provider observation', async () => { From 06d7eaee7c221f527d5d7a70a8ece8c7596ffac0 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 5 Nov 2026 04:47:26 +1300 Subject: [PATCH 5/5] Distinguish the visible final reply from command output in terminal proof --- tests/tuistory/continuation-output.tuistory.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tuistory/continuation-output.tuistory.test.ts b/tests/tuistory/continuation-output.tuistory.test.ts index 39f8e6ed8..55767d6ca 100644 --- a/tests/tuistory/continuation-output.tuistory.test.ts +++ b/tests/tuistory/continuation-output.tuistory.test.ts @@ -22,7 +22,7 @@ describe('continuation and command output release regressions', () => { it('shows a visible final answer after seq 1 400000 with a bounded provider observation', async () => { const server = await createMockOpenRouterSequenceServer([ JSON.stringify({ toolCalls: [{ tool: 'run_command', args: { command: 'seq 1 400000' } }] }), - JSON.stringify({ finalResponse: '400000' }), + JSON.stringify({ finalResponse: 'VISIBLE_FINAL: 400000' }), ]); const state = await createTempAutohandHome({ config: { openrouter: { baseUrl: server.baseUrl }, features: { automaticSpecialists: false }, agent: { autoMemory: false, maxIterations: 3, sessionRetryLimit: 0 }, ui: { promptSuggestions: false } } }); @@ -31,7 +31,7 @@ describe('continuation and command output release regressions', () => { { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { AUTOHAND_STREAM_TOOL_OUTPUT: '1' } }); try { await waitForExit(session, 30000); - expect(session.getRawOutput()).toContain('400000'); expect(session.exitInfo?.exitCode).toBe(0); + expect(session.getRawOutput()).toContain('VISIBLE_FINAL: 400000'); expect(session.exitInfo?.exitCode).toBe(0); const request = server.requests.at(-1)!; const messages = request.messages as Array<{ role: string; content: string }>; const observations = messages.filter(message => message.role === 'tool');