@aliou/pi-processes 0.9.4 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -12
- package/src/constants/types.ts +13 -0
- package/src/tools/actions/debug.ts +23 -16
- package/src/tools/actions/output-truncate.ts +187 -0
- package/src/tools/actions/output.test.ts +392 -0
- package/src/tools/actions/output.ts +278 -100
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-processes",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -52,6 +52,18 @@
|
|
|
52
52
|
"typescript": "^6.0.3",
|
|
53
53
|
"vitest": "^4.1.7"
|
|
54
54
|
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"lint": "biome check",
|
|
58
|
+
"format": "biome check --write",
|
|
59
|
+
"check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
|
|
60
|
+
"prepare": "[ -d .git ] && husky || true",
|
|
61
|
+
"changeset": "changeset",
|
|
62
|
+
"version": "changeset version",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"release": "pnpm changeset publish"
|
|
65
|
+
},
|
|
66
|
+
"packageManager": "pnpm@10.26.1",
|
|
55
67
|
"peerDependenciesMeta": {
|
|
56
68
|
"@earendil-works/pi-ai": {
|
|
57
69
|
"optional": true
|
|
@@ -65,15 +77,5 @@
|
|
|
65
77
|
"typebox": {
|
|
66
78
|
"optional": true
|
|
67
79
|
}
|
|
68
|
-
},
|
|
69
|
-
"scripts": {
|
|
70
|
-
"typecheck": "tsc --noEmit",
|
|
71
|
-
"lint": "biome check",
|
|
72
|
-
"format": "biome check --write",
|
|
73
|
-
"check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
|
|
74
|
-
"changeset": "changeset",
|
|
75
|
-
"version": "changeset version",
|
|
76
|
-
"test": "vitest run",
|
|
77
|
-
"release": "pnpm changeset publish"
|
|
78
80
|
}
|
|
79
|
-
}
|
|
81
|
+
}
|
package/src/constants/types.ts
CHANGED
|
@@ -89,14 +89,27 @@ export interface StartOptions {
|
|
|
89
89
|
logWatches?: LogWatch[];
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
import type { TruncationDetails } from "../tools/actions/output-truncate";
|
|
93
|
+
|
|
92
94
|
export interface ProcessesDetails {
|
|
93
95
|
action: ProcessAction;
|
|
94
96
|
success: boolean;
|
|
95
97
|
message: string;
|
|
96
98
|
process?: ProcessInfo;
|
|
97
99
|
processes?: ProcessInfo[];
|
|
100
|
+
/**
|
|
101
|
+
* Legacy-only raw output arrays retained so historical session results can
|
|
102
|
+
* still render. New executions must never populate this field; the bounded
|
|
103
|
+
* preview lives in tool-result `content` and a truncation summary lives in
|
|
104
|
+
* `truncation`.
|
|
105
|
+
*/
|
|
98
106
|
output?: { stdout: string[]; stderr: string[]; status: string };
|
|
99
107
|
logFiles?: { stdoutFile: string; stderrFile: string };
|
|
108
|
+
/**
|
|
109
|
+
* Metadata only (no raw output) describing how `content` was bounded.
|
|
110
|
+
* Present when the body exceeded the byte or line limits.
|
|
111
|
+
*/
|
|
112
|
+
truncation?: TruncationDetails;
|
|
100
113
|
cleared?: number;
|
|
101
114
|
}
|
|
102
115
|
|
|
@@ -101,26 +101,33 @@ export function executeDebugPreview(params: DebugParams): ExecuteResult {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
if (preview === "output") {
|
|
104
|
+
const message =
|
|
105
|
+
'"demo-server" (proc_42) [running]: 4 stdout lines, 2 stderr lines';
|
|
106
|
+
const content = [
|
|
107
|
+
message,
|
|
108
|
+
"stdout:",
|
|
109
|
+
"starting...",
|
|
110
|
+
"loading config",
|
|
111
|
+
"ready on http://localhost:3000",
|
|
112
|
+
"watching for changes",
|
|
113
|
+
"",
|
|
114
|
+
"stderr:",
|
|
115
|
+
"warn: deprecated option in config",
|
|
116
|
+
"error: simulated stack trace line",
|
|
117
|
+
"",
|
|
118
|
+
"Process is still running. Use watches instead of polling.",
|
|
119
|
+
"",
|
|
120
|
+
"[Complete currently-retained logs:",
|
|
121
|
+
"stdout=/tmp/pi-processes-demo/proc_42-stdout.log",
|
|
122
|
+
"stderr=/tmp/pi-processes-demo/proc_42-stderr.log]",
|
|
123
|
+
].join("\n");
|
|
124
|
+
|
|
104
125
|
return {
|
|
105
|
-
content: [{ type: "text", text:
|
|
126
|
+
content: [{ type: "text", text: content }],
|
|
106
127
|
details: {
|
|
107
128
|
action: "output",
|
|
108
129
|
success: true,
|
|
109
|
-
message
|
|
110
|
-
'"demo-server" (proc_42) [running]: 4 stdout lines, 2 stderr lines',
|
|
111
|
-
output: {
|
|
112
|
-
status: "running",
|
|
113
|
-
stdout: [
|
|
114
|
-
"starting...",
|
|
115
|
-
"loading config",
|
|
116
|
-
"ready on http://localhost:3000",
|
|
117
|
-
"watching for changes",
|
|
118
|
-
],
|
|
119
|
-
stderr: [
|
|
120
|
-
"warn: deprecated option in config",
|
|
121
|
-
"error: simulated stack trace line",
|
|
122
|
-
],
|
|
123
|
-
},
|
|
130
|
+
message,
|
|
124
131
|
logFiles: {
|
|
125
132
|
stdoutFile: "/tmp/pi-processes-demo/proc_42-stdout.log",
|
|
126
133
|
stderrFile: "/tmp/pi-processes-demo/proc_42-stderr.log",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local tail-truncation for the `process output` action.
|
|
3
|
+
*
|
|
4
|
+
* The installed Pi version (0.75.x) does not export its shared truncation
|
|
5
|
+
* helper, so this module keeps a local implementation that mirrors the v0.10
|
|
6
|
+
* behavior the agent depends on.
|
|
7
|
+
*
|
|
8
|
+
* Invariants:
|
|
9
|
+
* - Always keeps the newest output within the byte and line budgets.
|
|
10
|
+
* - A single oversized line yields a UTF-8-safe suffix instead of being
|
|
11
|
+
* dropped entirely.
|
|
12
|
+
* - ANSI and terminal control characters must be stripped by callers before
|
|
13
|
+
* truncation; this module operates on already-cleaned text.
|
|
14
|
+
*
|
|
15
|
+
* The `truncation` metadata returned alongside the content matches the shape
|
|
16
|
+
* Pi 0.10 exposes through `TruncationResult`, minus the `content` field, so it
|
|
17
|
+
* can be persisted in `details` without re-embedding raw output.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ceiling applied to the JSON-escaped serialized content. Newline-heavy or
|
|
24
|
+
* tab-heavy output inflates under `JSON.stringify`, so the composed result is
|
|
25
|
+
* measured against this tighter budget in addition to the raw byte limit.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_OUTPUT_JSON_BYTES = 96 * 1024;
|
|
28
|
+
|
|
29
|
+
export interface TruncationResult {
|
|
30
|
+
content: string;
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
truncatedBy: "bytes" | "lines" | null;
|
|
33
|
+
lastLinePartial: boolean;
|
|
34
|
+
outputLines: number;
|
|
35
|
+
outputBytes: number;
|
|
36
|
+
totalLines: number;
|
|
37
|
+
totalBytes: number;
|
|
38
|
+
maxBytes: number;
|
|
39
|
+
maxLines: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Metadata persisted in tool-result `details`. Mirrors `TruncationResult`
|
|
44
|
+
* minus the `content` field, so the bounded preview stays in `content` only.
|
|
45
|
+
*/
|
|
46
|
+
export type TruncationDetails = Omit<TruncationResult, "content">;
|
|
47
|
+
|
|
48
|
+
interface TruncateOptions {
|
|
49
|
+
maxBytes: number;
|
|
50
|
+
maxLines: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Truncate `text` from the tail, keeping the newest lines within the byte and
|
|
55
|
+
* line budgets. A single oversized line is sliced to a UTF-8-safe suffix
|
|
56
|
+
* rather than dropped entirely.
|
|
57
|
+
*/
|
|
58
|
+
export function truncateTail(
|
|
59
|
+
text: string,
|
|
60
|
+
options: TruncateOptions,
|
|
61
|
+
): TruncationResult {
|
|
62
|
+
const { maxBytes, maxLines } = options;
|
|
63
|
+
const totalBytes = Buffer.byteLength(text, "utf-8");
|
|
64
|
+
const lines = text.split("\n");
|
|
65
|
+
const totalLines = lines.length;
|
|
66
|
+
|
|
67
|
+
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
|
68
|
+
return {
|
|
69
|
+
content: text,
|
|
70
|
+
truncated: false,
|
|
71
|
+
truncatedBy: null,
|
|
72
|
+
lastLinePartial: false,
|
|
73
|
+
outputLines: totalLines,
|
|
74
|
+
outputBytes: totalBytes,
|
|
75
|
+
totalLines,
|
|
76
|
+
totalBytes,
|
|
77
|
+
maxBytes,
|
|
78
|
+
maxLines,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Walk backwards, keeping whole lines that fit. Track whether the byte
|
|
83
|
+
// budget was the binding constraint so the notice wording is accurate.
|
|
84
|
+
const kept: string[] = [];
|
|
85
|
+
let keptBytes = 0;
|
|
86
|
+
let hitBytes = false;
|
|
87
|
+
|
|
88
|
+
for (let i = lines.length - 1; i >= 0 && kept.length < maxLines; i--) {
|
|
89
|
+
const line = lines[i] ?? "";
|
|
90
|
+
const lineBytes =
|
|
91
|
+
Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0);
|
|
92
|
+
|
|
93
|
+
if (keptBytes + lineBytes > maxBytes) {
|
|
94
|
+
hitBytes = true;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
kept.unshift(line);
|
|
99
|
+
keptBytes += lineBytes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let lastLinePartial = false;
|
|
103
|
+
|
|
104
|
+
if (kept.length === 0) {
|
|
105
|
+
// The newest line alone exceeds the byte budget. Keep a UTF-8-safe suffix
|
|
106
|
+
// instead of returning an empty body, so the agent still sees recent
|
|
107
|
+
// output. The single byte budget minus one byte for the skipped joiner.
|
|
108
|
+
const lastLine = lines[lines.length - 1] ?? "";
|
|
109
|
+
const suffix = utf8Suffix(lastLine, maxBytes - 1);
|
|
110
|
+
kept.push(suffix);
|
|
111
|
+
keptBytes = Buffer.byteLength(suffix, "utf-8");
|
|
112
|
+
lastLinePartial = true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const content = kept.join("\n");
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
content,
|
|
119
|
+
truncated: true,
|
|
120
|
+
truncatedBy: hitBytes ? "bytes" : "lines",
|
|
121
|
+
lastLinePartial,
|
|
122
|
+
outputLines: kept.length,
|
|
123
|
+
outputBytes: keptBytes,
|
|
124
|
+
totalLines,
|
|
125
|
+
totalBytes,
|
|
126
|
+
maxBytes,
|
|
127
|
+
maxLines,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Slice a UTF-8-safe suffix from `line` at most `maxBytes` long. The suffix
|
|
133
|
+
* keeps the newest content (the tail of the line) and is prefixed with an
|
|
134
|
+
* ellipsis marker. The cut point walks forward past any leading continuation
|
|
135
|
+
* bytes (0b10xxxxxx) so the result never starts mid-code-point.
|
|
136
|
+
*/
|
|
137
|
+
function utf8Suffix(line: string, maxBytes: number): string {
|
|
138
|
+
const buffer = Buffer.from(line, "utf-8");
|
|
139
|
+
if (buffer.length <= maxBytes) return line;
|
|
140
|
+
|
|
141
|
+
const marker = "…";
|
|
142
|
+
const markerBytes = Buffer.byteLength(marker, "utf-8");
|
|
143
|
+
const budget = Math.max(0, maxBytes - markerBytes);
|
|
144
|
+
if (budget <= 0) return marker;
|
|
145
|
+
|
|
146
|
+
// Start the cut so the suffix is the last `budget` bytes of the line.
|
|
147
|
+
let start = buffer.length - budget;
|
|
148
|
+
// If the cut lands inside a multibyte code point, the first byte is a
|
|
149
|
+
// continuation byte (0b10xxxxxx). Walk forward to the next lead byte.
|
|
150
|
+
while (start < buffer.length && (buffer[start] ?? 0) >> 6 === 0b10) {
|
|
151
|
+
start++;
|
|
152
|
+
}
|
|
153
|
+
if (start >= buffer.length) return marker;
|
|
154
|
+
|
|
155
|
+
return `${marker}${buffer.subarray(start).toString("utf-8")}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function formatSize(bytes: number): string {
|
|
159
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
160
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
161
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Count logical lines, matching `text.split("\n").length`. Empty text has
|
|
166
|
+
* zero lines so the line budget is not consumed by a stray newline.
|
|
167
|
+
*/
|
|
168
|
+
export function countLines(text: string): number {
|
|
169
|
+
if (text.length === 0) return 0;
|
|
170
|
+
return text.split("\n").length;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Compose a truncation notice string matching the agent-facing wording other
|
|
175
|
+
* tools use, so renderers can locate it reliably.
|
|
176
|
+
*/
|
|
177
|
+
export function formatTruncationNotice(truncation: TruncationResult): string {
|
|
178
|
+
if (!truncation.truncated) return "";
|
|
179
|
+
const limit =
|
|
180
|
+
truncation.truncatedBy === "bytes"
|
|
181
|
+
? `${formatSize(truncation.maxBytes)} byte limit`
|
|
182
|
+
: `${truncation.maxLines} line limit`;
|
|
183
|
+
const partialNote = truncation.lastLinePartial
|
|
184
|
+
? " (final line is a partial suffix)"
|
|
185
|
+
: "";
|
|
186
|
+
return `[Preview truncated by ${limit}${partialNote}; showing ${truncation.outputLines} lines / ${formatSize(truncation.outputBytes)} of ${truncation.totalLines} lines / ${formatSize(truncation.totalBytes)}.]`;
|
|
187
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { configLoader } from "../../config";
|
|
4
|
+
import type { ExecuteResult, ProcessInfo } from "../../constants";
|
|
5
|
+
import type { ProcessManager } from "../../manager";
|
|
6
|
+
import { executeOutput, renderOutputResult } from "./output";
|
|
7
|
+
import { MAX_OUTPUT_BYTES, truncateTail } from "./output-truncate";
|
|
8
|
+
|
|
9
|
+
const STDOUT_FILE = "/tmp/proc_1-stdout.log";
|
|
10
|
+
const STDERR_FILE = "/tmp/proc_1-stderr.log";
|
|
11
|
+
|
|
12
|
+
interface OutputSnapshot {
|
|
13
|
+
stdout: string[];
|
|
14
|
+
stderr: string[];
|
|
15
|
+
status: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function mockProcess(overrides: Partial<ProcessInfo> = {}): ProcessInfo {
|
|
19
|
+
return {
|
|
20
|
+
id: "proc_1",
|
|
21
|
+
name: "server",
|
|
22
|
+
pid: 1234,
|
|
23
|
+
command: "npm start",
|
|
24
|
+
cwd: "/project",
|
|
25
|
+
startTime: 1,
|
|
26
|
+
endTime: null,
|
|
27
|
+
status: "running",
|
|
28
|
+
exitCode: null,
|
|
29
|
+
success: null,
|
|
30
|
+
stdoutFile: STDOUT_FILE,
|
|
31
|
+
stderrFile: STDERR_FILE,
|
|
32
|
+
alertOnSuccess: false,
|
|
33
|
+
alertOnFailure: true,
|
|
34
|
+
alertOnKill: false,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mockManager(
|
|
40
|
+
output: OutputSnapshot | null,
|
|
41
|
+
processOverrides: Partial<ProcessInfo> = {},
|
|
42
|
+
): ProcessManager {
|
|
43
|
+
const process = mockProcess(processOverrides);
|
|
44
|
+
return {
|
|
45
|
+
get: vi.fn().mockReturnValue(process),
|
|
46
|
+
getOutput: vi.fn().mockReturnValue(output),
|
|
47
|
+
getLogFiles: vi.fn().mockReturnValue({
|
|
48
|
+
stdoutFile: STDOUT_FILE,
|
|
49
|
+
stderrFile: STDERR_FILE,
|
|
50
|
+
combinedFile: "/tmp/proc_1-combined.log",
|
|
51
|
+
}),
|
|
52
|
+
} as unknown as ProcessManager;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runOutput(output: OutputSnapshot): ExecuteResult {
|
|
56
|
+
return executeOutput({ id: "proc_1" }, mockManager(output));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
await configLoader.load();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function contentText(result: ExecuteResult): string {
|
|
64
|
+
const block = result.content[0];
|
|
65
|
+
return block && block.type === "text" ? block.text : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("executeOutput", () => {
|
|
69
|
+
it("formats normal stdout and stderr", () => {
|
|
70
|
+
const result = runOutput({
|
|
71
|
+
stdout: ["line 1", "line 2"],
|
|
72
|
+
stderr: ["err 1"],
|
|
73
|
+
status: "running",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const content = contentText(result);
|
|
77
|
+
expect(content).toContain("stdout:");
|
|
78
|
+
expect(content).toContain("line 1");
|
|
79
|
+
expect(content).toContain("line 2");
|
|
80
|
+
expect(content).toContain("stderr:");
|
|
81
|
+
expect(content).toContain("err 1");
|
|
82
|
+
expect(content).toContain("2 stdout lines, 1 stderr lines");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("strips ANSI and terminal control characters before persistence", () => {
|
|
86
|
+
const result = runOutput({
|
|
87
|
+
stdout: ["\u001b[31mred\u001b[0m", "step 1\rstep 2\b done"],
|
|
88
|
+
stderr: [],
|
|
89
|
+
status: "exited",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const content = contentText(result);
|
|
93
|
+
expect(content).not.toContain("\u001b[31m");
|
|
94
|
+
expect(content).not.toContain("\r");
|
|
95
|
+
expect(content).not.toContain("\b");
|
|
96
|
+
expect(content).toContain("red");
|
|
97
|
+
expect(content).toContain("step 1step 2 done");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("never persists stdout, stderr, or output arrays in new result details", () => {
|
|
101
|
+
const result = runOutput({
|
|
102
|
+
stdout: ["line 1"],
|
|
103
|
+
stderr: ["err 1"],
|
|
104
|
+
status: "running",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const { details } = result;
|
|
108
|
+
expect(details.output).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not embed raw process output in serialized details", () => {
|
|
112
|
+
const result = runOutput({
|
|
113
|
+
stdout: ["SECRET-stdout-token"],
|
|
114
|
+
stderr: ["SECRET-stderr-token"],
|
|
115
|
+
status: "running",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const serialized = JSON.stringify(result.details);
|
|
119
|
+
expect(serialized).not.toContain("SECRET-stdout-token");
|
|
120
|
+
expect(serialized).not.toContain("SECRET-stderr-token");
|
|
121
|
+
// The raw-output property is absent from new result details.
|
|
122
|
+
expect(result.details.output).toBeUndefined();
|
|
123
|
+
expect("output" in JSON.parse(serialized)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("bounds a 2 MiB single line without growing the session entry", () => {
|
|
127
|
+
const huge = "x".repeat(2 * 1024 * 1024);
|
|
128
|
+
const result = runOutput({ stdout: [huge], stderr: [], status: "running" });
|
|
129
|
+
|
|
130
|
+
const serialized = JSON.stringify(result);
|
|
131
|
+
// Serialized result (content + details) stays below a fixed safe ceiling.
|
|
132
|
+
expect(Buffer.byteLength(serialized, "utf-8")).toBeLessThan(128 * 1024);
|
|
133
|
+
// The newest output still surfaces a partial suffix.
|
|
134
|
+
expect(contentText(result)).toContain("x");
|
|
135
|
+
expect(result.details.truncation?.truncated).toBe(true);
|
|
136
|
+
expect(result.details.truncation?.lastLinePartial).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles CR-only progress output without session growth", () => {
|
|
140
|
+
// CR-only progress collapses into a single line carrying the final state.
|
|
141
|
+
const progress = `${Array.from({ length: 1000 }, (_, i) => `phase ${i}\r`).join("")}done`;
|
|
142
|
+
const result = runOutput({
|
|
143
|
+
stdout: [progress],
|
|
144
|
+
stderr: [],
|
|
145
|
+
status: "running",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const serialized = JSON.stringify(result);
|
|
149
|
+
expect(Buffer.byteLength(serialized, "utf-8")).toBeLessThan(128 * 1024);
|
|
150
|
+
expect(contentText(result)).not.toContain("\r");
|
|
151
|
+
expect(contentText(result)).toContain("done");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("accounts for JSON-escaping expansion for a tab-heavy line", () => {
|
|
155
|
+
// Tabs survive stripAnsi but expand under JSON.stringify.
|
|
156
|
+
const tabHeavy = `${"\t".repeat(200_000)}tail-marker`;
|
|
157
|
+
const result = runOutput({
|
|
158
|
+
stdout: [tabHeavy],
|
|
159
|
+
stderr: [],
|
|
160
|
+
status: "running",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const serialized = JSON.stringify(result);
|
|
164
|
+
expect(Buffer.byteLength(serialized, "utf-8")).toBeLessThan(128 * 1024);
|
|
165
|
+
expect(contentText(result)).toContain("tail-marker");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("keeps a UTF-8-safe suffix when an oversized multibyte line is truncated", () => {
|
|
169
|
+
// Use a 4-byte UTF-8 codepoint (U+1F680) so an arbitrary mid-byte cut
|
|
170
|
+
// would produce replacement characters. Keep enough multibyte content so
|
|
171
|
+
// the suffix definitely lands inside a code point run.
|
|
172
|
+
const emoji = "\u{1F680}".repeat(50_000); // ~200 KiB
|
|
173
|
+
const result = runOutput({
|
|
174
|
+
stdout: [emoji],
|
|
175
|
+
stderr: [],
|
|
176
|
+
status: "running",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(result.details.truncation?.lastLinePartial).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("bounds combined large stdout and stderr", () => {
|
|
183
|
+
const stdout = Array.from({ length: 5000 }, (_, i) => `out ${i}`);
|
|
184
|
+
const stderr = Array.from({ length: 5000 }, (_, i) => `err ${i}`);
|
|
185
|
+
const result = runOutput({ stdout, stderr, status: "running" });
|
|
186
|
+
|
|
187
|
+
const serialized = JSON.stringify(result);
|
|
188
|
+
expect(Buffer.byteLength(serialized, "utf-8")).toBeLessThan(128 * 1024);
|
|
189
|
+
// Newest stderr lines are kept (they form the tail of the combined body).
|
|
190
|
+
expect(contentText(result)).toContain("err 4999");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("keeps the final serialized tool result below a fixed safe ceiling", () => {
|
|
194
|
+
const stdout = Array.from({ length: 10_000 }, (_, i) => `line ${i}`);
|
|
195
|
+
const stderr = Array.from({ length: 10_000 }, (_, i) => `err ${i}`);
|
|
196
|
+
const result = runOutput({ stdout, stderr, status: "running" });
|
|
197
|
+
|
|
198
|
+
expect(Buffer.byteLength(JSON.stringify(result), "utf-8")).toBeLessThan(
|
|
199
|
+
128 * 1024,
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("always retains complete log file paths in the textual result", () => {
|
|
204
|
+
const stdout = Array.from({ length: 10_000 }, (_, i) => `line ${i}`);
|
|
205
|
+
const result = runOutput({ stdout, stderr: [], status: "running" });
|
|
206
|
+
|
|
207
|
+
const content = contentText(result);
|
|
208
|
+
expect(content).toContain(STDOUT_FILE);
|
|
209
|
+
expect(content).toContain(STDERR_FILE);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("returns failure details without log files when the process is missing", () => {
|
|
213
|
+
const manager = {
|
|
214
|
+
get: vi.fn().mockReturnValue(null),
|
|
215
|
+
getOutput: vi.fn(),
|
|
216
|
+
getLogFiles: vi.fn(),
|
|
217
|
+
} as unknown as ProcessManager;
|
|
218
|
+
|
|
219
|
+
const result = executeOutput({ id: "missing" }, manager);
|
|
220
|
+
expect(result.details.success).toBe(false);
|
|
221
|
+
expect(result.details.logFiles).toBeUndefined();
|
|
222
|
+
expect(result.details.output).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("includes truncation metadata in details when content is truncated", () => {
|
|
226
|
+
const result = runOutput({
|
|
227
|
+
stdout: Array.from({ length: 5000 }, (_, i) => `line ${i}`),
|
|
228
|
+
stderr: [],
|
|
229
|
+
status: "running",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(result.details.truncation).toBeDefined();
|
|
233
|
+
expect(result.details.truncation?.truncated).toBe(true);
|
|
234
|
+
expect(result.details.truncation?.totalLines).toBeGreaterThan(
|
|
235
|
+
result.details.truncation?.outputLines ?? 0,
|
|
236
|
+
);
|
|
237
|
+
// Raw output never leaks through truncation metadata.
|
|
238
|
+
expect(JSON.stringify(result.details.truncation)).not.toContain(
|
|
239
|
+
"line 4999",
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("truncateTail (hardening)", () => {
|
|
245
|
+
it("keeps the newest lines within the byte and line budgets", () => {
|
|
246
|
+
const text = Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n");
|
|
247
|
+
const result = truncateTail(text, {
|
|
248
|
+
maxBytes: MAX_OUTPUT_BYTES,
|
|
249
|
+
maxLines: 10,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(result.truncated).toBe(true);
|
|
253
|
+
expect(result.content).toContain("line 99");
|
|
254
|
+
expect(result.content).not.toContain("line 0");
|
|
255
|
+
expect(result.outputLines).toBeLessThanOrEqual(10);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("returns a UTF-8-safe suffix from a single oversized line", () => {
|
|
259
|
+
const huge = "\u{1F680}".repeat(50_000);
|
|
260
|
+
const result = truncateTail(huge, { maxBytes: 1024, maxLines: 10 });
|
|
261
|
+
|
|
262
|
+
expect(result.truncated).toBe(true);
|
|
263
|
+
expect(result.lastLinePartial).toBe(true);
|
|
264
|
+
expect(result.content.length).toBeGreaterThan(0);
|
|
265
|
+
// No partial UTF-8 sequences reach the output.
|
|
266
|
+
expect(Buffer.from(result.content, "utf-8").toString("utf-8")).toBe(
|
|
267
|
+
result.content,
|
|
268
|
+
);
|
|
269
|
+
expect(result.content).not.toContain("\uFFFD");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// --- Renderer coverage ---
|
|
274
|
+
|
|
275
|
+
function mockTheme(): Theme {
|
|
276
|
+
return {
|
|
277
|
+
fg: (_color: string, text: string) => text,
|
|
278
|
+
bg: (_color: string, text: string) => text,
|
|
279
|
+
bold: (text: string) => text,
|
|
280
|
+
italic: (text: string) => text,
|
|
281
|
+
underline: (text: string) => text,
|
|
282
|
+
inverse: (text: string) => text,
|
|
283
|
+
strikethrough: (text: string) => text,
|
|
284
|
+
getFgAnsi: () => "",
|
|
285
|
+
getBgAnsi: () => "",
|
|
286
|
+
getColorMode: () => "truecolor",
|
|
287
|
+
getThinkingBorderColor: () => (text: string) => text,
|
|
288
|
+
getBashModeBorderColor: () => (text: string) => text,
|
|
289
|
+
} as unknown as Theme;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function render(
|
|
293
|
+
result: ExecuteResult,
|
|
294
|
+
options: { expanded?: boolean } = {},
|
|
295
|
+
): string[] {
|
|
296
|
+
const body = renderOutputResult(
|
|
297
|
+
result as never,
|
|
298
|
+
{ expanded: options.expanded ?? false } as never,
|
|
299
|
+
mockTheme(),
|
|
300
|
+
);
|
|
301
|
+
return body.render(120);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
describe("renderOutputResult", () => {
|
|
305
|
+
it("renders expanded content-based output from tool-result content", () => {
|
|
306
|
+
const result = runOutput({
|
|
307
|
+
stdout: ["ready on http://localhost:3000"],
|
|
308
|
+
stderr: [],
|
|
309
|
+
status: "running",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const lines = render(result, { expanded: true });
|
|
313
|
+
const joined = lines.join("\n");
|
|
314
|
+
|
|
315
|
+
expect(joined).toContain("ready on http://localhost:3000");
|
|
316
|
+
expect(joined).toContain("Log files:");
|
|
317
|
+
expect(joined).toContain(STDOUT_FILE);
|
|
318
|
+
expect(joined).toContain(STDERR_FILE);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("renders a collapsed preview from the bounded content", () => {
|
|
322
|
+
const result = runOutput({
|
|
323
|
+
stdout: ["first", "second", "third"],
|
|
324
|
+
stderr: [],
|
|
325
|
+
status: "running",
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const lines = render(result, { expanded: false });
|
|
329
|
+
const joined = lines.join("\n");
|
|
330
|
+
|
|
331
|
+
expect(joined).toContain("third");
|
|
332
|
+
expect(joined).toContain("Output");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("surfaces the truncation notice and log paths in expanded view", () => {
|
|
336
|
+
const result = runOutput({
|
|
337
|
+
stdout: Array.from({ length: 5000 }, (_, i) => `line ${i}`),
|
|
338
|
+
stderr: [],
|
|
339
|
+
status: "running",
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const lines = render(result, { expanded: true });
|
|
343
|
+
const joined = lines.join("\n");
|
|
344
|
+
|
|
345
|
+
expect(result.details.truncation?.truncated).toBe(true);
|
|
346
|
+
expect(joined).toContain("Preview truncated");
|
|
347
|
+
expect(joined).toContain(STDOUT_FILE);
|
|
348
|
+
expect(joined).toContain(STDERR_FILE);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("renders legacy session results that still carry details.output", () => {
|
|
352
|
+
const legacy = {
|
|
353
|
+
content: [{ type: "text" as const, text: "legacy content" }],
|
|
354
|
+
details: {
|
|
355
|
+
action: "output" as const,
|
|
356
|
+
success: true,
|
|
357
|
+
message: '"server" (proc_1) [running]: 1 stdout lines, 0 stderr lines',
|
|
358
|
+
output: {
|
|
359
|
+
stdout: ["legacy stdout line"],
|
|
360
|
+
stderr: [],
|
|
361
|
+
status: "running",
|
|
362
|
+
},
|
|
363
|
+
logFiles: {
|
|
364
|
+
stdoutFile: STDOUT_FILE,
|
|
365
|
+
stderrFile: STDERR_FILE,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const lines = render(legacy, { expanded: true });
|
|
371
|
+
const joined = lines.join("\n");
|
|
372
|
+
|
|
373
|
+
expect(joined).toContain("legacy stdout line");
|
|
374
|
+
expect(joined).toContain("Log files:");
|
|
375
|
+
expect(joined).toContain(STDOUT_FILE);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("does not mutate result details during rendering", () => {
|
|
379
|
+
const result = runOutput({
|
|
380
|
+
stdout: ["line 1"],
|
|
381
|
+
stderr: ["err 1"],
|
|
382
|
+
status: "running",
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const before = JSON.stringify(result.details);
|
|
386
|
+
render(result, { expanded: true });
|
|
387
|
+
render(result, { expanded: false });
|
|
388
|
+
const after = JSON.stringify(result.details);
|
|
389
|
+
|
|
390
|
+
expect(after).toBe(before);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
@@ -9,13 +9,23 @@ import { configLoader } from "../../config";
|
|
|
9
9
|
import type { ExecuteResult, ProcessesDetails } from "../../constants";
|
|
10
10
|
import type { ProcessManager } from "../../manager";
|
|
11
11
|
import { formatStatus, hasAnsi, stripAnsi } from "../../utils";
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
countLines,
|
|
14
|
+
formatTruncationNotice,
|
|
15
|
+
MAX_OUTPUT_BYTES,
|
|
16
|
+
MAX_OUTPUT_JSON_BYTES,
|
|
17
|
+
type TruncationDetails,
|
|
18
|
+
type TruncationResult,
|
|
19
|
+
truncateTail,
|
|
20
|
+
} from "./output-truncate";
|
|
14
21
|
|
|
15
22
|
interface OutputParams {
|
|
16
23
|
id?: string;
|
|
17
24
|
}
|
|
18
25
|
|
|
26
|
+
/** Marker delimiting the always-present complete-log footer in content. */
|
|
27
|
+
const LOG_FOOTER_MARKER = "[Complete currently-retained logs:";
|
|
28
|
+
|
|
19
29
|
export function renderOutputCall(
|
|
20
30
|
args: OutputParams,
|
|
21
31
|
theme: Theme,
|
|
@@ -35,28 +45,83 @@ export function renderOutputResult(
|
|
|
35
45
|
options: ToolRenderResultOptions,
|
|
36
46
|
theme: Theme,
|
|
37
47
|
): ToolBody {
|
|
38
|
-
const { details } = result;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
const { details, content } = result;
|
|
49
|
+
|
|
50
|
+
const textBlock = Array.isArray(content)
|
|
51
|
+
? content.find((block) => block.type === "text")
|
|
52
|
+
: undefined;
|
|
53
|
+
const contentText =
|
|
54
|
+
textBlock && textBlock.type === "text" ? textBlock.text : "";
|
|
55
|
+
|
|
56
|
+
// Legacy session results still carry raw stdout/stderr arrays in details.
|
|
57
|
+
// Render them so historical entries remain visible without errors.
|
|
58
|
+
if (details.output) {
|
|
59
|
+
return renderLegacyOutput(details, theme, options);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const bodyLines = extractOutputBody(contentText, details);
|
|
63
|
+
let hadAnsi = false;
|
|
64
|
+
|
|
65
|
+
const lines: string[] = [theme.fg("muted", details.message)];
|
|
66
|
+
|
|
67
|
+
if (bodyLines.length > 0) {
|
|
68
|
+
lines.push("");
|
|
69
|
+
for (const line of bodyLines) {
|
|
70
|
+
if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
|
|
71
|
+
lines.push(line);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
lines.push("", theme.fg("muted", "(no output)"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (details.truncation) {
|
|
78
|
+
lines.push(
|
|
79
|
+
"",
|
|
80
|
+
theme.fg("muted", buildTruncationSummary(details.truncation)),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (details.logFiles) {
|
|
85
|
+
lines.push(
|
|
86
|
+
"",
|
|
87
|
+
theme.fg("success", "Log files:"),
|
|
88
|
+
` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
|
|
89
|
+
` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (hadAnsi) {
|
|
94
|
+
lines.push(
|
|
95
|
+
"",
|
|
96
|
+
theme.fg("muted", "ANSI escape codes were stripped from output"),
|
|
53
97
|
);
|
|
54
98
|
}
|
|
55
99
|
|
|
100
|
+
const fields: Array<
|
|
101
|
+
{ label: string; value: string; showCollapsed?: boolean } | Text
|
|
102
|
+
> = [new Text(lines.join("\n"), 0, 0)];
|
|
103
|
+
|
|
104
|
+
// Collapsed preview: the last couple of body lines.
|
|
105
|
+
const preview =
|
|
106
|
+
bodyLines.slice(-2).join("\n") || theme.fg("muted", "(empty)");
|
|
107
|
+
fields.push({
|
|
108
|
+
label: "Output",
|
|
109
|
+
value: theme.fg("muted", preview),
|
|
110
|
+
showCollapsed: true,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return new ToolBody({ fields }, options, theme);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderLegacyOutput(
|
|
117
|
+
details: ProcessesDetails,
|
|
118
|
+
theme: Theme,
|
|
119
|
+
options: ToolRenderResultOptions,
|
|
120
|
+
): ToolBody {
|
|
56
121
|
const lines: string[] = [theme.fg("muted", details.message)];
|
|
57
122
|
let hadAnsi = false;
|
|
58
123
|
|
|
59
|
-
if (details.output
|
|
124
|
+
if (details.output?.stdout.length) {
|
|
60
125
|
lines.push("", theme.fg("accent", "stdout:"));
|
|
61
126
|
for (const line of details.output.stdout.slice(-20)) {
|
|
62
127
|
if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
|
|
@@ -72,7 +137,7 @@ export function renderOutputResult(
|
|
|
72
137
|
}
|
|
73
138
|
}
|
|
74
139
|
|
|
75
|
-
if (details.output
|
|
140
|
+
if (details.output?.stderr.length) {
|
|
76
141
|
lines.push("", theme.fg("warning", "stderr:"));
|
|
77
142
|
for (const line of details.output.stderr.slice(-10)) {
|
|
78
143
|
if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
|
|
@@ -104,30 +169,102 @@ export function renderOutputResult(
|
|
|
104
169
|
);
|
|
105
170
|
}
|
|
106
171
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// Collapsed summary
|
|
112
|
-
const previewSource =
|
|
113
|
-
details.output.stdout.length > 0
|
|
114
|
-
? details.output.stdout
|
|
115
|
-
: details.output.stderr;
|
|
172
|
+
const previewSource = details.output?.stdout.length
|
|
173
|
+
? details.output.stdout
|
|
174
|
+
: (details.output?.stderr ?? []);
|
|
116
175
|
const preview = previewSource
|
|
117
176
|
.slice(-2)
|
|
118
177
|
.map((l) => stripAnsi(l))
|
|
119
178
|
.join("\n");
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
value:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
179
|
+
|
|
180
|
+
const fields: Array<
|
|
181
|
+
{ label: string; value: string; showCollapsed?: boolean } | Text
|
|
182
|
+
> = [
|
|
183
|
+
new Text(lines.join("\n"), 0, 0),
|
|
184
|
+
{
|
|
185
|
+
label: "Output",
|
|
186
|
+
value: preview
|
|
187
|
+
? theme.fg("muted", preview)
|
|
188
|
+
: theme.fg("muted", "(empty)"),
|
|
189
|
+
showCollapsed: true,
|
|
190
|
+
},
|
|
191
|
+
];
|
|
127
192
|
|
|
128
193
|
return new ToolBody({ fields }, options, theme);
|
|
129
194
|
}
|
|
130
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Extract the bounded process-output body from tool-result content text.
|
|
198
|
+
*
|
|
199
|
+
* Content is structured as:
|
|
200
|
+
* - a one-line header (`details.message`);
|
|
201
|
+
* - the bounded output body (already ANSI-stripped);
|
|
202
|
+
* - an optional truncation notice line;
|
|
203
|
+
* - an always-present complete-log footer.
|
|
204
|
+
*
|
|
205
|
+
* Only the body is returned. The renderer uses `details` for metadata,
|
|
206
|
+
* truncation state, and log paths; this function never re-parses stream
|
|
207
|
+
* labels, so a real log line containing `stderr:` cannot confuse it.
|
|
208
|
+
*
|
|
209
|
+
* Legacy content that lost its header through tail truncation is accepted: if
|
|
210
|
+
* the first line does not match the expected header, the whole content is
|
|
211
|
+
* treated as body up to the footer.
|
|
212
|
+
*/
|
|
213
|
+
function extractOutputBody(
|
|
214
|
+
contentText: string,
|
|
215
|
+
details: ProcessesDetails,
|
|
216
|
+
): string[] {
|
|
217
|
+
if (!contentText) return [];
|
|
218
|
+
|
|
219
|
+
const lines = contentText.split("\n");
|
|
220
|
+
const header = details.message;
|
|
221
|
+
|
|
222
|
+
let bodyStart = lines[0] === header ? 1 : 0;
|
|
223
|
+
// Skip the blank separator following the header.
|
|
224
|
+
if (bodyStart > 0 && lines[bodyStart] === "") {
|
|
225
|
+
bodyStart++;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const footerStart = findLastLineIndex(
|
|
229
|
+
lines,
|
|
230
|
+
(line) => line === LOG_FOOTER_MARKER,
|
|
231
|
+
);
|
|
232
|
+
let bodyEnd = footerStart >= 0 ? footerStart : lines.length;
|
|
233
|
+
|
|
234
|
+
// Exclude the running-guidance line, which sits between the body and the
|
|
235
|
+
// footer/notice. It is metadata, not process output.
|
|
236
|
+
const guidance = "Process is still running. Use watches instead of polling.";
|
|
237
|
+
const guidanceIndex = findLastLineIndex(
|
|
238
|
+
lines,
|
|
239
|
+
(line, index) => index < bodyEnd && line === guidance,
|
|
240
|
+
);
|
|
241
|
+
if (guidanceIndex >= bodyStart) {
|
|
242
|
+
bodyEnd = guidanceIndex;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Exclude a preceding blank line that separated body from the footer/notice.
|
|
246
|
+
while (bodyEnd > bodyStart && (lines[bodyEnd - 1] ?? "") === "") {
|
|
247
|
+
bodyEnd--;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return lines.slice(bodyStart, bodyEnd);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildTruncationSummary(truncation: TruncationDetails): string {
|
|
254
|
+
const partialNote = truncation.lastLinePartial ? " · partial final line" : "";
|
|
255
|
+
return `Preview truncated · ${truncation.outputLines}/${truncation.totalLines} lines${partialNote}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function findLastLineIndex(
|
|
259
|
+
lines: string[],
|
|
260
|
+
predicate: (line: string, index: number) => boolean,
|
|
261
|
+
): number {
|
|
262
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
263
|
+
if (predicate(lines[i] ?? "", i)) return i;
|
|
264
|
+
}
|
|
265
|
+
return -1;
|
|
266
|
+
}
|
|
267
|
+
|
|
131
268
|
export function executeOutput(
|
|
132
269
|
params: OutputParams,
|
|
133
270
|
manager: ProcessManager,
|
|
@@ -156,7 +293,7 @@ export function executeOutput(
|
|
|
156
293
|
};
|
|
157
294
|
}
|
|
158
295
|
|
|
159
|
-
const { defaultTailLines } = configLoader.getConfig().output;
|
|
296
|
+
const { defaultTailLines, maxOutputLines } = configLoader.getConfig().output;
|
|
160
297
|
const output = manager.getOutput(proc.id, defaultTailLines);
|
|
161
298
|
if (!output) {
|
|
162
299
|
const message = `Could not read output for "${proc.name}" (${proc.id})`;
|
|
@@ -175,95 +312,136 @@ export function executeOutput(
|
|
|
175
312
|
const stderrLines = output.stderr.length;
|
|
176
313
|
const message = `"${proc.name}" (${proc.id}) [${formatStatus(proc)}]: ${stdoutLines} stdout lines, ${stderrLines} stderr lines`;
|
|
177
314
|
|
|
178
|
-
// Build the
|
|
179
|
-
//
|
|
180
|
-
const
|
|
315
|
+
// Build the stripped body text. stdout/stderr stay local and are never
|
|
316
|
+
// persisted in `details`; only the bounded preview survives in `content`.
|
|
317
|
+
const bodyLines: string[] = [];
|
|
181
318
|
if (output.stdout.length > 0) {
|
|
182
|
-
|
|
183
|
-
|
|
319
|
+
bodyLines.push("stdout:");
|
|
320
|
+
bodyLines.push(...output.stdout.map(stripAnsi));
|
|
184
321
|
}
|
|
185
322
|
if (output.stderr.length > 0) {
|
|
186
|
-
|
|
187
|
-
|
|
323
|
+
if (bodyLines.length > 0) bodyLines.push("");
|
|
324
|
+
bodyLines.push("stderr:");
|
|
325
|
+
bodyLines.push(...output.stderr.map(stripAnsi));
|
|
188
326
|
}
|
|
189
327
|
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
328
|
+
const guidance =
|
|
329
|
+
output.status === "running"
|
|
330
|
+
? "Process is still running. Use watches instead of polling."
|
|
331
|
+
: null;
|
|
332
|
+
|
|
333
|
+
const { contentText, truncation } = buildBoundedOutput(
|
|
334
|
+
message,
|
|
335
|
+
bodyLines.join("\n"),
|
|
336
|
+
guidance,
|
|
337
|
+
logFiles,
|
|
338
|
+
maxOutputLines,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const details: ProcessesDetails = {
|
|
342
|
+
action: "output",
|
|
343
|
+
success: true,
|
|
344
|
+
message,
|
|
345
|
+
logFiles: logFiles
|
|
346
|
+
? {
|
|
347
|
+
stdoutFile: logFiles.stdoutFile,
|
|
348
|
+
stderrFile: logFiles.stderrFile,
|
|
349
|
+
}
|
|
350
|
+
: undefined,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
if (truncation.truncated) {
|
|
354
|
+
const { content: _content, ...rest } = truncation;
|
|
355
|
+
details.truncation = rest;
|
|
356
|
+
}
|
|
193
357
|
|
|
194
358
|
return {
|
|
195
359
|
content: [{ type: "text", text: contentText }],
|
|
196
|
-
details
|
|
197
|
-
action: "output",
|
|
198
|
-
success: true,
|
|
199
|
-
message,
|
|
200
|
-
output,
|
|
201
|
-
logFiles: logFiles
|
|
202
|
-
? {
|
|
203
|
-
stdoutFile: logFiles.stdoutFile,
|
|
204
|
-
stderrFile: logFiles.stderrFile,
|
|
205
|
-
}
|
|
206
|
-
: undefined,
|
|
207
|
-
},
|
|
360
|
+
details,
|
|
208
361
|
};
|
|
209
362
|
}
|
|
210
363
|
|
|
211
364
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
365
|
+
* Compose the bounded content text. The header, optional guidance, truncation
|
|
366
|
+
* notice, and complete-log footer live outside the truncation window, so the
|
|
367
|
+
* agent always receives log paths even when the body is truncated.
|
|
368
|
+
*
|
|
369
|
+
* The byte and line budgets apply to the composed+JSON-escaped result. If the
|
|
370
|
+
* first pass overflows (because JSON escaping expands control characters or
|
|
371
|
+
* the fixed metadata consumes the budget), the body budget is shrunk and the
|
|
372
|
+
* body re-truncated while keeping the newest output.
|
|
215
373
|
*/
|
|
216
|
-
function
|
|
217
|
-
|
|
374
|
+
function buildBoundedOutput(
|
|
375
|
+
header: string,
|
|
376
|
+
body: string,
|
|
377
|
+
guidance: string | null,
|
|
218
378
|
logFiles: { stdoutFile: string; stderrFile: string } | null,
|
|
219
379
|
maxLines: number,
|
|
220
|
-
): string {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const totalLines = lines.length;
|
|
224
|
-
|
|
225
|
-
if (totalLines <= maxLines && totalBytes <= MAX_BYTES) {
|
|
226
|
-
return text;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Work backwards, collecting lines that fit
|
|
230
|
-
const kept: string[] = [];
|
|
231
|
-
let keptBytes = 0;
|
|
232
|
-
let hitBytes = false;
|
|
380
|
+
): { contentText: string; truncation: TruncationResult } {
|
|
381
|
+
let maxBodyBytes = MAX_OUTPUT_BYTES;
|
|
382
|
+
let maxBodyLines = maxLines;
|
|
233
383
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
384
|
+
let truncation = truncateTail(body, {
|
|
385
|
+
maxBytes: maxBodyBytes,
|
|
386
|
+
maxLines: maxBodyLines,
|
|
387
|
+
});
|
|
388
|
+
let contentText = composeContent(header, truncation, guidance, logFiles);
|
|
389
|
+
|
|
390
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
391
|
+
const excessBytes =
|
|
392
|
+
Buffer.byteLength(contentText, "utf-8") - MAX_OUTPUT_BYTES;
|
|
393
|
+
const excessLines = countLines(contentText) - maxLines;
|
|
394
|
+
const excessJsonBytes =
|
|
395
|
+
Buffer.byteLength(JSON.stringify(contentText), "utf-8") -
|
|
396
|
+
MAX_OUTPUT_JSON_BYTES;
|
|
397
|
+
|
|
398
|
+
if (excessBytes <= 0 && excessLines <= 0 && excessJsonBytes <= 0) {
|
|
399
|
+
return { contentText, truncation };
|
|
400
|
+
}
|
|
238
401
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
402
|
+
maxBodyBytes = Math.max(0, maxBodyBytes - Math.max(0, excessBytes));
|
|
403
|
+
if (excessJsonBytes > 0) {
|
|
404
|
+
maxBodyBytes = Math.floor(maxBodyBytes / 2);
|
|
242
405
|
}
|
|
406
|
+
maxBodyLines = Math.max(1, maxBodyLines - Math.max(0, excessLines));
|
|
243
407
|
|
|
244
|
-
|
|
245
|
-
|
|
408
|
+
truncation = truncateTail(body, {
|
|
409
|
+
maxBytes: maxBodyBytes,
|
|
410
|
+
maxLines: maxBodyLines,
|
|
411
|
+
});
|
|
412
|
+
contentText = composeContent(header, truncation, guidance, logFiles);
|
|
246
413
|
}
|
|
247
414
|
|
|
248
|
-
|
|
415
|
+
// Pathological metadata can still consume the whole budget. Bound the final
|
|
416
|
+
// composed string so a session entry cannot grow unbounded.
|
|
417
|
+
const finalTruncation = truncateTail(contentText, {
|
|
418
|
+
maxBytes: MAX_OUTPUT_BYTES,
|
|
419
|
+
maxLines: maxLines,
|
|
420
|
+
});
|
|
421
|
+
return { contentText: finalTruncation.content, truncation };
|
|
422
|
+
}
|
|
249
423
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
424
|
+
function composeContent(
|
|
425
|
+
header: string,
|
|
426
|
+
truncation: TruncationResult,
|
|
427
|
+
guidance: string | null,
|
|
428
|
+
logFiles: { stdoutFile: string; stderrFile: string } | null,
|
|
429
|
+
): string {
|
|
430
|
+
const sections: string[] = [header, truncation.content];
|
|
255
431
|
|
|
256
|
-
if (
|
|
257
|
-
|
|
432
|
+
if (guidance) {
|
|
433
|
+
sections.push(guidance);
|
|
258
434
|
}
|
|
259
435
|
|
|
260
|
-
|
|
436
|
+
if (truncation.truncated) {
|
|
437
|
+
sections.push(formatTruncationNotice(truncation));
|
|
438
|
+
}
|
|
261
439
|
|
|
262
|
-
|
|
263
|
-
|
|
440
|
+
if (logFiles) {
|
|
441
|
+
sections.push(
|
|
442
|
+
`${LOG_FOOTER_MARKER}\nstdout=${logFiles.stdoutFile}\nstderr=${logFiles.stderrFile}]`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
264
445
|
|
|
265
|
-
|
|
266
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
267
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
268
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
446
|
+
return sections.filter((section) => section.length > 0).join("\n\n");
|
|
269
447
|
}
|