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 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.2",
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.0",
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.");
@@ -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" }));
@@ -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
- if (trimmedStart === "rtk" || trimmedStart.startsWith("rtk ")) {
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
 
@@ -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: {
@@ -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: { ...DEFAULT_RTK_INTEGRATION_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: { ...DEFAULT_RTK_INTEGRATION_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,7 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
- import { mock } from "node:test";
3
2
 
4
- import { runTest } from "./test-helpers.ts";
3
+ import { mock, runTest } from "./test-helpers.ts";
5
4
 
6
5
  mock.module("@earendil-works/pi-coding-agent", {
7
6
  namedExports: {
@@ -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 rewritten = result.stdout?.trim();
80
- if (!rewritten) {
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,
@@ -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
+ }
@@ -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>;