mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-02 15:31:50 +00:00
Compare commits
2 Commits
main
...
v2-backgro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf18102af0 | ||
|
|
91cbb43cb3 |
@@ -168,6 +168,7 @@ import {
|
||||
backgroundLevel,
|
||||
type BackgroundLevel,
|
||||
type BackgroundSink,
|
||||
type BackgroundTerminal,
|
||||
backgroundToolCallPause,
|
||||
backgroundToolCallStates,
|
||||
type BackgroundTaskEnd,
|
||||
@@ -176,6 +177,8 @@ import {
|
||||
cancelBackgroundWork,
|
||||
droppedTasks,
|
||||
endBackgroundWork,
|
||||
finishBackgroundTerminalOutput,
|
||||
followBackgroundTerminalOutput,
|
||||
registerBackgroundToolCalls,
|
||||
takeBackgroundTerminalExit,
|
||||
takeBackgroundToolCallEnd,
|
||||
@@ -1487,7 +1490,7 @@ export type Session = {
|
||||
* terminal exits when the task ends, not at the tool result (see
|
||||
* `withRunningTerminal`). Only for a client that renders terminals and is
|
||||
* not AIR, which shows the command through its async tasks. */
|
||||
backgroundTerminals?: Map<string, { toolCallId: string; parentToolUseId?: string }>;
|
||||
backgroundTerminals?: Map<string, BackgroundTerminal>;
|
||||
/** The tool calls whose tasks went on in the background and still run, by
|
||||
* task id: a Monitor, a Workflow, or a backgrounded MCP call. The tool call
|
||||
* stays `in_progress` (or `paused`) after its result, until its task ends
|
||||
@@ -5422,6 +5425,7 @@ export class ClaudeAcpAgent {
|
||||
* status, once the task ends (see `Session.backgroundTerminals` and
|
||||
* `Session.backgroundToolCalls`). */
|
||||
const endBackgroundTask = async (taskId: string, end: BackgroundTaskEnd) => {
|
||||
await finishBackgroundTerminalOutput(session, taskId);
|
||||
const exit = takeBackgroundTerminalExit(session, params.sessionId, taskId);
|
||||
if (exit) await sendUpdate(exit);
|
||||
const toolCallEnd = takeBackgroundToolCallEnd(session, params.sessionId, taskId, end);
|
||||
@@ -8527,6 +8531,7 @@ export class ClaudeAcpAgent {
|
||||
const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
|
||||
let backgroundedToolCalls: ReadonlySet<string> = new Set();
|
||||
let runningTerminal: string | undefined;
|
||||
let runningTerminalTask: string | undefined;
|
||||
let runningToolCalls: ReadonlySet<string> = new Set();
|
||||
if (message.type === "user") {
|
||||
rememberResolvedToolNames(session, content);
|
||||
@@ -8558,7 +8563,11 @@ export class ClaudeAcpAgent {
|
||||
...(message.parent_tool_use_id
|
||||
? { parentToolUseId: message.parent_tool_use_id }
|
||||
: {}),
|
||||
...(backgroundBashTask.outputFilePath
|
||||
? { outputFilePath: backgroundBashTask.outputFilePath }
|
||||
: {}),
|
||||
});
|
||||
runningTerminalTask = backgroundBashTask.taskId;
|
||||
}
|
||||
runningToolCalls = registerBackgroundToolCalls(
|
||||
session,
|
||||
@@ -8642,6 +8651,22 @@ export class ClaudeAcpAgent {
|
||||
),
|
||||
);
|
||||
}
|
||||
// After the result's own updates: the command's output replaces
|
||||
// the placeholder that they gave its terminal.
|
||||
if (runningTerminalTask) {
|
||||
followBackgroundTerminalOutput(
|
||||
session,
|
||||
params.sessionId,
|
||||
runningTerminalTask,
|
||||
(notification) =>
|
||||
sendUpdate(notification).catch((error) => {
|
||||
this.logger.error(
|
||||
`Session ${params.sessionId}: failed to send background command output:`,
|
||||
error,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_progress": {
|
||||
|
||||
131
src/background-output.ts
Normal file
131
src/background-output.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The output of a command that went to the background, read from the file
|
||||
* that Claude Code writes it to. Claude Code streams no output of such a
|
||||
* command, so its display terminal would show only the placeholder of the
|
||||
* Bash result ("Command running in background with ID …") until the command
|
||||
* exits. ACP v2 asks an Agent to keep a listed terminal's output current
|
||||
* whenever it can read it (the v2 prompt lifecycle, "Background work").
|
||||
*
|
||||
* The file is polled: watching a file is not reliable on every platform, and
|
||||
* a missing file only means that nothing was written yet.
|
||||
*/
|
||||
import { open } from "node:fs/promises";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
|
||||
/** How often a running command's output file is read. */
|
||||
export const BACKGROUND_OUTPUT_POLL_MS = 500;
|
||||
|
||||
/** The most bytes one read takes, so a burst of output does not block the loop. */
|
||||
export const BACKGROUND_OUTPUT_READ_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* The most unread bytes that are still sent. A command that writes faster
|
||||
* than the polls read falls behind. Past this, the reads skip to the file's
|
||||
* last bytes, as a snapshot: a terminal shows the latest output, and a
|
||||
* client keeps a bounded amount of it anyway.
|
||||
*/
|
||||
export const BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Text that the file adds. A snapshot replaces the terminal's output so far:
|
||||
* the first one replaces the placeholder of the Bash result, and a later one
|
||||
* follows a skip or a file that shrank. Other text appends to it.
|
||||
*/
|
||||
export type BackgroundOutput = { text: string; replaces: boolean };
|
||||
|
||||
/**
|
||||
* Follows the output file `path` of one background command and hands each
|
||||
* piece of new text to `publish`, in order. Reads never overlap. A read that
|
||||
* fails, for example because the file does not exist yet, is tried again at
|
||||
* the next poll. `publish` must not reject: the next read waits for it.
|
||||
*/
|
||||
export class BackgroundOutputTail {
|
||||
private offset = 0;
|
||||
private decoder = new StringDecoder("utf8");
|
||||
/** Whether the next text replaces the terminal's output. */
|
||||
private replaces = true;
|
||||
private stopped = false;
|
||||
private reading: Promise<void> = Promise.resolve();
|
||||
private readonly timer: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
private readonly publish: (output: BackgroundOutput) => Promise<void>,
|
||||
) {
|
||||
this.timer = setInterval(() => void this.read(false), BACKGROUND_OUTPUT_POLL_MS);
|
||||
// A command that runs on must not keep the agent process alive.
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the rest of the file and stops. The command ended, so what the file
|
||||
* holds is its whole output, and it goes out before the terminal's exit.
|
||||
*/
|
||||
async finish(): Promise<void> {
|
||||
clearInterval(this.timer);
|
||||
await this.read(true);
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/** Stops without reading more: the session ended. */
|
||||
stop(): void {
|
||||
clearInterval(this.timer);
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads what the file adds, one read of at most
|
||||
* {@link BACKGROUND_OUTPUT_READ_BYTES} at a time, and publishes each read's
|
||||
* text before the next. `toEnd` reads until the end of the file, and ends a
|
||||
* character that the command cut off with a replacement character.
|
||||
*/
|
||||
private read(toEnd: boolean): Promise<void> {
|
||||
this.reading = this.reading.then(async () => {
|
||||
if (this.stopped) return;
|
||||
let file;
|
||||
try {
|
||||
file = await open(this.path, "r");
|
||||
} catch {
|
||||
// Nothing written yet, or the file is gone: try again at the next
|
||||
// poll. At the end, the terminal keeps the output it has.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { size } = await file.stat();
|
||||
// A file that shrank is a new one: its output starts over. Past the
|
||||
// backlog, the reads skip to the file's last bytes.
|
||||
const start = size < this.offset ? 0 : this.offset;
|
||||
const skipTo = Math.max(start, size - BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES);
|
||||
if (skipTo !== this.offset) {
|
||||
this.offset = skipTo;
|
||||
this.decoder = new StringDecoder("utf8");
|
||||
this.replaces = true;
|
||||
}
|
||||
const buffer = Buffer.alloc(BACKGROUND_OUTPUT_READ_BYTES);
|
||||
let bytesRead: number;
|
||||
do {
|
||||
({ bytesRead } = await file.read(buffer, 0, buffer.length, this.offset));
|
||||
this.offset += bytesRead;
|
||||
const text = this.decoder.write(buffer.subarray(0, bytesRead));
|
||||
if (text !== "") await this.send(text);
|
||||
} while (toEnd && bytesRead > 0);
|
||||
if (toEnd) {
|
||||
const rest = this.decoder.end();
|
||||
// A command that wrote nothing still replaces the placeholder.
|
||||
if (rest !== "" || this.replaces) await this.send(rest);
|
||||
}
|
||||
} catch {
|
||||
// The read failed: try again at the next poll.
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
});
|
||||
return this.reading;
|
||||
}
|
||||
|
||||
private async send(text: string): Promise<void> {
|
||||
const replaces = this.replaces;
|
||||
this.replaces = false;
|
||||
await this.publish({ text, replaces });
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification } from "./acp-subagents.js";
|
||||
import type { NativeSubagent } from "./native-subagents.js";
|
||||
import { type AcpToolCallRenderer, V2_CANCELLED, V2_PAUSED } from "./tool-calls/renderer.js";
|
||||
import { type BackgroundOutput, BackgroundOutputTail } from "./background-output.js";
|
||||
|
||||
/** An item of a `background` list. */
|
||||
export type BackgroundItem =
|
||||
@@ -38,6 +39,18 @@ export type BackgroundItem =
|
||||
| { type: "subagent"; sessionId: string }
|
||||
| { type: "_claude/task"; taskId: string; description: string };
|
||||
|
||||
/** The display terminal of a command that went to the background (see `Session.backgroundTerminals`). */
|
||||
export interface BackgroundTerminal {
|
||||
/** The Bash tool call, whose id names the terminal. */
|
||||
toolCallId: string;
|
||||
/** The subagent tool call that ran the command, if any. */
|
||||
parentToolUseId?: string;
|
||||
/** The file that Claude Code writes the command's output to, from the Bash result. */
|
||||
outputFilePath?: string;
|
||||
/** Reads that file into the terminal while the command runs. */
|
||||
output?: BackgroundOutputTail;
|
||||
}
|
||||
|
||||
/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */
|
||||
export interface BackgroundToolCall {
|
||||
toolCallId: string;
|
||||
@@ -82,7 +95,7 @@ export interface BackgroundSink {
|
||||
export interface BackgroundWorkSession {
|
||||
reportsBackground?: boolean;
|
||||
backgroundLevel?: BackgroundLevel;
|
||||
backgroundTerminals?: Map<string, { toolCallId: string; parentToolUseId?: string }>;
|
||||
backgroundTerminals?: Map<string, BackgroundTerminal>;
|
||||
backgroundToolCalls?: Map<string, BackgroundToolCall>;
|
||||
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
|
||||
liveBackgroundTasks?: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
|
||||
@@ -280,6 +293,61 @@ export function droppedTasks(session: BackgroundWorkSession, level: BackgroundLe
|
||||
].filter((taskId) => !level.has(taskId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reading the output of the background command `taskId` into its
|
||||
* terminal, when the session reports its background work and the Bash result
|
||||
* named the command's output file. `send` sends an update of the terminal's
|
||||
* tool call, routed as the call's other updates. Called once the Bash
|
||||
* result's own updates are sent: the first output replaces the placeholder
|
||||
* that they gave the terminal.
|
||||
*/
|
||||
export function followBackgroundTerminalOutput(
|
||||
session: BackgroundWorkSession,
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
send: (notification: AcpSessionNotification) => Promise<void>,
|
||||
): void {
|
||||
const terminal = session.backgroundTerminals?.get(taskId);
|
||||
if (!session.reportsBackground || !terminal?.outputFilePath || terminal.output) return;
|
||||
terminal.output = new BackgroundOutputTail(terminal.outputFilePath, (output) =>
|
||||
send(terminalOutputUpdate(sessionId, terminal, output)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The update that gives the terminal of `terminal` the text `output`. */
|
||||
function terminalOutputUpdate(
|
||||
sessionId: string,
|
||||
terminal: BackgroundTerminal,
|
||||
output: BackgroundOutput,
|
||||
): AcpSessionNotification {
|
||||
const data = { terminal_id: terminal.toolCallId, data: output.text };
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: terminal.toolCallId,
|
||||
_meta: {
|
||||
...(terminal.parentToolUseId
|
||||
? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
|
||||
: {}),
|
||||
...(output.replaces ? { terminal_output: data } : { terminal_output_delta: data }),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the rest of the output of the background command `taskId` into its
|
||||
* terminal: the command ended, and its output goes before its exit, as v2
|
||||
* asks.
|
||||
*/
|
||||
export async function finishBackgroundTerminalOutput(
|
||||
session: BackgroundWorkSession,
|
||||
taskId: string,
|
||||
): Promise<void> {
|
||||
await session.backgroundTerminals?.get(taskId)?.output?.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the terminal of the background command `taskId` of the session
|
||||
* `sessionId`, and returns the update that exits it, or undefined when the
|
||||
@@ -294,6 +362,7 @@ export function takeBackgroundTerminalExit(
|
||||
): AcpSessionNotification | undefined {
|
||||
const terminal = session.backgroundTerminals?.get(taskId);
|
||||
if (!terminal) return undefined;
|
||||
terminal.output?.stop();
|
||||
session.backgroundTerminals!.delete(taskId);
|
||||
return {
|
||||
sessionId,
|
||||
@@ -406,6 +475,8 @@ export function endBackgroundWork(
|
||||
sessionId: string,
|
||||
): AcpSessionNotification[] {
|
||||
if (!session.reportsBackground) return [];
|
||||
// No command writes output any more.
|
||||
for (const terminal of session.backgroundTerminals?.values() ?? []) terminal.output?.stop();
|
||||
const exits = [...(session.backgroundTerminals ?? [])].flatMap(([taskId, terminal]) => {
|
||||
const exit = terminal.parentToolUseId
|
||||
? undefined
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* schema, so a malformed v2 message fails the test.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -5598,6 +5598,78 @@ describe("ACP v2 terminal of a command in the background", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the command's output file into its terminal, and the rest before its exit", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "background-output-"));
|
||||
const outputFile = path.join(dir, "tasks", "bg1.output");
|
||||
await mkdir(path.dirname(outputFile));
|
||||
await writeFile(outputFile, "compiling\n");
|
||||
const ended = Promise.withResolvers<void>();
|
||||
scriptTurns(async function* (options) {
|
||||
yield level(options, [{}]);
|
||||
yield taskStarted(options);
|
||||
yield bashUse(options);
|
||||
// The Bash result names the file, as Claude Code writes it.
|
||||
const bashResult = backgrounded(options);
|
||||
yield {
|
||||
...bashResult,
|
||||
message: {
|
||||
...bashResult.message,
|
||||
content: [
|
||||
{
|
||||
...bashResult.message.content[0],
|
||||
content: `Command running in background with ID: bg1. Output is being written to: ${outputFile}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
await ended.promise;
|
||||
yield* taskEnded(options);
|
||||
yield level(options, []);
|
||||
});
|
||||
const output = (client: ReturnType<typeof v2Client>) =>
|
||||
client.sessionUpdates.flatMap(({ update }) => {
|
||||
if (update.sessionUpdate === "terminal_output_chunk") {
|
||||
return [`chunk ${Buffer.from(String(update.data), "base64").toString()}`];
|
||||
}
|
||||
if (update.sessionUpdate !== "terminal_update") return [];
|
||||
return [
|
||||
...(update.output
|
||||
? [
|
||||
`output ${Buffer.from((update.output as { data: string }).data, "base64").toString()}`,
|
||||
]
|
||||
: []),
|
||||
...(update.exitStatus ? ["exit"] : []),
|
||||
];
|
||||
});
|
||||
try {
|
||||
const client = await promptUntil("idle end_turn [toolu_bg]", async (_, __, client) => {
|
||||
await vi.waitFor(() => expect(output(client)).toContain("output compiling\n"), {
|
||||
timeout: 2000,
|
||||
});
|
||||
await appendFile(outputFile, "tests passed\n");
|
||||
await vi.waitFor(() => expect(output(client)).toContain("chunk tests passed\n"), {
|
||||
timeout: 2000,
|
||||
});
|
||||
// The command writes its last line as it ends.
|
||||
await appendFile(outputFile, "done\n");
|
||||
ended.resolve();
|
||||
await vi.waitFor(() => expect(backgroundTrace(client)).toContain("idle []"));
|
||||
});
|
||||
|
||||
// The file's first text replaces the Bash result's placeholder.
|
||||
expect(output(client).slice(-4)).toEqual([
|
||||
"output compiling\n",
|
||||
"chunk tests passed\n",
|
||||
"chunk done\n",
|
||||
"exit",
|
||||
]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ends the listed terminals when the query ends, as its background tasks end with it", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield level(options, [{}]);
|
||||
|
||||
129
src/tests/background-output.test.ts
Normal file
129
src/tests/background-output.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { appendFile, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES,
|
||||
BACKGROUND_OUTPUT_READ_BYTES,
|
||||
type BackgroundOutput,
|
||||
BackgroundOutputTail,
|
||||
} from "../background-output.js";
|
||||
|
||||
describe("BackgroundOutputTail", () => {
|
||||
let dir: string;
|
||||
let file: string;
|
||||
let published: BackgroundOutput[];
|
||||
let tail: BackgroundOutputTail | undefined;
|
||||
const follow = () =>
|
||||
(tail = new BackgroundOutputTail(file, async (output) => void published.push(output)));
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "background-output-"));
|
||||
file = path.join(dir, "bg1.output");
|
||||
published = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
tail?.stop();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("replaces the terminal's output with the file's first text, then appends what the file adds", async () => {
|
||||
await writeFile(file, "building\n");
|
||||
follow();
|
||||
await vi.waitFor(() => expect(published).toHaveLength(1), { timeout: 2000 });
|
||||
await appendFile(file, "done\n");
|
||||
await vi.waitFor(() => expect(published).toHaveLength(2), { timeout: 2000 });
|
||||
|
||||
expect(published).toEqual([
|
||||
{ text: "building\n", replaces: true },
|
||||
{ text: "done\n", replaces: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for a file that does not exist yet", async () => {
|
||||
follow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
expect(published).toEqual([]);
|
||||
|
||||
await writeFile(file, "started\n");
|
||||
await vi.waitFor(() => expect(published).toHaveLength(1), { timeout: 2000 });
|
||||
expect(published[0]).toEqual({ text: "started\n", replaces: true });
|
||||
});
|
||||
|
||||
it("holds a character that a read splits until its last byte is written", async () => {
|
||||
const check = Buffer.from("✓", "utf8");
|
||||
await writeFile(file, check.subarray(0, 1));
|
||||
follow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
expect(published).toEqual([]);
|
||||
|
||||
await appendFile(file, check.subarray(1));
|
||||
await tail!.finish();
|
||||
expect(published).toEqual([{ text: "✓", replaces: true }]);
|
||||
});
|
||||
|
||||
it("reads the rest of the file on finish, and nothing after", async () => {
|
||||
await writeFile(file, "a");
|
||||
follow();
|
||||
await appendFile(file, "b");
|
||||
await tail!.finish();
|
||||
await appendFile(file, "c");
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
|
||||
expect(published.map((output) => output.text).join("")).toBe("ab");
|
||||
});
|
||||
|
||||
it("sends at most the backlog, read by read, from the file's last bytes as a snapshot", async () => {
|
||||
// More than the backlog, written faster than the polls read it.
|
||||
const skipped = "x".repeat(BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES);
|
||||
const kept = "y".repeat(BACKGROUND_OUTPUT_MAX_BACKLOG_BYTES - 4) + "end\n";
|
||||
await writeFile(file, skipped + kept);
|
||||
follow();
|
||||
await tail!.finish();
|
||||
|
||||
expect(published.map((output) => output.text).join("")).toBe(kept);
|
||||
expect(published[0].replaces).toBe(true);
|
||||
expect(published.slice(1).every((output) => !output.replaces)).toBe(true);
|
||||
expect(
|
||||
published.every((output) => Buffer.byteLength(output.text) <= BACKGROUND_OUTPUT_READ_BYTES),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("starts over with a snapshot when the file shrinks", async () => {
|
||||
await writeFile(file, "old output\n");
|
||||
follow();
|
||||
await vi.waitFor(() => expect(published).toHaveLength(1), { timeout: 2000 });
|
||||
await writeFile(file, "new\n");
|
||||
await tail!.finish();
|
||||
|
||||
expect(published).toEqual([
|
||||
{ text: "old output\n", replaces: true },
|
||||
{ text: "new\n", replaces: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the placeholder with an empty output when the command wrote nothing", async () => {
|
||||
await writeFile(file, "");
|
||||
follow();
|
||||
await tail!.finish();
|
||||
|
||||
expect(published).toEqual([{ text: "", replaces: true }]);
|
||||
});
|
||||
|
||||
it("publishes nothing on finish when the file never appeared", async () => {
|
||||
follow();
|
||||
await tail!.finish();
|
||||
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads nothing after stop", async () => {
|
||||
follow();
|
||||
tail!.stop();
|
||||
await writeFile(file, "late\n");
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user