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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user