pi-rtk-optimizer 0.8.2 → 0.8.3
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/CHANGELOG.md +6 -0
- package/package.json +4 -4
- package/src/additional-coverage-test.ts +20 -2
- package/src/command-register.ts +31 -31
- package/src/command-rewriter-test.ts +39 -0
- package/src/command-rewriter.ts +3 -1
- package/src/config-modal-test.ts +1 -2
- package/src/config-store.ts +2 -2
- package/src/index-test.ts +1 -2
- package/src/output-compactor-test.ts +1 -2
- package/src/rtk-rewrite-provider.ts +111 -2
- package/src/test-helpers.ts +35 -0
- package/src/tool-execution-sanitizer.ts +82 -82
- package/src/types-shims.d.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.3] - 2026-06-16
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Added a runtime-agnostic `mock.module` shim so tests using `node:test` module mocking also pass under Bun's `bun:test` compatibility layer.
|
|
14
|
+
- Deep-cloned fallback default config objects in `config-store.ts` to prevent caller mutations from leaking into subsequent config loads.
|
|
15
|
+
|
|
10
16
|
## [0.8.2] - 2026-06-01
|
|
11
17
|
|
|
12
18
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-rtk-optimizer",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Pi extension that optimizes RTK command rewriting and tool output compaction for the coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"access": "public"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"esbuild": "0.28.
|
|
55
|
+
"esbuild": "0.28.1",
|
|
56
56
|
"typescript": "6.0.3"
|
|
57
57
|
},
|
|
58
58
|
"pi": {
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
]
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0",
|
|
65
|
-
"@earendil-works/pi-tui": "^0.74.0 || ^0.75.0"
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0",
|
|
65
|
+
"@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { mock } from "node:test";
|
|
4
3
|
|
|
5
4
|
import { clearOutputMetrics, getOutputMetricsSummary, trackOutputSavings } from "./output-metrics.ts";
|
|
6
|
-
import { runTest } from "./test-helpers.ts";
|
|
5
|
+
import { mock, runTest } from "./test-helpers.ts";
|
|
7
6
|
import { matchesCommandPatterns, normalizeCommandForDetection } from "./techniques/command-detection.ts";
|
|
8
7
|
import { compactPath } from "./techniques/path-utils.ts";
|
|
9
8
|
import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.ts";
|
|
@@ -143,6 +142,25 @@ runTest("config-store falls back to defaults when JSON is invalid", () => {
|
|
|
143
142
|
}
|
|
144
143
|
});
|
|
145
144
|
|
|
145
|
+
runTest("config-store malformed-file defaults are isolated from caller mutation", () => {
|
|
146
|
+
const tempPath = makeTempConfigPath();
|
|
147
|
+
cleanupFile(tempPath);
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
writeFileSync(tempPath, "{not valid json", "utf-8");
|
|
151
|
+
const firstLoad = loadRtkIntegrationConfig(tempPath);
|
|
152
|
+
firstLoad.config.outputCompaction.truncate.maxChars = 42_424;
|
|
153
|
+
firstLoad.config.outputCompaction.readCompaction.enabled = true;
|
|
154
|
+
|
|
155
|
+
const secondLoad = loadRtkIntegrationConfig(tempPath);
|
|
156
|
+
|
|
157
|
+
assert.equal(secondLoad.config.outputCompaction.truncate.maxChars, 12_000);
|
|
158
|
+
assert.equal(secondLoad.config.outputCompaction.readCompaction.enabled, false);
|
|
159
|
+
} finally {
|
|
160
|
+
cleanupFile(tempPath);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
146
164
|
runTest("output metrics summarize tracked savings and clear state", () => {
|
|
147
165
|
clearOutputMetrics();
|
|
148
166
|
assert.equal(getOutputMetricsSummary(), "RTK output compaction metrics: no data yet.");
|
package/src/command-register.ts
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { getRtkArgumentCompletions } from "./command-completions.js";
|
|
3
|
-
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
|
|
4
|
-
|
|
5
|
-
export interface RtkIntegrationController {
|
|
6
|
-
getConfig(): RtkIntegrationConfig;
|
|
7
|
-
setConfig(next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void;
|
|
8
|
-
getConfigPath(): string;
|
|
9
|
-
getRuntimeStatus(): RuntimeStatus;
|
|
10
|
-
refreshRuntimeStatus(): Promise<RuntimeStatus>;
|
|
11
|
-
getMetricsSummary(): string;
|
|
12
|
-
clearMetrics(): void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
let commandModalModulePromise: Promise<typeof import("./config-modal.js")> | undefined;
|
|
16
|
-
|
|
17
|
-
function loadCommandModalModule(): Promise<typeof import("./config-modal.js")> {
|
|
18
|
-
commandModalModulePromise ??= import("./config-modal.js");
|
|
19
|
-
return commandModalModulePromise;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function registerRtkIntegrationCommand(pi: ExtensionAPI, controller: RtkIntegrationController): void {
|
|
23
|
-
pi.registerCommand("rtk", {
|
|
24
|
-
description: "Configure RTK rewrite and output compaction integration",
|
|
25
|
-
getArgumentCompletions: getRtkArgumentCompletions,
|
|
26
|
-
handler: async (args, ctx) => {
|
|
27
|
-
const { handleRtkIntegrationCommand } = await loadCommandModalModule();
|
|
28
|
-
await handleRtkIntegrationCommand(args, ctx, controller);
|
|
29
|
-
},
|
|
30
|
-
});
|
|
31
|
-
}
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getRtkArgumentCompletions } from "./command-completions.js";
|
|
3
|
+
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface RtkIntegrationController {
|
|
6
|
+
getConfig(): RtkIntegrationConfig;
|
|
7
|
+
setConfig(next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void;
|
|
8
|
+
getConfigPath(): string;
|
|
9
|
+
getRuntimeStatus(): RuntimeStatus;
|
|
10
|
+
refreshRuntimeStatus(): Promise<RuntimeStatus>;
|
|
11
|
+
getMetricsSummary(): string;
|
|
12
|
+
clearMetrics(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let commandModalModulePromise: Promise<typeof import("./config-modal.js")> | undefined;
|
|
16
|
+
|
|
17
|
+
function loadCommandModalModule(): Promise<typeof import("./config-modal.js")> {
|
|
18
|
+
commandModalModulePromise ??= import("./config-modal.js");
|
|
19
|
+
return commandModalModulePromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function registerRtkIntegrationCommand(pi: ExtensionAPI, controller: RtkIntegrationController): void {
|
|
23
|
+
pi.registerCommand("rtk", {
|
|
24
|
+
description: "Configure RTK rewrite and output compaction integration",
|
|
25
|
+
getArgumentCompletions: getRtkArgumentCompletions,
|
|
26
|
+
handler: async (args, ctx) => {
|
|
27
|
+
const { handleRtkIntegrationCommand } = await loadCommandModalModule();
|
|
28
|
+
await handleRtkIntegrationCommand(args, ctx, controller);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -89,6 +89,26 @@ await runTest("already rtk unchanged", async () => {
|
|
|
89
89
|
assert.equal(decision.reason, "already_rtk");
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
await runTest("env-prefixed rtk command is treated as already RTK and never re-rewritten", async () => {
|
|
93
|
+
let execCallCount = 0;
|
|
94
|
+
const pi = {
|
|
95
|
+
exec: async () => {
|
|
96
|
+
execCallCount += 1;
|
|
97
|
+
return { code: 0, stdout: "rtk rtk status", stderr: "" };
|
|
98
|
+
},
|
|
99
|
+
} as unknown as ExtensionAPI;
|
|
100
|
+
|
|
101
|
+
const command = "CI=1 RTK_DB_PATH=/tmp/history.db rtk status";
|
|
102
|
+
const decision = await computeRewriteDecision(command, cloneDefaultConfig(), pi, {
|
|
103
|
+
executableResolution: { command: "rtk", resolver: "which" },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
assert.equal(decision.changed, false);
|
|
107
|
+
assert.equal(decision.rewrittenCommand, command);
|
|
108
|
+
assert.equal(decision.reason, "already_rtk");
|
|
109
|
+
assert.equal(execCallCount, 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
92
112
|
await runTest("rtk unsupported heredoc result leaves command unchanged", async () => {
|
|
93
113
|
const config = cloneDefaultConfig();
|
|
94
114
|
const decision = await computeRewriteDecision("cat <<EOF", config, createMockPi({ code: 1 }));
|
|
@@ -109,6 +129,25 @@ await runTest("quoted heredoc marker is delegated to RTK rewrite", async () => {
|
|
|
109
129
|
assert.equal(decision.reason, "ok");
|
|
110
130
|
});
|
|
111
131
|
|
|
132
|
+
await runTest("rg rewrite keeps the RTK ripgrep proxy instead of the grep proxy", async () => {
|
|
133
|
+
const config = cloneDefaultConfig();
|
|
134
|
+
const command = "cd /workspace && rg -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src";
|
|
135
|
+
const decision = await computeRewriteDecision(
|
|
136
|
+
command,
|
|
137
|
+
config,
|
|
138
|
+
createMockPi({
|
|
139
|
+
code: 3,
|
|
140
|
+
stdout: "cd /workspace && rtk grep -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src",
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
assert.equal(decision.changed, true);
|
|
144
|
+
assert.equal(
|
|
145
|
+
decision.rewrittenCommand,
|
|
146
|
+
"cd /workspace && rtk rg -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src",
|
|
147
|
+
);
|
|
148
|
+
assert.equal(decision.reason, "ok");
|
|
149
|
+
});
|
|
150
|
+
|
|
112
151
|
await runTest("legacy category toggles do not pre-filter RTK rewrite source of truth", async () => {
|
|
113
152
|
const config = { ...cloneDefaultConfig(), rewriteGitGithub: false };
|
|
114
153
|
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 3, stdout: "rtk git status" }));
|
package/src/command-rewriter.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveRtkRewrite, type RtkRewriteProviderOptions } from "./rtk-rewrite-provider.js";
|
|
2
|
+
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
|
|
2
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import type { RtkIntegrationConfig } from "./types.js";
|
|
4
5
|
|
|
@@ -21,7 +22,8 @@ export async function computeRewriteDecision(
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const trimmedStart = command.trimStart();
|
|
24
|
-
|
|
25
|
+
const effectiveCommand = splitLeadingEnvAssignments(trimmedStart).command.trimStart();
|
|
26
|
+
if (effectiveCommand === "rtk" || effectiveCommand.startsWith("rtk ")) {
|
|
25
27
|
return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "already_rtk" };
|
|
26
28
|
}
|
|
27
29
|
|
package/src/config-modal-test.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mock } from "node:test";
|
|
3
2
|
|
|
4
|
-
import { cloneDefaultConfig, runTest } from "./test-helpers.ts";
|
|
3
|
+
import { cloneDefaultConfig, mock, runTest } from "./test-helpers.ts";
|
|
5
4
|
|
|
6
5
|
mock.module("@earendil-works/pi-coding-agent", {
|
|
7
6
|
namedExports: {
|
package/src/config-store.ts
CHANGED
|
@@ -177,7 +177,7 @@ export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult
|
|
|
177
177
|
|
|
178
178
|
export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadResult {
|
|
179
179
|
if (!existsSync(configPath)) {
|
|
180
|
-
return { config:
|
|
180
|
+
return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
try {
|
|
@@ -187,7 +187,7 @@ export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadRe
|
|
|
187
187
|
} catch (error) {
|
|
188
188
|
const message = error instanceof Error ? error.message : String(error);
|
|
189
189
|
return {
|
|
190
|
-
config:
|
|
190
|
+
config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG),
|
|
191
191
|
warning: `Failed to parse ${configPath}: ${message}`,
|
|
192
192
|
};
|
|
193
193
|
}
|
package/src/index-test.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { mock } from "node:test";
|
|
4
3
|
|
|
5
|
-
import { cloneDefaultConfig, runTest } from "./test-helpers.ts";
|
|
4
|
+
import { cloneDefaultConfig, mock, runTest } from "./test-helpers.ts";
|
|
6
5
|
|
|
7
6
|
const TEST_AGENT_DIR = "/tmp/.pi/agent";
|
|
8
7
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
|
|
3
|
+
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
|
|
3
4
|
|
|
4
5
|
export interface RtkRewriteProviderResult {
|
|
5
6
|
changed: boolean;
|
|
@@ -29,6 +30,113 @@ function normalizeOptions(optionsOrTimeout: number | RtkRewriteProviderOptions):
|
|
|
29
30
|
return optionsOrTimeout;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
interface ShellSegmentSplit {
|
|
34
|
+
segments: string[];
|
|
35
|
+
separators: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function splitTopLevelShellSegments(command: string): ShellSegmentSplit {
|
|
39
|
+
const segments: string[] = [];
|
|
40
|
+
const separators: string[] = [];
|
|
41
|
+
let quote: '"' | "'" | "`" | null = null;
|
|
42
|
+
let escaped = false;
|
|
43
|
+
let segmentStart = 0;
|
|
44
|
+
|
|
45
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
46
|
+
const character = command[index] ?? "";
|
|
47
|
+
const nextCharacter = command[index + 1] ?? "";
|
|
48
|
+
const previousCharacter = index > 0 ? (command[index - 1] ?? "") : "";
|
|
49
|
+
|
|
50
|
+
if (escaped) {
|
|
51
|
+
escaped = false;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (quote !== null) {
|
|
56
|
+
if (character === "\\" && quote !== "'") {
|
|
57
|
+
escaped = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (character === quote) {
|
|
61
|
+
quote = null;
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (character === "\\") {
|
|
67
|
+
escaped = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (character === '"' || character === "'" || character === "`") {
|
|
72
|
+
quote = character;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const twoCharacterOperator = `${character}${nextCharacter}`;
|
|
77
|
+
const separator =
|
|
78
|
+
twoCharacterOperator === "&&" || twoCharacterOperator === "||" || twoCharacterOperator === "|&"
|
|
79
|
+
? twoCharacterOperator
|
|
80
|
+
: character === ";" || (character === "|" && previousCharacter !== ">")
|
|
81
|
+
? character
|
|
82
|
+
: null;
|
|
83
|
+
|
|
84
|
+
if (separator === null) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
segments.push(command.slice(segmentStart, index));
|
|
89
|
+
separators.push(separator);
|
|
90
|
+
index += separator.length - 1;
|
|
91
|
+
segmentStart = index + 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
segments.push(command.slice(segmentStart));
|
|
95
|
+
return { segments, separators };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function startsWithRipgrepCommand(segment: string): boolean {
|
|
99
|
+
const trimmed = segment.trimStart();
|
|
100
|
+
const effectiveCommand = splitLeadingEnvAssignments(trimmed).command.trimStart();
|
|
101
|
+
return /^rg(?=\s|$)/u.test(effectiveCommand);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function replaceRtkGrepProxyWithRtkRg(segment: string): string {
|
|
105
|
+
const leadingWhitespace = segment.match(/^\s*/u)?.[0] ?? "";
|
|
106
|
+
const withoutLeadingWhitespace = segment.slice(leadingWhitespace.length);
|
|
107
|
+
const { envPrefix, command } = splitLeadingEnvAssignments(withoutLeadingWhitespace);
|
|
108
|
+
const nextCommand = command.replace(/^(rtk)(\s+)grep(?=\s|$)/u, "$1$2rg");
|
|
109
|
+
return nextCommand === command ? segment : `${leadingWhitespace}${envPrefix}${nextCommand}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeRipgrepRewrite(originalCommand: string, rewrittenCommand: string): string {
|
|
113
|
+
const original = splitTopLevelShellSegments(originalCommand);
|
|
114
|
+
const rewritten = splitTopLevelShellSegments(rewrittenCommand);
|
|
115
|
+
if (original.segments.length !== rewritten.segments.length) {
|
|
116
|
+
return rewrittenCommand;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let changed = false;
|
|
120
|
+
const rewrittenSegments = rewritten.segments.map((segment, index) => {
|
|
121
|
+
if (!startsWithRipgrepCommand(original.segments[index] ?? "")) {
|
|
122
|
+
return segment;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const nextSegment = replaceRtkGrepProxyWithRtkRg(segment);
|
|
126
|
+
changed ||= nextSegment !== segment;
|
|
127
|
+
return nextSegment;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!changed) {
|
|
131
|
+
return rewrittenCommand;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return rewrittenSegments.reduce((accumulator, segment, index) => {
|
|
135
|
+
const separator = rewritten.separators[index - 1];
|
|
136
|
+
return separator === undefined ? segment : `${accumulator}${separator}${segment}`;
|
|
137
|
+
}, "");
|
|
138
|
+
}
|
|
139
|
+
|
|
32
140
|
export async function resolveRtkRewrite(
|
|
33
141
|
pi: ExtensionAPI,
|
|
34
142
|
command: string,
|
|
@@ -76,8 +184,8 @@ export async function resolveRtkRewrite(
|
|
|
76
184
|
}
|
|
77
185
|
|
|
78
186
|
if (result.code === 0 || result.code === 3) {
|
|
79
|
-
const
|
|
80
|
-
if (!
|
|
187
|
+
const rewrittenOutput = result.stdout?.trim();
|
|
188
|
+
if (!rewrittenOutput) {
|
|
81
189
|
return {
|
|
82
190
|
changed: false,
|
|
83
191
|
originalCommand: command,
|
|
@@ -87,6 +195,7 @@ export async function resolveRtkRewrite(
|
|
|
87
195
|
executableResolution,
|
|
88
196
|
};
|
|
89
197
|
}
|
|
198
|
+
const rewritten = normalizeRipgrepRewrite(command, rewrittenOutput);
|
|
90
199
|
if (rewritten === command) {
|
|
91
200
|
return {
|
|
92
201
|
changed: false,
|
package/src/test-helpers.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { mock as nodeTestMockRaw } from "node:test";
|
|
2
|
+
|
|
1
3
|
import { DEFAULT_RTK_INTEGRATION_CONFIG, type RtkIntegrationConfig } from "./types.ts";
|
|
2
4
|
|
|
3
5
|
type TestResult = void | Promise<void>;
|
|
6
|
+
type MockModuleOptions = { namedExports?: Record<string, unknown>; defaultExport?: unknown };
|
|
4
7
|
|
|
5
8
|
function isPromiseLike(value: TestResult): value is Promise<void> {
|
|
6
9
|
return Boolean(value && typeof (value as Promise<void>).then === "function");
|
|
@@ -21,3 +24,35 @@ export function runTest(name: string, testFn: () => TestResult): TestResult {
|
|
|
21
24
|
export function cloneDefaultConfig(): RtkIntegrationConfig {
|
|
22
25
|
return structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG);
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
// Runtime-agnostic module-mocking helper. The tests use the node:test-shaped
|
|
29
|
+
// API (`mock.module(specifier, { namedExports, defaultExport })`). Bun's
|
|
30
|
+
// implementation of `node:test` does not expose `mock.module`, but `bun:test`
|
|
31
|
+
// provides an equivalent that accepts a factory function. We detect the
|
|
32
|
+
// capability and adapt the options form to the factory form when needed.
|
|
33
|
+
const nodeTestMock = nodeTestMockRaw as unknown as { module?: unknown };
|
|
34
|
+
|
|
35
|
+
let mockModuleImpl: (specifier: string, options: MockModuleOptions) => void;
|
|
36
|
+
|
|
37
|
+
if (typeof nodeTestMock.module === "function") {
|
|
38
|
+
mockModuleImpl = nodeTestMock.module as (specifier: string, options: MockModuleOptions) => void;
|
|
39
|
+
} else {
|
|
40
|
+
const bunTest = (await import("bun:test")) as unknown as {
|
|
41
|
+
mock: { module: (specifier: string, factory: () => Record<string, unknown>) => void };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
mockModuleImpl = (specifier, options) => {
|
|
45
|
+
bunTest.mock.module(specifier, () => {
|
|
46
|
+
const moduleExports: Record<string, unknown> = {};
|
|
47
|
+
if (options.defaultExport !== undefined) {
|
|
48
|
+
moduleExports.default = options.defaultExport;
|
|
49
|
+
}
|
|
50
|
+
if (options.namedExports) {
|
|
51
|
+
Object.assign(moduleExports, options.namedExports);
|
|
52
|
+
}
|
|
53
|
+
return moduleExports;
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const mock = { module: mockModuleImpl };
|
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
import { toRecord } from "./record-utils.js";
|
|
2
|
-
import { stripAnsiFast } from "./techniques/ansi.js";
|
|
3
|
-
import { sanitizeRtkEmojiOutput } from "./techniques/emoji.js";
|
|
4
|
-
import { stripRtkHookWarnings } from "./techniques/rtk.js";
|
|
5
|
-
|
|
6
|
-
interface ToolResultTextBlock {
|
|
7
|
-
type: string;
|
|
8
|
-
text?: string;
|
|
9
|
-
[key: string]: unknown;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface StreamingBashExecutionSanitizationResult {
|
|
13
|
-
changed: boolean;
|
|
14
|
-
result: unknown;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function sanitizeStreamingBashText(text: string, command: string | undefined | null): string {
|
|
18
|
-
let nextText = stripAnsiFast(text);
|
|
19
|
-
|
|
20
|
-
const withoutRtkHookWarnings = stripRtkHookWarnings(nextText, command);
|
|
21
|
-
if (withoutRtkHookWarnings !== null) {
|
|
22
|
-
nextText = withoutRtkHookWarnings;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const withoutRtkEmoji = sanitizeRtkEmojiOutput(nextText, command);
|
|
26
|
-
if (withoutRtkEmoji !== null) {
|
|
27
|
-
nextText = withoutRtkEmoji;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return nextText;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Returns a sanitized shallow copy of streamed bash result blocks before the
|
|
35
|
-
* TUI renders them so RTK self-diagnostics never flash in partial or final
|
|
36
|
-
* tool output. The input object is not mutated.
|
|
37
|
-
*/
|
|
38
|
-
export function sanitizeStreamingBashExecutionResult(
|
|
39
|
-
result: unknown,
|
|
40
|
-
command: string | undefined | null,
|
|
41
|
-
): StreamingBashExecutionSanitizationResult {
|
|
42
|
-
const resultRecord = toRecord(result);
|
|
43
|
-
const sourceContent = Array.isArray(resultRecord.content) ? resultRecord.content : null;
|
|
44
|
-
if (!sourceContent || sourceContent.length === 0) {
|
|
45
|
-
return { changed: false, result };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
let changed = false;
|
|
49
|
-
const nextContent = sourceContent.map((block) => {
|
|
50
|
-
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
|
51
|
-
return block;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const contentBlock = block as ToolResultTextBlock;
|
|
55
|
-
if (contentBlock.type !== "text" || typeof contentBlock.text !== "string") {
|
|
56
|
-
return block;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const sanitizedText = sanitizeStreamingBashText(contentBlock.text, command);
|
|
60
|
-
if (sanitizedText === contentBlock.text) {
|
|
61
|
-
return block;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
changed = true;
|
|
65
|
-
return {
|
|
66
|
-
...contentBlock,
|
|
67
|
-
text: sanitizedText,
|
|
68
|
-
};
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
if (!changed) {
|
|
72
|
-
return { changed: false, result };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
changed: true,
|
|
77
|
-
result: {
|
|
78
|
-
...resultRecord,
|
|
79
|
-
content: nextContent,
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
}
|
|
1
|
+
import { toRecord } from "./record-utils.js";
|
|
2
|
+
import { stripAnsiFast } from "./techniques/ansi.js";
|
|
3
|
+
import { sanitizeRtkEmojiOutput } from "./techniques/emoji.js";
|
|
4
|
+
import { stripRtkHookWarnings } from "./techniques/rtk.js";
|
|
5
|
+
|
|
6
|
+
interface ToolResultTextBlock {
|
|
7
|
+
type: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StreamingBashExecutionSanitizationResult {
|
|
13
|
+
changed: boolean;
|
|
14
|
+
result: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sanitizeStreamingBashText(text: string, command: string | undefined | null): string {
|
|
18
|
+
let nextText = stripAnsiFast(text);
|
|
19
|
+
|
|
20
|
+
const withoutRtkHookWarnings = stripRtkHookWarnings(nextText, command);
|
|
21
|
+
if (withoutRtkHookWarnings !== null) {
|
|
22
|
+
nextText = withoutRtkHookWarnings;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const withoutRtkEmoji = sanitizeRtkEmojiOutput(nextText, command);
|
|
26
|
+
if (withoutRtkEmoji !== null) {
|
|
27
|
+
nextText = withoutRtkEmoji;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return nextText;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns a sanitized shallow copy of streamed bash result blocks before the
|
|
35
|
+
* TUI renders them so RTK self-diagnostics never flash in partial or final
|
|
36
|
+
* tool output. The input object is not mutated.
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeStreamingBashExecutionResult(
|
|
39
|
+
result: unknown,
|
|
40
|
+
command: string | undefined | null,
|
|
41
|
+
): StreamingBashExecutionSanitizationResult {
|
|
42
|
+
const resultRecord = toRecord(result);
|
|
43
|
+
const sourceContent = Array.isArray(resultRecord.content) ? resultRecord.content : null;
|
|
44
|
+
if (!sourceContent || sourceContent.length === 0) {
|
|
45
|
+
return { changed: false, result };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let changed = false;
|
|
49
|
+
const nextContent = sourceContent.map((block) => {
|
|
50
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
|
51
|
+
return block;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const contentBlock = block as ToolResultTextBlock;
|
|
55
|
+
if (contentBlock.type !== "text" || typeof contentBlock.text !== "string") {
|
|
56
|
+
return block;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sanitizedText = sanitizeStreamingBashText(contentBlock.text, command);
|
|
60
|
+
if (sanitizedText === contentBlock.text) {
|
|
61
|
+
return block;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
changed = true;
|
|
65
|
+
return {
|
|
66
|
+
...contentBlock,
|
|
67
|
+
text: sanitizedText,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!changed) {
|
|
72
|
+
return { changed: false, result };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
changed: true,
|
|
77
|
+
result: {
|
|
78
|
+
...resultRecord,
|
|
79
|
+
content: nextContent,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/types-shims.d.ts
CHANGED
|
@@ -168,6 +168,12 @@ declare module "node:test" {
|
|
|
168
168
|
};
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
declare module "bun:test" {
|
|
172
|
+
export const mock: {
|
|
173
|
+
module(specifier: string, factory: () => Record<string, unknown>): void;
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
171
177
|
declare const process: {
|
|
172
178
|
platform: string;
|
|
173
179
|
env: Record<string, string | undefined>;
|