pi-ui-extension 1.1.0
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/README.md +43 -0
- package/extensions/ctx/README.md +35 -0
- package/extensions/ctx/activity.ts +226 -0
- package/extensions/ctx/data.test.ts +65 -0
- package/extensions/ctx/data.ts +230 -0
- package/extensions/ctx/panel.test.ts +103 -0
- package/extensions/ctx/panel.ts +185 -0
- package/extensions/ctx/tools.test.ts +64 -0
- package/extensions/fabric-detection.test.ts +15 -0
- package/extensions/fabric-detection.ts +7 -0
- package/extensions/footer/index.ts +62 -0
- package/extensions/index.ts +577 -0
- package/extensions/mcp/integration.test.ts +64 -0
- package/extensions/mcp/resources.test.ts +27 -0
- package/extensions/mcp/resources.ts +31 -0
- package/extensions/resource-dialog.test.ts +45 -0
- package/extensions/session/mutations.test.ts +30 -0
- package/extensions/session/mutations.ts +17 -0
- package/extensions/session/panel.test.ts +238 -0
- package/extensions/session/panel.ts +461 -0
- package/extensions/sidebar/default-panels.test.ts +97 -0
- package/extensions/sidebar/default-panels.ts +155 -0
- package/extensions/sidebar/index.ts +212 -0
- package/extensions/sidebar/layout.test.ts +68 -0
- package/extensions/sidebar/layout.ts +75 -0
- package/extensions/sidebar/panel.ts +49 -0
- package/extensions/sidebar/registry.ts +35 -0
- package/extensions/sidebar/resize.test.ts +71 -0
- package/extensions/sidebar/resize.ts +154 -0
- package/extensions/tool-view/controller.test.ts +57 -0
- package/extensions/tool-view/controller.ts +519 -0
- package/extensions/tool-view/panel.test.ts +73 -0
- package/extensions/tool-view/panel.ts +221 -0
- package/extensions/tool-view/trust.test.ts +41 -0
- package/extensions/tool-view/types.ts +6 -0
- package/extensions/tool-view/usage.test.ts +231 -0
- package/extensions/tool-view/usage.ts +303 -0
- package/extensions/tool-view/wheel.test.ts +32 -0
- package/package.json +53 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
truncateToWidth,
|
|
4
|
+
visibleWidth,
|
|
5
|
+
type Component,
|
|
6
|
+
type TUI,
|
|
7
|
+
type TuiInputListener,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import { panelBottom, panelContent, panelTop } from "../sidebar/panel.ts";
|
|
10
|
+
import { installPriorityInputListener, parseSgrMouseEvent } from "../sidebar/resize.ts";
|
|
11
|
+
import { TOOL_VIEW_MODES, type ToolViewMode } from "./types.ts";
|
|
12
|
+
import type { ToolUsageCount, ToolUsageKind, ToolUsageSnapshot } from "./usage.ts";
|
|
13
|
+
|
|
14
|
+
export type ToolViewPanelOptions = {
|
|
15
|
+
readonly tui: TUI;
|
|
16
|
+
readonly getDesiredWidth: () => number;
|
|
17
|
+
readonly getMode: () => ToolViewMode | undefined;
|
|
18
|
+
readonly getUsage: () => ToolUsageSnapshot | undefined;
|
|
19
|
+
readonly onSelect: (mode: ToolViewMode) => void;
|
|
20
|
+
readonly showModeSelector?: () => boolean;
|
|
21
|
+
readonly showBottomBorder?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type LayoutRect = { readonly x: number; readonly y: number; readonly width: number; readonly height: number };
|
|
25
|
+
type LayoutBox = {
|
|
26
|
+
readonly component: Component;
|
|
27
|
+
readonly rect: LayoutRect;
|
|
28
|
+
readonly clip: LayoutRect;
|
|
29
|
+
readonly children: readonly LayoutBox[];
|
|
30
|
+
};
|
|
31
|
+
type LayoutTui = TUI & { readonly currentLayout?: { readonly root: LayoutBox } };
|
|
32
|
+
|
|
33
|
+
function contains(rect: LayoutRect, x: number, y: number): boolean {
|
|
34
|
+
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findLayoutBox(box: LayoutBox, component: Component): LayoutBox | undefined {
|
|
38
|
+
if (box.component === component) return box;
|
|
39
|
+
for (const child of box.children) {
|
|
40
|
+
const match = findLayoutBox(child, component);
|
|
41
|
+
if (match) return match;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function modeLabel(mode: ToolViewMode): string {
|
|
47
|
+
switch (mode) {
|
|
48
|
+
case "normal": return "Normal";
|
|
49
|
+
case "compact": return "Compact";
|
|
50
|
+
case "hidden": return "Hidden";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function centerText(text: string, width: number): string {
|
|
55
|
+
const clipped = truncateToWidth(text, Math.max(0, width), "");
|
|
56
|
+
const padding = Math.max(0, width - visibleWidth(clipped));
|
|
57
|
+
const left = Math.floor(padding / 2);
|
|
58
|
+
return `${" ".repeat(left)}${clipped}${" ".repeat(padding - left)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function usageText(name: string, exchange: number, session: number, width: number): string {
|
|
62
|
+
const value = `${exchange}/${session}`;
|
|
63
|
+
const nameWidth = Math.max(0, width - visibleWidth(value) - 1);
|
|
64
|
+
if (nameWidth === 0) return truncateToWidth(value, width, "");
|
|
65
|
+
const clippedName = truncateToWidth(name, nameWidth, "…");
|
|
66
|
+
const gap = " ".repeat(Math.max(1, width - visibleWidth(clippedName) - visibleWidth(value)));
|
|
67
|
+
return `${clippedName}${gap}${value}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function groupUsageCounts(counts: readonly ToolUsageCount[]): Readonly<Record<ToolUsageKind, readonly ToolUsageCount[]>> {
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
tool: Object.freeze(counts.filter((count) => count.kind === "tool")),
|
|
73
|
+
mcp: Object.freeze(counts.filter((count) => count.kind === "mcp")),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Clickable single-row selector for tool transcript rendering mode. */
|
|
78
|
+
export class ToolViewPanel implements Component {
|
|
79
|
+
private pressedMode: ToolViewMode | undefined;
|
|
80
|
+
private disposed = false;
|
|
81
|
+
private readonly uninstallInput: () => void;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
private readonly theme: Theme,
|
|
85
|
+
private readonly options: ToolViewPanelOptions,
|
|
86
|
+
) {
|
|
87
|
+
const listener: TuiInputListener = (data) => this.handleMouseInput(data);
|
|
88
|
+
this.uninstallInput = installPriorityInputListener(options.tui, listener);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private getLayoutBox(): LayoutBox | undefined {
|
|
92
|
+
const root = (this.options.tui as LayoutTui).currentLayout?.root;
|
|
93
|
+
return root ? findLayoutBox(root, this) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private modeAt(x: number, y: number): ToolViewMode | undefined {
|
|
97
|
+
if (this.options.showModeSelector?.() === false) return undefined;
|
|
98
|
+
const box = this.getLayoutBox();
|
|
99
|
+
if (!box || !contains(box.clip, x, y) || !contains(box.rect, x, y)) return undefined;
|
|
100
|
+
if (y - box.rect.y !== 1) return undefined;
|
|
101
|
+
|
|
102
|
+
const contentWidth = Math.max(1, Math.min(box.rect.width, this.options.getDesiredWidth()) - 4);
|
|
103
|
+
const contentColumn = x - box.rect.x - 2;
|
|
104
|
+
if (contentColumn < 0 || contentColumn >= contentWidth) return undefined;
|
|
105
|
+
|
|
106
|
+
const index = Math.min(
|
|
107
|
+
TOOL_VIEW_MODES.length - 1,
|
|
108
|
+
Math.floor(contentColumn * TOOL_VIEW_MODES.length / contentWidth),
|
|
109
|
+
);
|
|
110
|
+
return TOOL_VIEW_MODES[index];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private isInsidePanel(x: number, y: number): boolean {
|
|
114
|
+
const box = this.getLayoutBox();
|
|
115
|
+
return Boolean(box && contains(box.clip, x, y) && contains(box.rect, x, y));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private handleMouseInput(data: string): { consume: true } | undefined {
|
|
119
|
+
const mouse = parseSgrMouseEvent(data);
|
|
120
|
+
// Let the enclosing ScrollView receive wheel events before any click handling.
|
|
121
|
+
if (!mouse || (mouse.button & 64) !== 0) return undefined;
|
|
122
|
+
|
|
123
|
+
const x = mouse.x - 1;
|
|
124
|
+
const y = mouse.y - 1;
|
|
125
|
+
const inside = this.isInsidePanel(x, y);
|
|
126
|
+
const isLeft = (mouse.button & 3) === 0;
|
|
127
|
+
|
|
128
|
+
if (mouse.release) {
|
|
129
|
+
const pressed = this.pressedMode;
|
|
130
|
+
if (!pressed) return undefined;
|
|
131
|
+
this.pressedMode = undefined;
|
|
132
|
+
const released = this.modeAt(x, y);
|
|
133
|
+
if (released === pressed) {
|
|
134
|
+
queueMicrotask(() => {
|
|
135
|
+
const currentMode = this.options.getMode();
|
|
136
|
+
if (!this.disposed && currentMode !== undefined && currentMode !== released) {
|
|
137
|
+
this.options.onSelect(released);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
this.options.tui.requestRender();
|
|
142
|
+
return { consume: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (mouse.motion) {
|
|
146
|
+
if (!this.pressedMode) return undefined;
|
|
147
|
+
if (this.modeAt(x, y) !== this.pressedMode) {
|
|
148
|
+
this.pressedMode = undefined;
|
|
149
|
+
this.options.tui.requestRender();
|
|
150
|
+
}
|
|
151
|
+
return { consume: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!inside) return undefined;
|
|
155
|
+
if (!isLeft) return undefined;
|
|
156
|
+
|
|
157
|
+
if (this.options.getMode() === undefined) return { consume: true };
|
|
158
|
+
this.pressedMode = this.modeAt(x, y);
|
|
159
|
+
if (this.pressedMode) this.options.tui.requestRender();
|
|
160
|
+
return this.pressedMode ? { consume: true } : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
render(width: number): string[] {
|
|
164
|
+
const renderWidth = Math.max(1, Math.min(width, this.options.getDesiredWidth()));
|
|
165
|
+
const contentWidth = Math.max(1, renderWidth - 4);
|
|
166
|
+
const showModeSelector = this.options.showModeSelector?.() !== false;
|
|
167
|
+
const currentMode = this.options.getMode();
|
|
168
|
+
const connected = currentMode !== undefined;
|
|
169
|
+
const baseWidth = Math.floor(contentWidth / TOOL_VIEW_MODES.length);
|
|
170
|
+
const remainder = contentWidth % TOOL_VIEW_MODES.length;
|
|
171
|
+
const body = TOOL_VIEW_MODES.map((mode, index) => {
|
|
172
|
+
const selected = mode === currentMode;
|
|
173
|
+
const pressed = mode === this.pressedMode;
|
|
174
|
+
const marker = !connected ? "·" : selected ? "●" : "○";
|
|
175
|
+
const segmentWidth = baseWidth + (index < remainder ? 1 : 0);
|
|
176
|
+
const text = centerText(`${marker} ${modeLabel(mode)}`, segmentWidth);
|
|
177
|
+
return pressed
|
|
178
|
+
? this.theme.fg("warning", text)
|
|
179
|
+
: selected
|
|
180
|
+
? this.theme.fg("success", text)
|
|
181
|
+
: this.theme.fg("dim", text);
|
|
182
|
+
}).join("");
|
|
183
|
+
const selectorBody = showModeSelector
|
|
184
|
+
? body
|
|
185
|
+
: this.theme.fg("dim", centerText("View controlled by Fabric", contentWidth));
|
|
186
|
+
const usage = this.options.getUsage();
|
|
187
|
+
const grouped = groupUsageCounts(usage?.counts ?? []);
|
|
188
|
+
const usageRows: string[] = [];
|
|
189
|
+
const appendGroup = (label: "TOOLS" | "MCP", counts: readonly ToolUsageCount[]): void => {
|
|
190
|
+
if (counts.length === 0) return;
|
|
191
|
+
usageRows.push(panelContent(this.theme, label, "accent", contentWidth));
|
|
192
|
+
for (const count of counts) {
|
|
193
|
+
usageRows.push(panelContent(
|
|
194
|
+
this.theme,
|
|
195
|
+
usageText(count.name, count.exchange, count.session, contentWidth),
|
|
196
|
+
count.exchange > 0 ? "accent" : "dim",
|
|
197
|
+
contentWidth,
|
|
198
|
+
));
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
appendGroup("TOOLS", grouped.tool);
|
|
202
|
+
appendGroup("MCP", grouped.mcp);
|
|
203
|
+
|
|
204
|
+
const lines = [
|
|
205
|
+
panelTop(this.theme, "TOOL VIEW", renderWidth),
|
|
206
|
+
panelContent(this.theme, selectorBody, "dim", contentWidth),
|
|
207
|
+
...usageRows,
|
|
208
|
+
];
|
|
209
|
+
if (this.options.showBottomBorder !== false) lines.push(panelBottom(this.theme, renderWidth));
|
|
210
|
+
return lines.map((line) => truncateToWidth(line, renderWidth, ""));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
invalidate(): void {}
|
|
214
|
+
|
|
215
|
+
dispose(): void {
|
|
216
|
+
if (this.disposed) return;
|
|
217
|
+
this.disposed = true;
|
|
218
|
+
this.pressedMode = undefined;
|
|
219
|
+
this.uninstallInput();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { installToolViewController } from "./controller.ts";
|
|
7
|
+
|
|
8
|
+
test("managed bash honors trust changes and reloads project shell settings", async () => {
|
|
9
|
+
const directory = await mkdtemp(join(tmpdir(), "tool-view-trust-"));
|
|
10
|
+
const tools = new Map<string, any>();
|
|
11
|
+
const pi = { registerTool(tool: any) { tools.set(tool.name, tool); }, registerCommand() {}, registerShortcut() {}, on() {} } as any;
|
|
12
|
+
let trusted = false;
|
|
13
|
+
const ctx = {
|
|
14
|
+
cwd: directory,
|
|
15
|
+
isProjectTrusted: () => trusted,
|
|
16
|
+
sessionManager: { getSessionId: () => "trust-test", getSessionFile: () => undefined },
|
|
17
|
+
};
|
|
18
|
+
const run = async () => {
|
|
19
|
+
const result = await tools.get("bash").execute("test", { command: "echo COMMAND_MARKER", timeout: 5 }, undefined, undefined, ctx);
|
|
20
|
+
return result.content.map((item: any) => item.text ?? "").join("\n");
|
|
21
|
+
};
|
|
22
|
+
try {
|
|
23
|
+
await mkdir(join(directory, ".pi"));
|
|
24
|
+
const settings = join(directory, ".pi", "settings.json");
|
|
25
|
+
await writeFile(settings, JSON.stringify({ shellCommandPrefix: "echo PROJECT_PREFIX;" }));
|
|
26
|
+
await installToolViewController(pi, { modeFile: join(directory, "mode.json") });
|
|
27
|
+
assert.doesNotMatch(await run(), /PROJECT_PREFIX/);
|
|
28
|
+
trusted = true;
|
|
29
|
+
assert.match(await run(), /PROJECT_PREFIX/);
|
|
30
|
+
trusted = false;
|
|
31
|
+
assert.doesNotMatch(await run(), /PROJECT_PREFIX/);
|
|
32
|
+
await writeFile(settings, JSON.stringify({ shellPath: join(directory, "missing-shell") }));
|
|
33
|
+
assert.match(await run(), /COMMAND_MARKER/);
|
|
34
|
+
trusted = true;
|
|
35
|
+
await assert.rejects(run());
|
|
36
|
+
await writeFile(settings, JSON.stringify({ shellCommandPrefix: "echo UPDATED_PREFIX;" }));
|
|
37
|
+
assert.match(await run(), /UPDATED_PREFIX/);
|
|
38
|
+
} finally {
|
|
39
|
+
await rm(directory, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const TOOL_VIEW_MODES = ["normal", "compact", "hidden"] as const;
|
|
2
|
+
export type ToolViewMode = (typeof TOOL_VIEW_MODES)[number];
|
|
3
|
+
|
|
4
|
+
export function isToolViewMode(value: unknown): value is ToolViewMode {
|
|
5
|
+
return typeof value === "string" && TOOL_VIEW_MODES.includes(value as ToolViewMode);
|
|
6
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { ToolExecutionEndEvent } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { createToolUsageTracker, toolNamesFromBranch } from "./usage.ts";
|
|
5
|
+
|
|
6
|
+
function ended(toolName: string, details: unknown = {}): ToolExecutionEndEvent {
|
|
7
|
+
return {
|
|
8
|
+
type: "tool_execution_end",
|
|
9
|
+
toolCallId: `${toolName}-call`,
|
|
10
|
+
toolName,
|
|
11
|
+
result: { content: [], details },
|
|
12
|
+
isError: false,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("tracks top-level tools for the current question and session", () => {
|
|
17
|
+
const tracker = createToolUsageTracker(["read", "read", "bash"]);
|
|
18
|
+
tracker.startExchange();
|
|
19
|
+
tracker.record(ended("read"));
|
|
20
|
+
// Multiple internal LLM turns in one answer must continue accumulating.
|
|
21
|
+
tracker.record(ended("read"));
|
|
22
|
+
|
|
23
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
24
|
+
{ name: "read", kind: "tool", exchange: 2, session: 4 },
|
|
25
|
+
{ name: "bash", kind: "tool", exchange: 0, session: 1 },
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
tracker.startExchange();
|
|
29
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
30
|
+
{ name: "read", kind: "tool", exchange: 0, session: 4 },
|
|
31
|
+
{ name: "bash", kind: "tool", exchange: 0, session: 1 },
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("counts nested Fabric trace operations as their actual tools", () => {
|
|
36
|
+
const tracker = createToolUsageTracker();
|
|
37
|
+
tracker.startExchange();
|
|
38
|
+
tracker.record({ ...ended("fabric_exec", {
|
|
39
|
+
success: true,
|
|
40
|
+
trace: {
|
|
41
|
+
kind: "pi-fabric.execution",
|
|
42
|
+
version: 1,
|
|
43
|
+
outcome: "succeeded",
|
|
44
|
+
phases: [],
|
|
45
|
+
operations: [
|
|
46
|
+
{ type: "call", sequence: 1, ref: "pi.read", provider: "pi", action: "read", args: {}, outcome: "succeeded" },
|
|
47
|
+
{ type: "call", sequence: 2, ref: "pi.bash", provider: "pi", action: "bash", args: {}, outcome: "succeeded" },
|
|
48
|
+
{ type: "call", sequence: 3, ref: "mcp.context7.query-docs", provider: "mcp", action: "query-docs", args: {}, outcome: "succeeded" },
|
|
49
|
+
{ type: "call", sequence: 4, ref: "extensions.ask_user_question", provider: "extensions", action: "ask_user_question", args: {}, outcome: "succeeded" },
|
|
50
|
+
],
|
|
51
|
+
counts: { droppedValues: 0, truncatedValues: 0, redactedValues: 0, droppedOperations: 0 },
|
|
52
|
+
},
|
|
53
|
+
}), toolCallId: "call_outer_fabric" });
|
|
54
|
+
|
|
55
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
56
|
+
{ name: "ask_user_question", kind: "tool", exchange: 1, session: 1 },
|
|
57
|
+
{ name: "bash", kind: "tool", exchange: 1, session: 1 },
|
|
58
|
+
{ name: "fabric_exec", kind: "tool", exchange: 1, session: 1 },
|
|
59
|
+
{ name: "read", kind: "tool", exchange: 1, session: 1 },
|
|
60
|
+
{ name: "context7", kind: "mcp", exchange: 1, session: 1 },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("does not double-count nested lifecycle events replayed by Fabric", () => {
|
|
65
|
+
const tracker = createToolUsageTracker();
|
|
66
|
+
tracker.startExchange();
|
|
67
|
+
tracker.record(ended("read", {},));
|
|
68
|
+
tracker.record({ ...ended("read"), toolCallId: "fabric_nested", result: { content: [], details: {} } });
|
|
69
|
+
assert.equal(tracker.getSnapshot().counts.find((count) => count.name === "read")?.exchange, 1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("adds the MCP server for proxy calls without hiding the gateway count", () => {
|
|
73
|
+
const tracker = createToolUsageTracker();
|
|
74
|
+
tracker.startExchange();
|
|
75
|
+
tracker.record(ended("mcp", { mode: "call", server: "codebase-memory-mcp", tool: "search_graph" }));
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
78
|
+
{ name: "mcp", kind: "tool", exchange: 1, session: 1 },
|
|
79
|
+
{ name: "codebase-memory-mcp", kind: "mcp", exchange: 1, session: 1 },
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("counts each MCP call made by mcpScript", () => {
|
|
84
|
+
const tracker = createToolUsageTracker([], () => ["codebase-memory-mcp", "context-mode"]);
|
|
85
|
+
tracker.startExchange();
|
|
86
|
+
tracker.record(ended("mcpScript", {
|
|
87
|
+
mode: "script",
|
|
88
|
+
calls: [
|
|
89
|
+
{ operation: "search", query: "graph", ok: true },
|
|
90
|
+
{ operation: "call", path: "codebase-memory-mcp_search_graph", ok: true },
|
|
91
|
+
{ operation: "call", path: "codebase-memory-mcp_get_code_snippet", ok: true },
|
|
92
|
+
{ operation: "call", path: "context-mode_ctx_search", ok: false, error: "failed" },
|
|
93
|
+
],
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
97
|
+
{ name: "mcpScript", kind: "tool", exchange: 1, session: 1 },
|
|
98
|
+
{ name: "codebase-memory-mcp", kind: "mcp", exchange: 2, session: 2 },
|
|
99
|
+
{ name: "context-mode", kind: "mcp", exchange: 1, session: 1 },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("attributes direct context-mode bridge tools to the MCP server", () => {
|
|
104
|
+
const tracker = createToolUsageTracker();
|
|
105
|
+
tracker.startExchange();
|
|
106
|
+
tracker.record(ended("ctx_execute"));
|
|
107
|
+
|
|
108
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
109
|
+
{ name: "ctx_execute", kind: "tool", exchange: 1, session: 1 },
|
|
110
|
+
{ name: "context-mode", kind: "mcp", exchange: 1, session: 1 },
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("does not attribute unknown ctx-prefixed tools to context-mode", () => {
|
|
115
|
+
const tracker = createToolUsageTracker();
|
|
116
|
+
tracker.startExchange();
|
|
117
|
+
tracker.record(ended("ctx_custom"));
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
120
|
+
{ name: "ctx_custom", kind: "tool", exchange: 1, session: 1 },
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("rebuilds session totals when the active branch changes", () => {
|
|
125
|
+
const firstBranch = [{
|
|
126
|
+
type: "message",
|
|
127
|
+
message: { role: "toolResult", toolName: "read", details: {} },
|
|
128
|
+
}];
|
|
129
|
+
const secondBranch = [{
|
|
130
|
+
type: "message",
|
|
131
|
+
message: { role: "toolResult", toolName: "bash", details: {} },
|
|
132
|
+
}];
|
|
133
|
+
const tracker = createToolUsageTracker(toolNamesFromBranch(firstBranch));
|
|
134
|
+
tracker.startExchange();
|
|
135
|
+
tracker.resetSession(secondBranch);
|
|
136
|
+
|
|
137
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
138
|
+
{ name: "bash", kind: "tool", exchange: 0, session: 1 },
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("re-attributes restored mcpScript calls after MCP server discovery", () => {
|
|
143
|
+
const entries = [{
|
|
144
|
+
type: "message",
|
|
145
|
+
message: {
|
|
146
|
+
role: "toolResult",
|
|
147
|
+
toolName: "mcpScript",
|
|
148
|
+
details: {
|
|
149
|
+
mode: "script",
|
|
150
|
+
calls: [{ operation: "call", path: "team_core_search", ok: false, error: "failed" }],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
}];
|
|
154
|
+
const tracker = createToolUsageTracker(toolNamesFromBranch(entries));
|
|
155
|
+
tracker.resetSession(entries, ["team_core"]);
|
|
156
|
+
|
|
157
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
158
|
+
{ name: "mcpScript", kind: "tool", exchange: 0, session: 1 },
|
|
159
|
+
{ name: "team_core", kind: "mcp", exchange: 0, session: 1 },
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("keeps same-named Tool and MCP server in separate sections", () => {
|
|
164
|
+
const tracker = createToolUsageTracker();
|
|
165
|
+
tracker.startExchange();
|
|
166
|
+
tracker.record(ended("mcp", { mode: "call", server: "mcp", tool: "status" }));
|
|
167
|
+
|
|
168
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
169
|
+
{ name: "mcp", kind: "tool", exchange: 1, session: 1 },
|
|
170
|
+
{ name: "mcp", kind: "mcp", exchange: 1, session: 1 },
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("rebuilds current-question MCP counts when server discovery arrives late", () => {
|
|
175
|
+
let servers: string[] = [];
|
|
176
|
+
const details = {
|
|
177
|
+
mode: "script",
|
|
178
|
+
calls: [{ operation: "call", path: "team_core_search", ok: true }],
|
|
179
|
+
};
|
|
180
|
+
const entries = [{
|
|
181
|
+
type: "message",
|
|
182
|
+
message: { role: "toolResult", toolName: "mcpScript", details },
|
|
183
|
+
}];
|
|
184
|
+
const tracker = createToolUsageTracker([], () => servers);
|
|
185
|
+
tracker.startExchange();
|
|
186
|
+
tracker.record(ended("mcpScript", details));
|
|
187
|
+
servers = ["team_core"];
|
|
188
|
+
tracker.resetSession(entries, servers, { preserveExchange: true });
|
|
189
|
+
|
|
190
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
191
|
+
{ name: "mcpScript", kind: "tool", exchange: 1, session: 1 },
|
|
192
|
+
{ name: "team_core", kind: "mcp", exchange: 1, session: 1 },
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("clears current-question counts when the active branch changes", () => {
|
|
197
|
+
const tracker = createToolUsageTracker();
|
|
198
|
+
tracker.startExchange();
|
|
199
|
+
tracker.record(ended("read"));
|
|
200
|
+
tracker.resetSession([{
|
|
201
|
+
type: "message",
|
|
202
|
+
message: { role: "toolResult", toolName: "read", details: {} },
|
|
203
|
+
}]);
|
|
204
|
+
|
|
205
|
+
assert.deepEqual(tracker.getSnapshot().counts, [
|
|
206
|
+
{ name: "read", kind: "tool", exchange: 0, session: 1 },
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("restores completed tool and MCP counts from a session branch", () => {
|
|
211
|
+
const entries = [
|
|
212
|
+
{
|
|
213
|
+
type: "message",
|
|
214
|
+
message: {
|
|
215
|
+
role: "toolResult",
|
|
216
|
+
toolName: "read",
|
|
217
|
+
details: {},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
type: "message",
|
|
222
|
+
message: {
|
|
223
|
+
role: "toolResult",
|
|
224
|
+
toolName: "mcp",
|
|
225
|
+
details: { mode: "call", server: "codebase-memory-mcp", tool: "search_graph" },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
assert.deepEqual(toolNamesFromBranch(entries), ["read", "mcp", "codebase-memory-mcp"]);
|
|
231
|
+
});
|