Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/coding-agent/src/modes/components/bash-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class BashExecutionComponent extends Container {
#chunkGate = false;
#contentContainer: Container;
#headerText: Text;
#resultPersisted = false;

constructor(
private readonly command: string,
Expand Down Expand Up @@ -259,4 +260,17 @@ export class BashExecutionComponent extends Container {
getCommand(): string {
return this.command;
}

/**
* Record that this execution's message reached session state, so a transcript
* rebuild renders it from the session instead of keeping this live block parked.
*/
markResultPersisted(): void {
this.#resultPersisted = true;
}

/** Whether the session already holds this execution's message. */
hasPersistedResult(): boolean {
return this.#resultPersisted;
}
}
14 changes: 14 additions & 0 deletions packages/coding-agent/src/modes/components/eval-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class EvalExecutionComponent extends Container {
#expanded = false;
#contentContainer: Container;
#headerText: Text;
#resultPersisted = false;

#highlightLang(): "python" | "javascript" {
return this.language === "js" ? "javascript" : "python";
Expand Down Expand Up @@ -165,4 +166,17 @@ export class EvalExecutionComponent extends Container {
getCode(): string {
return this.code;
}

/**
* Record that this execution's message reached session state, so a transcript
* rebuild renders it from the session instead of keeping this live block parked.
*/
markResultPersisted(): void {
this.#resultPersisted = true;
}

/** Whether the session already holds this execution's message. */
hasPersistedResult(): boolean {
return this.#resultPersisted;
}
}
30 changes: 23 additions & 7 deletions packages/coding-agent/src/modes/controllers/command-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { getDisplayChangelogEntries } from "../../utils/changelog";
import { copyToClipboard } from "../../utils/clipboard";
import { openPath } from "../../utils/open";
import { setSessionTerminalTitle } from "../../utils/title-generator";
import { prepareTranscriptRebuild } from "../utils/ui-helpers";
import { addChatChild, prepareTranscriptRebuild, syncPendingExecutionComponents } from "../utils/ui-helpers";

type HindsightModule = typeof import("../../hindsight");
let hindsightModulePromise: Promise<HindsightModule> | undefined;
Expand Down Expand Up @@ -1147,7 +1147,8 @@ export class CommandController {

async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
const isDeferred = this.ctx.session.isStreaming;
this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
const component = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
this.ctx.bashComponent = component;

if (isDeferred) {
this.ctx.pendingMessagesContainer.addChild(this.ctx.bashComponent);
Expand All @@ -1165,7 +1166,9 @@ export class CommandController {
this.ctx.bashComponent.appendOutput(chunk);
}
},
{ excludeFromContext },
// A transcript rebuild racing this call must drop the parked block once the
// session owns its message, otherwise the rebuilt row is rendered twice.
{ excludeFromContext, onPersisted: () => component.markResultPersisted() },
);

if (this.ctx.bashComponent) {
Expand All @@ -1182,8 +1185,18 @@ export class CommandController {
this.ctx.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
}
const bashComponent = this.ctx.bashComponent;
if (isDeferred && bashComponent && this.ctx.pendingBashComponents.includes(bashComponent)) {
this.ctx.pendingMessagesContainer.detachChild(bashComponent);
if (isDeferred && bashComponent) {
// Parentage is the container's answer, never this bookkeeping's: `/clear`,
// `/context-clear`, extension redraws and the selector all call
// `pendingMessagesContainer.clear()`, which disposes and evicts the parked
// component while leaving it listed in `pendingBashComponents`. Ask the
// container whether it still holds a live child before moving anything;
// a disposed block is terminal and must never reach a fresh transcript.
if (this.ctx.pendingMessagesContainer.hasLiveChild(bashComponent)) {
this.ctx.pendingMessagesContainer.detachChild(bashComponent);
addChatChild(this.ctx, bashComponent);
}
syncPendingExecutionComponents(this.ctx);
}

this.ctx.bashComponent = undefined;
Expand All @@ -1192,7 +1205,8 @@ export class CommandController {

async handlePythonCommand(code: string, excludeFromContext = false): Promise<void> {
const isDeferred = this.ctx.session.isStreaming;
this.ctx.pythonComponent = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext);
const component = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext);
this.ctx.pythonComponent = component;

if (isDeferred) {
this.ctx.pendingMessagesContainer.addChild(this.ctx.pythonComponent);
Expand All @@ -1210,7 +1224,9 @@ export class CommandController {
this.ctx.pythonComponent.appendOutput(chunk);
}
},
{ excludeFromContext },
// A transcript rebuild racing this call must drop the parked block once the
// session owns its message, otherwise the rebuilt row is rendered twice.
{ excludeFromContext, onPersisted: () => component.markResultPersisted() },
);

if (this.ctx.pythonComponent) {
Expand Down
92 changes: 81 additions & 11 deletions packages/coding-agent/src/modes/utils/ui-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,31 @@ export function addChatChild(ctx: InteractiveModeContext, component: Component):
trimChatChildren(ctx);
}

/**
* Parked `!`/`$` execution components are listed in `pendingBashComponents` /
* `pendingPythonComponents`, but `pendingMessagesContainer` is the only
* authority on parentage: `/clear`, `/context-clear`, extension redraws and the
* selector all call `pendingMessagesContainer.clear()`, which disposes and
* evicts parked components without touching those arrays. Drop every entry the
* container no longer holds so nothing downstream can move a dead component.
*/
export function syncPendingExecutionComponents(ctx: InteractiveModeContext): void {
const container = ctx.pendingMessagesContainer;
ctx.pendingBashComponents = ctx.pendingBashComponents.filter(component => container.hasLiveChild(component));
ctx.pendingPythonComponents = ctx.pendingPythonComponents.filter(component => container.hasLiveChild(component));
}

/**
* Whether the session already owns this execution's message, i.e. the rebuilt
* transcript renders the block from session state. Set by the controller when
* `executeBash()` / `executePython()` reports the result as persisted.
*/
function hasPersistedExecutionResult(component: Component): boolean {
if (component instanceof BashExecutionComponent) return component.hasPersistedResult();
if (component instanceof EvalExecutionComponent) return component.hasPersistedResult();
return false;
}

export function trimChatChildren(ctx: InteractiveModeContext): void {
const children = ctx.chatContainer.children;

Expand Down Expand Up @@ -899,10 +924,24 @@ export class UiHelpers {
// This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI).
// Clear existing rendered chat first to avoid duplicating the full session in the container.
const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined;
// A still-running deferred `!`/`$` block is the only rendering of output whose
// message has not been published to the session yet, so the rebuild must keep it
// parked instead of disposing it. Finished blocks are dropped here: the rebuilt
// transcript renders them from the session. A block whose result was persisted
// while its controller was still suspended is finished for this purpose — keeping
// it would render the same execution twice.
const runningExecutionComponents = this.#detachPendingMessages(
component =>
(component === this.ctx.bashComponent || component === this.ctx.pythonComponent) &&
!hasPersistedExecutionResult(component),
);
this.ctx.pendingBashComponents = this.ctx.pendingBashComponents.filter(component =>
runningExecutionComponents.includes(component),
);
this.ctx.pendingPythonComponents = this.ctx.pendingPythonComponents.filter(component =>
runningExecutionComponents.includes(component),
);
this.ctx.chatContainer.clear();
this.ctx.pendingMessagesContainer.clear();
this.ctx.pendingBashComponents = [];
this.ctx.pendingPythonComponents = [];

// Reuse a pre-built context when available (e.g. from navigateTree) to avoid a second O(N) walk.
const context = prebuiltContext ?? this.ctx.sessionManager.buildSessionContext();
Expand All @@ -923,6 +962,9 @@ export class UiHelpers {
const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
this.ctx.showStatus(`Session compacted ${times}`);
}
for (const component of runningExecutionComponents) {
this.ctx.pendingMessagesContainer.addChild(component);
}
if (preservedChatChildren && preservedChatChildren.length > 0) {
for (const child of preservedChatChildren) {
addChatChild(this.ctx, child);
Expand Down Expand Up @@ -978,8 +1020,35 @@ export class UiHelpers {
this.ctx.ui.requestRender();
}

/**
* Empty the pending container, disposing the queued-message chips but handing back
* the parked `!`/`$` execution components matched by `retain`, in render order.
*
* `Container.clear()` disposes every child, which would tear down a running
* execution block mid-flight; retained components are reused instances that the
* caller re-attaches (pending area or chat transcript).
*/
#detachPendingMessages(retain: (component: Component) => boolean): Component[] {
syncPendingExecutionComponents(this.ctx);
const parked = new Set<Component>([...this.ctx.pendingBashComponents, ...this.ctx.pendingPythonComponents]);
const retained: Component[] = [];
for (const child of this.ctx.pendingMessagesContainer.children) {
if (parked.has(child) && retain(child)) {
retained.push(child);
} else {
child.dispose?.();
}
}
this.ctx.pendingMessagesContainer.detachAll();
return retained;
}

updatePendingMessagesDisplay(): void {
this.ctx.pendingMessagesContainer.clear();
// Rebuild only the queued-message chips: parked execution components stay attached
// so a mid-turn queue/dequeue event cannot dispose a streaming `!`/`$` block.
for (const component of this.#detachPendingMessages(() => true)) {
this.ctx.pendingMessagesContainer.addChild(component);
}
const queuedMessages = this.ctx.session.getQueuedMessages() as QueuedMessages;

const steeringMessages: Array<{ message: string; label: string }> = [];
Expand Down Expand Up @@ -1237,20 +1306,21 @@ export class UiHelpers {
}
}

/** Move pending bash components from pending area to chat */
/** Move pending bash/python components from the pending area to chat */
flushPendingBashComponents(): void {
// Move (detach, not dispose) the live execution components from the pending
// area into the chat transcript — they are reused instances, so a disposing
// removeChild() would tear them down before re-adding.
for (const component of this.ctx.pendingBashComponents) {
// removeChild() would tear them down before re-adding. Walk the container so
// the transcript keeps the order the pending area rendered, and so a
// component the container no longer holds can never be re-parented.
syncPendingExecutionComponents(this.ctx);
const parked = new Set<Component>([...this.ctx.pendingBashComponents, ...this.ctx.pendingPythonComponents]);
for (const component of [...this.ctx.pendingMessagesContainer.children]) {
if (!parked.has(component)) continue;
this.ctx.pendingMessagesContainer.detachChild(component);
addChatChild(this.ctx, component);
}
this.ctx.pendingBashComponents = [];
for (const component of this.ctx.pendingPythonComponents) {
this.ctx.pendingMessagesContainer.detachChild(component);
addChatChild(this.ctx, component);
}
this.ctx.pendingPythonComponents = [];
}

Expand Down
44 changes: 30 additions & 14 deletions packages/coding-agent/src/session/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2015,7 +2015,7 @@ export class AgentSession {

// Bash execution state
#bashAbortControllers = new Set<AbortController>();
#pendingBashMessages: BashExecutionMessage[] = [];
#pendingBashMessages: Array<{ message: BashExecutionMessage; onPersisted?: () => void }> = [];
#foregroundBashBackgroundRequestHandler: (() => void) | undefined;

// Python execution state
Expand All @@ -2031,7 +2031,7 @@ export class AgentSession {
readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
readonly #ownedMcpManager: MCPManager | undefined;
#startupTurnBarrier: Promise<void> | undefined;
#pendingPythonMessages: PythonExecutionMessage[] = [];
#pendingPythonMessages: Array<{ message: PythonExecutionMessage; onPersisted?: () => void }> = [];
#activeEvalExecutions = new Set<Promise<unknown>>();
#evalExecutionDisposing = false;

Expand Down Expand Up @@ -16086,11 +16086,13 @@ export class AgentSession {
* @param command The bash command to execute
* @param onChunk Optional streaming callback for output
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
* @param options.onPersisted Called once the execution's message is in session state
* (immediately when idle, at the post-turn flush while streaming)
*/
async executeBash(
command: string,
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean },
options?: { excludeFromContext?: boolean; onPersisted?: () => void },
): Promise<BashResult> {
const excludeFromContext = options?.excludeFromContext === true;
this.#markRetryReplayUnsafe();
Expand Down Expand Up @@ -16145,7 +16147,11 @@ export class AgentSession {
* Record a bash execution result in session history.
* Used by executeBash and by extensions that handle bash execution themselves.
*/
recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
recordBashResult(
command: string,
result: BashResult,
options?: { excludeFromContext?: boolean; onPersisted?: () => void },
): void {
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
const bashMessage: BashExecutionMessage = {
role: "bashExecution",
Expand All @@ -16162,13 +16168,14 @@ export class AgentSession {
// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
if (this.isStreaming) {
// Queue for later - will be flushed on agent_end
this.#pendingBashMessages.push(bashMessage);
this.#pendingBashMessages.push({ message: bashMessage, onPersisted: options?.onPersisted });
} else {
// Add to agent state immediately
this.agent.appendMessage(bashMessage);

// Save to session
this.sessionManager.appendMessage(bashMessage);
options?.onPersisted?.();
}
}

Expand Down Expand Up @@ -16198,12 +16205,13 @@ export class AgentSession {
#flushPendingBashMessages(): void {
if (this.#pendingBashMessages.length === 0) return;

for (const bashMessage of this.#pendingBashMessages) {
for (const pending of this.#pendingBashMessages) {
// Add to agent state
this.agent.appendMessage(bashMessage);
this.agent.appendMessage(pending.message);

// Save to session
this.sessionManager.appendMessage(bashMessage);
this.sessionManager.appendMessage(pending.message);
pending.onPersisted?.();
}

this.#pendingBashMessages = [];
Expand All @@ -16219,11 +16227,13 @@ export class AgentSession {
* @param code The Python code to execute
* @param onChunk Optional streaming callback for output
* @param options.excludeFromContext If true, execution won't be sent to LLM ($$ prefix)
* @param options.onPersisted Called once the execution's message is in session state
* (immediately when idle, at the post-turn flush while streaming)
*/
async executePython(
code: string,
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean },
options?: { excludeFromContext?: boolean; onPersisted?: () => void },
): Promise<PythonResult> {
const excludeFromContext = options?.excludeFromContext === true;
this.#markRetryReplayUnsafe();
Expand Down Expand Up @@ -16291,7 +16301,11 @@ export class AgentSession {
/**
* Record a Python execution result in session history.
*/
recordPythonResult(code: string, result: PythonResult, options?: { excludeFromContext?: boolean }): void {
recordPythonResult(
code: string,
result: PythonResult,
options?: { excludeFromContext?: boolean; onPersisted?: () => void },
): void {
const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
const pythonMessage: PythonExecutionMessage = {
role: "pythonExecution",
Expand All @@ -16307,10 +16321,11 @@ export class AgentSession {

// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
if (this.isStreaming) {
this.#pendingPythonMessages.push(pythonMessage);
this.#pendingPythonMessages.push({ message: pythonMessage, onPersisted: options?.onPersisted });
} else {
this.agent.appendMessage(pythonMessage);
this.sessionManager.appendMessage(pythonMessage);
options?.onPersisted?.();
}
}

Expand Down Expand Up @@ -16371,9 +16386,10 @@ export class AgentSession {
#flushPendingPythonMessages(): void {
if (this.#pendingPythonMessages.length === 0) return;

for (const pythonMessage of this.#pendingPythonMessages) {
this.agent.appendMessage(pythonMessage);
this.sessionManager.appendMessage(pythonMessage);
for (const pending of this.#pendingPythonMessages) {
this.agent.appendMessage(pending.message);
this.sessionManager.appendMessage(pending.message);
pending.onPersisted?.();
}

this.#pendingPythonMessages = [];
Expand Down
Loading
Loading