@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.
@@ -0,0 +1,213 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { AskPayload } from "./cli.ts";
3
+ import {
4
+ buildInjectionPack,
5
+ classifyTask,
6
+ createInjectionGuard,
7
+ describeStrategy,
8
+ injectionBudget,
9
+ injectionMessage,
10
+ MAX_QUERY_CHARS,
11
+ planInjection,
12
+ retrievalQuery,
13
+ } from "./prompt.ts";
14
+ import { DEFAULT_MIN_COVERAGE, type GraftState } from "./state.ts";
15
+
16
+ const FRESH: GraftState = { name: "fresh-structural", version: "0.18.0" };
17
+
18
+ function payload(overrides: Partial<AskPayload> = {}): AskPayload {
19
+ return {
20
+ hits: [
21
+ {
22
+ kind: "symbol",
23
+ title: "verify · function",
24
+ pointer: "src/auth.ts:L10-L20",
25
+ snippet: "checks the token",
26
+ code: "export function verify() {}",
27
+ },
28
+ ],
29
+ mode: "lexical",
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("classifyTask", () => {
35
+ it("recognizes the repository work that warrants context", () => {
36
+ const coding = [
37
+ "Fix the login crash when the token is expired",
38
+ "Refactor the retry logic in the HTTP client",
39
+ "Add a migration for the new column",
40
+ "Review this diff before I merge it",
41
+ "Why does `parseRequest` throw on empty input?",
42
+ "Update the build to include the new package",
43
+ "Write tests for src/core/exec.ts",
44
+ "Verify the release changes before deployment",
45
+ "Check implementations of subagent: is it compatible with Graft?",
46
+ "debug the flaky retryIntervals helper",
47
+ ];
48
+ for (const prompt of coding) expect(classifyTask(prompt), prompt).toBe("coding");
49
+ });
50
+
51
+ it("leaves conversation, meta questions and commands alone", () => {
52
+ const other = [
53
+ "hi",
54
+ "Thanks!",
55
+ "what can you do",
56
+ "/graft doctor",
57
+ " ",
58
+ "Tell me a joke about databases",
59
+ ];
60
+ for (const prompt of other) expect(classifyTask(prompt), prompt).toBe("other");
61
+ });
62
+
63
+ it("treats an explicit path or identifier as a task even without an action verb", () => {
64
+ expect(classifyTask("src/extensions/pi-graft/index.ts")).toBe("coding");
65
+ expect(classifyTask("What does buildGraphIfMissing return?")).toBe("coding");
66
+ });
67
+ });
68
+
69
+ describe("retrievalQuery", () => {
70
+ it("strips fenced code so a pasted snippet does not become the query", () => {
71
+ expect(retrievalQuery("Fix this\n```ts\nconst a = 1;\n```\nplease")).toBe("Fix this please");
72
+ });
73
+
74
+ it("collapses whitespace and bounds the length", () => {
75
+ expect(retrievalQuery(" a\n\n b ")).toBe("a b");
76
+ const long = retrievalQuery("word ".repeat(1000));
77
+ expect(long.length).toBe(MAX_QUERY_CHARS + 1);
78
+ expect(long.endsWith("…")).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("planInjection", () => {
83
+ const eligible = { mode: "push" as const, prompt: "Fix the login crash", state: FRESH, enabled: true };
84
+
85
+ it("injects nothing in pull mode, whatever the prompt", () => {
86
+ const plan = planInjection({ ...eligible, mode: "pull" });
87
+ expect(plan.inject).toBe(false);
88
+ expect(plan.reason).toContain("pull");
89
+ });
90
+
91
+ it("injects one bounded pack for an eligible repository task", () => {
92
+ expect(planInjection(eligible)).toEqual({
93
+ inject: true,
94
+ mode: "push",
95
+ query: "Fix the login crash",
96
+ reason: "eligible repository task",
97
+ });
98
+ });
99
+
100
+ it("skips prompts that are not repository work, so no latency is paid for chat", () => {
101
+ expect(planInjection({ ...eligible, prompt: "hey there" }).inject).toBe(false);
102
+ expect(planInjection({ ...eligible, prompt: " " }).inject).toBe(false);
103
+ });
104
+
105
+ it("skips turns where Graft cannot answer", () => {
106
+ for (const state of [{ name: "unbuilt" }, { name: "unavailable" }, { name: "unsupported" }] as GraftState[]) {
107
+ const plan = planInjection({ ...eligible, state });
108
+ expect(plan.inject, state.name).toBe(false);
109
+ expect(plan.reason).toContain(state.name);
110
+ }
111
+ });
112
+
113
+ it("still injects for a graph marked changed, because Graft refreshes on query", () => {
114
+ expect(planInjection({ ...eligible, state: { name: "changed" } }).inject).toBe(true);
115
+ });
116
+
117
+ it("injects nothing when the extension is disabled", () => {
118
+ expect(planInjection({ ...eligible, enabled: false }).inject).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe("injectionBudget", () => {
123
+ it("lets push spend the configured budget and keeps hybrid to a small orientation pack", () => {
124
+ expect(injectionBudget("push", 12_000)).toEqual({ limit: 8, maxBytes: 12_000 });
125
+ expect(injectionBudget("hybrid", 12_000)).toEqual({ limit: 3, maxBytes: 6 * 1024 });
126
+ expect(injectionBudget("hybrid", 2_000)).toEqual({ limit: 3, maxBytes: 2_000 });
127
+ });
128
+ });
129
+
130
+ describe("buildInjectionPack", () => {
131
+ it("refuses an empty result rather than injecting an empty block", () => {
132
+ expect(buildInjectionPack(payload({ hits: [] }), { maxBytes: 4096 })).toBeUndefined();
133
+ });
134
+
135
+ it("refuses a pack whose terms barely overlap the graph", () => {
136
+ expect(buildInjectionPack(payload({ coverage: 0.05 }), { maxBytes: 4096 })).toBeUndefined();
137
+ expect(buildInjectionPack(payload({ coverage: DEFAULT_MIN_COVERAGE }), { maxBytes: 4096 })).toBeDefined();
138
+ });
139
+
140
+ it("accepts a pack when the CLI reported no coverage at all", () => {
141
+ expect(buildInjectionPack(payload(), { maxBytes: 4096 })).toBeDefined();
142
+ });
143
+
144
+ it("bounds the pack and records its references and truncation", () => {
145
+ const pack = buildInjectionPack(
146
+ payload({
147
+ hits: [
148
+ {
149
+ kind: "symbol",
150
+ title: "big",
151
+ pointer: "src/big.ts:L1-L2",
152
+ snippet: "",
153
+ code: "x".repeat(100_000),
154
+ },
155
+ ],
156
+ coverage: 0.9,
157
+ }),
158
+ { maxBytes: 2_000, minCoverage: 0.25 },
159
+ )!;
160
+ expect(pack.truncated).toBe(true);
161
+ expect(pack.bytes).toBeLessThanOrEqual(2_000 + 200);
162
+ expect(pack.references).toEqual([{ path: "src/big.ts", span: "L1-L2" }]);
163
+ expect(pack.coverage).toBe(0.9);
164
+ });
165
+ });
166
+
167
+ describe("injectionMessage", () => {
168
+ it("declares its source, its mode, the graph quality, and that source files win", () => {
169
+ const pack = buildInjectionPack(payload({ coverage: 0.9 }), { maxBytes: 4096 })!;
170
+ const message = injectionMessage({ pack, mode: "hybrid", state: "fresh-structural" });
171
+ expect(message).toContain('<graft-context source="graft" mode="hybrid" graph="fresh-structural" references="1">');
172
+ expect(message).toContain("derived summary");
173
+ expect(message).toContain("authoritative");
174
+ expect(message).toContain("src/auth.ts:L10-L20");
175
+ expect(message).toContain("</graft-context>");
176
+ });
177
+
178
+ it("says a deep graph is deep, so the reader knows the provenance", () => {
179
+ const pack = buildInjectionPack(payload(), { maxBytes: 4096 })!;
180
+ expect(injectionMessage({ pack, mode: "push", state: "fresh-deep" })).toContain('graph="fresh-deep"');
181
+ });
182
+ });
183
+
184
+ describe("createInjectionGuard", () => {
185
+ it("injects at most once per turn even if the hook runs again", () => {
186
+ const guard = createInjectionGuard();
187
+ expect(guard.shouldInject()).toBe(true);
188
+ guard.markInjected();
189
+ expect(guard.shouldInject()).toBe(false);
190
+ guard.markInjected();
191
+ expect(guard.shouldInject()).toBe(false);
192
+ });
193
+
194
+ it("allows a new injection once the next turn begins", () => {
195
+ const guard = createInjectionGuard();
196
+ guard.markInjected();
197
+ guard.beginTurn();
198
+ expect(guard.shouldInject()).toBe(true);
199
+ });
200
+ });
201
+
202
+ describe("describeStrategy", () => {
203
+ it("explains each mode in the user's terms", () => {
204
+ expect(describeStrategy("pull")).toContain("nothing is injected");
205
+ expect(describeStrategy("push")).toContain("source-backed");
206
+ expect(describeStrategy("hybrid")).toContain("orientation");
207
+ });
208
+
209
+ it("never claims injection happens in pull mode", () => {
210
+ expect(describeStrategy("pull")).not.toContain("starts from");
211
+ expect(describeStrategy("push")).not.toContain("nothing is injected");
212
+ });
213
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Retrieval strategy: when Graft context reaches the model, and how much.
3
+ *
4
+ * Three genuinely different behaviors, not one behavior with a flag:
5
+ *
6
+ * - `pull` — inject nothing. The model calls the Graft tools when it wants
7
+ * repository context.
8
+ * - `push` — before an eligible coding turn, inject a bounded source-backed
9
+ * pack so the turn can start from repository understanding. Default.
10
+ * - `hybrid` — inject a small orientation pack for eligible coding turns and
11
+ * keep the precise tools for follow-up.
12
+ *
13
+ * Injection happens through the turn lifecycle as a custom message. User text
14
+ * is never rewritten, and the injected block says it is derived and that the
15
+ * source files remain authoritative.
16
+ */
17
+
18
+ import type { AskPayload, GraftReference } from "./cli.ts";
19
+ import {
20
+ askReferences,
21
+ boundText,
22
+ renderAsk,
23
+ DEFAULT_FIND_LIMIT,
24
+ } from "./cli.ts";
25
+ import {
26
+ DEFAULT_MAX_INJECTION_BYTES,
27
+ DEFAULT_MIN_COVERAGE,
28
+ type GraftState,
29
+ type GraftStateName,
30
+ isQueryable,
31
+ type RetrievalMode,
32
+ } from "./state.ts";
33
+
34
+ /** Longest prompt text handed to Graft as a retrieval query. */
35
+ export const MAX_QUERY_CHARS = 600;
36
+
37
+ /**
38
+ * Intent words that mean "this turn touches the repository". Kept deliberately
39
+ * concrete: a false positive costs a wasted Graft query and prompt noise, so
40
+ * the list is about *acting on code*, not about mentioning it.
41
+ */
42
+ const CODING_INTENT =
43
+ /\b(add|implement(?:ation)?s?|fix|bug|debug|refactor|rename|migrate|port|upgrade|remove|delete|replace|extract|move|rewrite|optimize|optimi[sz]e|revert|patch|wire|hook up|support|handle|test|spec|regression|verif(?:y|ication)|validat(?:e|ion)|crash|error|exception|stack ?trace|type ?error|lint|compile|build failure|review|diff|pull request|\bpr\b|commit|branch|merge|conflict|api|schema|migration|endpoint|component|function|class|method|module|package|dependency|config|performance|memory leak|deadlock|race)\b/i;
44
+
45
+ /** A path, an identifier, or an explicitly quoted symbol is a strong signal on its own. */
46
+ const CODE_SHAPE = /(`[^`]+`|\b[\w.-]+\.(ts|tsx|js|jsx|py|go|java|kt|php|swift|rs|c|cc|cpp|cs|rb|scala|ex|sol|ml|zig|dart|clj|nix|lua|R)\b|\b[a-z]+[A-Z][A-Za-z0-9]*\b|\b[A-Z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*\b|\b[\w-]+\/[\w./-]+\.\w+\b)/;
47
+
48
+ /** Prompts that are conversational or meta never warrant repository context. */
49
+ const NON_CODING =
50
+ /^(hi|hey|hello|thanks|thank you|ok|okay|cool|nice|great|yes|no|stop|never mind|nevermind|what (do you think|can you do)|who are you)\b[\s!.?]*$/i;
51
+
52
+ export type TaskClass = "coding" | "other";
53
+
54
+ /**
55
+ * Classify a user prompt as a repository task or not.
56
+ *
57
+ * `ponytail:` a lexical heuristic, not a model call — it exists so non-coding
58
+ * turns pay no Graft latency at all. It has a known ceiling: an indirect
59
+ * request ("the login flow feels slow") classifies as `other` and simply
60
+ * falls back to `pull` behavior. Upgrade path is a provider-backed classifier
61
+ * once the cheap gate measurably costs us turns.
62
+ */
63
+ export function classifyTask(prompt: string): TaskClass {
64
+ const text = prompt.trim();
65
+ if (!text) return "other";
66
+ if (NON_CODING.test(text)) return "other";
67
+ if (text.startsWith("/")) return "other";
68
+ if (CODING_INTENT.test(text) || CODE_SHAPE.test(text)) return "coding";
69
+ return "other";
70
+ }
71
+
72
+ /** Collapse a prompt into a bounded plain-text retrieval query. */
73
+ export function retrievalQuery(prompt: string): string {
74
+ const text = prompt
75
+ .replace(/```[\s\S]*?```/g, " ")
76
+ .replace(/\s+/g, " ")
77
+ .trim();
78
+ return text.length > MAX_QUERY_CHARS ? `${text.slice(0, MAX_QUERY_CHARS)}…` : text;
79
+ }
80
+
81
+ export interface InjectionPlan {
82
+ inject: boolean;
83
+ mode: RetrievalMode;
84
+ query: string;
85
+ /** Why nothing was injected; used by tests and by `/graft doctor`. */
86
+ reason: string;
87
+ }
88
+
89
+ /**
90
+ * Decide whether this turn gets an injected pack.
91
+ *
92
+ * Pure: the caller supplies the mode, the prompt, and the current graph state.
93
+ */
94
+ export function planInjection(input: {
95
+ mode: RetrievalMode;
96
+ prompt: string;
97
+ state: GraftState;
98
+ enabled: boolean;
99
+ }): InjectionPlan {
100
+ const base = { mode: input.mode, query: "" } as const;
101
+ if (!input.enabled) return { ...base, inject: false, reason: "extension disabled by settings" };
102
+ if (input.mode === "pull") return { ...base, inject: false, reason: "pull mode: the model retrieves on demand" };
103
+ if (!isQueryable(input.state)) {
104
+ return { ...base, inject: false, reason: `no usable graph (${input.state.name})` };
105
+ }
106
+ const query = retrievalQuery(input.prompt);
107
+ if (!query) return { ...base, inject: false, reason: "empty prompt" };
108
+ if (classifyTask(query) !== "coding") {
109
+ return { ...base, inject: false, reason: "prompt does not look like a repository task" };
110
+ }
111
+ return { inject: true, mode: input.mode, query, reason: "eligible repository task" };
112
+ }
113
+
114
+ /** Per-mode shape of the injected pack. Push spends more; hybrid orients. */
115
+ export function injectionBudget(mode: RetrievalMode, maxInjectionBytes: number): { limit: number; maxBytes: number } {
116
+ if (mode === "push") return { limit: DEFAULT_FIND_LIMIT, maxBytes: maxInjectionBytes };
117
+ return { limit: 3, maxBytes: Math.min(maxInjectionBytes, 6 * 1024) };
118
+ }
119
+
120
+ export interface InjectionPack {
121
+ text: string;
122
+ references: GraftReference[];
123
+ bytes: number;
124
+ truncated: boolean;
125
+ coverage?: number;
126
+ }
127
+
128
+ /**
129
+ * Turn an `ask` payload into a bounded pack, or refuse it.
130
+ *
131
+ * Refusal is the important half: an unprompted pack whose terms barely overlap
132
+ * the graph is noise that the model then has to reason around. Graft reports
133
+ * `coverage` for exactly this gate, so a low-coverage result is dropped rather
134
+ * than injected.
135
+ */
136
+ export function buildInjectionPack(
137
+ payload: AskPayload,
138
+ budget: { maxBytes: number; minCoverage?: number },
139
+ ): InjectionPack | undefined {
140
+ if (payload.hits.length === 0) return undefined;
141
+ const minCoverage = budget.minCoverage ?? DEFAULT_MIN_COVERAGE;
142
+ if (typeof payload.coverage === "number" && payload.coverage < minCoverage) return undefined;
143
+
144
+ const bounded = boundText(renderAsk(payload), { maxBytes: budget.maxBytes, maxLines: 300 });
145
+ if (!bounded.text.trim()) return undefined;
146
+ return {
147
+ text: bounded.text,
148
+ references: askReferences(payload),
149
+ bytes: Buffer.byteLength(bounded.text, "utf-8"),
150
+ truncated: bounded.truncated,
151
+ coverage: payload.coverage,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * The injected message body.
157
+ *
158
+ * Three properties are load-bearing, so they are stated in the text itself:
159
+ * where it came from, that it is derived rather than authoritative, and that
160
+ * the model should verify before editing.
161
+ */
162
+ export function injectionMessage(input: {
163
+ pack: InjectionPack;
164
+ mode: RetrievalMode;
165
+ state: GraftStateName;
166
+ }): string {
167
+ const { pack, mode, state } = input;
168
+ const header =
169
+ `<graft-context source="graft" mode="${mode}" graph="${state}" references="${pack.references.length}">`;
170
+ return [
171
+ header,
172
+ "Repository context prepared by the Graft code-context extension before this turn.",
173
+ "It is a derived summary of the working tree, not the source of truth: files in the",
174
+ "repository are authoritative, so read them before changing anything, and treat a",
175
+ "reachability claim here as a lead rather than proof.",
176
+ "",
177
+ pack.text,
178
+ "</graft-context>",
179
+ ].join("\n");
180
+ }
181
+
182
+ /**
183
+ * At-most-once injection per turn.
184
+ *
185
+ * `before_agent_start` runs once per user prompt, but a retried or replayed run
186
+ * can reach it again, and a reload re-registers handlers. The guard makes the
187
+ * seam idempotent regardless of how many times the hook fires.
188
+ */
189
+ export function createInjectionGuard(): {
190
+ shouldInject(): boolean;
191
+ markInjected(): void;
192
+ beginTurn(): void;
193
+ } {
194
+ let injected = false;
195
+ return {
196
+ shouldInject: () => !injected,
197
+ markInjected: () => {
198
+ injected = true;
199
+ },
200
+ beginTurn: () => {
201
+ injected = false;
202
+ },
203
+ };
204
+ }
205
+
206
+ /** One-line explanation of the current retrieval strategy, for `/graft status`. */
207
+ export function describeStrategy(mode: RetrievalMode): string {
208
+ switch (mode) {
209
+ case "pull":
210
+ return "pull — the model calls Graft tools when it needs repository context (nothing is injected).";
211
+ case "push":
212
+ return "push — an eligible repository task starts from a bounded source-backed Graft pack.";
213
+ case "hybrid":
214
+ return "hybrid — an eligible repository task starts from a small orientation pack; tools stay available.";
215
+ }
216
+ }
@@ -0,0 +1,258 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { SessionEntry } from "@selesai/code";
5
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
6
+ import {
7
+ ABSENT_GRAPH,
8
+ DEFAULT_MIN_COVERAGE,
9
+ DEFAULT_RETRIEVAL_MODE,
10
+ INITIAL_GRAFT_STATE,
11
+ MODE_ENTRY_TYPE,
12
+ readBranchMode,
13
+ readGraftSettings,
14
+ recoveryFor,
15
+ reduceGraftState,
16
+ statusDetail,
17
+ statusText,
18
+ isQueryable,
19
+ isRetrievalMode,
20
+ isSetupProblem,
21
+ type GraftState,
22
+ } from "./state.ts";
23
+
24
+ const UNBUILT: GraftState = { name: "unbuilt", detail: "no graph yet" };
25
+ const FRESH: GraftState = { name: "fresh-structural", version: "0.18.0", deep: false };
26
+ const DEEP: GraftState = { name: "fresh-deep", version: "0.18.0", deep: true };
27
+
28
+ describe("reduceGraftState", () => {
29
+ it("only claims a repository once root resolution says there is a Git working tree", () => {
30
+ const notGit = reduceGraftState(INITIAL_GRAFT_STATE, { type: "resolved", gitRepo: false });
31
+ expect(notGit.name).toBe("unsupported");
32
+ expect(notGit.detail).toContain("Git");
33
+
34
+ const git = reduceGraftState(INITIAL_GRAFT_STATE, { type: "resolved", gitRepo: true });
35
+ expect(git.name).toBe("unavailable");
36
+ });
37
+
38
+ it("derives built/fresh/deep from what is actually on disk", () => {
39
+ expect(reduceGraftState(gitState(), { type: "probe-ok", version: "0.18.0", graph: ABSENT_GRAPH })).toEqual({
40
+ name: "unbuilt",
41
+ version: "0.18.0",
42
+ detail: expect.stringContaining("/graft build"),
43
+ });
44
+ expect(
45
+ reduceGraftState(gitState(), { type: "probe-ok", version: "0.18.0", graph: { built: true, deep: false } }),
46
+ ).toEqual(FRESH);
47
+ expect(
48
+ reduceGraftState(gitState(), { type: "probe-ok", version: "0.18.0", graph: { built: true, deep: true } }),
49
+ ).toMatchObject({ name: "fresh-deep", deep: true });
50
+ });
51
+
52
+ it("records why the executable is unusable", () => {
53
+ const missing = reduceGraftState(gitState(), { type: "probe-missing", detail: "spawn graft ENOENT" });
54
+ expect(missing).toMatchObject({ name: "unavailable", detail: "spawn graft ENOENT" });
55
+
56
+ const old = reduceGraftState(gitState(), {
57
+ type: "probe-incompatible",
58
+ version: "0.4.0",
59
+ detail: "too old",
60
+ });
61
+ expect(old).toMatchObject({ name: "incompatible", version: "0.4.0" });
62
+ });
63
+
64
+ it("marks a graph stale only when there is a graph to go stale", () => {
65
+ expect(reduceGraftState(FRESH, { type: "mutation-observed" }).name).toBe("changed");
66
+ expect(reduceGraftState(DEEP, { type: "mutation-observed" }).name).toBe("changed");
67
+ expect(reduceGraftState(UNBUILT, { type: "mutation-observed" }).name).toBe("unbuilt");
68
+ expect(reduceGraftState({ name: "failed" }, { type: "mutation-observed" }).name).toBe("failed");
69
+ });
70
+
71
+ it("returns to fresh after a query, because Graft refreshes before answering", () => {
72
+ const changed = reduceGraftState(FRESH, { type: "mutation-observed" });
73
+ expect(changed.name).toBe("changed");
74
+ expect(reduceGraftState(changed, { type: "query-succeeded", presence: { built: true, deep: false } })).toEqual(
75
+ FRESH,
76
+ );
77
+ expect(reduceGraftState(changed, { type: "query-succeeded", presence: { built: true, deep: true } })).toMatchObject(
78
+ { name: "fresh-deep" },
79
+ );
80
+ });
81
+
82
+ it("keeps a failed build distinct from an unbuilt repository", () => {
83
+ const failed = reduceGraftState(UNBUILT, { type: "build-failed", detail: "exit 1" });
84
+ expect(failed).toMatchObject({ name: "failed", detail: "exit 1" });
85
+ expect(reduceGraftState({ name: "building", deep: true }, { type: "build-succeeded", deep: true, presence: { built: true, deep: true } })).toMatchObject({
86
+ name: "fresh-deep",
87
+ });
88
+ });
89
+
90
+ it("announces the build phase so the status line can show work in progress", () => {
91
+ expect(reduceGraftState(FRESH, { type: "build-started", deep: true })).toMatchObject({
92
+ name: "building",
93
+ deep: true,
94
+ version: "0.18.0",
95
+ });
96
+ });
97
+
98
+ it("only shows a refresh phase for a graph that was actually stale", () => {
99
+ const changed = reduceGraftState(FRESH, { type: "mutation-observed" });
100
+ expect(reduceGraftState(changed, { type: "refresh-started" }).name).toBe("refreshing");
101
+ expect(reduceGraftState(FRESH, { type: "refresh-started" }).name).toBe("fresh-structural");
102
+ });
103
+
104
+ it("resets to the initial state", () => {
105
+ expect(reduceGraftState(FRESH, { type: "reset" })).toEqual(INITIAL_GRAFT_STATE);
106
+ });
107
+ });
108
+
109
+ function gitState(): GraftState {
110
+ return reduceGraftState(INITIAL_GRAFT_STATE, { type: "resolved", gitRepo: true });
111
+ }
112
+
113
+ /** A session entry with the fields `readBranchMode` reads, and the base fields above them. */
114
+ function customEntry(customType: string, data: unknown): SessionEntry {
115
+ return {
116
+ type: "custom",
117
+ id: `entry-${customType}-${String((data as { mode?: string } | undefined)?.mode ?? "none")}`,
118
+ parentId: null,
119
+ timestamp: new Date(0).toISOString(),
120
+ customType,
121
+ data,
122
+ } as SessionEntry;
123
+ }
124
+
125
+ describe("state predicates and rendering", () => {
126
+ it("distinguishes states a query can answer from states it cannot", () => {
127
+ expect(isQueryable(FRESH)).toBe(true);
128
+ expect(isQueryable(DEEP)).toBe(true);
129
+ expect(isQueryable({ name: "changed" })).toBe(true);
130
+ expect(isQueryable(UNBUILT)).toBe(false);
131
+ expect(isQueryable({ name: "unavailable" })).toBe(false);
132
+ expect(isQueryable({ name: "incompatible" })).toBe(false);
133
+ expect(isQueryable({ name: "unsupported" })).toBe(false);
134
+ });
135
+
136
+ it("separates setup problems from runtime failures", () => {
137
+ expect(isSetupProblem({ name: "unavailable" })).toBe(true);
138
+ expect(isSetupProblem({ name: "incompatible" })).toBe(true);
139
+ expect(isSetupProblem({ name: "failed" })).toBe(false);
140
+ });
141
+
142
+ it("renders a compact status line with the version when it is known", () => {
143
+ expect(statusText(FRESH)).toBe("graft: ● structural v0.18.0");
144
+ expect(statusText(DEEP)).toBe("graft: ● deep v0.18.0");
145
+ expect(statusText({ name: "unavailable" })).toBe("graft: ✕ no cli");
146
+ expect(statusText({ name: "changed" })).toBe("graft: ◐ changed");
147
+ });
148
+
149
+ it("gives every broken state an actionable recovery step and healthy states none", () => {
150
+ expect(recoveryFor({ name: "unavailable" })).toContain("/graft setup");
151
+ expect(recoveryFor({ name: "incompatible" })).toContain("npm install -g @nanonets/graft");
152
+ expect(recoveryFor({ name: "unbuilt" })).toContain("/graft build");
153
+ expect(recoveryFor({ name: "unsupported" })).toContain("git init");
154
+ expect(recoveryFor({ name: "failed" })).toContain("/graft doctor");
155
+ expect(recoveryFor(FRESH)).toBeUndefined();
156
+ });
157
+
158
+ it("falls back to the state label when there is no detail", () => {
159
+ expect(statusDetail({ name: "changed" })).toBe("◐ changed");
160
+ expect(statusDetail({ name: "unbuilt", detail: "custom" })).toBe("custom");
161
+ });
162
+ });
163
+
164
+ describe("retrieval mode", () => {
165
+ it("accepts only the three documented strategies", () => {
166
+ expect(isRetrievalMode("pull")).toBe(true);
167
+ expect(isRetrievalMode("push")).toBe(true);
168
+ expect(isRetrievalMode("hybrid")).toBe(true);
169
+ expect(isRetrievalMode("auto")).toBe(false);
170
+ expect(isRetrievalMode(undefined)).toBe(false);
171
+ });
172
+
173
+ it("defaults to hybrid, the relevance-gated context mode", () => {
174
+ expect(DEFAULT_RETRIEVAL_MODE).toBe("hybrid");
175
+ });
176
+
177
+ it("restores the newest mode chosen on this branch, ignoring other custom entries", () => {
178
+ const branch = [
179
+ customEntry(MODE_ENTRY_TYPE, { mode: "hybrid" }),
180
+ customEntry("graft-injection", { mode: "push" }),
181
+ customEntry(MODE_ENTRY_TYPE, { mode: "push" }),
182
+ ];
183
+ expect(readBranchMode(branch)).toBe("push");
184
+ expect(readBranchMode([])).toBeUndefined();
185
+ expect(readBranchMode([customEntry(MODE_ENTRY_TYPE, { mode: "auto" })])).toBeUndefined();
186
+ expect(readBranchMode([customEntry(MODE_ENTRY_TYPE, undefined)])).toBeUndefined();
187
+ expect(readBranchMode([customEntry("other", { mode: "push" })])).toBeUndefined();
188
+ expect(readBranchMode([{ type: "message" } as never])).toBeUndefined();
189
+ });
190
+ });
191
+
192
+ describe("readGraftSettings", () => {
193
+ let root: string;
194
+ let agentDir: string;
195
+ let projectDir: string;
196
+
197
+ beforeAll(() => {
198
+ root = mkdtempSync(join(tmpdir(), "graft-settings-"));
199
+ agentDir = join(root, "agent");
200
+ projectDir = join(root, "project");
201
+ mkdirSync(agentDir, { recursive: true });
202
+ mkdirSync(join(projectDir, ".selesai"), { recursive: true });
203
+ });
204
+
205
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
206
+
207
+ const write = (path: string, value: unknown) => writeFileSync(path, JSON.stringify(value), "utf-8");
208
+ const read = (trusted: boolean, cwd = projectDir) => readGraftSettings({ cwd, trusted, agentDir });
209
+
210
+ it("returns defaults when no settings file exists", () => {
211
+ expect(read(false, join(root, "nowhere"))).toEqual({});
212
+ expect(read(true, join(root, "nowhere"))).toEqual({});
213
+ });
214
+
215
+ it("ignores a malformed settings file instead of breaking a session", () => {
216
+ const brokenDir = join(root, "broken");
217
+ mkdirSync(join(brokenDir, ".selesai"), { recursive: true });
218
+ writeFileSync(join(brokenDir, ".selesai", "settings.json"), "{ not json", "utf-8");
219
+ expect(read(true, brokenDir)).toEqual({});
220
+ });
221
+
222
+ it("reads the global graft section", () => {
223
+ write(join(agentDir, "settings.json"), { graft: { mode: "hybrid", minCoverage: 0.4, in: " packages/api " } });
224
+ expect(read(false)).toEqual({ mode: "hybrid", minCoverage: 0.4, in: "packages/api" });
225
+ });
226
+
227
+ it("ignores project settings until the project is trusted", () => {
228
+ write(join(projectDir, ".selesai", "settings.json"), { graft: { mode: "push" } });
229
+ expect(read(false).mode).toBe("hybrid");
230
+ expect(read(true).mode).toBe("push");
231
+ });
232
+
233
+ it("drops values that are not the documented shape", () => {
234
+ write(join(agentDir, "settings.json"), {
235
+ graft: {
236
+ enabled: "yes",
237
+ mode: "auto",
238
+ telemetry: "maybe",
239
+ minCoverage: 1.5,
240
+ maxInjectionBytes: -1,
241
+ maxResultBytes: 0,
242
+ refreshDebounceSeconds: "60",
243
+ in: " ",
244
+ },
245
+ });
246
+ expect(read(false)).toEqual({});
247
+ });
248
+
249
+ it("keeps a valid enabled flag and telemetry preference", () => {
250
+ write(join(agentDir, "settings.json"), { graft: { enabled: false, telemetry: "inherit" } });
251
+ expect(read(false)).toEqual({ enabled: false, telemetry: "inherit" });
252
+ });
253
+
254
+ it("defaults the injection gate to a value a real pack can clear", () => {
255
+ expect(DEFAULT_MIN_COVERAGE).toBeGreaterThan(0);
256
+ expect(DEFAULT_MIN_COVERAGE).toBeLessThan(1);
257
+ });
258
+ });