@selesai/code 0.13.22 → 0.13.24
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 +20 -0
- package/dist/core/exec.d.ts +2 -0
- package/dist/core/exec.js +1 -0
- package/dist/extensions/capability-gateway/index.ts +15 -4
- package/dist/extensions/capability-gateway/integration.test.ts +15 -0
- package/dist/extensions/package.json +2 -0
- package/dist/extensions/pi-graft/cli.test.ts +431 -0
- package/dist/extensions/pi-graft/cli.ts +548 -0
- package/dist/extensions/pi-graft/commands.test.ts +480 -0
- package/dist/extensions/pi-graft/commands.ts +392 -0
- package/dist/extensions/pi-graft/graft.integration.test.ts +153 -0
- package/dist/extensions/pi-graft/index.test.ts +167 -0
- package/dist/extensions/pi-graft/index.ts +621 -0
- package/dist/extensions/pi-graft/lifecycle.test.ts +653 -0
- package/dist/extensions/pi-graft/prompt.test.ts +213 -0
- package/dist/extensions/pi-graft/prompt.ts +216 -0
- package/dist/extensions/pi-graft/state.test.ts +258 -0
- package/dist/extensions/pi-graft/state.ts +335 -0
- package/dist/extensions/pi-graft/test-support.ts +199 -0
- package/dist/extensions/pi-graft/tools.test.ts +330 -0
- package/dist/extensions/pi-graft/tools.ts +334 -0
- package/dist/extensions/pi-graft/tsconfig.json +16 -0
- package/dist/extensions/pi-subagents/src/agents/builtin-agent-augmentations.ts +71 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +9 -3
- package/dist/extensions/pi-subagents/test/unit/builtin-agent-augmentations.test.ts +44 -0
- package/package.json +2 -2
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graft graph state, project settings, and session-native persistence.
|
|
3
|
+
*
|
|
4
|
+
* The graph is a project-local, regenerable cache — never a committed artifact —
|
|
5
|
+
* so its state machine is mostly about telling the user *what the context they
|
|
6
|
+
* are about to receive actually is*: absent, structural-only, deep-enriched, or
|
|
7
|
+
* known-stale relative to the working tree.
|
|
8
|
+
*
|
|
9
|
+
* State transitions are a pure reducer {@link reduceGraftState} so they can be
|
|
10
|
+
* asserted without a session, a process, or a clock.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@selesai/code";
|
|
16
|
+
import type { SessionEntry } from "@selesai/code";
|
|
17
|
+
import { INSTALL_COMMAND } from "./cli.ts";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Retrieval strategy
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How Graft context reaches the model.
|
|
25
|
+
*
|
|
26
|
+
* - `pull` — the model calls the Graft tools when it needs repository context.
|
|
27
|
+
* - `push` — an eligible coding turn starts from a bounded source-backed pack.
|
|
28
|
+
* - `hybrid` — a cheap orientation pack for eligible coding turns, tools kept.
|
|
29
|
+
*/
|
|
30
|
+
export type RetrievalMode = "pull" | "push" | "hybrid";
|
|
31
|
+
|
|
32
|
+
export const RETRIEVAL_MODES: readonly RetrievalMode[] = ["pull", "push", "hybrid"];
|
|
33
|
+
export const DEFAULT_RETRIEVAL_MODE: RetrievalMode = "hybrid";
|
|
34
|
+
|
|
35
|
+
export function isRetrievalMode(value: unknown): value is RetrievalMode {
|
|
36
|
+
return typeof value === "string" && (RETRIEVAL_MODES as readonly string[]).includes(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Project settings
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export interface GraftSettings {
|
|
44
|
+
/** Set false to keep Graft tools and commands out of the session entirely. */
|
|
45
|
+
enabled?: boolean;
|
|
46
|
+
/** Default retrieval strategy; a session choice overrides it. */
|
|
47
|
+
mode?: RetrievalMode;
|
|
48
|
+
/** `opt-out` (default) forces `DO_NOT_TRACK=1`; `inherit` leaves the user's own Graft choice alone. */
|
|
49
|
+
telemetry?: "opt-out" | "inherit";
|
|
50
|
+
/** Hybrid/push relevance gate: skip injection below this `coverage` share. */
|
|
51
|
+
minCoverage?: number;
|
|
52
|
+
/** Cap on the bytes of a pushed pack. */
|
|
53
|
+
maxInjectionBytes?: number;
|
|
54
|
+
/** Cap on the bytes of a tool result. */
|
|
55
|
+
maxResultBytes?: number;
|
|
56
|
+
/** Repository-relative prefix every query is narrowed to. */
|
|
57
|
+
in?: string;
|
|
58
|
+
/** Seconds a mutation stays "changed" before a proactive refresh is attempted. */
|
|
59
|
+
refreshDebounceSeconds?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const SETTINGS_KEY = "graft";
|
|
63
|
+
|
|
64
|
+
/** Relevance gate default for push/hybrid. Graft's own hooks use the same idea. */
|
|
65
|
+
export const DEFAULT_MIN_COVERAGE = 0.25;
|
|
66
|
+
export const DEFAULT_MAX_INJECTION_BYTES = 12 * 1024;
|
|
67
|
+
export const DEFAULT_REFRESH_DEBOUNCE_SECONDS = 20;
|
|
68
|
+
|
|
69
|
+
function readSettingsFile(path: string): Record<string, unknown> | undefined {
|
|
70
|
+
let raw: string;
|
|
71
|
+
try {
|
|
72
|
+
raw = readFileSync(path, "utf-8");
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const parsed: unknown = JSON.parse(raw);
|
|
78
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : undefined;
|
|
79
|
+
} catch {
|
|
80
|
+
// A malformed settings file must not break a session; ignore it.
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeSettings(raw: unknown): GraftSettings {
|
|
86
|
+
if (typeof raw !== "object" || raw === null) return {};
|
|
87
|
+
const value = raw as Record<string, unknown>;
|
|
88
|
+
const settings: GraftSettings = {};
|
|
89
|
+
if (typeof value.enabled === "boolean") settings.enabled = value.enabled;
|
|
90
|
+
if (isRetrievalMode(value.mode)) settings.mode = value.mode;
|
|
91
|
+
if (value.telemetry === "opt-out" || value.telemetry === "inherit") settings.telemetry = value.telemetry;
|
|
92
|
+
if (typeof value.minCoverage === "number" && value.minCoverage >= 0 && value.minCoverage <= 1) {
|
|
93
|
+
settings.minCoverage = value.minCoverage;
|
|
94
|
+
}
|
|
95
|
+
for (const key of ["maxInjectionBytes", "maxResultBytes", "refreshDebounceSeconds"] as const) {
|
|
96
|
+
const candidate = value[key];
|
|
97
|
+
if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) settings[key] = candidate;
|
|
98
|
+
}
|
|
99
|
+
if (typeof value.in === "string" && value.in.trim()) settings.in = value.in.trim();
|
|
100
|
+
return settings;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve Graft settings from the global agent settings and, for a trusted
|
|
105
|
+
* project only, the project settings. Project values win.
|
|
106
|
+
*
|
|
107
|
+
* Untrusted projects contribute nothing: reading project-local configuration
|
|
108
|
+
* before project trust is established is exactly the behavior Selesai's trust
|
|
109
|
+
* gate exists to prevent.
|
|
110
|
+
*/
|
|
111
|
+
export function readGraftSettings(options: {
|
|
112
|
+
cwd: string;
|
|
113
|
+
trusted: boolean;
|
|
114
|
+
agentDir?: string;
|
|
115
|
+
}): GraftSettings {
|
|
116
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
117
|
+
const global = normalizeSettings(readSettingsFile(join(agentDir, "settings.json"))?.[SETTINGS_KEY]);
|
|
118
|
+
if (!options.trusted) return global;
|
|
119
|
+
const project = normalizeSettings(
|
|
120
|
+
readSettingsFile(join(options.cwd, CONFIG_DIR_NAME, "settings.json"))?.[SETTINGS_KEY],
|
|
121
|
+
);
|
|
122
|
+
return { ...global, ...project };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Graph state
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export type GraftStateName =
|
|
130
|
+
| "unsupported"
|
|
131
|
+
| "unavailable"
|
|
132
|
+
| "incompatible"
|
|
133
|
+
| "unbuilt"
|
|
134
|
+
| "building"
|
|
135
|
+
| "fresh-structural"
|
|
136
|
+
| "fresh-deep"
|
|
137
|
+
| "changed"
|
|
138
|
+
| "refreshing"
|
|
139
|
+
| "failed";
|
|
140
|
+
|
|
141
|
+
export interface GraftState {
|
|
142
|
+
name: GraftStateName;
|
|
143
|
+
/** Executable version, when one was read. */
|
|
144
|
+
version?: string;
|
|
145
|
+
/** The graph on disk carries provider-backed summaries. */
|
|
146
|
+
deep?: boolean;
|
|
147
|
+
/** One actionable sentence for the user. */
|
|
148
|
+
detail?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const INITIAL_GRAFT_STATE: GraftState = { name: "unsupported", detail: "not checked yet" };
|
|
152
|
+
|
|
153
|
+
export type GraftEvent =
|
|
154
|
+
/** Repository root resolution finished. */
|
|
155
|
+
| { type: "resolved"; gitRepo: boolean }
|
|
156
|
+
| { type: "probe-ok"; version: string; graph: GraphPresence }
|
|
157
|
+
| { type: "probe-missing"; detail: string }
|
|
158
|
+
| { type: "probe-incompatible"; version?: string; detail: string }
|
|
159
|
+
| { type: "build-started"; deep: boolean }
|
|
160
|
+
| { type: "build-succeeded"; deep: boolean; presence: GraphPresence }
|
|
161
|
+
| { type: "build-failed"; detail: string }
|
|
162
|
+
| { type: "refresh-started" }
|
|
163
|
+
/** A query returned; Graft refreshed the graph against the working tree first. */
|
|
164
|
+
| { type: "query-succeeded"; presence?: GraphPresence }
|
|
165
|
+
/** A successful edit/write/mutating bash was observed. */
|
|
166
|
+
| { type: "mutation-observed" }
|
|
167
|
+
| { type: "reset" };
|
|
168
|
+
|
|
169
|
+
/** What is actually on disk for a resolved repository. */
|
|
170
|
+
export interface GraphPresence {
|
|
171
|
+
/** A `graft/` directory with an index exists. */
|
|
172
|
+
built: boolean;
|
|
173
|
+
/** Provider-backed summaries are present for the current code. */
|
|
174
|
+
deep: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const ABSENT_GRAPH: GraphPresence = { built: false, deep: false };
|
|
178
|
+
|
|
179
|
+
function applyPresence(presence: GraphPresence): GraftState {
|
|
180
|
+
if (!presence.built) return { name: "unbuilt", detail: "no graph here yet — run /graft build" };
|
|
181
|
+
if (presence.deep) return { name: "fresh-deep", deep: true };
|
|
182
|
+
return { name: "fresh-structural", deep: false };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Pure state transition.
|
|
187
|
+
*
|
|
188
|
+
* Invariants worth stating because callers depend on them:
|
|
189
|
+
* - `mutation-observed` never *downgrades* a terminal failure or an unbuilt
|
|
190
|
+
* repository; there is nothing to mark stale.
|
|
191
|
+
* - `query-succeeded` returns a stale graph to fresh, because Graft refreshes
|
|
192
|
+
* against the working tree before it answers. That is what makes the state
|
|
193
|
+
* honest without a synchronous rebuild after every edit.
|
|
194
|
+
*/
|
|
195
|
+
export function reduceGraftState(current: GraftState, event: GraftEvent): GraftState {
|
|
196
|
+
switch (event.type) {
|
|
197
|
+
case "resolved":
|
|
198
|
+
if (!event.gitRepo) {
|
|
199
|
+
return { name: "unsupported", detail: "Graft needs a Git repository (the working tree is its file set)" };
|
|
200
|
+
}
|
|
201
|
+
return current.name === "unsupported" && current.detail === "not checked yet"
|
|
202
|
+
? { name: "unavailable", detail: "checking for the graft executable…" }
|
|
203
|
+
: current;
|
|
204
|
+
case "probe-ok":
|
|
205
|
+
return { ...applyPresence(event.graph), version: event.version };
|
|
206
|
+
case "probe-missing":
|
|
207
|
+
return { name: "unavailable", detail: event.detail };
|
|
208
|
+
case "probe-incompatible":
|
|
209
|
+
return { name: "incompatible", version: event.version, detail: event.detail };
|
|
210
|
+
case "build-started":
|
|
211
|
+
return { name: "building", deep: event.deep, version: current.version };
|
|
212
|
+
case "build-succeeded":
|
|
213
|
+
return { ...applyPresence(event.presence), version: current.version };
|
|
214
|
+
case "build-failed":
|
|
215
|
+
return { name: "failed", version: current.version, detail: event.detail };
|
|
216
|
+
case "refresh-started":
|
|
217
|
+
return current.name === "changed" ? { ...current, name: "refreshing" } : current;
|
|
218
|
+
case "query-succeeded": {
|
|
219
|
+
const presence = event.presence ?? { built: true, deep: current.deep === true };
|
|
220
|
+
return { ...applyPresence(presence), version: current.version };
|
|
221
|
+
}
|
|
222
|
+
case "mutation-observed":
|
|
223
|
+
switch (current.name) {
|
|
224
|
+
case "fresh-structural":
|
|
225
|
+
case "fresh-deep":
|
|
226
|
+
case "changed":
|
|
227
|
+
case "refreshing":
|
|
228
|
+
return { name: "changed", deep: current.deep, version: current.version };
|
|
229
|
+
default:
|
|
230
|
+
return current;
|
|
231
|
+
}
|
|
232
|
+
case "reset":
|
|
233
|
+
return INITIAL_GRAFT_STATE;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Whether a graph-state name means "a query can answer from this repository". */
|
|
238
|
+
export function isQueryable(state: GraftState): boolean {
|
|
239
|
+
switch (state.name) {
|
|
240
|
+
case "unbuilt":
|
|
241
|
+
case "unavailable":
|
|
242
|
+
case "incompatible":
|
|
243
|
+
case "unsupported":
|
|
244
|
+
return false;
|
|
245
|
+
default:
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether the state is a terminal setup problem that only the user can clear. */
|
|
251
|
+
export function isSetupProblem(state: GraftState): boolean {
|
|
252
|
+
return state.name === "unavailable" || state.name === "incompatible";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Status rendering
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
export const STATUS_KEY = "graft";
|
|
260
|
+
|
|
261
|
+
const STATUS_LABELS: Record<GraftStateName, string> = {
|
|
262
|
+
unsupported: "✕ n/a",
|
|
263
|
+
unavailable: "✕ no cli",
|
|
264
|
+
incompatible: "! version",
|
|
265
|
+
unbuilt: "○ unbuilt",
|
|
266
|
+
building: "⚙ building",
|
|
267
|
+
"fresh-structural": "● structural",
|
|
268
|
+
"fresh-deep": "● deep",
|
|
269
|
+
changed: "◐ changed",
|
|
270
|
+
refreshing: "⚙ syncing",
|
|
271
|
+
failed: "✕ failed",
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/** Short footer text for the current state. */
|
|
275
|
+
export function statusText(state: GraftState): string {
|
|
276
|
+
const version = state.version ? ` v${state.version}` : "";
|
|
277
|
+
return `graft: ${STATUS_LABELS[state.name]}${version}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Actionable one-liner, the same words the doctor reports. */
|
|
281
|
+
export function statusDetail(state: GraftState): string {
|
|
282
|
+
return state.detail ?? STATUS_LABELS[state.name];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Human-readable recovery step for a state, or undefined when nothing is wrong. */
|
|
286
|
+
export function recoveryFor(state: GraftState): string | undefined {
|
|
287
|
+
switch (state.name) {
|
|
288
|
+
case "unsupported":
|
|
289
|
+
return "Graft indexes a Git working tree. Run `git init` (or open the repository itself) and try again.";
|
|
290
|
+
case "unavailable":
|
|
291
|
+
return `Run /graft setup to install the compatible CLI, or install it yourself: ${INSTALL_COMMAND}`;
|
|
292
|
+
case "incompatible":
|
|
293
|
+
return `Upgrade or pin a supported version: ${INSTALL_COMMAND}`;
|
|
294
|
+
case "unbuilt":
|
|
295
|
+
return "Run /graft build to index this repository (structural, no model, no key).";
|
|
296
|
+
case "failed":
|
|
297
|
+
return "Run /graft doctor for the failing command, then /graft build to retry.";
|
|
298
|
+
default:
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Session-native persistence
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
export const MODE_ENTRY_TYPE = "graft-mode";
|
|
308
|
+
export const INJECTION_ENTRY_TYPE = "graft-injection";
|
|
309
|
+
|
|
310
|
+
export interface GraftModeEntryData {
|
|
311
|
+
mode: RetrievalMode;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** What was injected into one turn, for session observability after resume/fork. */
|
|
315
|
+
function dataOf(entry: SessionEntry, customType: string): Record<string, unknown> | undefined {
|
|
316
|
+
if (entry.type !== "custom") return undefined;
|
|
317
|
+
const custom = entry as SessionEntry & { customType?: string; data?: unknown };
|
|
318
|
+
if (custom.customType !== customType) return undefined;
|
|
319
|
+
if (typeof custom.data !== "object" || custom.data === null) return undefined;
|
|
320
|
+
return custom.data as Record<string, unknown>;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The retrieval mode most recently chosen on this branch.
|
|
325
|
+
*
|
|
326
|
+
* Reading the branch (not the whole session) keeps fork and tree navigation
|
|
327
|
+
* honest: a fork from before a mode change keeps the older mode.
|
|
328
|
+
*/
|
|
329
|
+
export function readBranchMode(entries: readonly SessionEntry[]): RetrievalMode | undefined {
|
|
330
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
331
|
+
const data = dataOf(entries[i]!, MODE_ENTRY_TYPE);
|
|
332
|
+
if (data && isRetrievalMode(data.mode)) return data.mode;
|
|
333
|
+
}
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test doubles for the Graft extension.
|
|
3
|
+
*
|
|
4
|
+
* These mirror the seam the existing bundled-extension tests use (see
|
|
5
|
+
* `agent-browser.test.ts`): a minimal ExtensionAPI stub that captures what the
|
|
6
|
+
* extension registers, plus a controllable extension context. Tests assert
|
|
7
|
+
* observable behavior — registered names, command arguments, working
|
|
8
|
+
* directories, diagnostics, session entries — never private helpers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
export interface StubExecResult {
|
|
14
|
+
code: number;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
killed: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const OK: StubExecResult = { code: 0, stdout: "", stderr: "", killed: false };
|
|
21
|
+
export const FAILED: StubExecResult = { code: 1, stdout: "", stderr: "command not found", killed: false };
|
|
22
|
+
|
|
23
|
+
export type ExecImpl = (command: string, args: string[], options?: unknown) => Promise<StubExecResult>;
|
|
24
|
+
export type RegisteredHandler = (...args: never[]) => unknown;
|
|
25
|
+
|
|
26
|
+
/** The subset of ExtensionAPI these extensions actually call. */
|
|
27
|
+
export interface PiApiStub {
|
|
28
|
+
exec: ReturnType<typeof vi.fn>;
|
|
29
|
+
on: (event: string, handler: RegisteredHandler) => void;
|
|
30
|
+
registerTool: (tool: Record<string, unknown>) => void;
|
|
31
|
+
registerCommand: (name: string, options: Record<string, unknown>) => void;
|
|
32
|
+
appendEntry: (customType: string, data?: unknown) => void;
|
|
33
|
+
sendUserMessage: ReturnType<typeof vi.fn>;
|
|
34
|
+
sendMessage: ReturnType<typeof vi.fn>;
|
|
35
|
+
events: {
|
|
36
|
+
on(event: string, handler: (data: unknown) => void): void;
|
|
37
|
+
emit(event: string, data: unknown): void;
|
|
38
|
+
};
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PiHarness {
|
|
43
|
+
/** The ExtensionAPI the extension under test receives. */
|
|
44
|
+
pi: PiApiStub;
|
|
45
|
+
exec: ReturnType<typeof vi.fn>;
|
|
46
|
+
handlers: Map<string, RegisteredHandler[]>;
|
|
47
|
+
tools: Map<string, Record<string, unknown>>;
|
|
48
|
+
commands: Map<string, Record<string, unknown>>;
|
|
49
|
+
/** Every `appendEntry` call, in order. */
|
|
50
|
+
entries: { customType: string; data: unknown }[];
|
|
51
|
+
/** Every exec call flattened to `command arg arg` for readable assertions. */
|
|
52
|
+
commandsRun(): string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build an ExtensionAPI stub that records everything an extension registers. */
|
|
56
|
+
export function makePi(execImpl?: ExecImpl): PiHarness {
|
|
57
|
+
const handlers = new Map<string, RegisteredHandler[]>();
|
|
58
|
+
const tools = new Map<string, Record<string, unknown>>();
|
|
59
|
+
const commands = new Map<string, Record<string, unknown>>();
|
|
60
|
+
const entries: { customType: string; data: unknown }[] = [];
|
|
61
|
+
const eventHandlers = new Map<string, Array<(data: unknown) => void>>();
|
|
62
|
+
const exec = vi.fn(execImpl ?? (async () => ({ ...OK })));
|
|
63
|
+
|
|
64
|
+
const harness: PiHarness = {
|
|
65
|
+
pi: {
|
|
66
|
+
exec,
|
|
67
|
+
on: (event: string, handler: RegisteredHandler) => {
|
|
68
|
+
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
69
|
+
},
|
|
70
|
+
registerTool: (tool: Record<string, unknown>) => {
|
|
71
|
+
tools.set(tool.name as string, tool);
|
|
72
|
+
},
|
|
73
|
+
registerCommand: (name: string, options: Record<string, unknown>) => {
|
|
74
|
+
commands.set(name, options);
|
|
75
|
+
},
|
|
76
|
+
appendEntry: (customType: string, data: unknown) => {
|
|
77
|
+
entries.push({ customType, data });
|
|
78
|
+
},
|
|
79
|
+
sendUserMessage: vi.fn(),
|
|
80
|
+
sendMessage: vi.fn(),
|
|
81
|
+
events: {
|
|
82
|
+
on: (event, handler) => eventHandlers.set(event, [...(eventHandlers.get(event) ?? []), handler]),
|
|
83
|
+
emit: (event, data) => eventHandlers.get(event)?.forEach((handler) => handler(data)),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
exec,
|
|
87
|
+
handlers,
|
|
88
|
+
tools,
|
|
89
|
+
commands,
|
|
90
|
+
entries,
|
|
91
|
+
commandsRun: () =>
|
|
92
|
+
exec.mock.calls.map((call) => [call[0], ...((call[1] as string[]) ?? [])].join(" ")),
|
|
93
|
+
};
|
|
94
|
+
return harness;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every handler registered for an event, in registration order. */
|
|
98
|
+
export function handlersFor(harness: PiHarness, event: string): RegisteredHandler[] {
|
|
99
|
+
return harness.handlers.get(event) ?? [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The first handler for an event, or a clear failure when none was registered. */
|
|
103
|
+
export function handlerFor(harness: PiHarness, event: string, index = 0): RegisteredHandler {
|
|
104
|
+
const handler = handlersFor(harness, event)[index];
|
|
105
|
+
if (!handler) throw new Error(`no ${event} handler registered at index ${index}`);
|
|
106
|
+
return handler;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The named tool definition the extension registered. */
|
|
110
|
+
export function toolFor(harness: PiHarness, name: string): Record<string, unknown> {
|
|
111
|
+
const tool = harness.tools.get(name);
|
|
112
|
+
if (!tool) throw new Error(`no ${name} tool registered`);
|
|
113
|
+
return tool;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CtxStub {
|
|
117
|
+
ui: {
|
|
118
|
+
notify: ReturnType<typeof vi.fn>;
|
|
119
|
+
confirm: ReturnType<typeof vi.fn>;
|
|
120
|
+
setStatus: ReturnType<typeof vi.fn>;
|
|
121
|
+
select: ReturnType<typeof vi.fn>;
|
|
122
|
+
input: ReturnType<typeof vi.fn>;
|
|
123
|
+
};
|
|
124
|
+
hasUI: boolean;
|
|
125
|
+
mode: string;
|
|
126
|
+
cwd: string;
|
|
127
|
+
isProjectTrusted: ReturnType<typeof vi.fn>;
|
|
128
|
+
isIdle: ReturnType<typeof vi.fn>;
|
|
129
|
+
getSystemPrompt: ReturnType<typeof vi.fn>;
|
|
130
|
+
sessionManager: {
|
|
131
|
+
getBranch: () => unknown[];
|
|
132
|
+
getEntries: () => unknown[];
|
|
133
|
+
getLeafId: () => string | null;
|
|
134
|
+
getSessionFile: () => string | undefined;
|
|
135
|
+
};
|
|
136
|
+
signal: AbortSignal | undefined;
|
|
137
|
+
abort: ReturnType<typeof vi.fn>;
|
|
138
|
+
/** Latest value written for each status key. */
|
|
139
|
+
statuses: Map<string, string | undefined>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** All notify messages, joined, for readable `toContain` assertions. */
|
|
143
|
+
export function notified(ctx: CtxStub): string {
|
|
144
|
+
return ctx.ui.notify.mock.calls.map((call) => String(call[0])).join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function makeCtx(
|
|
148
|
+
overrides: Partial<{
|
|
149
|
+
hasUI: boolean;
|
|
150
|
+
confirm: boolean;
|
|
151
|
+
cwd: string;
|
|
152
|
+
trusted: boolean;
|
|
153
|
+
branch: unknown[];
|
|
154
|
+
signal: AbortSignal;
|
|
155
|
+
}> = {},
|
|
156
|
+
): CtxStub {
|
|
157
|
+
const statuses = new Map<string, string | undefined>();
|
|
158
|
+
const ctx: CtxStub = {
|
|
159
|
+
ui: {
|
|
160
|
+
notify: vi.fn(),
|
|
161
|
+
confirm: vi.fn(async () => overrides.confirm ?? true),
|
|
162
|
+
setStatus: vi.fn((key: string, text: string | undefined) => {
|
|
163
|
+
statuses.set(key, text);
|
|
164
|
+
}),
|
|
165
|
+
select: vi.fn(async () => undefined),
|
|
166
|
+
input: vi.fn(async () => undefined),
|
|
167
|
+
},
|
|
168
|
+
hasUI: overrides.hasUI ?? true,
|
|
169
|
+
mode: overrides.hasUI === false ? "print" : "tui",
|
|
170
|
+
cwd: overrides.cwd ?? process.cwd(),
|
|
171
|
+
isProjectTrusted: vi.fn(() => overrides.trusted ?? true),
|
|
172
|
+
isIdle: vi.fn(() => true),
|
|
173
|
+
getSystemPrompt: vi.fn(() => ""),
|
|
174
|
+
sessionManager: {
|
|
175
|
+
getBranch: () => overrides.branch ?? [],
|
|
176
|
+
getEntries: () => overrides.branch ?? [],
|
|
177
|
+
getLeafId: () => null,
|
|
178
|
+
getSessionFile: () => undefined,
|
|
179
|
+
},
|
|
180
|
+
signal: overrides.signal,
|
|
181
|
+
abort: vi.fn(),
|
|
182
|
+
statuses,
|
|
183
|
+
};
|
|
184
|
+
return ctx;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `git rev-parse` answers with `gitRoot`, the Graft probe answers with `version`,
|
|
189
|
+
* and every other command succeeds with empty output.
|
|
190
|
+
*/
|
|
191
|
+
export function healthyExec(overrides: { gitRoot?: string; version?: string; queryOutput?: string } = {}): ExecImpl {
|
|
192
|
+
const gitRoot = overrides.gitRoot ?? "/repo";
|
|
193
|
+
const version = overrides.version ?? "0.18.0";
|
|
194
|
+
return async (command, args) => {
|
|
195
|
+
if (command === "git" && args.includes("--show-toplevel")) return { ...OK, stdout: `${gitRoot}\n` };
|
|
196
|
+
if (args.includes("--version")) return { ...OK, stdout: `${version}\n` };
|
|
197
|
+
return { ...OK, stdout: overrides.queryOutput ?? "" };
|
|
198
|
+
};
|
|
199
|
+
}
|