From 504bdb392f9a19d90642153f9c87c96e13c98a5d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 16 Jul 2026 22:04:27 +0200 Subject: [PATCH 01/23] feat(coding-agent): surface refinement and queue prompts --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/cli/daemon-command.ts | 7 + .../coding-agent/src/cli/daemon-launch.ts | 1 + .../coding-agent/src/core/agent-session.ts | 147 +++++++++++------- packages/coding-agent/src/core/cron-jobs.ts | 2 + .../src/modes/agent-connection/snapshot.ts | 1 + .../src/modes/agent-connection/types.ts | 3 + .../modes/agents-view/agents-view-state.ts | 3 + .../src/modes/daemon/daemon-mode.ts | 34 ++-- .../src/modes/daemon/daemon-session-list.ts | 4 + .../modes/daemon/daemon-session-summarizer.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 30 +++- .../coding-agent/src/modes/rpc/rpc-mode.ts | 1 + .../coding-agent/src/modes/rpc/rpc-types.ts | 1 + .../test/agents-view-state.test.ts | 5 + .../test/interactive-mode-status.test.ts | 25 +++ .../suite/agent-session-compaction.test.ts | 24 +-- .../test/suite/agent-session-queue.test.ts | 103 ++++++++---- 18 files changed, 281 insertions(+), 113 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7e2d3c7e68..3d53e8312b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Added `@` file-path autocomplete to new-agent and reply prompts in the Agents View. +- Added visible refinement status and queued user prompts until refinement finishes. ## [0.3.1] - 2026-07-15 diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index 082899e568..39604a847d 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -1440,6 +1440,12 @@ class DaemonAttachTerminal { case "compaction_end": this.writeLine(chalk.dim(`Compaction ${event.aborted ? "aborted" : "finished"}: ${event.reason}`)); return; + case "refinement_start": + this.writeLine(chalk.dim("Refinement started.")); + return; + case "refinement_end": + this.writeLine(chalk.dim("Refinement finished.")); + return; case "auto_retry_start": this.writeLine(chalk.dim(`Retry ${event.attempt}/${event.maxAttempts}: ${event.errorMessage}`)); return; @@ -1615,6 +1621,7 @@ function isSessionSummary(value: unknown): value is SessionSummary { typeof candidate.activity === "string" && typeof candidate.isStreaming === "boolean" && typeof candidate.isCompacting === "boolean" && + (candidate.isRefining === undefined || typeof candidate.isRefining === "boolean") && typeof candidate.attachedClients === "number" && typeof candidate.messageCount === "number" && typeof candidate.pendingMessageCount === "number" diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 6f605fcde2..39211964a8 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -158,6 +158,7 @@ export function isSessionBusy(summary: SessionSummary): boolean { return ( summary.isStreaming || summary.isCompacting || + summary.isRefining === true || summary.isBashRunning === true || summary.hasRunningRlmChildren === true || summary.pendingMessageCount > 0 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 677de8f02c..23710606ec 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -269,6 +269,8 @@ export type AgentSessionEvent = followUp: readonly string[]; } | { type: "compaction_start"; reason: CompactionReason; customInstructions?: string } + | { type: "refinement_start" } + | { type: "refinement_end" } | { type: "session_info_changed"; name: string | undefined } | { type: "thinking_level_changed"; level: ThinkingLevel } | { type: "service_tier_changed"; serviceTier: ServiceTier } @@ -893,8 +895,8 @@ export class AgentSession { private _autoRefineInProgress = false; private _compactAutoRefinePending = false; private _turnIntervalAutoRefinePending = false; - private _postCompactionContinuationScheduled = false; - private _postCompactionContinuationTimer: ReturnType | undefined; + private _queuedContinueScheduled = false; + private _queuedContinueTimer: ReturnType | undefined; private _postCompactionContinuationMessages: AgentMessage[] = []; private _queuedAutonomousThresholdContinuations = new WeakMap(); private _queuedAutonomousContinuationSnapshots = new WeakMap(); @@ -1689,7 +1691,7 @@ export class AgentSession { this._continueAfterThresholdCompaction = false; } if (!this.agent.hasQueuedMessages()) { - this._cancelPostCompactionContinue(); + this._cancelQueuedContinue(); } } @@ -2558,7 +2560,7 @@ export class AgentSession { // resolution cannot write harness state or re-subscribe handlers. this._autoRefineReviewAbort?.abort(); this._refineAbortController?.abort(); - this._discardPendingAutoRefine({ cancelPostCompactionContinue: true }); + this._discardPendingAutoRefine({ cancelQueuedContinue: true }); this._autoRefineBranchVersion++; this._cancelActiveRlmChildRuns("Parent session disposed"); for (const unsubscribe of this._retainedRlmChildUnsubscribes.values()) { @@ -2699,6 +2701,11 @@ export class AgentSession { ); } + /** Whether continual harness refinement is currently running. */ + get isRefining(): boolean { + return this._refineInFlight !== undefined; + } + /** All messages including custom types like BashExecutionMessage */ get messages(): AgentMessage[] { return this.agent.state.messages; @@ -3155,8 +3162,20 @@ export class AgentSession { // enqueue according to the requested behavior. const shouldQueueForStreaming = this.isStreaming; const shouldQueueForPendingWork = hasQueueIfBusyBackpressure(); - if (shouldQueueForStreaming || shouldQueueForPendingWork) { - if (!options?.streamingBehavior) { + const shouldQueueUserPromptBehindExistingMessages = + options?.agentMessageId === undefined && this._queuedContinueScheduled && this.pendingMessageCount > 0; + const shouldQueueForRefinement = this.isRefining; + if ( + shouldQueueForStreaming || + shouldQueueForPendingWork || + shouldQueueUserPromptBehindExistingMessages || + shouldQueueForRefinement + ) { + const streamingBehavior = + shouldQueueForRefinement || shouldQueueUserPromptBehindExistingMessages + ? "followUp" + : options?.streamingBehavior; + if (!streamingBehavior) { const stateDescription = shouldQueueForStreaming ? "Agent is already processing" : "Agent has queued work"; @@ -3167,12 +3186,12 @@ export class AgentSession { const queued = await this._queuePromptWithPendingNextTurnMessages( expandedText, currentImages, - options.streamingBehavior, + streamingBehavior, { - queueKey: options.followUpQueueKey, - agentMessageId: options.agentMessageId, - suppressAutonomousContinuation: options.suppressAutonomousContinuation, - customMessage: options.customMessage, + queueKey: options?.followUpQueueKey, + agentMessageId: options?.agentMessageId, + suppressAutonomousContinuation: options?.suppressAutonomousContinuation, + customMessage: options?.customMessage, }, ); if (!queued) { @@ -3332,18 +3351,21 @@ export class AgentSession { reportPreflight(false); throw new Error("Accepted agent message was cleared before delivery."); } + const refiningAtHandoff = this.isRefining; const shouldQueueAtHandoff = - options?.queueIfBusy === true && - (this.isStreaming || - this.pendingMessageCount > 0 || - this.isCompacting || - this.isRetrying || - this.isBashRunning || - this._acceptedPromptCompletions.size > 0 || - (this._acceptedAgentMessagePrompt !== undefined && - this._acceptedAgentMessagePrompt !== acceptedAgentMessagePrompt)); + refiningAtHandoff || + (options?.queueIfBusy === true && + (this.isStreaming || + this.pendingMessageCount > 0 || + this.isCompacting || + this.isRetrying || + this.isBashRunning || + this._acceptedPromptCompletions.size > 0 || + (this._acceptedAgentMessagePrompt !== undefined && + this._acceptedAgentMessagePrompt !== acceptedAgentMessagePrompt))); if (shouldQueueAtHandoff) { - if (!options?.streamingBehavior) { + const streamingBehavior = refiningAtHandoff ? "followUp" : options?.streamingBehavior; + if (!streamingBehavior) { if (acceptedAgentMessagePrompt && this._acceptedAgentMessagePrompt === acceptedAgentMessagePrompt) { this._acceptedAgentMessagePrompt = undefined; } @@ -3360,12 +3382,12 @@ export class AgentSession { const queued = await this._queuePromptWithPendingNextTurnMessages( expandedText, currentImages, - options.streamingBehavior, + streamingBehavior, { - queueKey: options.followUpQueueKey, - agentMessageId: options.agentMessageId, - suppressAutonomousContinuation: options.suppressAutonomousContinuation, - customMessage: options.customMessage, + queueKey: options?.followUpQueueKey, + agentMessageId: options?.agentMessageId, + suppressAutonomousContinuation: options?.suppressAutonomousContinuation, + customMessage: options?.customMessage, }, ); if (!queued) { @@ -4499,7 +4521,7 @@ export class AgentSession { * @param customInstructions Optional instructions for the compaction summary */ async compact(customInstructions?: string): Promise { - const hadPostCompactionContinue = this._postCompactionContinuationScheduled; + const hadQueuedContinue = this._queuedContinueScheduled; this._disconnectFromAgent(); await this.abort(); let didCompact = false; @@ -4561,9 +4583,9 @@ export class AgentSession { } resolveCompactionOperation(); if (didCompact) { - this._discardPendingAutoRefine({ cancelPostCompactionContinue: true }); - if (hadPostCompactionContinue) { - this._schedulePostCompactionContinue(); + this._discardPendingAutoRefine({ cancelQueuedContinue: true }); + if (hadQueuedContinue) { + this._scheduleQueuedContinue(); } this._scheduleAutoRefine("compact"); } @@ -4672,26 +4694,26 @@ export class AgentSession { return this._rlmDepth === 0 && this._localHarnessStateDir() !== undefined; } - private _cancelPostCompactionContinue(): void { - if (this._postCompactionContinuationTimer) { - clearTimeout(this._postCompactionContinuationTimer); - this._postCompactionContinuationTimer = undefined; + private _cancelQueuedContinue(): void { + if (this._queuedContinueTimer) { + clearTimeout(this._queuedContinueTimer); + this._queuedContinueTimer = undefined; } - this._postCompactionContinuationScheduled = false; + this._queuedContinueScheduled = false; } - private _discardPendingAutoRefine(options: { cancelPostCompactionContinue?: boolean } = {}): void { + private _discardPendingAutoRefine(options: { cancelQueuedContinue?: boolean } = {}): void { this._compactAutoRefinePending = false; this._turnIntervalAutoRefinePending = false; this._pendingAutoRefineReview = undefined; - if (options.cancelPostCompactionContinue) { - this._cancelPostCompactionContinue(); + if (options.cancelQueuedContinue) { + this._cancelQueuedContinue(); } } private async _invalidatePendingAutoRefineForBranchChange(): Promise { this._autoRefineReviewAbort?.abort(); - this._discardPendingAutoRefine({ cancelPostCompactionContinue: true }); + this._discardPendingAutoRefine({ cancelQueuedContinue: true }); this._assistantTurnsSinceAutoRefine = 0; this._autoRefineBranchVersion++; await this._waitForRefineIdle(); @@ -4706,7 +4728,7 @@ export class AgentSession { return; } if (this._compactAutoRefinePending) { - if (this._postCompactionContinuationScheduled) { + if (this._queuedContinueScheduled) { return; } this._scheduleAutoRefine("compact"); @@ -4728,29 +4750,35 @@ export class AgentSession { this._scheduleAutoRefine("compact"); } - private _schedulePostCompactionContinue(): void { - if (this._postCompactionContinuationScheduled) { + private _scheduleQueuedContinue(): void { + if (this._queuedContinueScheduled) { return; } - this._postCompactionContinuationScheduled = true; - this._postCompactionContinuationTimer = setTimeout(() => { - this._postCompactionContinuationTimer = undefined; - void this._runScheduledPostCompactionContinue(); + this._queuedContinueScheduled = true; + this._queuedContinueTimer = setTimeout(() => { + this._queuedContinueTimer = undefined; + void this._runScheduledQueuedContinue(); }, 100); } - private async _runScheduledPostCompactionContinue(): Promise { + private async _runScheduledQueuedContinue(): Promise { await this._waitForRefineIdle(); - if (!this._postCompactionContinuationScheduled) { + if (!this._queuedContinueScheduled) { return; } - if (this.isStreaming || this.isCompacting) { - this._postCompactionContinuationScheduled = false; - this._schedulePostCompactionContinue(); + if ( + this.isStreaming || + this.isCompacting || + this.isRetrying || + this.isBashRunning || + this.hasAcceptedPromptInFlight + ) { + this._queuedContinueScheduled = false; + this._scheduleQueuedContinue(); return; } - this._postCompactionContinuationScheduled = false; + this._queuedContinueScheduled = false; const continuationMessages = [...this._postCompactionContinuationMessages]; try { await this.agent.continue(); @@ -4758,7 +4786,7 @@ export class AgentSession { } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("already processing")) { - this._schedulePostCompactionContinue(); + this._scheduleQueuedContinue(); } } } @@ -4997,11 +5025,16 @@ export class AgentSession { () => undefined, ); this._refineInFlight = settled; + this._emit({ type: "refinement_start" }); try { return await run; } finally { if (this._refineInFlight === settled) { this._refineInFlight = undefined; + if (this.agent.hasQueuedMessages()) { + this._scheduleQueuedContinue(); + } + this._emit({ type: "refinement_end" }); } } } @@ -5275,7 +5308,7 @@ export class AgentSession { // A requested compaction stopped the loop on purpose; don't stall if it fails. const resumeAfterFailure = () => { if (reason === "requested" && (shouldContinueAfterCompaction || this.agent.hasQueuedMessages())) { - this._schedulePostCompactionContinue(); + this._scheduleQueuedContinue(); } }; @@ -5335,13 +5368,13 @@ export class AgentSession { this.agent.state.messages = messages.slice(0, -1); } - this._schedulePostCompactionContinue(); + this._scheduleQueuedContinue(); this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); return true; } else if (shouldContinueAfterCompaction || hasQueuedMessages) { // Compaction can intentionally stop a tool loop between turns. // Queued follow-up/steering/custom messages can also be waiting. - this._schedulePostCompactionContinue(); + this._scheduleQueuedContinue(); this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); } else { this._scheduleAutoRefineAfterCompaction(willContinueAfterCompaction); @@ -6781,7 +6814,7 @@ export class AgentSession { } if (this._retryAttempt > 0) { this._autoCompactionAbortController?.abort(); - this._cancelPostCompactionContinue(); + this._cancelQueuedContinue(); this._emit({ type: "auto_retry_end", success: false, diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index 5e5717ba58..290441baf8 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -85,6 +85,7 @@ export interface AgentCronSchedulerHooks { export interface HeartbeatCronSessionActivity { isStreaming: boolean; isCompacting?: boolean; + isRefining?: boolean; isRetrying?: boolean; isBashRunning: boolean; hasAcceptedPromptInFlight?: boolean; @@ -1260,6 +1261,7 @@ export function shouldDeferHeartbeatCronJob(job: AgentCronJob, activity: Heartbe // regardless of delivery mode. const busyBesidesStreaming = activity.isCompacting === true || + activity.isRefining === true || activity.isRetrying === true || activity.isBashRunning || activity.hasAcceptedPromptInFlight === true || diff --git a/packages/coding-agent/src/modes/agent-connection/snapshot.ts b/packages/coding-agent/src/modes/agent-connection/snapshot.ts index 9f54b77a62..98a8e563a9 100644 --- a/packages/coding-agent/src/modes/agent-connection/snapshot.ts +++ b/packages/coding-agent/src/modes/agent-connection/snapshot.ts @@ -34,6 +34,7 @@ export function createAgentConnectionState( availableThinkingLevels: session.getAvailableThinkingLevels(), isStreaming: session.isStreaming, isCompacting: session.isCompacting, + isRefining: session.isRefining, isBashRunning: session.isBashRunning, retryAttempt: session.retryAttempt, steeringMode: session.steeringMode, diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index 7ea3bf183c..c3065de6fb 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -290,6 +290,7 @@ export interface AgentConnectionState { availableThinkingLevels: ThinkingLevel[]; isStreaming: boolean; isCompacting: boolean; + isRefining?: boolean; isBashRunning: boolean; retryAttempt: number; steeringMode: AgentConnectionQueueMode; @@ -495,6 +496,8 @@ export type AgentConnectionSessionEvent = reason: "manual" | "threshold" | "overflow" | "requested"; customInstructions?: string; } + | { type: "refinement_start" } + | { type: "refinement_end" } | { type: "session_info_changed"; name: string | undefined } | { type: "thinking_level_changed"; level: ThinkingLevel } | { type: "service_tier_changed"; serviceTier: ServiceTier } diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts index b5a009521a..d66be53bfa 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts @@ -379,6 +379,9 @@ function getSessionSubtitle(summary: SessionSummary): string { } function getSessionStatusLabel(summary: SessionSummary): string { + if (summary.isRefining) { + return "refining"; + } if (summary.isCompacting) { return "compacting"; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 23e262a8b2..9a0c325ba8 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -290,6 +290,8 @@ const RECOVERY_CHECKPOINT_EVENTS: ReadonlySet = new Set([ "tool_execution_end", "compaction_start", "compaction_end", + "refinement_start", + "refinement_end", "auto_retry_start", "auto_retry_end", "bash_start", @@ -1681,21 +1683,23 @@ export class AgentDaemon { const session = state.runtime.session; const messages = session.messages; const latest = messages.at(-1); - const status = session.isStreaming - ? session.state.pendingToolCalls.size > 0 - ? "tool" - : "model" - : session.isCompacting - ? "compacting" - : session.isRetrying || - session.isBashRunning || - session.hasAcceptedPromptInFlight || - session.pendingMessageCount > 0 || - session.hasRunningRlmChildren() - ? "busy" - : state.clients.size > 0 - ? "user" - : "idle"; + const status = session.isRefining + ? "refining" + : session.isStreaming + ? session.state.pendingToolCalls.size > 0 + ? "tool" + : "model" + : session.isCompacting + ? "compacting" + : session.isRetrying || + session.isBashRunning || + session.hasAcceptedPromptInFlight || + session.pendingMessageCount > 0 || + session.hasRunningRlmChildren() + ? "busy" + : state.clients.size > 0 + ? "user" + : "idle"; return { activeSessionId: state.activeSessionId, sessionId: summary.sessionId, diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index e219d80abb..b120830c34 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -37,6 +37,7 @@ export interface SessionSummary { thinkingLevel?: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; + isRefining?: boolean; isBashRunning?: boolean; hasRunningRlmChildren?: boolean; /** True while the agent is streaming with tool calls pending; drives the "running tools" label. */ @@ -150,6 +151,7 @@ export function summaryForActiveSession(activeSession: ActiveSessionState, saved thinkingLevel: session.thinkingLevel, isStreaming: session.isStreaming, isCompacting: session.isCompacting, + isRefining: session.isRefining, isBashRunning: session.isBashRunning, hasRunningRlmChildren: session.hasRunningRlmChildren(), isRunningTools: session.isStreaming && session.state.pendingToolCalls.size > 0, @@ -202,6 +204,7 @@ export function summaryForInactiveSession(session: SessionInfo): SessionSummary cwd: session.cwd, isStreaming: false, isCompacting: false, + isRefining: false, attachedClients: 0, messageCount: session.messageCount, pendingMessageCount: 0, @@ -338,6 +341,7 @@ export function isActiveSessionBusy(activeSession: ActiveSessionState): boolean return ( session.isStreaming || session.isCompacting || + session.isRefining || session.isBashRunning || effectivePendingMessageCount(session) > 0 || session.hasRunningRlmChildren() diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-summarizer.ts b/packages/coding-agent/src/modes/daemon/daemon-session-summarizer.ts index 0cbdf11e22..b1d962f56b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-summarizer.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-summarizer.ts @@ -199,7 +199,7 @@ export function agentStatusChanged(previous: AgentStatus | undefined, next: Agen function isSessionWorking(state: ActiveSessionState): boolean { const session = state.runtime.session; - return session.isStreaming || session.isCompacting || session.pendingMessageCount > 0; + return session.isStreaming || session.isCompacting || session.isRefining || session.pendingMessageCount > 0; } /** diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3c01d638cf..e5fa29ff83 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2219,6 +2219,7 @@ export class InteractiveMode { this.sessionRecap = state.recap; this.renderRecap(); this.updateWorkingPulse(); + this.syncWorkingLoader(); } private patchConnectionState(patch: Partial): void { @@ -2282,6 +2283,12 @@ export class InteractiveMode { case "compaction_end": this.patchConnectionState({ isCompacting: false }); break; + case "refinement_start": + this.patchConnectionState({ isRefining: true }); + break; + case "refinement_end": + this.patchConnectionState({ isRefining: false }); + break; case "session_info_changed": this.patchConnectionState({ sessionName: event.name }); break; @@ -2345,6 +2352,10 @@ export class InteractiveMode { return this.connectionState?.isCompacting ?? false; } + private isAgentRefining(): boolean { + return this.connectionState?.isRefining ?? false; + } + private isBashRunning(): boolean { return this.connectionState?.isBashRunning ?? false; } @@ -2720,6 +2731,9 @@ export class InteractiveMode { const status = this.activityTracker.getStatus(); // The subagent count/recaps live in the tree above the loader, so the loader // message itself no longer repeats "N subagents running". + if (this.isAgentRefining()) { + return elapsed === undefined ? "Refining" : `Refining · ${elapsed}`; + } if (!this.isAgentStreaming()) { return ""; } @@ -2828,8 +2842,8 @@ export class InteractiveMode { private shouldShowWorkingLoader(): boolean { // Background subagents (agent turn done, asyncio tasks still running) would // otherwise show a textless spinner; the subagent tree above the loader carries - // that state, so the loader only shows while the main agent is itself streaming. - return this.workingVisible && this.isAgentStreaming(); + // that state, so the loader only shows while the main agent is streaming or refining. + return this.workingVisible && (this.isAgentStreaming() || this.isAgentRefining()); } // Reconcile the loader with current state for transitions that fire no live @@ -4635,6 +4649,18 @@ export class InteractiveMode { this.ui.requestRender(); break; + case "refinement_start": + this.stopWorkingLoader(); + if (this.workingVisible) { + this.startWorkingLoader(); + } + this.ui.requestRender(); + break; + + case "refinement_end": + this.syncWorkingLoader(); + break; + case "compaction_start": { if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index ff7e93a336..e9f4a73ab6 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -434,6 +434,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { expect(classifyAgentsViewSession(makeSummary({ activity: "idle", messageCount: 4 }))).toBe("needs-input"); }); + test("labels active refinement work", () => { + const [row] = buildAgentsViewRows([makeSummary({ isRefining: true, activity: "working" })]); + expect(row?.statusLabel).toBe("refining"); + }); + test("idle sessions split by the summarizer's completion verdict", () => { // Working is heuristic and ignores taskState. expect( diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 6e615057e2..4d0e42d7b3 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -90,6 +90,7 @@ function createConnectionState(overrides: Partial = {}): A availableThinkingLevels: ["minimal", "low", "medium", "high", "xhigh"], isStreaming: false, isCompacting: false, + isRefining: false, isBashRunning: false, retryAttempt: 0, steeringMode: "all", @@ -108,6 +109,30 @@ function createConnectionState(overrides: Partial = {}): A }; } +test("tracks refinement lifecycle for the working indicator", () => { + const syncWorkingLoader = vi.fn(); + const fakeThis = { + connectionState: createConnectionState(), + updateWorkingPulse: vi.fn(), + syncWorkingLoader, + activityTracker: new AgentActivityTracker(), + workingStartedAt: Date.now(), + workingMessage: undefined, + } as unknown as InteractiveMode; + Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); + const prototype = InteractiveMode.prototype as unknown as { + updateConnectionStateFromEvent(this: InteractiveMode, event: AgentConnectionSessionEvent): void; + getWorkingLoaderMessage(this: InteractiveMode): string; + }; + + prototype.updateConnectionStateFromEvent.call(fakeThis, { type: "refinement_start" }); + expect((fakeThis as unknown as { connectionState: AgentConnectionState }).connectionState.isRefining).toBe(true); + expect(prototype.getWorkingLoaderMessage.call(fakeThis)).toContain("Refining"); + + prototype.updateConnectionStateFromEvent.call(fakeThis, { type: "refinement_end" }); + expect((fakeThis as unknown as { connectionState: AgentConnectionState }).connectionState.isRefining).toBe(false); +}); + describe("mergeChildAgentSnapshots", () => { const rich: AgentConnectionRlmChildAgentSnapshot = { id: "child-1", diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index d84f188f6a..88bcd61f48 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -114,20 +114,20 @@ describe("AgentSession compaction characterization", () => { }); harnesses.push(harness); const internals = harness.session as unknown as { - _schedulePostCompactionContinue(): void; - _cancelPostCompactionContinue(): void; - _postCompactionContinuationScheduled: boolean; + _scheduleQueuedContinue(): void; + _cancelQueuedContinue(): void; + _queuedContinueScheduled: boolean; }; try { await harness.session.prompt("one"); await harness.session.prompt("two"); - internals._schedulePostCompactionContinue(); + internals._scheduleQueuedContinue(); await harness.session.compact(); - expect(internals._postCompactionContinuationScheduled).toBe(true); + expect(internals._queuedContinueScheduled).toBe(true); } finally { - internals._cancelPostCompactionContinue(); + internals._cancelQueuedContinue(); } }); @@ -580,7 +580,7 @@ describe("AgentSession compaction characterization", () => { }); harnesses.push(harness); const sessionInternals = harness.session as unknown as { - _schedulePostCompactionContinue(): void; + _scheduleQueuedContinue(): void; _postCompactionContinuationMessages: AgentMessage[]; }; const steeringMessage = { @@ -600,7 +600,7 @@ describe("AgentSession compaction characterization", () => { const continueSpy = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); const followUpSpy = vi.spyOn(harness.session.agent, "followUp"); - sessionInternals._schedulePostCompactionContinue(); + sessionInternals._scheduleQueuedContinue(); await vi.advanceTimersByTimeAsync(100); expect(continueSpy).toHaveBeenCalledTimes(1); @@ -622,9 +622,9 @@ describe("AgentSession compaction characterization", () => { }); harnesses.push(harness); const sessionInternals = harness.session as unknown as { - _schedulePostCompactionContinue(): void; + _scheduleQueuedContinue(): void; _postCompactionContinuationMessages: AgentMessage[]; - _postCompactionContinuationScheduled: boolean; + _queuedContinueScheduled: boolean; }; const queuedMessage = { role: "user", @@ -639,12 +639,12 @@ describe("AgentSession compaction characterization", () => { .spyOn(harness.session.agent, "continue") .mockRejectedValueOnce(new Error("already processing")); - sessionInternals._schedulePostCompactionContinue(); + sessionInternals._scheduleQueuedContinue(); await vi.advanceTimersByTimeAsync(100); expect(continueSpy).toHaveBeenCalledTimes(1); expect(sessionInternals._postCompactionContinuationMessages).toEqual([queuedMessage]); - expect(sessionInternals._postCompactionContinuationScheduled).toBe(true); + expect(sessionInternals._queuedContinueScheduled).toBe(true); }); it("clears queued autonomous threshold continuations when autonomous mode is disabled", async () => { diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index efe0561d15..73e0abcf2a 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -25,14 +25,14 @@ type AutoRefineInternals = { _scheduleAutoRefine(reason: AutoRefineReason): void; _scheduleAutoRefineAfterCompaction(willContinueAfterCompaction: boolean): void; _scheduleAutoRefineAfterAgentEnd(): void; - _schedulePostCompactionContinue(): void; + _scheduleQueuedContinue(): void; _invalidatePendingAutoRefineForBranchChange(): Promise; - _cancelPostCompactionContinue(): void; + _cancelQueuedContinue(): void; _assistantTurnsSinceAutoRefine: number; _lastAutoRefineReviewAt: number; _compactAutoRefinePending: boolean; _turnIntervalAutoRefinePending: boolean; - _postCompactionContinuationScheduled: boolean; + _queuedContinueScheduled: boolean; _pendingAutoRefineReview?: unknown; _autoRefineInProgress: boolean; _autoRefineBranchVersion: number; @@ -245,13 +245,13 @@ describe("AgentSession queue characterization", () => { const internals = harness.session as unknown as AutoRefineInternals; const scheduleAutoRefine = vi.spyOn(internals, "_scheduleAutoRefine").mockImplementation(() => {}); internals._compactAutoRefinePending = true; - internals._postCompactionContinuationScheduled = true; + internals._queuedContinueScheduled = true; internals._scheduleAutoRefineAfterAgentEnd(); expect(scheduleAutoRefine).not.toHaveBeenCalled(); - internals._postCompactionContinuationScheduled = false; + internals._queuedContinueScheduled = false; internals._scheduleAutoRefineAfterAgentEnd(); expect(scheduleAutoRefine).toHaveBeenCalledWith("compact"); @@ -324,16 +324,16 @@ describe("AgentSession queue characterization", () => { .mockResolvedValueOnce(); try { - internals._schedulePostCompactionContinue(); + internals._scheduleQueuedContinue(); await vi.advanceTimersByTimeAsync(100); expect(continueAgent).toHaveBeenCalledTimes(1); - expect(internals._postCompactionContinuationScheduled).toBe(true); + expect(internals._queuedContinueScheduled).toBe(true); await vi.advanceTimersByTimeAsync(100); expect(continueAgent).toHaveBeenCalledTimes(2); - expect(internals._postCompactionContinuationScheduled).toBe(false); + expect(internals._queuedContinueScheduled).toBe(false); } finally { vi.useRealTimers(); } @@ -349,12 +349,12 @@ describe("AgentSession queue characterization", () => { const continueAgent = vi.spyOn(harness.session.agent, "continue").mockResolvedValue(); try { - internals._schedulePostCompactionContinue(); + internals._scheduleQueuedContinue(); await internals._invalidatePendingAutoRefineForBranchChange(); await vi.advanceTimersByTimeAsync(100); expect(continueAgent).not.toHaveBeenCalled(); - expect(internals._postCompactionContinuationScheduled).toBe(false); + expect(internals._queuedContinueScheduled).toBe(false); } finally { vi.useRealTimers(); } @@ -368,13 +368,13 @@ describe("AgentSession queue characterization", () => { harnesses.push(harness); const internals = harness.session as unknown as AutoRefineInternals; try { - internals._schedulePostCompactionContinue(); + internals._scheduleQueuedContinue(); await expect(harness.session.compact()).rejects.toThrow("Session is too short to compact"); - expect(internals._postCompactionContinuationScheduled).toBe(true); + expect(internals._queuedContinueScheduled).toBe(true); } finally { - internals._cancelPostCompactionContinue(); + internals._cancelQueuedContinue(); vi.useRealTimers(); } }); @@ -1164,7 +1164,7 @@ describe("AgentSession queue characterization", () => { } }); - it("persists a prompt started while a background refine is in flight", async () => { + it("queues and resumes a prompt submitted during refinement", async () => { const harness = await createAutoRefineHarness(); harnesses.push(harness); const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; @@ -1192,30 +1192,81 @@ describe("AgentSession queue characterization", () => { ); }, fauxAssistantMessage("prompt reply"), + fauxAssistantMessage("second reply"), ]); + let secondPrompt: Promise | undefined; + const refinementStates: Array<[string, boolean]> = []; + harness.session.subscribe((event) => { + if (event.type === "refinement_start" || event.type === "refinement_end") { + refinementStates.push([event.type, harness.session.isRefining]); + } + if (event.type === "refinement_end") { + secondPrompt = harness.session.prompt("second prompt"); + } + }); const refinePromise = harness.session.refine({ instructions: "background refine" }); await planStartedPromise; + await harness.session.prompt("hello during refine"); - const promptPromise = harness.session.prompt("hello during refine"); - await new Promise((resolve) => setTimeout(resolve, 10)); - // The prompt must wait for the refine (its response is still queued); - // running now would drop its events while the session is detached. - expect(harness.getPendingResponseCount()).toBe(1); + expect(harness.session.getSteeringMessages()).toEqual([]); + expect(harness.session.getFollowUpMessages()).toEqual(["hello during refine"]); + expect(harness.getPendingResponseCount()).toBe(2); releasePlan?.(); await refinePromise; - await promptPromise; + await secondPrompt; + await vi.waitFor(() => { + expect(getAssistantTexts(harness)).toContain("prompt reply"); + }); - expect( - harness - .eventsOfType("message_end") - .some((event) => event.message.role === "assistant" && getMessageText(event.message) === "prompt reply"), - ).toBe(true); + expect(refinementStates).toEqual([ + ["refinement_start", true], + ["refinement_end", false], + ]); + expect(getUserTexts(harness)).toEqual(["hello during refine", "second prompt"]); + expect(harness.session.pendingMessageCount).toBe(0); const persistedAssistants = harness.sessionManager .getEntries() .filter((entry) => entry.type === "message" && entry.message.role === "assistant"); - expect(persistedAssistants).toHaveLength(1); + expect(persistedAssistants).toHaveLength(2); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + + it("resumes queued prompts after refinement failure", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + let releasePlan: (() => void) | undefined; + const planGate = new Promise((resolve) => { + releasePlan = resolve; + }); + harness.setResponses([ + async () => { + await planGate; + throw new Error("refinement failed"); + }, + fauxAssistantMessage("queued reply"), + ]); + + const refinePromise = harness.session.refine({ instructions: "fail this refinement" }); + await vi.waitFor(() => expect(harness.session.isRefining).toBe(true)); + await harness.session.prompt("preserve this prompt"); + releasePlan?.(); + + await expect(refinePromise).rejects.toThrow(); + await vi.waitFor(() => { + expect(getAssistantTexts(harness)).toContain("queued reply"); + }); + expect(harness.session.pendingMessageCount).toBe(0); } finally { if (previousAgentDir === undefined) { delete process.env.PRIME_AGENT_CODING_AGENT_DIR; From 3130f0ed42b4e7abef4ef70b4127f2ea3c0234ee Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 16 Jul 2026 22:28:01 +0200 Subject: [PATCH 02/23] fix(coding-agent): preserve internal prompt handoff semantics --- packages/coding-agent/src/core/agent-session.ts | 4 ++-- .../test/suite/agent-session-retry-events.test.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c725e314bb..26e665703c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -3192,7 +3192,7 @@ export class AgentSession { const shouldQueueForPendingWork = hasQueueIfBusyBackpressure(); const shouldQueueUserPromptBehindExistingMessages = options?.agentMessageId === undefined && this._queuedContinueScheduled && this.pendingMessageCount > 0; - const shouldQueueForRefinement = this.isRefining; + const shouldQueueForRefinement = options?.agentMessageId === undefined && this.isRefining; if ( shouldQueueForStreaming || shouldQueueForPendingWork || @@ -3379,7 +3379,7 @@ export class AgentSession { reportPreflight(false); throw new Error("Accepted agent message was cleared before delivery."); } - const refiningAtHandoff = this.isRefining; + const refiningAtHandoff = options?.agentMessageId === undefined && this.isRefining; const shouldQueueAtHandoff = refiningAtHandoff || (options?.queueIfBusy === true && diff --git a/packages/coding-agent/test/suite/agent-session-retry-events.test.ts b/packages/coding-agent/test/suite/agent-session-retry-events.test.ts index e14901b296..e38c63f70d 100644 --- a/packages/coding-agent/test/suite/agent-session-retry-events.test.ts +++ b/packages/coding-agent/test/suite/agent-session-retry-events.test.ts @@ -42,11 +42,11 @@ type SessionRetryCompactionInternals = { _retryPromise: Promise | undefined; _retryResolve: (() => void) | undefined; _autoCompactionAbortController: AbortController | undefined; - _postCompactionContinuationScheduled: boolean; + _queuedContinueScheduled: boolean; _processAgentEvent: (event: AgentEvent) => Promise; _checkCompaction: (message: AssistantMessage) => Promise; - _schedulePostCompactionContinue: () => void; - _cancelPostCompactionContinue: () => void; + _scheduleQueuedContinue: () => void; + _cancelQueuedContinue: () => void; }; describe("AgentSession retry and event characterization", () => { @@ -293,15 +293,15 @@ describe("AgentSession retry and event characterization", () => { internals._retryResolve = resolve; }); internals._autoCompactionAbortController = compactionAbortController; - internals._schedulePostCompactionContinue(); + internals._scheduleQueuedContinue(); try { - expect(internals._postCompactionContinuationScheduled).toBe(true); + expect(internals._queuedContinueScheduled).toBe(true); harness.session.abortRetry(); expect(compactionAbortController.signal.aborted).toBe(true); - expect(internals._postCompactionContinuationScheduled).toBe(false); + expect(internals._queuedContinueScheduled).toBe(false); expect(internals._retryAttempt).toBe(0); expect(harness.session.isRetrying).toBe(false); expect(harness.eventsOfType("auto_retry_end").at(-1)).toMatchObject({ @@ -311,7 +311,7 @@ describe("AgentSession retry and event characterization", () => { }); } finally { internals._autoCompactionAbortController = undefined; - internals._cancelPostCompactionContinue(); + internals._cancelQueuedContinue(); } }); From d51b8a1206810a9f942996052d4fc41f46a08988 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 17 Jul 2026 23:51:27 +0200 Subject: [PATCH 03/23] fix(coding-agent): preserve refinement queue order --- .../coding-agent/src/core/agent-session.ts | 11 ++++- .../test/suite/agent-session-queue.test.ts | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e656b1628f..d76ff3a30f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -3407,8 +3407,11 @@ export class AgentSession { throw new Error("Accepted agent message was cleared before delivery."); } const refiningAtHandoff = options?.agentMessageId === undefined && this.isRefining; + const shouldQueueUserPromptBehindExistingMessagesAtHandoff = + options?.agentMessageId === undefined && this._pendingMessageResumeRequested && this.pendingMessageCount > 0; const shouldQueueAtHandoff = refiningAtHandoff || + shouldQueueUserPromptBehindExistingMessagesAtHandoff || (options?.queueIfBusy === true && (this.isStreaming || this.pendingMessageCount > 0 || @@ -3419,7 +3422,10 @@ export class AgentSession { (this._acceptedAgentMessagePrompt !== undefined && this._acceptedAgentMessagePrompt !== acceptedAgentMessagePrompt))); if (shouldQueueAtHandoff) { - const streamingBehavior = refiningAtHandoff ? "followUp" : options?.streamingBehavior; + const streamingBehavior = + refiningAtHandoff || shouldQueueUserPromptBehindExistingMessagesAtHandoff + ? "followUp" + : options?.streamingBehavior; if (!streamingBehavior) { if (acceptedAgentMessagePrompt && this._acceptedAgentMessagePrompt === acceptedAgentMessagePrompt) { this._acceptedAgentMessagePrompt = undefined; @@ -3443,7 +3449,8 @@ export class AgentSession { agentMessageId: options?.agentMessageId, suppressAutonomousContinuation: options?.suppressAutonomousContinuation, customMessage: options?.customMessage, - resumeIfIdle: options?.resumeIfIdle || refiningAtHandoff, + resumeIfIdle: + options?.resumeIfIdle || refiningAtHandoff || shouldQueueUserPromptBehindExistingMessagesAtHandoff, }, ); if (!queued) { diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index f928372287..87670e9816 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1239,6 +1239,50 @@ describe("AgentSession queue characterization", () => { } }); + it("queues a handoff prompt behind refinement follow-ups already scheduled to resume", async () => { + let releasePreflight: (() => void) | undefined; + const preflightGate = new Promise((resolve) => { + releasePreflight = resolve; + }); + let preflightStarted: (() => void) | undefined; + const preflightStartedPromise = new Promise((resolve) => { + preflightStarted = resolve; + }); + const harness = await createAutoRefineHarness({ + extensionFactories: [ + (pi) => { + pi.on("before_agent_start", async () => { + preflightStarted?.(); + await preflightGate; + }); + }, + ], + }); + harnesses.push(harness); + let releaseRefine: (() => void) | undefined; + const refineGate = new Promise((resolve) => { + releaseRefine = resolve; + }); + const internals = harness.session as unknown as { + _refineInFlight?: Promise; + }; + harness.setResponses([fauxAssistantMessage("first reply"), fauxAssistantMessage("second reply")]); + + const latePrompt = harness.session.prompt("late handoff prompt"); + await preflightStartedPromise; + internals._refineInFlight = refineGate; + await harness.session.followUp("already queued", undefined, { resumeIfIdle: true }); + releasePreflight?.(); + await Promise.resolve(); + internals._refineInFlight = undefined; + releaseRefine?.(); + + await latePrompt; + await vi.waitFor(() => expect(harness.session.pendingMessageCount).toBe(0)); + expect(getUserTexts(harness)).toEqual(["already queued", "late handoff prompt"]); + expect(getAssistantTexts(harness)).toEqual(["first reply", "second reply"]); + }); + it("resumes queued prompts after refinement failure", async () => { const harness = await createAutoRefineHarness(); harnesses.push(harness); From a73a1ca99274a31f5e438ba652ae61ff649f5090 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 18 Jul 2026 10:01:55 +0200 Subject: [PATCH 04/23] fix: preserve queued prompt semantics --- .../coding-agent/src/core/agent-session.ts | 4 +- .../suite/agent-session-autonomous.test.ts | 37 ++++++++++++++++++- .../test/suite/agent-session-queue.test.ts | 20 ++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index d76ff3a30f..be553f2ce2 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -3246,7 +3246,8 @@ export class AgentSession { agentMessageId: options?.agentMessageId, suppressAutonomousContinuation: options?.suppressAutonomousContinuation, customMessage: options?.customMessage, - resumeIfIdle: options?.resumeIfIdle || shouldQueueForRefinement, + resumeIfIdle: + options?.resumeIfIdle || shouldQueueForRefinement || shouldQueueUserPromptBehindExistingMessages, }, ); if (!queued) { @@ -3723,6 +3724,7 @@ export class AgentSession { agentMessageId: options.agentMessageId, message: options.customMessage, prefixMessages: pendingNextTurnMessages, + suppressAutonomousContinuation: options.suppressAutonomousContinuation, resumeIfIdle: options.resumeIfIdle, }); if (!queued) { diff --git a/packages/coding-agent/test/suite/agent-session-autonomous.test.ts b/packages/coding-agent/test/suite/agent-session-autonomous.test.ts index a9299e03c0..8962dbd92b 100644 --- a/packages/coding-agent/test/suite/agent-session-autonomous.test.ts +++ b/packages/coding-agent/test/suite/agent-session-autonomous.test.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fauxAssistantMessage } from "@earendil-works/pi-ai"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { addAutonomousUsage, createAutonomousRuntimeState, @@ -333,6 +333,41 @@ describe("AgentSession autonomous mode", () => { expect(harness.getPendingResponseCount()).toBe(0); }); + it("preserves autonomous suppression for prompts queued during refinement", async () => { + const harness = await createHarness({ + autonomous: { + enabled: true, + maxContinuations: 2, + gates: { + commands: [`${process.execPath} -e "console.error('gate failed'); process.exit(1)"`], + maxRetries: 2, + }, + }, + }); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("Still failing."), fauxAssistantMessage("Unexpected continuation.")]); + let releaseRefine: (() => void) | undefined; + const refineGate = new Promise((resolve) => { + releaseRefine = resolve; + }); + const internals = harness.session as unknown as { _refineInFlight?: Promise }; + internals._refineInFlight = refineGate; + + harness.session.recordHostAutonomousContinuation(); + await harness.session.prompt("queued host gate follow-up", { + internalPrompt: true, + suppressAutonomousContinuation: true, + }); + internals._refineInFlight = undefined; + releaseRefine?.(); + + await vi.waitFor(() => expect(harness.session.pendingMessageCount).toBe(0)); + expect(getUserTexts(harness)).toEqual(["queued host gate follow-up"]); + expect(getAssistantTexts(harness)).toEqual(["Still failing."]); + expect(harness.session.getAutonomousStatus().continuationsUsed).toBe(1); + expect(harness.getPendingResponseCount()).toBe(1); + }); + it("advances retry budget without rerunning a failed autonomous gate until the workspace changes", async () => { const tempDir = join(process.cwd(), `.tmp-autonomous-gate-${Date.now()}-${Math.random().toString(36).slice(2)}`); execFileSync("mkdir", ["-p", join(tempDir, "verification")]); diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 87670e9816..42ebf9933b 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1283,6 +1283,26 @@ describe("AgentSession queue characterization", () => { expect(getAssistantTexts(harness)).toEqual(["first reply", "second reply"]); }); + it("re-arms resume when an early prompt queues behind pending work", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("first reply"), fauxAssistantMessage("second reply")]); + const internals = harness.session as unknown as { + _compactionAbortController?: AbortController; + _pendingMessageResumeQueue: Promise; + }; + internals._compactionAbortController = new AbortController(); + await harness.session.followUp("already queued", undefined, { resumeIfIdle: true }); + await internals._pendingMessageResumeQueue; + internals._compactionAbortController = undefined; + + await harness.session.prompt("queued at early gate"); + + await vi.waitFor(() => expect(harness.session.pendingMessageCount).toBe(0)); + expect(getUserTexts(harness)).toEqual(["already queued", "queued at early gate"]); + expect(getAssistantTexts(harness)).toEqual(["first reply", "second reply"]); + }); + it("resumes queued prompts after refinement failure", async () => { const harness = await createAutoRefineHarness(); harnesses.push(harness); From 1d9cf0a1d3375740ab75bf6dad9450da38b43865 Mon Sep 17 00:00:00 2001 From: Seth Date: Mon, 17 Aug 2026 22:02:25 -0700 Subject: [PATCH 05/23] feat(coding-agent): show exact refinement edits --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/core/agent-session.ts | 43 ++++-- packages/coding-agent/src/core/messages.ts | 85 +++++++++++- .../components/conversation-components.ts | 13 ++ .../components/refinement-outcome-message.ts | 116 ++++++++++++++++ .../src/modes/interactive/interactive-mode.ts | 86 ++++++------ .../test/interactive-mode-status.test.ts | 13 -- .../test/refinement-outcome-message.test.ts | 128 ++++++++++++++++++ .../test/suite/agent-session-queue.test.ts | 15 +- 9 files changed, 436 insertions(+), 65 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts create mode 100644 packages/coding-agent/test/refinement-outcome-message.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1b450d9c02..098531b3bb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- Added visible continual-harness refinement status and kept prompts submitted during refinement or compaction queued until they can receive a response. +- Added visible continual-harness refinement status, durable expandable exact-edit outcomes, and safe prompt continuation across refinement or compaction. - Changed RLM guidance to orchestrate independent workers in parallel, use available async shell helpers safely, end the turn instead of sleeping, polling, or blocking on long awaits, provide proactive outcome-focused progress updates from root agents, and use simplified technical English for user-facing prose. - Fixed new top-level daemon sessions inheriting an RLM child depth from the supervisor process. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ef003848f1..fc0b210f3f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -179,6 +179,7 @@ import { type CustomMessage, createCompactionOutcomeMessage, createHeartbeatPromptMessage, + createRefinementOutcomeMessage, createRlmChildFailureMessage, createRlmChildTerminalNoticeMessage, createSessionSlashCommandMessage, @@ -5896,6 +5897,7 @@ export class AgentSession { const input = action.payload; try { let resultText: string | undefined; + let displayResult = true; switch (input.command.name) { case "compact": await this.compact(input.command.args || undefined, { @@ -5905,8 +5907,9 @@ export class AgentSession { case "refine": { const options = parseRefineCommandOptions(input.command.args); const result = await this.refine(options, { skipAbort: true }); - const applied = result.appliedEdits.filter((appliedEdit) => appliedEdit.applied).length; + const applied = result.appliedEdits.filter((edit) => edit.applied).length; resultText = `Refined continual harness state: ${applied} edit${applied === 1 ? "" : "s"} applied.`; + displayResult = false; break; } case "goal": @@ -5920,7 +5923,7 @@ export class AgentSession { break; } if (resultText) { - this._appendDurableSessionCommandMessage(resultText, input.command, true, false); + this._appendDurableSessionCommandMessage(resultText, input.command, true, false, displayResult); } } catch (error) { if (error instanceof CompactionSkippedError) return; @@ -5944,14 +5947,19 @@ export class AgentSession { command: SessionSlashCommand, isResult: boolean, isError = false, + display = true, ): void { const message: CustomMessage = isResult - ? createSessionSlashCommandResultMessage(content, { - command, - success: !isError, - severity: isError ? "error" : "info", - ...(isError ? { error: content.replace(/^Command failed:\s*/, "") } : {}), - }) + ? createSessionSlashCommandResultMessage( + content, + { + command, + success: !isError, + severity: isError ? "error" : "info", + ...(isError ? { error: content.replace(/^Command failed:\s*/, "") } : {}), + }, + display, + ) : createSessionSlashCommandMessage(command); // Persist before touching live state so a failed write cannot leave an // unsaved leaf that the next entry would silently parent onto. @@ -7942,6 +7950,24 @@ export class AgentSession { return { ...plan, baselineState }; } + private _recordRefinementOutcome(result: RefinementResult): void { + const message = createRefinementOutcomeMessage(result); + try { + this.sessionManager.appendCustomMessageEntryWithRollback( + message.customType, + message.content, + message.display, + message.details, + ); + } catch { + // The full result is already stored as refinement history. Keep the + // expandable outcome visible for this process even if transcript persistence fails. + } + this.agent.state.messages.push(message); + this._emit({ type: "message_start", message }); + this._emit({ type: "message_end", message }); + } + /** * Synchronous application phase: disconnects from the agent, aborts any * in-flight agent run, applies the refinement plan to disk and memory, then @@ -8017,6 +8043,7 @@ export class AgentSession { this.sessionManager.appendCustomEntry("prime-agent.refinement", result); this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); this.agent.state.systemPrompt = this._baseSystemPrompt; + this._recordRefinementOutcome(result); try { this._emit({ type: "refine_complete", result }); } catch { diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 1174bdb97a..2ba2a9a2a9 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -8,6 +8,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; import type { AgentCronJob } from "./cron-jobs.js"; +import type { AppliedRefinementEdit, HarnessScope, RefinementResult } from "./refinement/refinement.js"; import { isSessionSlashCommandName, parseSessionSlashCommand, type SessionSlashCommand } from "./slash-commands.js"; export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: @@ -31,6 +32,7 @@ export const IPYTHON_STATE_RESTORED_CUSTOM_TYPE = "ipython_state_restored"; export const SESSION_SLASH_COMMAND_CUSTOM_TYPE = "session_slash_command"; export const SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE = "session_slash_command_result"; export const COMPACTION_OUTCOME_CUSTOM_TYPE = "compaction_outcome"; +export const REFINEMENT_OUTCOME_CUSTOM_TYPE = "refinement_outcome"; export const RLM_CHILD_FAILURE_CUSTOM_TYPE = "rlm_child_failure"; export const RLM_CHILD_TERMINAL_NOTICE_CUSTOM_TYPE = "rlm_child_terminal_notice"; @@ -73,6 +75,20 @@ export interface CompactionOutcomeMessage extends CustomMessage { + customType: typeof REFINEMENT_OUTCOME_CUSTOM_TYPE; + content: string; + details: RefinementOutcomeDetails; +} + export interface RlmChildFailureDetails { childId: string; sessionName: string; @@ -325,6 +341,27 @@ export function createCompactionOutcomeMessage( }; } +export function createRefinementOutcomeMessage( + result: RefinementResult, + display = true, + timestamp = Date.now(), +): RefinementOutcomeMessage { + return { + role: "custom", + customType: REFINEMENT_OUTCOME_CUSTOM_TYPE, + content: `Refinement complete: ${result.summary}`, + display, + details: { + refinementId: result.id, + summary: result.summary, + scope: result.scope ?? "local", + ...(result.rollbackOf ? { rollbackOf: result.rollbackOf } : {}), + edits: result.appliedEdits, + }, + timestamp, + }; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -398,6 +435,51 @@ export function isCompactionOutcomeMessage(message: unknown): message is Compact ); } +function isRefinementKind(value: unknown): value is AppliedRefinementEdit["kind"] { + return value === "prompt" || value === "memory" || value === "skill" || value === "subagent"; +} + +function isHarnessEntry(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.id === "string" && + isRefinementKind(value.kind) && + typeof value.title === "string" && + typeof value.content === "string" && + typeof value.path === "string" && + (value.scope === undefined || value.scope === "local" || value.scope === "global") && + isRecord(value.reference) && + isRecord(value.arguments) && + isRecord(value.metadata) + ); +} + +function isAppliedRefinementEdit(value: unknown): value is AppliedRefinementEdit { + if (!isRecord(value)) return false; + return ( + (value.action === "create" || value.action === "update" || value.action === "delete") && + isRefinementKind(value.kind) && + typeof value.id === "string" && + typeof value.applied === "boolean" && + (value.error === undefined || typeof value.error === "string") && + (value.before === undefined || isHarnessEntry(value.before)) && + (value.after === undefined || isHarnessEntry(value.after)) + ); +} + +export function isRefinementOutcomeMessage(message: unknown): message is RefinementOutcomeMessage { + if (!isRecord(message) || !hasValidCustomMessageEnvelope(message, REFINEMENT_OUTCOME_CUSTOM_TYPE)) return false; + if (!isRecord(message.details)) return false; + return ( + typeof message.details.refinementId === "string" && + typeof message.details.summary === "string" && + (message.details.scope === "local" || message.details.scope === "global") && + (message.details.rollbackOf === undefined || typeof message.details.rollbackOf === "string") && + Array.isArray(message.details.edits) && + message.details.edits.every(isAppliedRefinementEdit) + ); +} + export function createHeartbeatPromptMessage( job: AgentCronJob, timestamp = Date.now(), @@ -445,7 +527,8 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { if ( m.customType === SESSION_SLASH_COMMAND_CUSTOM_TYPE || m.customType === SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE || - m.customType === COMPACTION_OUTCOME_CUSTOM_TYPE + m.customType === COMPACTION_OUTCOME_CUSTOM_TYPE || + m.customType === REFINEMENT_OUTCOME_CUSTOM_TYPE ) { return undefined; } diff --git a/packages/coding-agent/src/modes/interactive/components/conversation-components.ts b/packages/coding-agent/src/modes/interactive/components/conversation-components.ts index dd2bb456e7..7f15853515 100644 --- a/packages/coding-agent/src/modes/interactive/components/conversation-components.ts +++ b/packages/coding-agent/src/modes/interactive/components/conversation-components.ts @@ -4,8 +4,10 @@ import { isAgentSessionMessage } from "../../../core/agent-messages.js"; import { COMPACTION_OUTCOME_CUSTOM_TYPE, isCompactionOutcomeMessage, + isRefinementOutcomeMessage, isSessionSlashCommandMessage, isSessionSlashCommandResultMessage, + REFINEMENT_OUTCOME_CUSTOM_TYPE, SESSION_SLASH_COMMAND_CUSTOM_TYPE, SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE, } from "../../../core/messages.js"; @@ -18,6 +20,10 @@ import { } from "./compaction-outcome-message.js"; import { InjectedPromptMessageComponent, isInjectedPromptMessage } from "./injected-prompt-message.js"; import { IPythonCellComponent } from "./ipython-cell.js"; +import { + MalformedRefinementOutcomeMessageComponent, + RefinementOutcomeMessageComponent, +} from "./refinement-outcome-message.js"; import { SlashCommandMessageComponent } from "./slash-command-message.js"; import { SlashCommandResultMessageComponent } from "./slash-command-result-message.js"; import { @@ -142,6 +148,13 @@ export function buildConversationComponents( ? new CompactionOutcomeMessageComponent(message) : new MalformedCompactionOutcomeMessageComponent(), ); + } else if (message.role === "custom" && message.customType === REFINEMENT_OUTCOME_CUSTOM_TYPE) { + if (!message.display) continue; + const component = isRefinementOutcomeMessage(message) + ? new RefinementOutcomeMessageComponent(message) + : new MalformedRefinementOutcomeMessageComponent(); + component.setEditDiffsExpanded(editDiffsExpanded); + components.push(component); } else if (isAgentSessionMessage(message) && message.display) { const component = new AgentMessageComponent(message, options.markdownTheme, { suppressLeadingSpace: isCompactAgentMessageNeighbor(components.at(-1)), diff --git a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts new file mode 100644 index 0000000000..b7e668bafa --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts @@ -0,0 +1,116 @@ +import { Container, Spacer, Text } from "@earendil-works/pi-tui"; +import type { RefinementOutcomeMessage } from "../../../core/messages.js"; +import type { AppliedRefinementEdit, HarnessEntry } from "../../../core/refinement/refinement.js"; +import { generateDiffString } from "../../../core/tools/edit-diff.js"; +import { theme } from "../theme/theme.js"; +import { renderDiff } from "./diff.js"; +import { expandCollapseHint } from "./keybinding-hints.js"; + +function editableEntry(entry: HarnessEntry): Record { + return { + title: entry.title, + content: entry.content, + path: entry.path, + reference: entry.reference, + arguments: entry.arguments, + metadata: entry.metadata, + }; +} + +function proposedEntry(edit: AppliedRefinementEdit): Record { + return { + ...(edit.title === undefined ? {} : { title: edit.title }), + ...(edit.content === undefined ? {} : { content: edit.content }), + ...(edit.path === undefined ? {} : { path: edit.path }), + ...(edit.reference === undefined ? {} : { reference: edit.reference }), + ...(edit.arguments === undefined ? {} : { arguments: edit.arguments }), + ...(edit.metadata === undefined ? {} : { metadata: edit.metadata }), + }; +} + +function entryText(entry: Record | undefined): string { + return entry === undefined ? "" : `${JSON.stringify(entry, null, 2)}\n`; +} + +function editDiff(edit: AppliedRefinementEdit): string { + const before = edit.before ? editableEntry(edit.before) : undefined; + const after = edit.after ? editableEntry(edit.after) : edit.action === "delete" ? undefined : proposedEntry(edit); + return generateDiffString(entryText(before), entryText(after), 4).diff; +} + +function editScope(edit: AppliedRefinementEdit, fallback: "local" | "global"): "local" | "global" { + return edit.after?.scope ?? edit.before?.scope ?? fallback; +} + +function editLabel(edit: AppliedRefinementEdit, fallbackScope: "local" | "global"): string { + const scope = editScope(edit, fallbackScope); + if (!edit.applied) { + const error = edit.error ? `: ${edit.error}` : ""; + return theme.fg("error", `Failed to ${edit.action} ${scope} ${edit.kind} \`${edit.id}\`${error}`); + } + const verb = edit.action === "create" ? "Created" : edit.action === "update" ? "Updated" : "Deleted"; + return `${theme.fg("success", verb)} ${scope} ${edit.kind} \`${edit.id}\``; +} + +/** Durable refinement result with exact harness edits behind the code-edit expansion toggle. */ +export class RefinementOutcomeMessageComponent extends Container { + private readonly content = new Container(); + private editDiffsExpanded = false; + + constructor(private readonly message: RefinementOutcomeMessage) { + super(); + this.addChild(new Spacer(1)); + this.addChild(this.content); + this.rebuild(); + } + + setExpanded(_expanded: boolean): void {} + + setEditDiffsExpanded(expanded: boolean): void { + if (this.editDiffsExpanded === expanded) return; + this.editDiffsExpanded = expanded; + this.rebuild(); + } + + override invalidate(): void { + super.invalidate(); + this.rebuild(); + } + + private rebuild(): void { + this.content.clear(); + this.content.addChild( + new Text(theme.fg("success", `✓ Refinement complete: ${this.message.details.summary}`), 0, 0), + ); + + const edits = this.message.details.edits; + const applied = edits.filter((edit) => edit.applied).length; + const count = + edits.length === applied + ? `${applied} edit${applied === 1 ? "" : "s"} applied` + : `${applied}/${edits.length} edits applied`; + const hint = edits.length === 0 ? "" : ` · ${expandCollapseHint("app.edits.expand", this.editDiffsExpanded)}`; + this.content.addChild(new Text(`Refined continual harness state: ${count}${hint}`, 0, 0)); + + for (const edit of edits) { + this.content.addChild( + new Text(`${theme.fg("dim", " ╰─ ")}${editLabel(edit, this.message.details.scope)}`, 0, 0), + ); + if (this.editDiffsExpanded) { + const diff = editDiff(edit); + if (diff) this.content.addChild(new Text(renderDiff(diff), 4, 0)); + } + } + } +} + +export class MalformedRefinementOutcomeMessageComponent extends Container { + constructor() { + super(); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("error", "[Malformed refinement outcome message]"), 0, 0)); + } + + setExpanded(_expanded: boolean): void {} + setEditDiffsExpanded(_expanded: boolean): void {} +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0d3bf6d763..5586e40833 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -100,12 +100,15 @@ import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.j import { bashOutputToText, COMPACTION_OUTCOME_CUSTOM_TYPE, + type CustomMessage, createHeartbeatPromptMessage, HEARTBEAT_PROMPT_CUSTOM_TYPE, HEARTBEAT_PROMPT_PREVIEW_LABEL, isCompactionOutcomeMessage, + isRefinementOutcomeMessage, isSessionSlashCommandMessage, isSessionSlashCommandResultMessage, + REFINEMENT_OUTCOME_CUSTOM_TYPE, SESSION_SLASH_COMMAND_CUSTOM_TYPE, SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE, } from "../../core/messages.js"; @@ -205,6 +208,10 @@ import { InjectedPromptMessageComponent, isInjectedPromptMessage } from "./compo import { formatKeyText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js"; import type { AuthSelectorProvider } from "./components/oauth-selector.js"; import { PrimeOnboardingSplashComponent } from "./components/prime-onboarding-splash.js"; +import { + MalformedRefinementOutcomeMessageComponent, + RefinementOutcomeMessageComponent, +} from "./components/refinement-outcome-message.js"; import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js"; import { SettingsSelectorComponent } from "./components/settings-selector.js"; import { SideQuestionComponent } from "./components/side-question.js"; @@ -5779,7 +5786,6 @@ export class InteractiveMode { break; case "refine_complete": - this.showRefinementOutcome(event.result.summary); break; } } @@ -6305,6 +6311,40 @@ export class InteractiveMode { } } + private createDisplayedCustomMessageComponent(message: CustomMessage): Component { + if (isSessionSlashCommandMessage(message)) return new SlashCommandMessageComponent(message.content); + if (isSessionSlashCommandResultMessage(message)) return new SlashCommandResultMessageComponent(message); + if ( + message.customType === SESSION_SLASH_COMMAND_CUSTOM_TYPE || + message.customType === SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE + ) { + return new UserMessageComponent("[Malformed session command message]", this.getMarkdownThemeWithSettings()); + } + if (isCompactionOutcomeMessage(message)) return new CompactionOutcomeMessageComponent(message); + if (message.customType === COMPACTION_OUTCOME_CUSTOM_TYPE) { + return new MalformedCompactionOutcomeMessageComponent(); + } + if (isRefinementOutcomeMessage(message)) return new RefinementOutcomeMessageComponent(message); + if (message.customType === REFINEMENT_OUTCOME_CUSTOM_TYPE) { + return new MalformedRefinementOutcomeMessageComponent(); + } + if (isAgentSessionMessage(message)) { + return new AgentMessageComponent(message, this.getMarkdownThemeWithSettings(), { + suppressLeadingSpace: isCompactAgentMessageNeighbor(this.chatContainer.children.at(-1)), + }); + } + if (isInjectedPromptMessage(message)) { + return new InjectedPromptMessageComponent(message, this.getMarkdownThemeWithSettings()); + } + return new CustomMessageComponent( + message, + this.bindLocalSessionExtensions + ? this.getLocalSessionHost().getExtensionRunner().getMessageRenderer(message.customType) + : undefined, + this.getMarkdownThemeWithSettings(), + ); + } + private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { switch (message.role) { case "bashExecution": { @@ -6325,42 +6365,13 @@ export class InteractiveMode { } case "custom": { if (message.display) { - const reservedSessionCommand = - message.customType === SESSION_SLASH_COMMAND_CUSTOM_TYPE || - message.customType === SESSION_SLASH_COMMAND_RESULT_CUSTOM_TYPE; - const component = isSessionSlashCommandMessage(message) - ? new SlashCommandMessageComponent(message.content) - : isSessionSlashCommandResultMessage(message) - ? new SlashCommandResultMessageComponent(message) - : reservedSessionCommand - ? new UserMessageComponent( - "[Malformed session command message]", - this.getMarkdownThemeWithSettings(), - ) - : isCompactionOutcomeMessage(message) - ? new CompactionOutcomeMessageComponent(message) - : message.customType === COMPACTION_OUTCOME_CUSTOM_TYPE - ? new MalformedCompactionOutcomeMessageComponent() - : isAgentSessionMessage(message) - ? new AgentMessageComponent(message, this.getMarkdownThemeWithSettings(), { - suppressLeadingSpace: isCompactAgentMessageNeighbor( - this.chatContainer.children.at(-1), - ), - }) - : isInjectedPromptMessage(message) - ? new InjectedPromptMessageComponent(message, this.getMarkdownThemeWithSettings()) - : new CustomMessageComponent( - message, - this.bindLocalSessionExtensions - ? this.getLocalSessionHost() - .getExtensionRunner() - .getMessageRenderer(message.customType) - : undefined, - this.getMarkdownThemeWithSettings(), - ); - if (!(component instanceof UserMessageComponent)) { + const component = this.createDisplayedCustomMessageComponent(message); + if (isExpandable(component)) { component.setExpanded(this.expansionStateFor(component)); } + if (hasEditDiffsExpansion(component)) { + component.setEditDiffsExpanded(this.editDiffsExpanded); + } if (isSessionSlashCommandMessage(message) && this.chatContainer.children.length > 0) { this.chatContainer.addChild(new Spacer(1)); } @@ -7459,11 +7470,6 @@ export class InteractiveMode { this.ui.requestRender(); } - showRefinementOutcome(summary: string): void { - this.chatContainer.addChild(new Text(theme.fg("success", `✓ Refinement complete: ${summary}`), 1, 0)); - this.ui.requestRender(); - } - showNewVersionNotification(newVersion: string): void { this.chatContainer.addChild(new Text(formatUpdateAvailableNotice(newVersion), 1, 0)); this.ui.requestRender(); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index d15ced1ea2..568d216fba 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -139,19 +139,6 @@ test("tracks refinement lifecycle for the working indicator", () => { expect((fakeThis as unknown as { connectionState: AgentConnectionState }).connectionState.isRefining).toBe(false); }); -test("shows the refinement result after work finishes", () => { - initTheme("dark"); - const chatContainer = new Container(); - const fakeThis = { - chatContainer, - ui: { requestRender: vi.fn() }, - } as unknown as InteractiveMode; - - InteractiveMode.prototype.showRefinementOutcome.call(fakeThis, "Updated the local memory."); - - expect(normalizeRenderedOutput(chatContainer)).toBe("✓ Refinement complete: Updated the local memory."); -}); - describe("InteractiveMode update notifications", () => { beforeAll(() => { initTheme("dark"); diff --git a/packages/coding-agent/test/refinement-outcome-message.test.ts b/packages/coding-agent/test/refinement-outcome-message.test.ts new file mode 100644 index 0000000000..abac34cab4 --- /dev/null +++ b/packages/coding-agent/test/refinement-outcome-message.test.ts @@ -0,0 +1,128 @@ +import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; +import stripAnsi from "strip-ansi"; +import { beforeAll, describe, expect, test } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.js"; +import { convertToLlm, createRefinementOutcomeMessage, isRefinementOutcomeMessage } from "../src/core/messages.js"; +import type { HarnessEntry, RefinementResult } from "../src/core/refinement/refinement.js"; +import { buildConversationComponents } from "../src/modes/interactive/components/conversation-components.js"; +import { RefinementOutcomeMessageComponent } from "../src/modes/interactive/components/refinement-outcome-message.js"; +import { initTheme } from "../src/modes/interactive/theme/theme.js"; + +function entry(overrides: Partial = {}): HarnessEntry { + return { + id: "rhyme-response-guidance", + kind: "prompt", + title: "Rhyme response guidance", + content: "Make conversational responses rhyme.", + path: "prompts/rhyme-response-guidance.md", + scope: "local", + reference: {}, + arguments: {}, + metadata: {}, + source: "refinement", + created_at: "2026-08-18T00:00:00.000Z", + updated_at: "2026-08-18T00:00:00.000Z", + version: 1, + ...overrides, + }; +} + +function result(): RefinementResult { + const after = entry(); + return { + id: "refine-rhyme", + summary: "Added local guidance to make conversational responses rhyme.", + rationale: "The user requested rhyming guidance.", + expectedOutcome: "Conversational responses rhyme.", + appliedEdits: [ + { + action: "create", + kind: "prompt", + id: after.id, + title: after.title, + content: after.content, + path: after.path, + after, + applied: true, + }, + ], + harnessStatePath: "/tmp/harness/state.json", + scope: "local", + }; +} + +function rendered(component: RefinementOutcomeMessageComponent): string { + return stripAnsi(component.render(100).join("\n")); +} + +describe("RefinementOutcomeMessageComponent", () => { + beforeAll(() => { + initTheme("dark"); + setKeybindings(new KeybindingsManager()); + }); + + test("shows edit identity collapsed and exact payload through the code-edit toggle", () => { + const message = createRefinementOutcomeMessage(result()); + const component = new RefinementOutcomeMessageComponent(message); + + const collapsed = rendered(component); + expect(collapsed).toContain( + "✓ Refinement complete: Added local guidance to make conversational responses rhyme.", + ); + expect(collapsed).toContain("Created local prompt `rhyme-response-guidance`"); + expect(collapsed).toContain("Ctrl+J to expand"); + expect(collapsed).not.toContain("Make conversational responses rhyme."); + + component.setEditDiffsExpanded(true); + const expanded = rendered(component); + expect(expanded).toContain("Ctrl+J to collapse"); + expect(expanded).toContain('"content": "Make conversational responses rhyme."'); + expect(expanded).toContain('"path": "prompts/rhyme-response-guidance.md"'); + }); + + test("renders exact before and after payloads for updates and deletes", () => { + const base = result(); + const before = entry({ id: "tone-guidance", content: "Respond plainly." }); + const after = entry({ id: "tone-guidance", content: "Respond in rhyme.", version: 2 }); + const deleted = entry({ id: "obsolete-guidance", content: "Use prose." }); + const message = createRefinementOutcomeMessage({ + ...base, + appliedEdits: [ + { action: "update", kind: "prompt", id: before.id, before, after, applied: true }, + { action: "delete", kind: "prompt", id: deleted.id, before: deleted, applied: true }, + ], + }); + const component = new RefinementOutcomeMessageComponent(message); + component.setEditDiffsExpanded(true); + const output = rendered(component); + + expect(output).toContain("Updated local prompt `tone-guidance`"); + expect(output).toContain("Deleted local prompt `obsolete-guidance`"); + expect(output).toContain('"content": "Respond plainly."'); + expect(output).toContain('"content": "Respond in rhyme."'); + expect(output).toContain('"content": "Use prose."'); + }); + + test("replays the durable outcome with the saved edit expansion state", () => { + const message = createRefinementOutcomeMessage(result()); + const [component] = buildConversationComponents([message], { + ui: {} as TUI, + cwd: "/tmp", + toolOptions: {}, + getToolDefinition: () => undefined, + editDiffsExpanded: true, + }); + + expect(component).toBeInstanceOf(RefinementOutcomeMessageComponent); + expect(stripAnsi(component!.render(100).join("\n"))).toContain( + '"content": "Make conversational responses rhyme."', + ); + }); + + test("uses a typed, presentation-only custom message", () => { + const message = createRefinementOutcomeMessage(result()); + expect(isRefinementOutcomeMessage(message)).toBe(true); + expect(convertToLlm([message])).toEqual([]); + expect(isRefinementOutcomeMessage({ ...message, details: { ...message.details, edits: [{}] } })).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 126b62ae25..1b0454a83d 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -12,7 +12,11 @@ import { createAgentSessionMessagePrompt, } from "../../src/core/agent-messages.js"; import { type AgentCronJob, shouldDeferHeartbeatCronJob } from "../../src/core/cron-jobs.js"; -import { createSessionSlashCommandMessage } from "../../src/core/messages.js"; +import { + createSessionSlashCommandMessage, + isRefinementOutcomeMessage, + REFINEMENT_OUTCOME_CUSTOM_TYPE, +} from "../../src/core/messages.js"; import { applyRefinementProposal, getGlobalHarnessStateDir, @@ -1062,6 +1066,13 @@ describe("AgentSession queue characterization", () => { ["refinement_start", true], ["refinement_end", false], ]); + const outcome = harness.session.messages.find(isRefinementOutcomeMessage); + expect(outcome?.details.summary).toBe("no-op"); + expect( + harness.sessionManager + .getEntries() + .some((entry) => entry.type === "custom_message" && entry.customType === REFINEMENT_OUTCOME_CUSTOM_TYPE), + ).toBe(true); expect( harness .eventsOfType("message_end") @@ -2536,7 +2547,7 @@ describe("AgentSession queue characterization", () => { (entry) => entry.type === "custom_message" && entry.customType === "session_slash_command_result", ); expect(inputEntry).toBeDefined(); - expect(resultEntry).toBeDefined(); + expect(resultEntry).toMatchObject({ display: false }); expect(harness.sessionManager.getBranch(resultEntry!.id).map((entry) => entry.id)).toContain(inputEntry!.id); }); From d3a3bb8c54b146033939e7f04ce7f6f790c28350 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 19 Aug 2026 12:25:59 +0200 Subject: [PATCH 06/23] chore: restore main's package-lock.json --- package-lock.json | 90 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8c17f1fd07..68df418862 100644 --- a/package-lock.json +++ b/package-lock.json @@ -481,6 +481,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -498,6 +501,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -515,6 +521,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -532,6 +541,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -599,6 +611,31 @@ "resolved": "packages/tui", "link": true }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", @@ -606,6 +643,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1155,6 +1193,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1171,6 +1212,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1187,6 +1231,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1203,6 +1250,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1219,6 +1269,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1475,6 +1528,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1492,6 +1548,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1509,6 +1568,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1526,6 +1588,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1543,6 +1608,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1560,6 +1628,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3029,7 +3100,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3627,7 +3697,6 @@ "node_modules/jiti": { "version": "2.7.0", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -3819,6 +3888,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3840,6 +3912,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3861,6 +3936,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3882,6 +3960,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5076,7 +5157,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5143,7 +5223,6 @@ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -5254,7 +5333,6 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -5545,7 +5623,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -5617,7 +5694,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From a9b5bf67880157e1aa437e661e338ec0c63de7b0 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 19 Aug 2026 12:28:32 +0200 Subject: [PATCH 07/23] test: assert isRefining stays false while a public refine waits for idle --- packages/coding-agent/test/suite/agent-session-queue.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 1b0454a83d..e2c70ee3bd 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1057,6 +1057,9 @@ describe("AgentSession queue characterization", () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(refineSettled).toBe(false); + // The turn barrier (_refineInFlight) is up, but status must stay off + // until the apply phase actually starts. + expect(harness.session.isRefining).toBe(false); promptGate.resolve(); await refinePromise; From a0fe8211c1ddd141f2360ae8ac1195efe1cd031b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 19 Aug 2026 12:43:47 +0200 Subject: [PATCH 08/23] review: bump daemon schema to 17, drop duplicate attach-terminal refinement lines, tighten comments --- packages/coding-agent/src/cli/daemon-command.ts | 10 +++------- packages/coding-agent/src/core/agent-session.ts | 10 +++------- .../src/modes/daemon/daemon-protocol.ts | 5 +++-- .../components/refinement-outcome-message.ts | 1 - packages/coding-agent/test/daemon-protocol.test.ts | 14 ++++++++++++++ .../test/suite/agent-session-queue.test.ts | 3 +-- 6 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index b81b2f53cb..fdc2403239 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -1460,11 +1460,6 @@ class DaemonAttachTerminal { case "compaction_end": this.writeLine(chalk.dim(`Compaction ${event.aborted ? "aborted" : "finished"}: ${event.reason}`)); return; - case "refinement_start": - this.writeLine(chalk.dim("Refinement started.")); - return; - case "refinement_end": - return; case "auto_retry_start": this.writeLine(chalk.dim(`Retry ${event.attempt}/${event.maxAttempts}: ${event.errorMessage}`)); return; @@ -1480,9 +1475,10 @@ class DaemonAttachTerminal { case "refine_failed": this.writeLine(chalk.red(`Refinement failed: ${event.error}`)); return; + // The persisted refinement outcome message prints via message_end. case "refine_complete": - this.writeLine(chalk.dim(`Refinement complete: ${event.result.summary}`)); - return; + case "refinement_start": + case "refinement_end": case "turn_start": case "turn_end": case "message_start": diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index bece3a4dee..69448588f8 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4037,15 +4037,12 @@ export class AgentSession { ); } - /** Whether continual harness refinement is currently applying changes. */ get isRefining(): boolean { return this._refinementApplyActive; } - /** - * Runs _applyRefine and emits refinement_start/refinement_end around the - * apply phase only, so status stays off while a refine waits for idle. - */ + // Status brackets only the apply phase; _refineInFlight is set earlier as a + // turn barrier while public refine() still waits for idle. private async _applyRefineWithStatus( plan: RefinementPlan, options: { instructions?: string; rollbackId?: string; global?: boolean }, @@ -7687,8 +7684,7 @@ export class AgentSession { message.details, ); } catch { - // The full result is already stored as refinement history. Keep the - // expandable outcome visible for this process even if transcript persistence fails. + // Best-effort: the full result is already persisted as refinement history. } this.agent.state.messages.push(message); this._emit({ type: "message_start", message }); diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index b26eb1bdc2..7ab6b6b009 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -60,8 +60,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 14 carries the client's monotonic telemetry opt-out on attach and reattach. // Revision 15 adds the mutate_queued_message command and queue_message_mutation capability. // Revision 16 adds the "stopping" workerState and stops reporting disconnected workers as "ready". -export const DAEMON_SCHEMA_REVISION = 16; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-16-1bcb9e7f1a49"; +// Revision 17 adds refinement_start/refinement_end session events and optional isRefining summary/state fields. +export const DAEMON_SCHEMA_REVISION = 17; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-17-1bcb9e7f1a49"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; diff --git a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts index b7e668bafa..c11dbb0794 100644 --- a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts @@ -52,7 +52,6 @@ function editLabel(edit: AppliedRefinementEdit, fallbackScope: "local" | "global return `${theme.fg("success", verb)} ${scope} ${edit.kind} \`${edit.id}\``; } -/** Durable refinement result with exact harness edits behind the code-edit expansion toggle. */ export class RefinementOutcomeMessageComponent extends Container { private readonly content = new Container(); private editDiffsExpanded = false; diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index d7eb8acf4a..1e650a569a 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -138,6 +138,20 @@ describe("daemon protocol helpers", () => { expect(DAEMON_SCHEMA_REVISION).toBeGreaterThanOrEqual(16); }); + it("keeps refinement status additions backward-compatible at revision 17", () => { + // Old daemons never emit these events and omit isRefining; new clients + // treat the absent field as false. Old clients no-op on the unknown + // event types, so no capability gate is needed. + expect(DAEMON_SCHEMA_REVISION).toBeGreaterThanOrEqual(17); + const newDaemonEvent: DaemonOutbound = { + type: "session_event", + activeSessionId: "active-1", + event: { type: "refinement_start" }, + }; + expect(DAEMON_OUTBOUND_COMPATIBILITY.session_event).toEqual({ minProtocol: 7 }); + expect(newDaemonEvent).toMatchObject({ event: { type: "refinement_start" } }); + }); + it("keeps refine failure events backward-compatible on the existing session event channel", () => { const event: DaemonOutbound = { type: "session_event", diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index e2c70ee3bd..8741bf363e 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1057,8 +1057,7 @@ describe("AgentSession queue characterization", () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(refineSettled).toBe(false); - // The turn barrier (_refineInFlight) is up, but status must stay off - // until the apply phase actually starts. + // The turn barrier is up, but status stays off until the apply phase starts. expect(harness.session.isRefining).toBe(false); promptGate.resolve(); From e74629a8932c02bc7d227e4fe18c12f3c7909b65 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 19 Aug 2026 12:51:42 +0200 Subject: [PATCH 09/23] test: executable compatibility assertions for revision-17 refinement additions --- .../coding-agent/src/core/agent-session.ts | 3 +- .../coding-agent/test/daemon-protocol.test.ts | 31 ++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 69448588f8..4da43ad840 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4041,8 +4041,7 @@ export class AgentSession { return this._refinementApplyActive; } - // Status brackets only the apply phase; _refineInFlight is set earlier as a - // turn barrier while public refine() still waits for idle. + // Status brackets only the apply phase; _refineInFlight goes up earlier as a turn barrier while public refine() still waits for idle. private async _applyRefineWithStatus( plan: RefinementPlan, options: { instructions?: string; rollbackId?: string; global?: boolean }, diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index 1e650a569a..f96da3a645 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -2,6 +2,8 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { acpUpdatesForSessionEvent } from "../src/modes/acp/acp-events.js"; +import { buildAgentsViewRows } from "../src/modes/agents-view/agents-view-state.js"; import { createDaemonCommandEnvelope, createDaemonEventEnvelope, @@ -21,6 +23,7 @@ import { isDaemonMutatingCommand, salvageDaemonCommandId, } from "../src/modes/daemon/daemon-protocol.js"; +import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; describe("daemon protocol helpers", () => { it("keeps the advertised schema identity synchronized with wire type shapes", () => { @@ -139,17 +142,29 @@ describe("daemon protocol helpers", () => { }); it("keeps refinement status additions backward-compatible at revision 17", () => { - // Old daemons never emit these events and omit isRefining; new clients - // treat the absent field as false. Old clients no-op on the unknown - // event types, so no capability gate is needed. + // Optional isRefining fields plus new events on the ungated session-event channel; no capability gate. expect(DAEMON_SCHEMA_REVISION).toBeGreaterThanOrEqual(17); - const newDaemonEvent: DaemonOutbound = { - type: "session_event", + expect(DAEMON_OUTBOUND_COMPATIBILITY.session_event).toEqual({ minProtocol: 7 }); + // A session-event handler written before revision 17 ignores the new lifecycle events. + expect(acpUpdatesForSessionEvent({ type: "refinement_start" })).toEqual([]); + expect(acpUpdatesForSessionEvent({ type: "refinement_end" })).toEqual([]); + // An old-daemon summary omits isRefining; a new client must not read it as refining. + const oldDaemonSummary: SessionSummary = { + id: "active-1", activeSessionId: "active-1", - event: { type: "refinement_start" }, + lifecycle: "live", + activity: "working", + isSessionActive: true, + sessionId: "session-1", + cwd: "/tmp/project", + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 1, + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, }; - expect(DAEMON_OUTBOUND_COMPATIBILITY.session_event).toEqual({ minProtocol: 7 }); - expect(newDaemonEvent).toMatchObject({ event: { type: "refinement_start" } }); + const [row] = buildAgentsViewRows([oldDaemonSummary]); + expect(row?.statusLabel).not.toBe("refining"); }); it("keeps refine failure events backward-compatible on the existing session event channel", () => { From b9df96f98023f8825e41eecf2f164797a1fc9266 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 19 Aug 2026 13:23:10 +0200 Subject: [PATCH 10/23] fix: mirror compaction_outcome handling for refinement_outcome in print mode and context rebuilds --- .../coding-agent/src/core/agent-session.ts | 17 +++++----- .../src/modes/headless-completion.ts | 7 ++++- packages/coding-agent/test/print-mode.test.ts | 22 +++++++++++++ .../test/suite/agent-session-queue.test.ts | 31 +++++++++++++++++++ 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4da43ad840..84fcf16415 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1054,7 +1054,7 @@ export class AgentSession { private _agentMessageOutcomes = new Map(); private _lateIpythonSentAgentMessages = new Map(); /** Outcome disclosures whose session-file append failed; retained for context rebuilds. */ - private readonly _unpersistedCompactionOutcomes: CustomMessage[] = []; + private readonly _unpersistedOutcomes: CustomMessage[] = []; private _bashAbortController: AbortController | undefined = undefined; private _userBashRunning = false; @@ -4066,12 +4066,12 @@ export class AgentSession { for (const message of context.messages) { this._applyLateIpythonSentAgentMessages(message); } - this._mergeUnpersistedCompactionOutcomes(context.messages); + this._mergeUnpersistedOutcomes(context.messages); return context; } - private _mergeUnpersistedCompactionOutcomes(messages: AgentMessage[]): void { - for (const outcome of this._unpersistedCompactionOutcomes) { + private _mergeUnpersistedOutcomes(messages: AgentMessage[]): void { + for (const outcome of this._unpersistedOutcomes) { let insertAt = messages.length; while (insertAt > 0 && messages[insertAt - 1]!.timestamp > outcome.timestamp) { insertAt -= 1; @@ -7048,7 +7048,7 @@ export class AgentSession { ); const newEntries = this.sessionManager.getEntries(); this.agent.state.messages = this.sessionManager.buildSessionContext().messages; - this._mergeUnpersistedCompactionOutcomes(this.agent.state.messages); + this._mergeUnpersistedOutcomes(this.agent.state.messages); this._restoreLateIpythonSentAgentMessages(); const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as @@ -7683,7 +7683,8 @@ export class AgentSession { message.details, ); } catch { - // Best-effort: the full result is already persisted as refinement history. + // Not in the session file, so context rebuilds would drop the outcome. + this._unpersistedOutcomes.push(message); } this.agent.state.messages.push(message); this._emit({ type: "message_start", message }); @@ -7986,7 +7987,7 @@ export class AgentSession { { reason, outcome }, ); // Not in the session file, so context rebuilds would drop the disclosure. - this._unpersistedCompactionOutcomes.push(outcomeMessage); + this._unpersistedOutcomes.push(outcomeMessage); } this.agent.state.messages.push(outcomeMessage); this._emit({ type: "message_start", message: outcomeMessage }); @@ -10685,7 +10686,7 @@ export class AgentSession { const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; - this._mergeUnpersistedCompactionOutcomes(this.agent.state.messages); + this._mergeUnpersistedOutcomes(this.agent.state.messages); this._restoreLateIpythonSentAgentMessages(); this._reloadGoalStateFromBranch(); this._reloadRlmMaxDepthFromBranch(); diff --git a/packages/coding-agent/src/modes/headless-completion.ts b/packages/coding-agent/src/modes/headless-completion.ts index b3d51d03f5..85384d43be 100644 --- a/packages/coding-agent/src/modes/headless-completion.ts +++ b/packages/coding-agent/src/modes/headless-completion.ts @@ -11,6 +11,7 @@ import { type CompactionOutcomeMessage, isCompactionOutcomeMessage, isSessionSlashCommandResultMessage, + REFINEMENT_OUTCOME_CUSTOM_TYPE, type SessionSlashCommandResultMessage, } from "../core/messages.js"; @@ -37,7 +38,11 @@ export function selectHeadlessTerminalResult(messages: readonly AgentMessage[]): } // A corrupt outcome is still part of the terminal outcome suffix. Skip it // without letting it hide earlier valid outcomes or their failure status. - if (message.role === "custom" && message.customType === COMPACTION_OUTCOME_CUSTOM_TYPE) { + if ( + message.role === "custom" && + (message.customType === COMPACTION_OUTCOME_CUSTOM_TYPE || + message.customType === REFINEMENT_OUTCOME_CUSTOM_TYPE) + ) { index--; continue; } diff --git a/packages/coding-agent/test/print-mode.test.ts b/packages/coding-agent/test/print-mode.test.ts index 100a03be8d..d21501c958 100644 --- a/packages/coding-agent/test/print-mode.test.ts +++ b/packages/coding-agent/test/print-mode.test.ts @@ -5,6 +5,7 @@ import type { AgentAutonomousStatus } from "../src/core/autonomous.js"; import { createCompactionOutcomeMessage, createCustomMessage, + createRefinementOutcomeMessage, createSessionSlashCommandResultMessage, } from "../src/core/messages.js"; import type { SessionShutdownEvent } from "../src/index.js"; @@ -318,6 +319,27 @@ describe("runPrintMode", () => { expect(errorSpy).toHaveBeenCalledWith("Auto-compaction skipped: nothing to compact"); }); + it("prints assistant output past a trailing refinement outcome", async () => { + const outcome = createRefinementOutcomeMessage({ + id: "refine-1", + summary: "Added a local memory.", + rationale: "", + expectedOutcome: "", + appliedEdits: [], + harnessStatePath: "/tmp/harness/state.json", + scope: "local", + }); + const runtimeHost = createRuntimeHost([createAssistantMessage({ text: "done" }), outcome]); + output.write.mockClear(); + + const exitCode = await runPrintMode(runtimeHost as unknown as Parameters[0], { + mode: "text", + }); + + expect(exitCode).toBe(0); + expect(output.write).toHaveBeenCalledWith("done\n"); + }); + it("reports an outcome-only failure and exits non-zero", async () => { const outcome = createCompactionOutcomeMessage("Context overflow recovery failed", { reason: "overflow", diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 8741bf363e..854a06e771 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1093,6 +1093,37 @@ describe("AgentSession queue characterization", () => { } }); + it("keeps an unpersisted refinement outcome across context rebuilds", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + harness.setResponses([fauxAssistantMessage(refinePlanJson("no-op"))]); + vi.spyOn(harness.sessionManager, "appendCustomMessageEntryWithRollback").mockImplementationOnce(() => { + throw new Error("disk full"); + }); + + await harness.session.refine({ instructions: "outcome persistence failure" }); + + const outcome = harness.session.messages.find(isRefinementOutcomeMessage); + expect(outcome?.details.summary).toBe("no-op"); + expect( + harness.sessionManager + .getEntries() + .some((entry) => entry.type === "custom_message" && entry.customType === REFINEMENT_OUTCOME_CUSTOM_TYPE), + ).toBe(false); + // The memory-only outcome survives context rebuilds despite the failed write. + expect(harness.session.buildSessionContext().messages.some(isRefinementOutcomeMessage)).toBe(true); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + it("preserves a same-entry harness write made during background planning", async () => { const harness = await createAutoRefineHarness(); harnesses.push(harness); From 9fff25001c9fad7456ddbe1bec71143a9338aba5 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 19 Aug 2026 21:04:24 +0200 Subject: [PATCH 11/23] Render refinement outcomes like compaction and skill messages Replace the bespoke checkmark rendering with the shared custom-message pattern: a boxed bold [refinement] label on the customMessage background, collapsed to a single summary line and expanded through the same tool-output toggle (ctrl+o) that compaction summaries and skill invocations use, instead of a separate edit-diff axis. --- .../components/conversation-components.ts | 2 +- .../components/refinement-outcome-message.ts | 84 ++++++++++--------- .../test/refinement-outcome-message.test.ts | 28 +++---- 3 files changed, 59 insertions(+), 55 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/conversation-components.ts b/packages/coding-agent/src/modes/interactive/components/conversation-components.ts index 7f15853515..8e06264658 100644 --- a/packages/coding-agent/src/modes/interactive/components/conversation-components.ts +++ b/packages/coding-agent/src/modes/interactive/components/conversation-components.ts @@ -153,7 +153,7 @@ export function buildConversationComponents( const component = isRefinementOutcomeMessage(message) ? new RefinementOutcomeMessageComponent(message) : new MalformedRefinementOutcomeMessageComponent(); - component.setEditDiffsExpanded(editDiffsExpanded); + component.setExpanded(expanded); components.push(component); } else if (isAgentSessionMessage(message) && message.display) { const component = new AgentMessageComponent(message, options.markdownTheme, { diff --git a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts index c11dbb0794..77a9f4598e 100644 --- a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts @@ -1,4 +1,4 @@ -import { Container, Spacer, Text } from "@earendil-works/pi-tui"; +import { Box, Spacer, Text } from "@earendil-works/pi-tui"; import type { RefinementOutcomeMessage } from "../../../core/messages.js"; import type { AppliedRefinementEdit, HarnessEntry } from "../../../core/refinement/refinement.js"; import { generateDiffString } from "../../../core/tools/edit-diff.js"; @@ -52,64 +52,68 @@ function editLabel(edit: AppliedRefinementEdit, fallbackScope: "local" | "global return `${theme.fg("success", verb)} ${scope} ${edit.kind} \`${edit.id}\``; } -export class RefinementOutcomeMessageComponent extends Container { - private readonly content = new Container(); - private editDiffsExpanded = false; +function editCount(edits: AppliedRefinementEdit[]): string { + const applied = edits.filter((edit) => edit.applied).length; + return edits.length === applied + ? `${applied} edit${applied === 1 ? "" : "s"} applied` + : `${applied}/${edits.length} edits applied`; +} + +/** + * Renders a durable refinement outcome with collapsed/expanded state. + * Mirrors the compaction-summary and skill-invocation components: a + * custom-message box with a bold [refinement] label, collapsed to a single + * line and expanded via the shared tool-output expansion toggle. + */ +export class RefinementOutcomeMessageComponent extends Box { + private expanded = false; constructor(private readonly message: RefinementOutcomeMessage) { - super(); - this.addChild(new Spacer(1)); - this.addChild(this.content); - this.rebuild(); + super(1, 1, (t) => theme.bg("customMessageBg", t)); + this.updateDisplay(); } - setExpanded(_expanded: boolean): void {} - - setEditDiffsExpanded(expanded: boolean): void { - if (this.editDiffsExpanded === expanded) return; - this.editDiffsExpanded = expanded; - this.rebuild(); + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + this.updateDisplay(); } override invalidate(): void { super.invalidate(); - this.rebuild(); + this.updateDisplay(); } - private rebuild(): void { - this.content.clear(); - this.content.addChild( - new Text(theme.fg("success", `✓ Refinement complete: ${this.message.details.summary}`), 0, 0), - ); - - const edits = this.message.details.edits; - const applied = edits.filter((edit) => edit.applied).length; - const count = - edits.length === applied - ? `${applied} edit${applied === 1 ? "" : "s"} applied` - : `${applied}/${edits.length} edits applied`; - const hint = edits.length === 0 ? "" : ` · ${expandCollapseHint("app.edits.expand", this.editDiffsExpanded)}`; - this.content.addChild(new Text(`Refined continual harness state: ${count}${hint}`, 0, 0)); + private updateDisplay(): void { + this.clear(); + + const { summary, edits, scope } = this.message.details; + const label = theme.fg("customMessageLabel", `\x1b[1m[refinement]\x1b[22m`); + if (!this.expanded) { + const line = + `${label} ` + + theme.fg("customMessageText", `${summary} · ${editCount(edits)}`) + + ` ${expandCollapseHint("app.tools.expand", false)}`; + this.addChild(new Text(line, 0, 0)); + return; + } + this.addChild(new Text(label, 0, 0)); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("customMessageText", `${summary} · ${editCount(edits)}`), 0, 0)); for (const edit of edits) { - this.content.addChild( - new Text(`${theme.fg("dim", " ╰─ ")}${editLabel(edit, this.message.details.scope)}`, 0, 0), - ); - if (this.editDiffsExpanded) { - const diff = editDiff(edit); - if (diff) this.content.addChild(new Text(renderDiff(diff), 4, 0)); - } + this.addChild(new Text(`${theme.fg("dim", " ╰─ ")}${editLabel(edit, scope)}`, 0, 0)); + const diff = editDiff(edit); + if (diff) this.addChild(new Text(renderDiff(diff), 4, 0)); } } } -export class MalformedRefinementOutcomeMessageComponent extends Container { +export class MalformedRefinementOutcomeMessageComponent extends Box { constructor() { - super(); - this.addChild(new Spacer(1)); + super(1, 1, (t) => theme.bg("customMessageBg", t)); this.addChild(new Text(theme.fg("error", "[Malformed refinement outcome message]"), 0, 0)); } setExpanded(_expanded: boolean): void {} - setEditDiffsExpanded(_expanded: boolean): void {} } diff --git a/packages/coding-agent/test/refinement-outcome-message.test.ts b/packages/coding-agent/test/refinement-outcome-message.test.ts index abac34cab4..a3a76cd2f2 100644 --- a/packages/coding-agent/test/refinement-outcome-message.test.ts +++ b/packages/coding-agent/test/refinement-outcome-message.test.ts @@ -52,7 +52,7 @@ function result(): RefinementResult { } function rendered(component: RefinementOutcomeMessageComponent): string { - return stripAnsi(component.render(100).join("\n")); + return stripAnsi(component.render(120).join("\n")); } describe("RefinementOutcomeMessageComponent", () => { @@ -61,21 +61,21 @@ describe("RefinementOutcomeMessageComponent", () => { setKeybindings(new KeybindingsManager()); }); - test("shows edit identity collapsed and exact payload through the code-edit toggle", () => { + test("collapses to a labeled one-liner and expands through the shared tool toggle", () => { const message = createRefinementOutcomeMessage(result()); const component = new RefinementOutcomeMessageComponent(message); const collapsed = rendered(component); - expect(collapsed).toContain( - "✓ Refinement complete: Added local guidance to make conversational responses rhyme.", - ); - expect(collapsed).toContain("Created local prompt `rhyme-response-guidance`"); - expect(collapsed).toContain("Ctrl+J to expand"); - expect(collapsed).not.toContain("Make conversational responses rhyme."); + expect(collapsed).toContain("[refinement]"); + expect(collapsed).toContain("Added local guidance to make conversational responses rhyme."); + expect(collapsed).toContain("1 edit applied"); + expect(collapsed).toContain("Ctrl+O to expand"); + expect(collapsed).not.toContain("Created local prompt"); + expect(collapsed).not.toContain('Make conversational responses rhyme."'); - component.setEditDiffsExpanded(true); + component.setExpanded(true); const expanded = rendered(component); - expect(expanded).toContain("Ctrl+J to collapse"); + expect(expanded).toContain("Created local prompt `rhyme-response-guidance`"); expect(expanded).toContain('"content": "Make conversational responses rhyme."'); expect(expanded).toContain('"path": "prompts/rhyme-response-guidance.md"'); }); @@ -93,7 +93,7 @@ describe("RefinementOutcomeMessageComponent", () => { ], }); const component = new RefinementOutcomeMessageComponent(message); - component.setEditDiffsExpanded(true); + component.setExpanded(true); const output = rendered(component); expect(output).toContain("Updated local prompt `tone-guidance`"); @@ -103,18 +103,18 @@ describe("RefinementOutcomeMessageComponent", () => { expect(output).toContain('"content": "Use prose."'); }); - test("replays the durable outcome with the saved edit expansion state", () => { + test("replays the durable outcome with the saved tool expansion state", () => { const message = createRefinementOutcomeMessage(result()); const [component] = buildConversationComponents([message], { ui: {} as TUI, cwd: "/tmp", toolOptions: {}, getToolDefinition: () => undefined, - editDiffsExpanded: true, + toolsExpanded: true, }); expect(component).toBeInstanceOf(RefinementOutcomeMessageComponent); - expect(stripAnsi(component!.render(100).join("\n"))).toContain( + expect(stripAnsi(component!.render(120).join("\n"))).toContain( '"content": "Make conversational responses rhyme."', ); }); From eb25ef7890c3b3edf307e61bd39594cb13f10dbc Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 19 Aug 2026 22:58:56 +0200 Subject: [PATCH 12/23] Strip the live-status plumbing; keep only the durable outcome message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The apply phase is sub-second, so a "Refining" loader, agents-view status label, daemon status string, heartbeat deferral, and an isRefining wire flag were all periphery for something users can barely see. Drop refinement_start/end events and every isRefining touchpoint (daemon protocol schema stays at 16 — no wire change at all), and slim the outcome-message validator to the shallow envelope checks the compaction outcome uses. The feature is now just the persisted [refinement] transcript card. --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/cli/daemon-command.ts | 5 +--- .../coding-agent/src/core/agent-session.ts | 29 ++---------------- packages/coding-agent/src/core/cron-jobs.ts | 2 -- packages/coding-agent/src/core/messages.ts | 30 ++----------------- .../src/modes/agent-connection/snapshot.ts | 1 - .../src/modes/agent-connection/types.ts | 3 -- .../modes/agents-view/agents-view-state.ts | 3 -- .../src/modes/daemon/daemon-mode.ts | 26 +++++++--------- .../src/modes/daemon/daemon-protocol.ts | 5 ++-- .../src/modes/daemon/daemon-session-list.ts | 3 -- .../src/modes/interactive/interactive-mode.ts | 30 ++----------------- .../coding-agent/src/modes/rpc/rpc-mode.ts | 1 - .../coding-agent/src/modes/rpc/rpc-types.ts | 1 - .../test/agents-view-state.test.ts | 5 ---- packages/coding-agent/test/cron-jobs.test.ts | 9 ------ .../coding-agent/test/daemon-protocol.test.ts | 29 ------------------ .../test/interactive-mode-status.test.ts | 22 -------------- .../test/suite/agent-session-queue.test.ts | 15 +--------- 19 files changed, 24 insertions(+), 197 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 88038a26e6..78f29ea663 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- Added a visible refinement indicator and a durable refinement outcome message with expandable exact harness edits. +- Added a durable `[refinement]` transcript message after each refinement showing the applied harness edits, expandable to exact before/after diffs via the shared tool-output toggle. - Fixed large IPython variables repeatedly slowing later turns by excluding them from persistent snapshots and removing them when context is compacted. - Fixed daemon socket paths being used verbatim in identity derivations: on supported platforms, `--daemon-socket` spellings differing only by duplicate or trailing slashes now normalize to one canonical path, so worker-descriptor namespaces, daemon log files, and persisted descriptors agree. - Added a `thinking` option to `rlm.run` for spawning subagents with an explicit reasoning level; invalid levels for the resolved child model fail spawn. diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index fdc2403239..2deb4080c6 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -1475,10 +1475,8 @@ class DaemonAttachTerminal { case "refine_failed": this.writeLine(chalk.red(`Refinement failed: ${event.error}`)); return; - // The persisted refinement outcome message prints via message_end. case "refine_complete": - case "refinement_start": - case "refinement_end": + return; case "turn_start": case "turn_end": case "message_start": @@ -1643,7 +1641,6 @@ function isSessionSummary(value: unknown): value is SessionSummary { typeof candidate.isSessionActive === "boolean" && typeof candidate.isStreaming === "boolean" && typeof candidate.isCompacting === "boolean" && - (candidate.isRefining === undefined || typeof candidate.isRefining === "boolean") && typeof candidate.attachedClients === "number" && typeof candidate.messageCount === "number" && (candidate.unfinishedActionCount === undefined || typeof candidate.unfinishedActionCount === "number") && diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 84fcf16415..dadedd4a60 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -309,8 +309,6 @@ export type AgentSessionEvent = reason: CompactionReason; customInstructions?: string; } - | { type: "refinement_start" } - | { type: "refinement_end" } | { type: "session_info_changed"; name: string | undefined } | { type: "thinking_level_changed"; level: ThinkingLevel } | { type: "service_tier_changed"; serviceTier: ServiceTier } @@ -1165,7 +1163,6 @@ export class AgentSession { private readonly _autoRefineReviewer?: AutoRefineReviewer; private readonly _serializedRefine: boolean; private _refineInFlight?: Promise; - private _refinementApplyActive = false; private _refinePlanInFlight?: Promise; private _serializedPlanInFlight?: Promise; private _serializedPlanClaim?: Promise; @@ -2344,7 +2341,7 @@ export class AgentSession { }); this._refineInFlight = applySettled; try { - await this._applyRefineWithStatus(bgResult.plan, bgResult.options, bgResult.abort); + await this._applyRefine(bgResult.plan, bgResult.options, bgResult.abort); } finally { resolveApplySettled(); if (this._refineInFlight === applySettled) { @@ -2546,7 +2543,7 @@ export class AgentSession { }); this._refineInFlight = applySettled; try { - await this._applyRefineWithStatus(plan, options, refineAbort); + await this._applyRefine(plan, options, refineAbort); } finally { resolveApplySettled(); if (this._refineInFlight === applySettled) { @@ -4037,26 +4034,6 @@ export class AgentSession { ); } - get isRefining(): boolean { - return this._refinementApplyActive; - } - - // Status brackets only the apply phase; _refineInFlight goes up earlier as a turn barrier while public refine() still waits for idle. - private async _applyRefineWithStatus( - plan: RefinementPlan, - options: { instructions?: string; rollbackId?: string; global?: boolean }, - refineAbort: AbortController, - ): Promise { - this._refinementApplyActive = true; - this._emit({ type: "refinement_start" }); - try { - return await this._applyRefine(plan, options, refineAbort); - } finally { - this._refinementApplyActive = false; - this._emit({ type: "refinement_end" }); - } - } - get messages(): AgentMessage[] { return this.agent.state.messages; } @@ -7582,7 +7559,7 @@ export class AgentSession { if (this._disposed || refineAbort.signal.aborted) { throw new Error("Refinement cancelled because the session was disposed."); } - return await this._applyRefineWithStatus(plan, options, refineAbort); + return await this._applyRefine(plan, options, refineAbort); } finally { resolveApplySettled(); if (this._refineInFlight === applySettled) { diff --git a/packages/coding-agent/src/core/cron-jobs.ts b/packages/coding-agent/src/core/cron-jobs.ts index 5c375d4514..3c26ed275c 100644 --- a/packages/coding-agent/src/core/cron-jobs.ts +++ b/packages/coding-agent/src/core/cron-jobs.ts @@ -86,7 +86,6 @@ export interface AgentCronSchedulerHooks { export interface HeartbeatCronSessionActivity { isStreaming: boolean; isCompacting?: boolean; - isRefining?: boolean; isRetrying?: boolean; isBashRunning: boolean; hasPendingSessionWork: boolean; @@ -1356,7 +1355,6 @@ export function shouldDeferHeartbeatCronJob(job: AgentCronJob, activity: Heartbe // regardless of delivery mode. const busyBesidesStreaming = activity.isCompacting === true || - activity.isRefining === true || activity.isRetrying === true || activity.isBashRunning || activity.hasPendingSessionWork || diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 628481a070..81a77117f4 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -434,35 +434,13 @@ export function isCompactionOutcomeMessage(message: unknown): message is Compact ); } -function isRefinementKind(value: unknown): value is AppliedRefinementEdit["kind"] { - return value === "prompt" || value === "memory" || value === "skill" || value === "subagent"; -} - -function isHarnessEntry(value: unknown): boolean { - return ( - isRecord(value) && - typeof value.id === "string" && - isRefinementKind(value.kind) && - typeof value.title === "string" && - typeof value.content === "string" && - typeof value.path === "string" && - (value.scope === undefined || value.scope === "local" || value.scope === "global") && - isRecord(value.reference) && - isRecord(value.arguments) && - isRecord(value.metadata) - ); -} - function isAppliedRefinementEdit(value: unknown): value is AppliedRefinementEdit { - if (!isRecord(value)) return false; return ( + isRecord(value) && (value.action === "create" || value.action === "update" || value.action === "delete") && - isRefinementKind(value.kind) && + typeof value.kind === "string" && typeof value.id === "string" && - typeof value.applied === "boolean" && - (value.error === undefined || typeof value.error === "string") && - (value.before === undefined || isHarnessEntry(value.before)) && - (value.after === undefined || isHarnessEntry(value.after)) + typeof value.applied === "boolean" ); } @@ -470,10 +448,8 @@ export function isRefinementOutcomeMessage(message: unknown): message is Refinem if (!isRecord(message) || !hasValidCustomMessageEnvelope(message, REFINEMENT_OUTCOME_CUSTOM_TYPE)) return false; if (!isRecord(message.details)) return false; return ( - typeof message.details.refinementId === "string" && typeof message.details.summary === "string" && (message.details.scope === "local" || message.details.scope === "global") && - (message.details.rollbackOf === undefined || typeof message.details.rollbackOf === "string") && Array.isArray(message.details.edits) && message.details.edits.every(isAppliedRefinementEdit) ); diff --git a/packages/coding-agent/src/modes/agent-connection/snapshot.ts b/packages/coding-agent/src/modes/agent-connection/snapshot.ts index 2fe8598d7c..d772c76d26 100644 --- a/packages/coding-agent/src/modes/agent-connection/snapshot.ts +++ b/packages/coding-agent/src/modes/agent-connection/snapshot.ts @@ -34,7 +34,6 @@ export function createAgentConnectionState( availableThinkingLevels: session.getAvailableThinkingLevels(), isStreaming: session.isStreaming, isCompacting: session.isCompacting, - isRefining: session.isRefining, isBashRunning: session.isBashRunning, retryAttempt: session.retryAttempt, steeringMode: session.steeringMode, diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index 53568dba21..a5e8ea699c 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -330,7 +330,6 @@ export interface AgentConnectionState { availableThinkingLevels: ThinkingLevel[]; isStreaming: boolean; isCompacting: boolean; - isRefining?: boolean; isBashRunning: boolean; retryAttempt: number; steeringMode: AgentConnectionQueueMode; @@ -576,8 +575,6 @@ export type AgentConnectionSessionEvent = reason: "manual" | "threshold" | "overflow" | "requested"; customInstructions?: string; } - | { type: "refinement_start" } - | { type: "refinement_end" } | { type: "session_info_changed"; name: string | undefined } | { type: "thinking_level_changed"; level: ThinkingLevel } | { type: "service_tier_changed"; serviceTier: ServiceTier } diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts index 216c1d6f13..50b7ad1138 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts @@ -960,9 +960,6 @@ function getSessionSubtitle(summary: SessionSummary): string { } function getSessionStatusLabel(summary: SessionSummary, hasActiveHeartbeat = summary.hasActiveHeartbeat): string { - if (summary.isRefining) { - return "refining"; - } if (summary.isCompacting) { return "compacting"; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index ff9e8167e0..05a7632a25 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -356,8 +356,6 @@ const RECOVERY_CHECKPOINT_EVENTS: ReadonlySet = new Set([ "tool_execution_end", "compaction_start", "compaction_end", - "refinement_start", - "refinement_end", "auto_retry_start", "auto_retry_end", "bash_start", @@ -3235,19 +3233,17 @@ export class AgentDaemon { const session = state.runtime.session; const messages = session.messages; const latest = messages.at(-1); - const status = session.isRefining - ? "refining" - : session.isStreaming - ? session.state.pendingToolCalls.size > 0 - ? "tool" - : "model" - : session.isCompacting - ? "compacting" - : session.isSessionActive || session.hasRunningRlmChildren() - ? "busy" - : state.clients.size > 0 - ? "user" - : "idle"; + const status = session.isStreaming + ? session.state.pendingToolCalls.size > 0 + ? "tool" + : "model" + : session.isCompacting + ? "compacting" + : session.isSessionActive || session.hasRunningRlmChildren() + ? "busy" + : state.clients.size > 0 + ? "user" + : "idle"; return { activeSessionId: state.activeSessionId, sessionId: summary.sessionId, diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index 7ab6b6b009..b26eb1bdc2 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -60,9 +60,8 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 14 carries the client's monotonic telemetry opt-out on attach and reattach. // Revision 15 adds the mutate_queued_message command and queue_message_mutation capability. // Revision 16 adds the "stopping" workerState and stops reporting disconnected workers as "ready". -// Revision 17 adds refinement_start/refinement_end session events and optional isRefining summary/state fields. -export const DAEMON_SCHEMA_REVISION = 17; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-17-1bcb9e7f1a49"; +export const DAEMON_SCHEMA_REVISION = 16; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-16-1bcb9e7f1a49"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index 949cfea8ab..73dc116ba5 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -51,7 +51,6 @@ export interface SessionSummary { thinkingLevel?: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; - isRefining?: boolean; isBashRunning?: boolean; hasRunningRlmChildren?: boolean; /** True while the agent is streaming with tool calls pending; drives the "running tools" label. */ @@ -231,7 +230,6 @@ export function summaryForActiveSession( thinkingLevel: session.thinkingLevel, isStreaming: session.isStreaming, isCompacting: session.isCompacting, - isRefining: session.isRefining, isBashRunning: session.isBashRunning, hasRunningRlmChildren: session.hasRunningRlmChildren(), isRunningTools: session.isStreaming && session.state.pendingToolCalls.size > 0, @@ -311,7 +309,6 @@ export function summaryForInactiveSession( cwd: session.cwd, isStreaming: false, isCompacting: false, - isRefining: false, attachedClients: 0, messageCount: session.messageCount, unfinishedActionCount: 0, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 227444a2a7..b039b91448 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2597,7 +2597,6 @@ export class InteractiveMode { this.sessionRecap = state.recap; this.renderRecap(); this.updateWorkingPulse(); - this.syncWorkingLoader(); } private patchConnectionState(patch: Partial): void { @@ -2666,12 +2665,6 @@ export class InteractiveMode { case "compaction_end": this.patchConnectionState({ isCompacting: false }); break; - case "refinement_start": - this.patchConnectionState({ isRefining: true }); - break; - case "refinement_end": - this.patchConnectionState({ isRefining: false }); - break; case "session_info_changed": this.patchConnectionState({ sessionName: event.name }); break; @@ -2735,10 +2728,6 @@ export class InteractiveMode { return this.connectionState?.isCompacting ?? false; } - private isAgentRefining(): boolean { - return this.connectionState?.isRefining ?? false; - } - private isBashRunning(): boolean { return this.connectionState?.isBashRunning ?? false; } @@ -3137,9 +3126,6 @@ export class InteractiveMode { const status = this.activityTracker.getStatus(); // The subagent count/recaps live in the tree above the loader, so the loader // message itself no longer repeats "N subagents running". - if (this.isAgentRefining()) { - return elapsed === undefined ? "Refining" : `Refining · ${elapsed}`; - } if (!this.isAgentStreaming()) { return ""; } @@ -3360,8 +3346,8 @@ export class InteractiveMode { private shouldShowWorkingLoader(): boolean { // Background subagents (agent turn done, asyncio tasks still running) would // otherwise show a textless spinner; the subagent tree above the loader carries - // that state, so the loader only shows while the main agent is streaming or refining. - return this.workingVisible && (this.isAgentStreaming() || this.isAgentRefining()); + // that state, so the loader only shows while the main agent is itself streaming. + return this.workingVisible && this.isAgentStreaming(); } // Reconcile the loader with current state for transitions that fire no live @@ -5574,18 +5560,6 @@ export class InteractiveMode { this.ui.requestRender(); break; - case "refinement_start": - this.stopWorkingLoader(); - if (this.workingVisible) { - this.startWorkingLoader(); - } - this.ui.requestRender(); - break; - - case "refinement_end": - this.syncWorkingLoader(); - break; - case "compaction_start": { this.startCompactionLoader(event.reason, event.customInstructions); break; diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 2483e22c75..a9d5690485 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -249,7 +249,6 @@ async function runRpcModeWithConnectionInternal( thinkingLevel: state.thinkingLevel, isStreaming: state.isStreaming, isCompacting: state.isCompacting, - isRefining: state.isRefining ?? false, steeringMode: state.steeringMode, followUpMode: state.followUpMode, sessionFile: state.sessionFile, diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 7752913ce6..7cba4f8a9b 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -140,7 +140,6 @@ export interface RpcSessionState { thinkingLevel: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; - isRefining: boolean; steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; sessionFile?: string; diff --git a/packages/coding-agent/test/agents-view-state.test.ts b/packages/coding-agent/test/agents-view-state.test.ts index 991f8428a9..1466f6e23d 100644 --- a/packages/coding-agent/test/agents-view-state.test.ts +++ b/packages/coding-agent/test/agents-view-state.test.ts @@ -98,11 +98,6 @@ describe("agents view state", () => { ).toBe("inactive"); }); - test("labels active refinement work", () => { - const [row] = buildAgentsViewRows([makeSummary({ isRefining: true, activity: "working" })]); - expect(row?.statusLabel).toBe("refining"); - }); - test("places all non-busy resident sessions in Idle", () => { // Working is heuristic and ignores taskState. expect( diff --git a/packages/coding-agent/test/cron-jobs.test.ts b/packages/coding-agent/test/cron-jobs.test.ts index a518f3694f..838dd688e4 100644 --- a/packages/coding-agent/test/cron-jobs.test.ts +++ b/packages/coding-agent/test/cron-jobs.test.ts @@ -1406,15 +1406,6 @@ describe("shouldDeferHeartbeatCronJob", () => { unfinishedActionCount: 0, }), ).toBe(true); - expect( - shouldDeferHeartbeatCronJob(job, { - isStreaming: false, - isRefining: true, - isBashRunning: false, - hasPendingSessionWork: false, - unfinishedActionCount: 0, - }), - ).toBe(true); expect( shouldDeferHeartbeatCronJob(job, { isStreaming: false, diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index f96da3a645..d7eb8acf4a 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -2,8 +2,6 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { acpUpdatesForSessionEvent } from "../src/modes/acp/acp-events.js"; -import { buildAgentsViewRows } from "../src/modes/agents-view/agents-view-state.js"; import { createDaemonCommandEnvelope, createDaemonEventEnvelope, @@ -23,7 +21,6 @@ import { isDaemonMutatingCommand, salvageDaemonCommandId, } from "../src/modes/daemon/daemon-protocol.js"; -import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; describe("daemon protocol helpers", () => { it("keeps the advertised schema identity synchronized with wire type shapes", () => { @@ -141,32 +138,6 @@ describe("daemon protocol helpers", () => { expect(DAEMON_SCHEMA_REVISION).toBeGreaterThanOrEqual(16); }); - it("keeps refinement status additions backward-compatible at revision 17", () => { - // Optional isRefining fields plus new events on the ungated session-event channel; no capability gate. - expect(DAEMON_SCHEMA_REVISION).toBeGreaterThanOrEqual(17); - expect(DAEMON_OUTBOUND_COMPATIBILITY.session_event).toEqual({ minProtocol: 7 }); - // A session-event handler written before revision 17 ignores the new lifecycle events. - expect(acpUpdatesForSessionEvent({ type: "refinement_start" })).toEqual([]); - expect(acpUpdatesForSessionEvent({ type: "refinement_end" })).toEqual([]); - // An old-daemon summary omits isRefining; a new client must not read it as refining. - const oldDaemonSummary: SessionSummary = { - id: "active-1", - activeSessionId: "active-1", - lifecycle: "live", - activity: "working", - isSessionActive: true, - sessionId: "session-1", - cwd: "/tmp/project", - isStreaming: false, - isCompacting: false, - attachedClients: 0, - messageCount: 1, - sessionActions: { queuedCount: 0, steering: [], followUps: [] }, - }; - const [row] = buildAgentsViewRows([oldDaemonSummary]); - expect(row?.statusLabel).not.toBe("refining"); - }); - it("keeps refine failure events backward-compatible on the existing session event channel", () => { const event: DaemonOutbound = { type: "session_event", diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 1a048b6b1a..d699993abd 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -99,7 +99,6 @@ function createConnectionState(overrides: Partial = {}): A availableThinkingLevels: ["minimal", "low", "medium", "high", "xhigh"], isStreaming: false, isCompacting: false, - isRefining: false, isBashRunning: false, retryAttempt: 0, steeringMode: "all", @@ -118,27 +117,6 @@ function createConnectionState(overrides: Partial = {}): A }; } -test("tracks refinement lifecycle for the working indicator", () => { - const fakeThis = { - connectionState: createConnectionState(), - activityTracker: new AgentActivityTracker(), - workingStartedAt: Date.now(), - workingMessage: undefined, - } as unknown as InteractiveMode; - Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); - const prototype = InteractiveMode.prototype as unknown as { - updateConnectionStateFromEvent(this: InteractiveMode, event: AgentConnectionSessionEvent): void; - getWorkingLoaderMessage(this: InteractiveMode): string; - }; - - prototype.updateConnectionStateFromEvent.call(fakeThis, { type: "refinement_start" }); - expect((fakeThis as unknown as { connectionState: AgentConnectionState }).connectionState.isRefining).toBe(true); - expect(prototype.getWorkingLoaderMessage.call(fakeThis)).toContain("Refining"); - - prototype.updateConnectionStateFromEvent.call(fakeThis, { type: "refinement_end" }); - expect((fakeThis as unknown as { connectionState: AgentConnectionState }).connectionState.isRefining).toBe(false); -}); - describe("InteractiveMode update notifications", () => { beforeAll(() => { initTheme("dark"); diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 854a06e771..212afb1164 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1004,7 +1004,7 @@ describe("AgentSession queue characterization", () => { } }); - it("surfaces refinement apply state while preserving a concurrent prompt result", async () => { + it("records a durable refinement outcome while preserving a concurrent prompt result", async () => { const harness = await createAutoRefineHarness(); harnesses.push(harness); const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; @@ -1015,12 +1015,6 @@ describe("AgentSession queue characterization", () => { const promptGate = createDeferred(); const promptStartedPromise = createDeferred(); let promptSignal: AbortSignal | undefined; - const refinementStates: Array<["refinement_start" | "refinement_end", boolean]> = []; - harness.session.subscribe((event) => { - if (event.type === "refinement_start" || event.type === "refinement_end") { - refinementStates.push([event.type, harness.session.isRefining]); - } - }); harness.setResponses([ async () => { planStartedPromise.resolve(); @@ -1037,7 +1031,6 @@ describe("AgentSession queue characterization", () => { const refinePromise = harness.session.refine({ instructions: "background refine" }); await planStartedPromise.promise; - expect(harness.session.isRefining).toBe(false); const promptPromise = harness.session.prompt("hello during refine"); await promptStartedPromise.promise; @@ -1057,17 +1050,11 @@ describe("AgentSession queue characterization", () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(refineSettled).toBe(false); - // The turn barrier is up, but status stays off until the apply phase starts. - expect(harness.session.isRefining).toBe(false); promptGate.resolve(); await refinePromise; await promptPromise; - expect(refinementStates).toEqual([ - ["refinement_start", true], - ["refinement_end", false], - ]); const outcome = harness.session.messages.find(isRefinementOutcomeMessage); expect(outcome?.details.summary).toBe("no-op"); expect( From 8ea6c960d38feb5418147b154631f69183c8fb8f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 19 Aug 2026 23:42:13 +0200 Subject: [PATCH 13/23] Show a live loader for user-issued /refine by reusing existing edges No new events or state: start the loader when the /refine slash-command message reaches the transcript and stop it on the already-wire-visible refine_complete/refine_failed events, mirroring the compaction loader. --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/modes/interactive/interactive-mode.ts | 28 +++++++++++++++++++ .../test/interactive-mode-status.test.ts | 24 ++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 78f29ea663..5a1cd3dcf8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- Added a durable `[refinement]` transcript message after each refinement showing the applied harness edits, expandable to exact before/after diffs via the shared tool-output toggle. +- Added a durable `[refinement]` transcript message after each refinement showing the applied harness edits (expandable to exact before/after diffs via the shared tool-output toggle), and a live loader while a user-issued /refine runs. - Fixed large IPython variables repeatedly slowing later turns by excluding them from persistent snapshots and removing them when context is compacted. - Fixed daemon socket paths being used verbatim in identity derivations: on supported platforms, `--daemon-socket` spellings differing only by duplicate or trailing slashes now normalize to one canonical path, so worker-descriptor namespaces, daemon log files, and persisted descriptors agree. - Added a `thinking` option to `rlm.run` for spawning subagents with an explicit reasoning level; invalid levels for the resolved child model fail spawn. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b039b91448..0f4d1a9f94 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1005,6 +1005,7 @@ export class InteractiveMode { private signalCleanupHandlers: Array<() => void> = []; private autoCompactionLoader: Loader | undefined = undefined; + private refineLoader: Loader | undefined = undefined; private retryLoader: Loader | undefined = undefined; private retryCountdown: CountdownTimer | undefined = undefined; @@ -3381,6 +3382,28 @@ export class InteractiveMode { this.ui.requestRender(); } + /** Live status for a user-issued /refine, mirroring the compaction loader. */ + private startRefineLoader(): void { + this.stopWorkingLoader(); + this.statusContainer.clear(); + this.refineLoader = new Loader( + this.ui, + (spinner) => theme.fg("muted", spinner), + (text) => theme.fg("muted", text), + "Refining continual harness state...", + ); + this.statusContainer.addChild(this.refineLoader); + this.ui.requestRender(); + } + + private stopRefineLoader(): void { + if (!this.refineLoader) return; + this.refineLoader.stop(); + this.refineLoader = undefined; + this.statusContainer.clear(); + this.syncWorkingLoader(); + } + private syncWorkingLoader(): void { // A compaction that started before this client attached (or while another // view was open) has no start-event edge; restore its loader from state. @@ -5411,6 +5434,9 @@ export class InteractiveMode { case "message_start": if (event.message.role === "custom") { + if (isSessionSlashCommandMessage(event.message) && event.message.details.command.name === "refine") { + this.startRefineLoader(); + } this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { @@ -5657,10 +5683,12 @@ export class InteractiveMode { break; case "refine_failed": + this.stopRefineLoader(); this.showError(`Refinement failed: ${event.error}`); break; case "refine_complete": + this.stopRefineLoader(); break; } } diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index d699993abd..0200051c3b 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -5098,3 +5098,27 @@ describe("InteractiveMode.showLoadedResources", () => { expect(output).not.toContain("[Skill conflicts]"); }); }); + +test("shows a refine loader from the /refine command message until refine_complete", () => { + initTheme("dark"); + const statusContainer = new Container(); + const fakeThis = { + ui: { requestRender: vi.fn() } as unknown as TUI, + statusContainer, + refineLoader: undefined, + stopWorkingLoader: vi.fn(), + syncWorkingLoader: vi.fn(), + } as unknown as InteractiveMode; + Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); + const prototype = InteractiveMode.prototype as unknown as { + startRefineLoader(this: InteractiveMode): void; + stopRefineLoader(this: InteractiveMode): void; + }; + + prototype.startRefineLoader.call(fakeThis); + expect(statusContainer.children.length).toBe(1); + + prototype.stopRefineLoader.call(fakeThis); + expect(statusContainer.children.length).toBe(0); + expect((fakeThis as unknown as { syncWorkingLoader: () => void }).syncWorkingLoader).toHaveBeenCalled(); +}); From e9a5edbead57ae40a43aa5a012518f7cdd1a0fd8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 20 Aug 2026 10:14:09 +0200 Subject: [PATCH 14/23] Extract the shared expandable custom-message card skeleton Compaction, skill, and now refinement cards each copied the same Box subclass with an expanded flag, setExpanded, invalidate, and bold-label formatting. Pull that into ExpandableCustomMessageBox + a customMessageLabel helper and rebase all three components onto it, so the refinement card only carries its edit-row/diff formatting. --- .../components/compaction-summary-message.ts | 37 ++++---------- .../components/expandable-custom-message.ts | 33 ++++++++++++ .../components/refinement-outcome-message.ts | 51 +++++++------------ .../components/skill-invocation-message.ts | 43 +++++----------- 4 files changed, 73 insertions(+), 91 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/expandable-custom-message.ts diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index 24164a157d..653dbfa22b 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -1,39 +1,24 @@ -import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; +import { Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; import type { CompactionSummaryMessage } from "../../../core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; +import { customMessageLabel, ExpandableCustomMessageBox } from "./expandable-custom-message.js"; import { expandCollapseHint } from "./keybinding-hints.js"; -/** - * Component that renders a compaction message with collapsed/expanded state. - * Uses same background color as custom messages for visual consistency. - */ -export class CompactionSummaryMessageComponent extends Box { - private expanded = false; - private message: CompactionSummaryMessage; - private markdownTheme: MarkdownTheme; - - constructor(message: CompactionSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme()) { - super(1, 1, (t) => theme.bg("customMessageBg", t)); - this.message = message; - this.markdownTheme = markdownTheme; - this.updateDisplay(); - } - - setExpanded(expanded: boolean): void { - this.expanded = expanded; - this.updateDisplay(); - } - - override invalidate(): void { - super.invalidate(); +/** Compaction summary card: full markdown summary when expanded. */ +export class CompactionSummaryMessageComponent extends ExpandableCustomMessageBox { + constructor( + private readonly message: CompactionSummaryMessage, + private readonly markdownTheme: MarkdownTheme = getMarkdownTheme(), + ) { + super(); this.updateDisplay(); } - private updateDisplay(): void { + protected updateDisplay(): void { this.clear(); const tokenStr = this.message.tokensBefore.toLocaleString(); - const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`); + const label = customMessageLabel("compaction"); this.addChild(new Text(label, 0, 0)); this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/components/expandable-custom-message.ts b/packages/coding-agent/src/modes/interactive/components/expandable-custom-message.ts new file mode 100644 index 0000000000..c397a51b08 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/expandable-custom-message.ts @@ -0,0 +1,33 @@ +import { Box } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.js"; + +/** + * Shared skeleton for boxed custom-message cards (compaction, skill, + * refinement) with a collapsed/expanded state driven by the shared + * tool-output expansion toggle. + */ +export abstract class ExpandableCustomMessageBox extends Box { + protected expanded = false; + + constructor() { + super(1, 1, (t) => theme.bg("customMessageBg", t)); + } + + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + this.updateDisplay(); + } + + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + + protected abstract updateDisplay(): void; +} + +/** Bold custom-message label like `[refinement]`. */ +export function customMessageLabel(name: string): string { + return theme.fg("customMessageLabel", `\x1b[1m[${name}]\x1b[22m`); +} diff --git a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts index 77a9f4598e..fc9c0b972d 100644 --- a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts @@ -1,9 +1,10 @@ -import { Box, Spacer, Text } from "@earendil-works/pi-tui"; +import { Spacer, Text } from "@earendil-works/pi-tui"; import type { RefinementOutcomeMessage } from "../../../core/messages.js"; import type { AppliedRefinementEdit, HarnessEntry } from "../../../core/refinement/refinement.js"; import { generateDiffString } from "../../../core/tools/edit-diff.js"; import { theme } from "../theme/theme.js"; import { renderDiff } from "./diff.js"; +import { customMessageLabel, ExpandableCustomMessageBox } from "./expandable-custom-message.js"; import { expandCollapseHint } from "./keybinding-hints.js"; function editableEntry(entry: HarnessEntry): Record { @@ -59,48 +60,27 @@ function editCount(edits: AppliedRefinementEdit[]): string { : `${applied}/${edits.length} edits applied`; } -/** - * Renders a durable refinement outcome with collapsed/expanded state. - * Mirrors the compaction-summary and skill-invocation components: a - * custom-message box with a bold [refinement] label, collapsed to a single - * line and expanded via the shared tool-output expansion toggle. - */ -export class RefinementOutcomeMessageComponent extends Box { - private expanded = false; - +/** Durable refinement outcome card: per-edit rows with before/after diffs when expanded. */ +export class RefinementOutcomeMessageComponent extends ExpandableCustomMessageBox { constructor(private readonly message: RefinementOutcomeMessage) { - super(1, 1, (t) => theme.bg("customMessageBg", t)); - this.updateDisplay(); - } - - setExpanded(expanded: boolean): void { - if (this.expanded === expanded) return; - this.expanded = expanded; - this.updateDisplay(); - } - - override invalidate(): void { - super.invalidate(); + super(); this.updateDisplay(); } - private updateDisplay(): void { + protected updateDisplay(): void { this.clear(); const { summary, edits, scope } = this.message.details; - const label = theme.fg("customMessageLabel", `\x1b[1m[refinement]\x1b[22m`); + const label = customMessageLabel("refinement"); + const headline = theme.fg("customMessageText", `${summary} · ${editCount(edits)}`); if (!this.expanded) { - const line = - `${label} ` + - theme.fg("customMessageText", `${summary} · ${editCount(edits)}`) + - ` ${expandCollapseHint("app.tools.expand", false)}`; - this.addChild(new Text(line, 0, 0)); + this.addChild(new Text(`${label} ${headline} ${expandCollapseHint("app.tools.expand", false)}`, 0, 0)); return; } this.addChild(new Text(label, 0, 0)); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("customMessageText", `${summary} · ${editCount(edits)}`), 0, 0)); + this.addChild(new Text(headline, 0, 0)); for (const edit of edits) { this.addChild(new Text(`${theme.fg("dim", " ╰─ ")}${editLabel(edit, scope)}`, 0, 0)); const diff = editDiff(edit); @@ -109,11 +89,14 @@ export class RefinementOutcomeMessageComponent extends Box { } } -export class MalformedRefinementOutcomeMessageComponent extends Box { +export class MalformedRefinementOutcomeMessageComponent extends ExpandableCustomMessageBox { constructor() { - super(1, 1, (t) => theme.bg("customMessageBg", t)); - this.addChild(new Text(theme.fg("error", "[Malformed refinement outcome message]"), 0, 0)); + super(); + this.updateDisplay(); } - setExpanded(_expanded: boolean): void {} + protected updateDisplay(): void { + this.clear(); + this.addChild(new Text(theme.fg("error", "[Malformed refinement outcome message]"), 0, 0)); + } } diff --git a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts index c5491eb795..fab70367ec 100644 --- a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -1,42 +1,24 @@ -import { Box, Markdown, type MarkdownTheme, Text } from "@earendil-works/pi-tui"; +import { Markdown, type MarkdownTheme, Text } from "@earendil-works/pi-tui"; import type { ParsedSkillBlock } from "../../../core/skill-blocks.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; +import { customMessageLabel, ExpandableCustomMessageBox } from "./expandable-custom-message.js"; import { expandCollapseHint } from "./keybinding-hints.js"; -/** - * Component that renders a skill invocation message with collapsed/expanded state. - * Uses same background color as custom messages for visual consistency. - * Only renders the skill block itself - user message is rendered separately. - */ -export class SkillInvocationMessageComponent extends Box { - private expanded = false; - private skillBlock: ParsedSkillBlock; - private markdownTheme: MarkdownTheme; - - constructor(skillBlock: ParsedSkillBlock, markdownTheme: MarkdownTheme = getMarkdownTheme()) { - super(1, 1, (t) => theme.bg("customMessageBg", t)); - this.skillBlock = skillBlock; - this.markdownTheme = markdownTheme; - this.updateDisplay(); - } - - setExpanded(expanded: boolean): void { - this.expanded = expanded; - this.updateDisplay(); - } - - override invalidate(): void { - super.invalidate(); +/** Skill invocation card; the user message is rendered separately. */ +export class SkillInvocationMessageComponent extends ExpandableCustomMessageBox { + constructor( + private readonly skillBlock: ParsedSkillBlock, + private readonly markdownTheme: MarkdownTheme = getMarkdownTheme(), + ) { + super(); this.updateDisplay(); } - private updateDisplay(): void { + protected updateDisplay(): void { this.clear(); if (this.expanded) { - // Expanded: label + skill name header + full content - const label = theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m`); - this.addChild(new Text(label, 0, 0)); + this.addChild(new Text(customMessageLabel("skill"), 0, 0)); const header = `**${this.skillBlock.name}**\n\n`; this.addChild( new Markdown(header + this.skillBlock.content, 0, 0, this.markdownTheme, { @@ -44,9 +26,8 @@ export class SkillInvocationMessageComponent extends Box { }), ); } else { - // Collapsed: single line - [skill] name (hint to expand) const line = - theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + + `${customMessageLabel("skill")} ` + theme.fg("customMessageText", this.skillBlock.name) + ` ${expandCollapseHint("app.tools.expand", false)}`; this.addChild(new Text(line, 0, 0)); From 113d50c26ba4b26e4fe6404291d915a097c37ff7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 20 Aug 2026 10:41:44 +0200 Subject: [PATCH 15/23] Emit refine_failed when a queued /refine command fails A failed /refine persisted its command-error row without emitting refine_failed, so the TUI loader kept spinning. Emit it from the queued command catch, matching the refine.run and auto-refine failure paths. --- .../coding-agent/src/core/agent-session.ts | 1 + .../test/suite/agent-session-queue.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index dadedd4a60..693ac47746 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -5718,6 +5718,7 @@ export class AgentSession { } catch (error) { if (error instanceof CompactionSkippedError) return; const commandError = error instanceof Error ? error : new Error(String(error)); + if (input.command.name === "refine") this._emitRefineFailed(commandError); try { this._appendDurableSessionCommandMessage( `Command failed: ${commandError.message}`, diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 212afb1164..6a4568bf8e 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -2571,6 +2571,26 @@ describe("AgentSession queue characterization", () => { expect(harness.sessionManager.getBranch(resultEntry!.id).map((entry) => entry.id)).toContain(inputEntry!.id); }); + it("emits refine_failed when a queued /refine command fails", async () => { + const harness = await createHarness(); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("one")]); + await harness.session.prompt("one"); + vi.spyOn(harness.session, "refine").mockRejectedValue(new Error("planner unavailable")); + + const failures: string[] = []; + harness.session.subscribe((event) => { + if (event.type === "refine_failed") failures.push(event.error); + }); + + await harness.session.prompt("/refine --local").catch(() => undefined); + expect(failures).toEqual(["planner unavailable"]); + const errorRow = harness.sessionManager + .getEntries() + .find((entry) => entry.type === "custom_message" && entry.customType === "session_slash_command_result"); + expect(errorRow).toMatchObject({ content: "Command failed: planner unavailable" }); + }); + it("allows a /compact extension hook to navigate without deadlocking on the commit fence", async () => { let targetId: string | undefined; let navigateFromContext: ((target: string) => Promise<{ cancelled: boolean }>) | undefined; From 07bdea981ba7a8595837c74a4fbf39b6b7ac6af9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 20 Aug 2026 11:22:50 +0200 Subject: [PATCH 16/23] Align the refinement card layout with compaction and shield its loader Collapsed card now renders the [refinement] label line above the summary line, matching the compaction card structure. The refine loader joins compaction/retry in the syncWorkingLoader ownership guard so periodic reconcile paths (subagent updates, connection refreshes) cannot clear it mid-refine. --- .../interactive/components/refinement-outcome-message.ts | 7 +++---- .../coding-agent/src/modes/interactive/interactive-mode.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts index fc9c0b972d..3891325848 100644 --- a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts @@ -71,15 +71,14 @@ export class RefinementOutcomeMessageComponent extends ExpandableCustomMessageBo this.clear(); const { summary, edits, scope } = this.message.details; - const label = customMessageLabel("refinement"); const headline = theme.fg("customMessageText", `${summary} · ${editCount(edits)}`); + this.addChild(new Text(customMessageLabel("refinement"), 0, 0)); + this.addChild(new Spacer(1)); if (!this.expanded) { - this.addChild(new Text(`${label} ${headline} ${expandCollapseHint("app.tools.expand", false)}`, 0, 0)); + this.addChild(new Text(`${headline} ${expandCollapseHint("app.tools.expand", false)}`, 0, 0)); return; } - this.addChild(new Text(label, 0, 0)); - this.addChild(new Spacer(1)); this.addChild(new Text(headline, 0, 0)); for (const edit of edits) { this.addChild(new Text(`${theme.fg("dim", " ╰─ ")}${editLabel(edit, scope)}`, 0, 0)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0f4d1a9f94..29cc2d63fc 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3411,8 +3411,8 @@ export class InteractiveMode { this.startCompactionLoader("manual"); return; } - // Compaction/retry own the status container while active; don't fight them. - if (this.autoCompactionLoader || this.retryLoader) { + // Compaction/retry/refine own the status container while active; don't fight them. + if (this.autoCompactionLoader || this.retryLoader || this.refineLoader) { return; } if (this.shouldShowWorkingLoader()) { From 26e5a49e7c606e350d3b6492f9b200129552b8c5 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 20 Aug 2026 11:59:06 +0200 Subject: [PATCH 17/23] Truncate the collapsed refinement summary instead of wrapping Long summaries wrapped the collapsed line onto a second row. Render the collapsed line through a width-aware component that ellipsizes the summary while keeping the edit count and expand hint visible. --- .../components/refinement-outcome-message.ts | 23 +++++++++++++++---- .../test/refinement-outcome-message.test.ts | 19 ++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts index 3891325848..b2ef6d7a71 100644 --- a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts @@ -1,4 +1,4 @@ -import { Spacer, Text } from "@earendil-works/pi-tui"; +import { type Component, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { RefinementOutcomeMessage } from "../../../core/messages.js"; import type { AppliedRefinementEdit, HarnessEntry } from "../../../core/refinement/refinement.js"; import { generateDiffString } from "../../../core/tools/edit-diff.js"; @@ -60,6 +60,21 @@ function editCount(edits: AppliedRefinementEdit[]): string { : `${applied}/${edits.length} edits applied`; } +/** Width-aware collapsed line: truncates the summary so the line never wraps. */ +class CollapsedOutcomeLine implements Component { + constructor( + private readonly summary: string, + private readonly suffix: string, + ) {} + + render(width: number): string[] { + const room = Math.max(20, width - visibleWidth(this.suffix) - 1); + return [`${theme.fg("customMessageText", truncateToWidth(this.summary, room, "…"))} ${this.suffix}`]; + } + + invalidate(): void {} +} + /** Durable refinement outcome card: per-edit rows with before/after diffs when expanded. */ export class RefinementOutcomeMessageComponent extends ExpandableCustomMessageBox { constructor(private readonly message: RefinementOutcomeMessage) { @@ -71,15 +86,15 @@ export class RefinementOutcomeMessageComponent extends ExpandableCustomMessageBo this.clear(); const { summary, edits, scope } = this.message.details; - const headline = theme.fg("customMessageText", `${summary} · ${editCount(edits)}`); this.addChild(new Text(customMessageLabel("refinement"), 0, 0)); this.addChild(new Spacer(1)); if (!this.expanded) { - this.addChild(new Text(`${headline} ${expandCollapseHint("app.tools.expand", false)}`, 0, 0)); + const suffix = `${theme.fg("customMessageText", `· ${editCount(edits)}`)} ${expandCollapseHint("app.tools.expand", false)}`; + this.addChild(new CollapsedOutcomeLine(summary, suffix)); return; } - this.addChild(new Text(headline, 0, 0)); + this.addChild(new Text(theme.fg("customMessageText", `${summary} · ${editCount(edits)}`), 0, 0)); for (const edit of edits) { this.addChild(new Text(`${theme.fg("dim", " ╰─ ")}${editLabel(edit, scope)}`, 0, 0)); const diff = editDiff(edit); diff --git a/packages/coding-agent/test/refinement-outcome-message.test.ts b/packages/coding-agent/test/refinement-outcome-message.test.ts index a3a76cd2f2..a6c078dec4 100644 --- a/packages/coding-agent/test/refinement-outcome-message.test.ts +++ b/packages/coding-agent/test/refinement-outcome-message.test.ts @@ -1,4 +1,4 @@ -import { setKeybindings, type TUI } from "@earendil-works/pi-tui"; +import { setKeybindings, type TUI, visibleWidth } from "@earendil-works/pi-tui"; import stripAnsi from "strip-ansi"; import { beforeAll, describe, expect, test } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.js"; @@ -80,6 +80,23 @@ describe("RefinementOutcomeMessageComponent", () => { expect(expanded).toContain('"path": "prompts/rhyme-response-guidance.md"'); }); + test("truncates the collapsed summary so the line never wraps", () => { + const long = result(); + long.summary = + "Created local memory entries for the verifiers project context and running subagent tracking, plus a reusable subagent spec for parallel codebase exploration."; + const component = new RefinementOutcomeMessageComponent(createRefinementOutcomeMessage(long)); + + const lines = component.render(80).map((line) => stripAnsi(line)); + const content = lines.filter((line) => line.trim().length > 0); + expect(content).toHaveLength(2); + expect(content[1]).toContain("…"); + expect(content[1]).toContain("1 edit applied"); + expect(content[1]).toContain("Ctrl+O to expand"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(80); + } + }); + test("renders exact before and after payloads for updates and deletes", () => { const base = result(); const before = entry({ id: "tone-guidance", content: "Respond plainly." }); From b248b3e586b0938b7605392e96ff26b61ec91b33 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 20 Aug 2026 12:10:14 +0200 Subject: [PATCH 18/23] Remount the refine loader after compaction and hard-clip the collapsed line A compaction that starts mid-refine owns the status container; on compaction_end syncWorkingLoader now remounts the refine loader instead of bailing on the ownership guard. The collapsed outcome line is also clipped to the render width after assembly so extreme narrow viewports cannot wrap it. --- .../components/refinement-outcome-message.ts | 3 ++- .../src/modes/interactive/interactive-mode.ts | 12 +++++++-- .../test/interactive-mode-status.test.ts | 26 +++++++++++++++++++ .../test/refinement-outcome-message.test.ts | 6 +++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts index b2ef6d7a71..8b65e1bf02 100644 --- a/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts @@ -69,7 +69,8 @@ class CollapsedOutcomeLine implements Component { render(width: number): string[] { const room = Math.max(20, width - visibleWidth(this.suffix) - 1); - return [`${theme.fg("customMessageText", truncateToWidth(this.summary, room, "…"))} ${this.suffix}`]; + const line = `${theme.fg("customMessageText", truncateToWidth(this.summary, room, "…"))} ${this.suffix}`; + return [truncateToWidth(line, Math.max(1, width), "")]; } invalidate(): void {} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 29cc2d63fc..8353d24be5 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3411,8 +3411,16 @@ export class InteractiveMode { this.startCompactionLoader("manual"); return; } - // Compaction/retry/refine own the status container while active; don't fight them. - if (this.autoCompactionLoader || this.retryLoader || this.refineLoader) { + // Compaction/retry own the status container while active; don't fight them. + if (this.autoCompactionLoader || this.retryLoader) { + return; + } + // Remount the refine loader if another owner (e.g. a compaction) cleared it. + if (this.refineLoader) { + if (!this.statusContainer.children.includes(this.refineLoader)) { + this.statusContainer.clear(); + this.statusContainer.addChild(this.refineLoader); + } return; } if (this.shouldShowWorkingLoader()) { diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 0200051c3b..7a62bd7ae4 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -5122,3 +5122,29 @@ test("shows a refine loader from the /refine command message until refine_comple expect(statusContainer.children.length).toBe(0); expect((fakeThis as unknown as { syncWorkingLoader: () => void }).syncWorkingLoader).toHaveBeenCalled(); }); + +test("syncWorkingLoader remounts a refine loader that a compaction cleared", () => { + initTheme("dark"); + const statusContainer = new Container(); + const fakeThis = { + ui: { requestRender: vi.fn() } as unknown as TUI, + statusContainer, + refineLoader: undefined, + autoCompactionLoader: undefined, + retryLoader: undefined, + stopWorkingLoader: vi.fn(), + isAgentCompacting: () => false, + } as unknown as InteractiveMode; + Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); + const prototype = InteractiveMode.prototype as unknown as { + startRefineLoader(this: InteractiveMode): void; + syncWorkingLoader(this: InteractiveMode): void; + }; + + prototype.startRefineLoader.call(fakeThis); + statusContainer.clear(); + expect(statusContainer.children.length).toBe(0); + + prototype.syncWorkingLoader.call(fakeThis); + expect(statusContainer.children.length).toBe(1); +}); diff --git a/packages/coding-agent/test/refinement-outcome-message.test.ts b/packages/coding-agent/test/refinement-outcome-message.test.ts index a6c078dec4..7802ebf941 100644 --- a/packages/coding-agent/test/refinement-outcome-message.test.ts +++ b/packages/coding-agent/test/refinement-outcome-message.test.ts @@ -95,6 +95,12 @@ describe("RefinementOutcomeMessageComponent", () => { for (const line of lines) { expect(visibleWidth(line)).toBeLessThanOrEqual(80); } + + for (const width of [40, 24, 12]) { + for (const line of component.render(width)) { + expect(visibleWidth(stripAnsi(line))).toBeLessThanOrEqual(width); + } + } }); test("renders exact before and after payloads for updates and deletes", () => { From d049814f9406fb7d121f94f7478de86e11c2f164 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 20 Aug 2026 21:04:21 +0200 Subject: [PATCH 19/23] fix: settle the /refine loader on its own result row and discard it on teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refine_complete carries no request identity, so an agent or auto refinement settling while a queued user /refine waited on it killed the loader early. The /refine result row is the user refine's settle edge (emitted after refine() returns, error row on failure), so the loader stops there — and on refine_failed, which covers a failed result-row append. Session switches and stop() now discard the loader timer, which previously leaked when the view never received a settle event. --- .../src/modes/interactive/interactive-mode.ts | 21 +++++- .../test/interactive-mode-status.test.ts | 75 ++++++++++++++++++- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8105e8e7d5..3e5b74a186 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2854,6 +2854,8 @@ export class InteractiveMode { // bash_end will reach it once the reference is dropped. this.activeBashComponent?.setComplete(undefined, true); this.activeBashComponent = undefined; + // Likewise: the next session's view may never see this refine settle. + this.discardRefineLoader(); this.pendingBashComponents = []; this.activityTracker.reset(); this.contextUsageTokenBaseline = 0; @@ -3399,12 +3401,17 @@ export class InteractiveMode { private stopRefineLoader(): void { if (!this.refineLoader) return; - this.refineLoader.stop(); - this.refineLoader = undefined; + this.discardRefineLoader(); this.statusContainer.clear(); this.syncWorkingLoader(); } + /** Stops the loader timer without container churn — for teardown and session switches. */ + private discardRefineLoader(): void { + this.refineLoader?.stop(); + this.refineLoader = undefined; + } + private syncWorkingLoader(): void { // A compaction that started before this client attached (or while another // view was open) has no start-event edge; restore its loader from state. @@ -5446,6 +5453,14 @@ export class InteractiveMode { if (isSessionSlashCommandMessage(event.message) && event.message.details.command.name === "refine") { this.startRefineLoader(); } + // The /refine result row is the user refine's settle edge; refine_complete + // alone can belong to an agent/auto refinement the queued /refine waited on. + if ( + isSessionSlashCommandResultMessage(event.message) && + event.message.details.command.name === "refine" + ) { + this.stopRefineLoader(); + } this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { @@ -5697,7 +5712,6 @@ export class InteractiveMode { break; case "refine_complete": - this.stopRefineLoader(); break; } } @@ -9955,6 +9969,7 @@ ${interrupt ? `| \`${interrupt}\` | Interrupt current operation |\n` : ""}${shor this.ui.terminal.setProgress(false); } this.stopWorkingLoader(); + this.discardRefineLoader(); this.endFeatureHintRun(); this.stopWorkingPulse(); this.stopGoalTrayTimer(); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 195b8d8c1e..42add90f9f 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -21,6 +21,7 @@ import type { AgentCronJob } from "../src/core/cron-jobs.js"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.js"; import { emptyGoalState, type GoalState } from "../src/core/goals.js"; import { KeybindingsManager } from "../src/core/keybindings.js"; +import { createSessionSlashCommandMessage, createSessionSlashCommandResultMessage } from "../src/core/messages.js"; import type { ModelRegistry } from "../src/core/model-registry.js"; import { PRIME_INFERENCE_PROVIDER_ID } from "../src/core/prime-inference-auth.js"; import { SettingsManager } from "../src/core/settings-manager.js"; @@ -1249,6 +1250,7 @@ describe("InteractiveMode pending bash components", () => { pendingBashComponents: [component], activityTracker: { reset: vi.fn() }, agentRunFileChanges: new Map(), + discardRefineLoader: vi.fn(), recapContainer: new Container(), renderRecap: vi.fn(), ipythonToolComponents: new Map(), @@ -5201,7 +5203,7 @@ describe("InteractiveMode.showLoadedResources", () => { }); }); -test("shows a refine loader from the /refine command message until refine_complete", () => { +test("shows a refine loader from the /refine command message until its result row settles it", () => { initTheme("dark"); const statusContainer = new Container(); const fakeThis = { @@ -5250,3 +5252,74 @@ test("syncWorkingLoader remounts a refine loader that a compaction cleared", () prototype.syncWorkingLoader.call(fakeThis); expect(statusContainer.children.length).toBe(1); }); + +test("an agent refinement completing does not settle a queued user /refine loader", async () => { + initTheme("dark"); + const statusContainer = new Container(); + const fakeThis = { + ui: { requestRender: vi.fn() } as unknown as TUI, + statusContainer, + refineLoader: undefined, + stopWorkingLoader: vi.fn(), + syncWorkingLoader: vi.fn(), + addMessageToChat: vi.fn(), + isInitialized: true, + footer: { invalidate: vi.fn() }, + updateConnectionStateFromEvent: vi.fn(), + prepareFeatureHintRun: vi.fn(), + activityTracker: { handleEvent: vi.fn(), reset: vi.fn() }, + updateWorkingLoaderMessage: vi.fn(), + renderRecap: vi.fn(), + } as unknown as InteractiveMode; + Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); + const prototype = InteractiveMode.prototype as unknown as { + handleEvent(this: InteractiveMode, event: unknown): Promise; + }; + const command = { name: "refine", args: "", text: "/refine" } as const; + + await prototype.handleEvent.call(fakeThis, { + type: "message_start", + message: createSessionSlashCommandMessage(command), + }); + expect(statusContainer.children.length).toBe(1); + + // The waited-on agent/auto refinement settles first; the user /refine is still running. + await prototype.handleEvent.call(fakeThis, { type: "refine_complete", result: {} }); + expect(statusContainer.children.length).toBe(1); + + await prototype.handleEvent.call(fakeThis, { + type: "message_start", + message: createSessionSlashCommandResultMessage( + "Refined continual harness state: 0 edits applied.", + { command, success: true, severity: "info" }, + false, + ), + }); + expect(statusContainer.children.length).toBe(0); +}); + +test("session teardown discards a running refine loader without remounting anything", () => { + initTheme("dark"); + const statusContainer = new Container(); + const fakeThis = { + ui: { requestRender: vi.fn() } as unknown as TUI, + statusContainer, + refineLoader: undefined, + stopWorkingLoader: vi.fn(), + syncWorkingLoader: vi.fn(), + } as unknown as InteractiveMode; + Object.setPrototypeOf(fakeThis, InteractiveMode.prototype); + const prototype = InteractiveMode.prototype as unknown as { + startRefineLoader(this: InteractiveMode): void; + discardRefineLoader(this: InteractiveMode): void; + }; + + prototype.startRefineLoader.call(fakeThis); + const loader = (fakeThis as unknown as { refineLoader?: { stop(): void } }).refineLoader; + const stopSpy = vi.spyOn(loader!, "stop"); + + prototype.discardRefineLoader.call(fakeThis); + expect(stopSpy).toHaveBeenCalled(); + expect((fakeThis as unknown as { refineLoader?: unknown }).refineLoader).toBeUndefined(); + expect((fakeThis as unknown as { syncWorkingLoader: () => void }).syncWorkingLoader).not.toHaveBeenCalled(); +}); From 6f101fd3ac2d5ba1899494ae4316b5d6432a9d7b Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 20 Aug 2026 12:14:12 -0700 Subject: [PATCH 20/23] fix(coding-agent): correlate refine loader settlement --- .../coding-agent/src/core/agent-session.ts | 10 +++++- .../src/modes/interactive/interactive-mode.ts | 2 +- .../test/interactive-mode-status.test.ts | 5 ++- .../test/suite/agent-session-queue.test.ts | 33 +++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 662ff3cf19..0bf7fa0351 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -6020,7 +6020,15 @@ export class AgentSession { true, ); } catch { - // Surfacing the command failure matters more than persisting its row. + // The result row is also the command-correlated UI settle edge. + const message = createSessionSlashCommandResultMessage(`Command failed: ${commandError.message}`, { + command: input.command, + success: false, + severity: "error", + error: commandError.message, + }); + this._emit({ type: "message_start", message }); + this._emit({ type: "message_end", message }); } throw commandError; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 32f892a67e..98972dbcf4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -5707,7 +5707,7 @@ export class InteractiveMode { break; case "refine_failed": - this.stopRefineLoader(); + // This event has no request identity; the matching command result settles its loader. this.showError(`Refinement failed: ${event.error}`); break; diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 42add90f9f..ab3b6e8138 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -5253,7 +5253,7 @@ test("syncWorkingLoader remounts a refine loader that a compaction cleared", () expect(statusContainer.children.length).toBe(1); }); -test("an agent refinement completing does not settle a queued user /refine loader", async () => { +test("only the queued user /refine settlement stops its loader", async () => { initTheme("dark"); const statusContainer = new Container(); const fakeThis = { @@ -5263,6 +5263,7 @@ test("an agent refinement completing does not settle a queued user /refine loade stopWorkingLoader: vi.fn(), syncWorkingLoader: vi.fn(), addMessageToChat: vi.fn(), + showError: vi.fn(), isInitialized: true, footer: { invalidate: vi.fn() }, updateConnectionStateFromEvent: vi.fn(), @@ -5286,6 +5287,8 @@ test("an agent refinement completing does not settle a queued user /refine loade // The waited-on agent/auto refinement settles first; the user /refine is still running. await prototype.handleEvent.call(fakeThis, { type: "refine_complete", result: {} }); expect(statusContainer.children.length).toBe(1); + await prototype.handleEvent.call(fakeThis, { type: "refine_failed", error: "background failure" }); + expect(statusContainer.children.length).toBe(1); await prototype.handleEvent.call(fakeThis, { type: "message_start", diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index d4bd91e582..0db3bff33a 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -2591,6 +2591,39 @@ describe("AgentSession queue characterization", () => { expect(errorRow).toMatchObject({ content: "Command failed: planner unavailable" }); }); + it("emits an unpersisted /refine result when error-row persistence fails", async () => { + const harness = await createHarness(); + harnesses.push(harness); + harness.setResponses([fauxAssistantMessage("one")]); + await harness.session.prompt("one"); + vi.spyOn(harness.session, "refine").mockRejectedValue(new Error("planner unavailable")); + + const append = harness.sessionManager.appendCustomMessageEntryWithRollback.bind(harness.sessionManager); + vi.spyOn(harness.sessionManager, "appendCustomMessageEntryWithRollback").mockImplementation((...args) => { + if (args[0] === "session_slash_command_result") throw new Error("disk full"); + return append(...args); + }); + const resultMessages: string[] = []; + harness.session.subscribe((event) => { + if ( + event.type === "message_start" && + event.message.role === "custom" && + event.message.customType === "session_slash_command_result" && + typeof event.message.content === "string" + ) { + resultMessages.push(event.message.content); + } + }); + + await harness.session.prompt("/refine --local").catch(() => undefined); + expect(resultMessages).toEqual(["Command failed: planner unavailable"]); + expect( + harness.sessionManager + .getEntries() + .some((entry) => entry.type === "custom_message" && entry.customType === "session_slash_command_result"), + ).toBe(false); + }); + it("allows a /compact extension hook to navigate without deadlocking on the commit fence", async () => { let targetId: string | undefined; let navigateFromContext: ((target: string) => Promise<{ cancelled: boolean }>) | undefined; From 80c3b6ea96b115bdf139ef6071efa85e79c5d9f3 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 21 Aug 2026 11:10:46 +0200 Subject: [PATCH 21/23] fix: emit refine_failed only for the refinement itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queued /refine catch emitted refine_failed for any error in the command try, including a result-row persist failure after refine() succeeded — reporting a completed harness update as failed. The emit now wraps only the parse+refine call; the outer catch keeps its row-append duty as the correlated settle edge. --- .../coding-agent/src/core/agent-session.ts | 13 ++++++-- .../test/suite/agent-session-queue.test.ts | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 0bf7fa0351..ec9574daf1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -5988,8 +5988,16 @@ export class AgentSession { }); break; case "refine": { - const options = parseRefineCommandOptions(input.command.args); - const result = await this.refine(options, { skipAbort: true }); + let result: RefinementResult; + try { + const options = parseRefineCommandOptions(input.command.args); + result = await this.refine(options, { skipAbort: true }); + } catch (error) { + // Only a failure of the refinement itself is a refine failure; a later + // result-row persist error must not report a completed refinement as failed. + this._emitRefineFailed(this._asError(error)); + throw error; + } const applied = result.appliedEdits.filter((edit) => edit.applied).length; resultText = `Refined continual harness state: ${applied} edit${applied === 1 ? "" : "s"} applied.`; displayResult = false; @@ -6011,7 +6019,6 @@ export class AgentSession { } catch (error) { if (error instanceof CompactionSkippedError) return; const commandError = error instanceof Error ? error : new Error(String(error)); - if (input.command.name === "refine") this._emitRefineFailed(commandError); try { this._appendDurableSessionCommandMessage( `Command failed: ${commandError.message}`, diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 0db3bff33a..5b29d9e396 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -2624,6 +2624,37 @@ describe("AgentSession queue characterization", () => { ).toBe(false); }); + it("does not emit refine_failed when only the result-row persist fails after a successful refine", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage(refinePlanJson("no-op"))]); + await harness.session.prompt("one"); + const append = harness.sessionManager.appendCustomMessageEntryWithRollback.bind(harness.sessionManager); + vi.spyOn(harness.sessionManager, "appendCustomMessageEntryWithRollback").mockImplementation((...args) => { + if (args[0] === "session_slash_command_result") throw new Error("disk full"); + return append(...args); + }); + const failures: string[] = []; + harness.session.subscribe((event) => { + if (event.type === "refine_failed") failures.push(event.error); + }); + + await harness.session.prompt("/refine --local").catch(() => undefined); + + expect(failures).toEqual([]); + expect(harness.session.messages.find(isRefinementOutcomeMessage)?.details.summary).toBe("no-op"); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + it("allows a /compact extension hook to navigate without deadlocking on the commit fence", async () => { let targetId: string | undefined; let navigateFromContext: ((target: string) => Promise<{ cancelled: boolean }>) | undefined; From 3c2e3ea16d9ef8ef3b2d8b698e216adc6385ec01 Mon Sep 17 00:00:00 2001 From: Seth Date: Fri, 21 Aug 2026 10:12:49 -0700 Subject: [PATCH 22/23] fix(coding-agent): remove refine loader on teardown --- .../coding-agent/src/modes/interactive/interactive-mode.ts | 6 ++++-- packages/coding-agent/test/interactive-mode-status.test.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 22f7ac86d6..8ad3d11135 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3406,9 +3406,11 @@ export class InteractiveMode { this.syncWorkingLoader(); } - /** Stops the loader timer without container churn — for teardown and session switches. */ + /** Stops and removes the loader without remounting old-session state. */ private discardRefineLoader(): void { - this.refineLoader?.stop(); + if (!this.refineLoader) return; + this.refineLoader.stop(); + this.statusContainer.removeChild(this.refineLoader); this.refineLoader = undefined; } diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index ab3b6e8138..445c0bb90c 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -5301,7 +5301,7 @@ test("only the queued user /refine settlement stops its loader", async () => { expect(statusContainer.children.length).toBe(0); }); -test("session teardown discards a running refine loader without remounting anything", () => { +test("session teardown removes a running refine loader without remounting anything", () => { initTheme("dark"); const statusContainer = new Container(); const fakeThis = { @@ -5323,6 +5323,7 @@ test("session teardown discards a running refine loader without remounting anyth prototype.discardRefineLoader.call(fakeThis); expect(stopSpy).toHaveBeenCalled(); + expect(statusContainer.children).toHaveLength(0); expect((fakeThis as unknown as { refineLoader?: unknown }).refineLoader).toBeUndefined(); expect((fakeThis as unknown as { syncWorkingLoader: () => void }).syncWorkingLoader).not.toHaveBeenCalled(); }); From 25474d13a2965f890d5e4888ff77ceede12315a6 Mon Sep 17 00:00:00 2001 From: Seth Date: Fri, 21 Aug 2026 10:38:40 -0700 Subject: [PATCH 23/23] fix(coding-agent): retain outcomes when refine audit write fails --- .../coding-agent/src/core/agent-session.ts | 14 +++++++-- .../test/suite/agent-session-queue.test.ts | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a29f791705..26a8e2989c 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -8161,10 +8161,20 @@ export class AgentSession { if (targetScope === "global") { appendGlobalRefinement(globalHarnessStateDir, result); } - this.sessionManager.appendCustomEntry("prime-agent.refinement", result); + let refinementAuditAppendError: { error: unknown } | undefined; + try { + this.sessionManager.appendCustomEntry("prime-agent.refinement", result); + } catch (error) { + refinementAuditAppendError = { error }; + } + try { + this._recordRefinementOutcome(result); + } catch (error) { + if (!refinementAuditAppendError) throw error; + } + if (refinementAuditAppendError) throw refinementAuditAppendError.error; this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); this.agent.state.systemPrompt = this._baseSystemPrompt; - this._recordRefinementOutcome(result); try { this._emit({ type: "refine_complete", result }); } catch { diff --git a/packages/coding-agent/test/suite/agent-session-queue.test.ts b/packages/coding-agent/test/suite/agent-session-queue.test.ts index 7562c5823e..835e15629d 100644 --- a/packages/coding-agent/test/suite/agent-session-queue.test.ts +++ b/packages/coding-agent/test/suite/agent-session-queue.test.ts @@ -1082,6 +1082,37 @@ describe("AgentSession queue characterization", () => { } }); + it("keeps an unpersisted refinement outcome when the refinement audit append fails", async () => { + const harness = await createAutoRefineHarness(); + harnesses.push(harness); + const previousAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR; + process.env.PRIME_AGENT_CODING_AGENT_DIR = `${harness.tempDir}/agent`; + try { + harness.setResponses([fauxAssistantMessage(refinePlanJson("no-op"))]); + const auditAppendError = new Error("audit write failed"); + vi.spyOn(harness.sessionManager, "appendCustomEntry").mockImplementationOnce(() => { + throw auditAppendError; + }); + vi.spyOn(harness.sessionManager, "appendCustomMessageEntryWithRollback").mockImplementationOnce(() => { + throw new Error("outcome write failed"); + }); + + await expect(harness.session.refine({ instructions: "audit persistence failure" })).rejects.toThrow( + auditAppendError, + ); + + expect(harness.session.messages.some(isRefinementOutcomeMessage)).toBe(true); + // The outcome survives context rebuilds even when neither session entry could persist. + expect(harness.session.buildSessionContext().messages.some(isRefinementOutcomeMessage)).toBe(true); + } finally { + if (previousAgentDir === undefined) { + delete process.env.PRIME_AGENT_CODING_AGENT_DIR; + } else { + process.env.PRIME_AGENT_CODING_AGENT_DIR = previousAgentDir; + } + } + }); + it("keeps an unpersisted refinement outcome across context rebuilds", async () => { const harness = await createAutoRefineHarness(); harnesses.push(harness);