Skip to content

Commit 62960fe

Browse files
committed
mason: cut read-only spine tools to tool_call
1 parent e485b3f commit 62960fe

15 files changed

Lines changed: 363 additions & 370 deletions

File tree

crates/aft/src/subc_translate.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,9 @@ fn translate_search(args: &Value) -> Result<Translated, TranslateError> {
542542
out.insert("hint".to_string(), hint.clone());
543543
}
544544
}
545+
if let Some(include_tests) = map_in.get("includeTests").and_then(Value::as_bool) {
546+
out.insert("include_tests".to_string(), Value::Bool(include_tests));
547+
}
545548

546549
Ok(Translated {
547550
command: "semantic_search".into(),
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Found 0 match across 0 file
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"ctx": {
3+
"agent_args": {
4+
"pattern": "missing"
5+
},
6+
"project_root": "<PROJECT_ROOT>"
7+
},
8+
"native_response_json": {
9+
"files_with_matches": 0,
10+
"id": "1",
11+
"matches": [],
12+
"success": true,
13+
"total_matches": 0
14+
},
15+
"tool_name": "grep"
16+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"args": {
3+
"include_tests": true,
4+
"query": "fixtures",
5+
"top_k": 7
6+
},
7+
"command": "semantic_search"
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"agent_args": {
3+
"includeTests": true,
4+
"query": "fixtures",
5+
"topK": 7
6+
},
7+
"project_root": "<PROJECT_ROOT>",
8+
"tool_name": "search"
9+
}

packages/aft-bridge/src/command-timeouts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const LONG_RUNNING_COMMAND_TIMEOUT_MS: Record<string, number> = {
2121
inspect: 60_000,
2222
grep: 60_000,
2323
glob: 60_000,
24+
search: 60_000,
2425
semantic_search: 60_000,
2526
};
2627

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/// <reference path="../../bun-test.d.ts" />
2+
3+
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
4+
import { mkdir, writeFile } from "node:fs/promises";
5+
import type { BridgePool } from "@cortexkit/aft-bridge";
6+
import type { ToolContext } from "@opencode-ai/plugin";
7+
import { inspectTools } from "../../tools/inspect.js";
8+
import { searchTools } from "../../tools/search.js";
9+
import { semanticTools } from "../../tools/semantic.js";
10+
import type { PluginContext } from "../../types.js";
11+
import { noopAsk } from "../test-helpers";
12+
import {
13+
cleanupHarnesses,
14+
createHarness,
15+
type E2EHarness,
16+
type PreparedBinary,
17+
prepareBinary,
18+
} from "./helpers.js";
19+
20+
const initialBinary = await prepareBinary();
21+
const maybeDescribe = describe.skipIf(!initialBinary.binaryPath);
22+
23+
function createMockClient(): PluginContext["client"] {
24+
return { lsp: {}, find: {} } as PluginContext["client"];
25+
}
26+
27+
function createPluginContext(harness: E2EHarness): PluginContext {
28+
const pool = { getBridge: () => harness.bridge } as unknown as BridgePool;
29+
return {
30+
pool,
31+
client: createMockClient(),
32+
config: {} as PluginContext["config"],
33+
storageDir: harness.path(".aft-test-storage"),
34+
};
35+
}
36+
37+
function createToolContext(harness: E2EHarness): ToolContext {
38+
return {
39+
sessionID: "read-only-spine-toolcall-e2e",
40+
messageID: "read-only-spine-toolcall-message",
41+
agent: "test",
42+
directory: harness.tempDir,
43+
worktree: harness.tempDir,
44+
abort: new AbortController().signal,
45+
metadata: () => {},
46+
ask: noopAsk,
47+
} as ToolContext;
48+
}
49+
50+
async function createFixtureProject(harness: E2EHarness): Promise<void> {
51+
await mkdir(harness.path("src"), { recursive: true });
52+
await Promise.all([
53+
writeFile(
54+
harness.path("src", "hit.ts"),
55+
[
56+
"export const toolCallGrepMarker = 'tool_call_grep_marker';",
57+
"export const toolCallSearchMarker = 'tool_call_search_marker';",
58+
"// TODO cutover inspect marker",
59+
"",
60+
].join("\n"),
61+
"utf8",
62+
),
63+
writeFile(harness.path("src", "other.ts"), "export const unrelated = true;\n", "utf8"),
64+
]);
65+
}
66+
67+
maybeDescribe("e2e read-only spine tool_call cutover", () => {
68+
let preparedBinary: PreparedBinary = initialBinary;
69+
const harnesses: E2EHarness[] = [];
70+
71+
beforeAll(async () => {
72+
preparedBinary = await prepareBinary();
73+
});
74+
75+
afterEach(async () => {
76+
await cleanupHarnesses(harnesses);
77+
});
78+
79+
async function harness(): Promise<E2EHarness> {
80+
const created = await createHarness(preparedBinary, {
81+
fixtureNames: [],
82+
timeoutMs: 20_000,
83+
tempPrefix: "aft-plugin-readonly-spine-",
84+
});
85+
harnesses.push(created);
86+
await createFixtureProject(created);
87+
return created;
88+
}
89+
90+
test("grep returns server-rendered matches through tool_call", async () => {
91+
const h = await harness();
92+
const tools = searchTools(createPluginContext(h));
93+
94+
const output = await tools.grep.execute(
95+
{ pattern: "tool_call_grep_marker", path: "src" },
96+
createToolContext(h),
97+
);
98+
99+
expect(output).toContain("tool_call_grep_marker");
100+
expect(output).toContain("Found 1 match across 1 file");
101+
});
102+
103+
test("grep appends the plugin-side skipped path footer after tool_call", async () => {
104+
const h = await harness();
105+
const tools = searchTools(createPluginContext(h));
106+
107+
const output = await tools.grep.execute(
108+
{ pattern: "tool_call_grep_marker", path: "src missing-dir" },
109+
createToolContext(h),
110+
);
111+
112+
expect(output).toContain("tool_call_grep_marker");
113+
expect(output).toContain("Found 1 match across 1 file");
114+
expect(output).toContain("Skipped 1 path not found: missing-dir");
115+
});
116+
117+
test("aft_search returns server-rendered literal search text through tool_call", async () => {
118+
const h = await harness();
119+
const tools = semanticTools(createPluginContext(h));
120+
121+
const output = await tools.aft_search.execute(
122+
{ query: "tool_call_search_marker", hint: "literal", topK: 5 },
123+
createToolContext(h),
124+
);
125+
126+
expect(output).toContain("tool_call_search_marker");
127+
expect(output).toContain("Found ");
128+
});
129+
130+
test("aft_inspect returns server-rendered todos details through tool_call", async () => {
131+
const h = await harness();
132+
const tools = inspectTools(createPluginContext(h));
133+
134+
const output = await tools.aft_inspect.execute(
135+
{ sections: "todos", topK: 5 },
136+
createToolContext(h),
137+
);
138+
139+
expect(output).toContain("TODOs: 1");
140+
expect(output).toContain("src/hit.ts:3 TODO cutover inspect marker");
141+
});
142+
});

packages/opencode-plugin/src/__tests__/inspect.test.ts

Lines changed: 35 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type { ToolContext } from "@opencode-ai/plugin";
66
import {
77
createInspectTier2IdleScheduler,
88
inspectTools,
9-
renderInspectDiagnostics,
109
shouldRegisterInspectTool,
1110
} from "../tools/inspect.js";
1211
import type { PluginContext } from "../types.js";
@@ -18,6 +17,12 @@ type SendCall = {
1817
params: Record<string, unknown>;
1918
options?: Record<string, unknown>;
2019
};
20+
type ToolCallCall = {
21+
sessionId: string | undefined;
22+
name: string;
23+
rawArgs: Record<string, unknown>;
24+
options?: Record<string, unknown>;
25+
};
2126

2227
type CapturedTimer = {
2328
callback: () => void;
@@ -66,6 +71,7 @@ function createInspectHarness(
6671
) => Promise<BridgeResponse> | BridgeResponse,
6772
) {
6873
const sendCalls: SendCall[] = [];
74+
const toolCallCalls: ToolCallCall[] = [];
6975
const localBridge = {
7076
send: async (
7177
command: string,
@@ -75,12 +81,22 @@ function createInspectHarness(
7581
sendCalls.push({ command, params, options });
7682
return await sendImpl(command, params);
7783
},
84+
toolCall: async (
85+
sessionId: string | undefined,
86+
name: string,
87+
rawArgs: Record<string, unknown> = {},
88+
options?: Record<string, unknown>,
89+
) => {
90+
toolCallCalls.push({ sessionId, name, rawArgs, options });
91+
return await sendImpl(name, rawArgs);
92+
},
7893
};
7994
const pool = {
8095
getBridge: () => localBridge,
8196
} as unknown as BridgePool;
8297
return {
8398
sendCalls,
99+
toolCallCalls,
84100
tools: inspectTools(createPluginContext(pool, {})),
85101
};
86102
}
@@ -101,21 +117,25 @@ describe("aft_inspect tool", () => {
101117
});
102118

103119
test("sends corrected inspect field names to the bridge", async () => {
104-
const { sendCalls, tools } = createInspectHarness(() => ({ success: true, summary: {} }));
120+
const { sendCalls, toolCallCalls, tools } = createInspectHarness(() => ({
121+
success: true,
122+
text: "ok",
123+
}));
105124

106125
await tools.aft_inspect.execute(
107126
{ sections: ["todos", "dead_code"], scope: "src", topK: 7 },
108127
createMockSdkContext("/repo"),
109128
);
110129

111-
expect(sendCalls).toEqual([
130+
expect(sendCalls).toEqual([]);
131+
expect(toolCallCalls).toEqual([
112132
{
113-
command: "inspect",
114-
params: {
133+
sessionId: "inspect-session",
134+
name: "inspect",
135+
rawArgs: {
115136
sections: ["todos", "dead_code"],
116137
scope: "/repo/src",
117138
topK: 7,
118-
session_id: "inspect-session",
119139
},
120140
options: expect.objectContaining({
121141
keepBridgeOnTimeout: true,
@@ -126,87 +146,34 @@ describe("aft_inspect tool", () => {
126146
});
127147

128148
test("normalizes empty sections and scope sentinels", async () => {
129-
const { sendCalls, tools } = createInspectHarness(() => ({ success: true, summary: {} }));
149+
const { toolCallCalls, tools } = createInspectHarness(() => ({ success: true, text: "ok" }));
130150

131151
await tools.aft_inspect.execute(
132152
{ sections: [], scope: "", topK: undefined },
133153
createMockSdkContext("/repo"),
134154
);
135155

136-
expect(sendCalls[0]?.params.sections).toBeUndefined();
137-
expect(sendCalls[0]?.params.scope).toBeUndefined();
138-
expect(sendCalls[0]?.params.topK).toBeUndefined();
139-
});
140-
141-
test("renders diagnostics counts, sentinels, and details defensively", () => {
142-
expect(
143-
renderInspectDiagnostics({
144-
summary: { diagnostics: { errors: 1, warnings: 2, info: 0, hints: 3 } },
145-
details: {
146-
diagnostics: [
147-
{
148-
file: "src/app.ts",
149-
line: 7,
150-
column: 2,
151-
severity: "error",
152-
message: "bad type",
153-
source: "tsserver",
154-
},
155-
],
156-
},
157-
}),
158-
).toContain("diagnostics: 1 errors, 2 warnings, 0 info, 3 hints");
159-
160-
const pending = renderInspectDiagnostics({
161-
summary: {
162-
diagnostics: {
163-
status: "pending",
164-
servers_pending: ["typescript-language-server"],
165-
servers_not_installed: ["pyright"],
166-
},
167-
},
168-
});
169-
expect(pending).toContain("diagnostics: pending");
170-
expect(pending).toContain("typescript-language-server");
171-
expect(pending).toContain("pyright");
172-
expect(pending).not.toContain("0 errors");
173-
174-
// Partial result with counts-so-far AND a pending server: must show BOTH
175-
// the already-found counts and the pending signal, so real errors found by
176-
// one server aren't hidden while another server is still working.
177-
const partial = renderInspectDiagnostics({
178-
summary: {
179-
diagnostics: {
180-
errors: 2,
181-
warnings: 0,
182-
info: 0,
183-
hints: 0,
184-
status: "pending",
185-
servers_pending: ["oxlint"],
186-
},
187-
},
188-
});
189-
expect(partial).toContain("2 errors");
190-
expect(partial).toContain("so far");
191-
expect(partial).toContain("oxlint");
156+
expect(toolCallCalls[0]?.rawArgs.sections).toBeUndefined();
157+
expect(toolCallCalls[0]?.rawArgs.scope).toBeUndefined();
158+
expect(toolCallCalls[0]?.rawArgs.topK).toBeUndefined();
192159
});
193160

194-
test("returns the Rust text body with diagnostics appended (no JSON dump)", async () => {
161+
test("returns the server-rendered Rust text body without JSON fallback", async () => {
162+
const rendered =
163+
"Duplicates: 2 (top by cost):\n 1083 a.ts == b.ts\nDead code: 1 (rust 1):\n x.rs::foo\n\ndiagnostics: 1 errors, 0 warnings, 0 info, 2 hints";
195164
const { tools } = createInspectHarness(() => ({
196165
success: true,
197-
text: "Duplicates: 2 (top by cost):\n 1083 a.ts == b.ts\nDead code: 1 (rust 1):\n x.rs::foo",
166+
text: rendered,
198167
summary: { diagnostics: { errors: 1, warnings: 0, info: 0, hints: 2 } },
199168
}));
200169

201170
const result = await tools.aft_inspect.execute({}, createMockSdkContext("/repo"));
202171
const text = typeof result === "string" ? result : (result.output as string);
203172

204-
// Rust body is surfaced verbatim …
173+
expect(text).toBe(rendered);
205174
expect(text).toContain("Duplicates: 2 (top by cost):");
206175
expect(text).toContain(" x.rs::foo");
207-
// … with the diagnostics line appended after it …
208176
expect(text).toContain("diagnostics: 1 errors, 0 warnings, 0 info, 2 hints");
209-
// … and never the raw JSON fallback.
210177
expect(text).not.toContain('"success"');
211178
expect(text).not.toContain("scanner_state");
212179
});

packages/opencode-plugin/src/__tests__/permission-layer-audit.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ function createHarness(
7070
calls.push({ command, params });
7171
return await sendImpl(command, params);
7272
},
73+
toolCall: async (
74+
sessionId: string | undefined,
75+
name: string,
76+
rawArgs: Record<string, unknown> = {},
77+
) => {
78+
calls.push({ command: name, params: { ...rawArgs, session_id: sessionId } });
79+
return await sendImpl(name, rawArgs);
80+
},
7381
};
7482
const pool = { getBridge: () => bridge } as unknown as BridgePool;
7583
return { calls, tools: toolFactory(createPluginContext(pool)) };

0 commit comments

Comments
 (0)