fix(coding-agent): streamed write progress

Emitted partial write-tool updates before filesystem, archive, SQLite, internal URL, and conflict writes so the TUI can render execution-phase progress instead of waiting for the final result.

Updated the write renderer to keep partial results pending, show the progress snapshot, and suppress diagnostics until the final result.

Fixes #3960
This commit is contained in:
roboomp
2026-07-01 01:57:14 +00:00
parent b2a859a7c5
commit 58c84e4c94
4 changed files with 109 additions and 9 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed the write tool not streaming execution progress to the TUI while files were being written. ([#3960](https://github.com/can1357/oh-my-pi/issues/3960))
## [16.2.11] - 2026-07-01
### Fixed
+41 -9
View File
@@ -165,6 +165,18 @@ function appendNoteToResult(result: AgentToolResult<WriteToolDetails>, note: str
}
}
function emitWriteProgress(
onUpdate: AgentToolUpdateCallback<WriteToolDetails> | undefined,
content: string,
displayPath: string,
resolvedPath?: string,
): void {
onUpdate?.({
content: [{ type: "text", text: `Writing ${content.length} bytes to ${displayPath}...` }],
details: resolvedPath ? { resolvedPath } : {},
});
}
/**
* If `content` begins with a `#!` shebang, ensure the file is executable.
*
@@ -781,7 +793,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
_toolCallId: string,
{ path: rawPath, content }: WriteParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<WriteToolDetails>,
onUpdate?: AgentToolUpdateCallback<WriteToolDetails>,
context?: AgentToolContext,
): Promise<AgentToolResult<WriteToolDetails>> {
// Strip a hashline `[path#TAG]` wrapper up front so every downstream
@@ -807,6 +819,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
// Handler-owned writes (vault:// notes, host URIs) mutate user
// data outside the local sandbox — plan mode must reject them.
enforcePlanModeWrite(this.session, path, { op: "update" });
emitWriteProgress(onUpdate, cleanContent, path);
await handler.write(parsed, cleanContent, { cwd: this.session.cwd, signal });
let resultText = `Successfully wrote ${cleanContent.length} bytes to ${path}`;
if (stripped) {
@@ -826,6 +839,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
`Conflict URI scope '/${conflictUri.scope}' is read-only — read \`conflict://${conflictUri.id}/${conflictUri.scope}\` to inspect that side. To write, drop the scope (\`conflict://${conflictUri.id}\`) and put the chosen content (or shorthand like \`@${conflictUri.scope}\`) in \`content\`.`,
);
}
emitWriteProgress(onUpdate, cleanContent, path);
const result =
conflictUri.id === "*"
? await this.#resolveAllConflicts(cleanContent, stripped, signal)
@@ -844,6 +858,14 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
op: resolvedArchivePath.exists ? "update" : "create",
});
emitWriteProgress(
onUpdate,
cleanContent,
`${formatPathRelativeToCwd(resolvedArchivePath.absolutePath, this.session.cwd)}:${
resolvedArchivePath.archiveSubPath
}`,
resolvedArchivePath.absolutePath,
);
const archiveResult = await this.#writeArchiveEntry(cleanContent, resolvedArchivePath);
if (stripped) {
const firstText = archiveResult.content.find(
@@ -861,6 +883,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
if (resolvedSqlitePath) {
enforcePlanModeWrite(this.session, resolvedSqlitePath.sqlitePath, { op: "update" });
emitWriteProgress(onUpdate, cleanContent, path, resolvedSqlitePath.absolutePath);
const sqliteResult = await this.#writeSqliteRow(path, cleanContent, resolvedSqlitePath);
if (stripped) {
const firstText = sqliteResult.content.find(
@@ -883,11 +906,13 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
await assertEditableFile(absolutePath, path);
}
const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
emitWriteProgress(onUpdate, cleanContent, displayPath, absolutePath);
// Try ACP bridge first for editor-visible filesystem paths. Internal
// artifacts such as local:// plans are owned by OMP, not the editor.
if (await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent)) {
const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
const writeLine = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
let resultText = header ? `${header}\n${writeLine}` : writeLine;
@@ -908,7 +933,6 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
this.session.bumpFileMutationVersion?.(absolutePath);
const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
const writeLine = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
let resultText = header ? `${header}\n${writeLine}` : writeLine;
@@ -1128,14 +1152,19 @@ export const writeToolRenderer = {
}));
}
const isPartial = options.isPartial === true;
const progressText = result.content?.find(c => c.type === "text")?.text ?? "";
const lineCount = countLines(fileContent);
const lineSuffix = formatLineCountSuffix(lineCount, uiTheme);
const execSuffix = result.details?.madeExecutable
? `${uiTheme.fg("dim", " · ")}${uiTheme.fg("success", "made executable!")}`
: "";
const execSuffix =
!isPartial && result.details?.madeExecutable
? `${uiTheme.fg("dim", " · ")}${uiTheme.fg("success", "made executable!")}`
: "";
const header = renderStatusLine(
{
iconOverride: uiTheme.styledSymbol("tool.write", "accent"),
icon: isPartial ? "running" : undefined,
iconOverride: isPartial ? undefined : uiTheme.styledSymbol("tool.write", "accent"),
spinnerFrame: options.spinnerFrame,
title: "Write",
description: `${langIcon} ${pathDisplay}${lineSuffix}${execSuffix}`,
},
@@ -1147,7 +1176,10 @@ export const writeToolRenderer = {
return framedBlock(uiTheme, width => {
const { expanded } = options;
let body = renderContentPreview(fileContent, expanded, lang, uiTheme, previewCache);
if (diagnostics) {
if (isPartial && progressText) {
body = `${uiTheme.fg("muted", replaceTabs(progressText))}${body ? `\n${body}` : ""}`;
}
if (!isPartial && diagnostics) {
const diagText = formatDiagnostics(diagnostics, expanded, uiTheme, fp =>
uiTheme.getLangIcon(getLanguageFromPath(fp)),
);
@@ -1162,7 +1194,7 @@ export const writeToolRenderer = {
return {
header,
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
state: "success",
state: isPartial ? "pending" : "success",
borderColor: "borderMuted",
width,
};
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { resolveLocalUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls";
import type { PlanModeState } from "@oh-my-pi/pi-coding-agent/plan-mode/state";
@@ -36,6 +37,14 @@ function createSession(cwd: string, options: SessionOptions = {}): ToolSession {
};
}
function resultText(result: AgentToolResult): string {
const text: string[] = [];
for (const block of result.content) {
if (block.type === "text") text.push(block.text);
}
return text.join("\n");
}
describe("write tool ACP fs routing", () => {
let tmpDir: string;
@@ -74,6 +83,29 @@ describe("write tool ACP fs routing", () => {
}
});
it("emits a progress snapshot before filesystem writes complete", async () => {
const filePath = path.join(tmpDir, "progress.txt");
const session = createSession(tmpDir);
const tool = new WriteTool(session);
const updates: AgentToolResult[] = [];
const result = await tool.execute(
"call-progress",
{ path: filePath, content: FILE_CONTENT },
undefined,
update => {
updates.push(update);
},
);
expect(updates).toHaveLength(1);
expect(updates[0]?.content).toEqual([
{ type: "text", text: `Writing ${FILE_CONTENT.length} bytes to progress.txt...` },
]);
expect(updates[0]?.details).toEqual({ resolvedPath: filePath });
expect(resultText(result)).toContain(`Successfully wrote ${FILE_CONTENT.length} bytes to progress.txt`);
});
it("writes local plan artifacts to disk instead of the ACP bridge", async () => {
const planPath = "local://PLAN.md";
const planContent = "# Plan\n\nhello world\n";
@@ -82,4 +82,36 @@ describe("write streaming preview honors Ctrl+O expansion", () => {
component.render(120);
expect(highlightSpy).toHaveBeenCalledTimes(1);
});
it("renders execution progress as a partial result without diagnostics", async () => {
if (!initialized) {
await themeModule.initTheme();
initialized = true;
}
const uiTheme = (await themeModule.getThemeByName("dark")) ?? (await themeModule.getThemeByName("light"));
if (!uiTheme) {
throw new Error("expected an initialized theme");
}
const component = writeToolRenderer.renderResult(
{
content: [{ type: "text", text: "Writing 12 bytes to progress.ts..." }],
details: {
resolvedPath: "/tmp/progress.ts",
diagnostics: {
errored: true,
summary: "1 error",
messages: ["diagnostic sentinel"],
},
},
},
{ expanded: false, isPartial: true, spinnerFrame: 0 },
uiTheme,
{ path: "/tmp/progress.ts", content: "const x = 1;" },
);
const rendered = stripAnsi(component.render(100).join("\n"));
expect(rendered).toContain("Writing 12 bytes to progress.ts...");
expect(rendered).not.toContain("diagnostic sentinel");
});
});