diff --git a/packages/coding-agent/.changes/feat-refinement-status-queue.md b/packages/coding-agent/.changes/feat-refinement-status-queue.md new file mode 100644 index 000000000..4b441a99f --- /dev/null +++ b/packages/coding-agent/.changes/feat-refinement-status-queue.md @@ -0,0 +1 @@ +- 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. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 187253a4c..26a8e2989 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -167,6 +167,7 @@ import { type CustomMessage, createCompactionOutcomeMessage, createHeartbeatPromptMessage, + createRefinementOutcomeMessage, createRlmChildFailureMessage, createRlmChildTerminalNoticeMessage, createSessionSlashCommandMessage, @@ -1089,7 +1090,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; @@ -4196,12 +4197,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; @@ -6018,6 +6019,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, { @@ -6025,10 +6027,19 @@ export class AgentSession { }); break; 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; + 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; break; } case "goal": @@ -6042,7 +6053,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; @@ -6055,7 +6066,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; } @@ -6066,14 +6085,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. @@ -7403,7 +7427,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 @@ -8047,6 +8071,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 { + // 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 }); + 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 @@ -8119,7 +8161,18 @@ 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; try { @@ -8342,7 +8395,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 }); @@ -11307,7 +11360,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/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 7099c5eac..81a77117f 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; @@ -324,6 +340,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; } @@ -397,6 +434,27 @@ export function isCompactionOutcomeMessage(message: unknown): message is Compact ); } +function isAppliedRefinementEdit(value: unknown): value is AppliedRefinementEdit { + return ( + isRecord(value) && + (value.action === "create" || value.action === "update" || value.action === "delete") && + typeof value.kind === "string" && + typeof value.id === "string" && + typeof value.applied === "boolean" + ); +} + +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.summary === "string" && + (message.details.scope === "local" || message.details.scope === "global") && + Array.isArray(message.details.edits) && + message.details.edits.every(isAppliedRefinementEdit) + ); +} + export function createHeartbeatPromptMessage( job: AgentCronJob, timestamp = Date.now(), @@ -443,7 +501,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/headless-completion.ts b/packages/coding-agent/src/modes/headless-completion.ts index 94ffe2c57..e6524d314 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/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index 24164a157..653dbfa22 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/conversation-components.ts b/packages/coding-agent/src/modes/interactive/components/conversation-components.ts index dd2bb456e..8e0626465 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.setExpanded(expanded); + 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/expandable-custom-message.ts b/packages/coding-agent/src/modes/interactive/components/expandable-custom-message.ts new file mode 100644 index 000000000..c397a51b0 --- /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 new file mode 100644 index 000000000..8b65e1bf0 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/refinement-outcome-message.ts @@ -0,0 +1,117 @@ +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"; +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 { + 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}\``; +} + +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`; +} + +/** 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); + const line = `${theme.fg("customMessageText", truncateToWidth(this.summary, room, "…"))} ${this.suffix}`; + return [truncateToWidth(line, Math.max(1, width), "")]; + } + + 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) { + super(); + this.updateDisplay(); + } + + protected updateDisplay(): void { + this.clear(); + + const { summary, edits, scope } = this.message.details; + this.addChild(new Text(customMessageLabel("refinement"), 0, 0)); + this.addChild(new Spacer(1)); + if (!this.expanded) { + const suffix = `${theme.fg("customMessageText", `· ${editCount(edits)}`)} ${expandCollapseHint("app.tools.expand", false)}`; + this.addChild(new CollapsedOutcomeLine(summary, suffix)); + return; + } + + 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); + if (diff) this.addChild(new Text(renderDiff(diff), 4, 0)); + } + } +} + +export class MalformedRefinementOutcomeMessageComponent extends ExpandableCustomMessageBox { + constructor() { + super(); + this.updateDisplay(); + } + + 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 c5491eb79..fab70367e 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)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index ba1ec204f..817fb8776 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -100,11 +100,14 @@ import { runMcpManagementCommand } from "../../core/mcp/mcp-command.js"; import { bashOutputToText, COMPACTION_OUTCOME_CUSTOM_TYPE, + type CustomMessage, createHeartbeatPromptMessage, 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"; @@ -204,6 +207,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"; @@ -1004,6 +1011,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; @@ -2851,6 +2859,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; @@ -3395,6 +3405,35 @@ 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.discardRefineLoader(); + this.statusContainer.clear(); + this.syncWorkingLoader(); + } + + /** Stops and removes the loader without remounting old-session state. */ + private discardRefineLoader(): void { + if (!this.refineLoader) return; + this.refineLoader.stop(); + this.statusContainer.removeChild(this.refineLoader); + 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. @@ -3406,6 +3445,14 @@ export class InteractiveMode { 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()) { // A bare `loadingAnimation != null` check isn't proof it's on screen: // other paths clear statusContainer without nulling it, orphaning the @@ -5433,6 +5480,17 @@ export class InteractiveMode { } } if (event.message.role === "custom") { + 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") { @@ -5680,6 +5738,7 @@ export class InteractiveMode { break; case "refine_failed": + // This event has no request identity; the matching command result settles its loader. this.showError(`Refinement failed: ${event.error}`); break; @@ -6204,6 +6263,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": { @@ -6224,42 +6317,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)); } @@ -9941,6 +10005,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 be2ae6ed7..16f8e57bc 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"; @@ -1358,6 +1359,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(), @@ -5321,3 +5323,128 @@ describe("InteractiveMode.showLoadedResources", () => { expect(output).not.toContain("[Skill conflicts]"); }); }); + +test("shows a refine loader from the /refine command message until its result row settles it", () => { + 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(); +}); + +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); +}); + +test("only the queued user /refine settlement stops its 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(), + showError: 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: "refine_failed", error: "background failure" }); + 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 removes 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(statusContainer.children).toHaveLength(0); + expect((fakeThis as unknown as { refineLoader?: unknown }).refineLoader).toBeUndefined(); + expect((fakeThis as unknown as { syncWorkingLoader: () => void }).syncWorkingLoader).not.toHaveBeenCalled(); +}); diff --git a/packages/coding-agent/test/print-mode.test.ts b/packages/coding-agent/test/print-mode.test.ts index d1a6b702e..065d5d04a 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"; @@ -321,6 +322,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/refinement-outcome-message.test.ts b/packages/coding-agent/test/refinement-outcome-message.test.ts new file mode 100644 index 000000000..7802ebf94 --- /dev/null +++ b/packages/coding-agent/test/refinement-outcome-message.test.ts @@ -0,0 +1,151 @@ +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"; +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(120).join("\n")); +} + +describe("RefinementOutcomeMessageComponent", () => { + beforeAll(() => { + initTheme("dark"); + setKeybindings(new KeybindingsManager()); + }); + + 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]"); + 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.setExpanded(true); + const expanded = rendered(component); + 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"'); + }); + + 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); + } + + 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", () => { + 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.setExpanded(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 tool expansion state", () => { + const message = createRefinementOutcomeMessage(result()); + const [component] = buildConversationComponents([message], { + ui: {} as TUI, + cwd: "/tmp", + toolOptions: {}, + getToolDefinition: () => undefined, + toolsExpanded: true, + }); + + expect(component).toBeInstanceOf(RefinementOutcomeMessageComponent); + expect(stripAnsi(component!.render(120).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 658db2182..835e15629 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, @@ -1002,7 +1006,7 @@ describe("AgentSession queue characterization", () => { } }); - it("persists a prompt started while a background refine is in flight", 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; @@ -1053,6 +1057,13 @@ describe("AgentSession queue characterization", () => { await refinePromise; await promptPromise; + 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") @@ -1071,6 +1082,68 @@ 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); + 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); @@ -2527,10 +2600,94 @@ 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); }); + 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("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("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;