@selesai/code 0.13.37 → 0.13.39
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 +15 -0
- package/dist/extensions/jev/decisions.test.ts +18 -0
- package/dist/extensions/jev/decisions.ts +54 -6
- package/dist/extensions/jev-ask-tool.test.ts +371 -25
- package/dist/extensions/jev-ask-tool.ts +826 -167
- package/dist/extensions/jev-find-source.ts +190 -0
- package/dist/extensions/package.json +1 -1
- package/dist/extensions/pi-graft/cli.test.ts +5 -5
- package/dist/extensions/pi-graft/cli.ts +10 -5
- package/dist/extensions/pi-graft/commands.test.ts +5 -5
- package/dist/extensions/pi-graft/graft.integration.test.ts +1 -1
- package/dist/extensions/pi-graft/lifecycle.test.ts +26 -26
- package/dist/extensions/pi-graft/prompt.test.ts +1 -1
- package/dist/extensions/pi-graft/state.test.ts +9 -9
- package/dist/extensions/pi-graft/test-support.ts +1 -1
- package/dist/extensions/pi-graft/tools.test.ts +1 -1
- package/dist/extensions/pi-subagents/agents/researcher.md +4 -5
- package/dist/extensions/pi-subagents/docs/agents.md +1 -7
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +3 -4
- package/dist/extensions/pi-web-agent/src/extension.ts +11 -0
- package/dist/extensions/tokenin-onboarding.ts +138 -1
- package/docs/settings.md +8 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@selesai/code` will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.13.39] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- **`/tokenin usage` shows the account balance.** It queries `/tokenin/account/me/balance` and shows available credit (plan, PAYG, debt), active plan credit used, and plan expiry; a 404 falls back to the LiteLLM `/key/info` quota view.
|
|
9
|
+
- **The `researcher` subagent uses `web_explore`** from the bundled `pi-web-agent` extension instead of `pi-web-access` tools; `pi-web-agent` registers a builtin-agent augmentation so foreground `researcher` children load it.
|
|
10
|
+
- **Graft CLI floor raised to 0.21** (`MIN_GRAFT_VERSION` 0.21.0, install spec `@nanonets/graft@^0.21`).
|
|
11
|
+
|
|
12
|
+
## [0.13.38] - 2026-10-01
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **`jev_find` returns source evidence, not just file pointers.** Large trees are narrowed through directory, file, and source-unit judging; results include ranked files, reading leads, and accepted source verbatim with original line numbers, capped at 12 Jev requests and 16 KiB of source per call. JavaScript and TypeScript are split into declarations when the parser is available; other files and installs without it use bounded text chunks.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Jev Find remains useful when Jev cannot judge.** It returns ripgrep-ranked candidates with local keyword-window source, and provider diagnostics expose only a failure category and optional HTTP status, never raw provider error text.
|
|
19
|
+
|
|
5
20
|
## [0.13.37] - 2026-09-30
|
|
6
21
|
|
|
7
22
|
### Changed
|
|
@@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({ settingsPath: "" }));
|
|
|
17
17
|
// layer owns the non-streaming decisions request), so a suite stubs that.
|
|
18
18
|
import {
|
|
19
19
|
askJev,
|
|
20
|
+
askJevAnswers,
|
|
20
21
|
buildConversation,
|
|
21
22
|
buildJevPayload,
|
|
22
23
|
confidenceBucket,
|
|
@@ -278,6 +279,23 @@ describe("askJev", () => {
|
|
|
278
279
|
expect(await ask()).toMatchObject({ failure: "malformed" });
|
|
279
280
|
});
|
|
280
281
|
|
|
282
|
+
it("reports safe transport diagnostics without returning raw provider errors", async () => {
|
|
283
|
+
const error = Object.assign(new Error("secret=must-not-escape"), { status: 503 });
|
|
284
|
+
completeMock.mockRejectedValue(error);
|
|
285
|
+
|
|
286
|
+
const result = await askJevAnswers(runtime() as never, CONNECTION, { payload: {}, maxBytes: 1_024 });
|
|
287
|
+
expect(result).toMatchObject({ failure: "transport", diagnostic: { kind: "provider-error", httpStatus: 503 } });
|
|
288
|
+
expect(JSON.stringify(result)).not.toContain("must-not-escape");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("extracts only the HTTP status from TokenIn's error completion", async () => {
|
|
292
|
+
completeMock.mockResolvedValue(jevResponse("", { stopReason: "error", errorMessage: "HTTP 429: private response body" }));
|
|
293
|
+
|
|
294
|
+
const result = await askJevAnswers(runtime() as never, CONNECTION, { payload: {}, maxBytes: 1_024 });
|
|
295
|
+
expect(result).toMatchObject({ failure: "transport", diagnostic: { kind: "provider-error", httpStatus: 429 } });
|
|
296
|
+
expect(JSON.stringify(result)).not.toContain("private response body");
|
|
297
|
+
});
|
|
298
|
+
|
|
281
299
|
it("abstains per question on an unlisted, low-confidence, or missing answer", async () => {
|
|
282
300
|
completeMock.mockResolvedValue(jevResponse(jevAnswers({ repository_context: { choice: "maybe", confidence: 1 } })));
|
|
283
301
|
expect(await ask()).toMatchObject({ choices: {}, failure: "unknown-choice" });
|
|
@@ -426,10 +426,41 @@ function responseText(response: { content: readonly { type: string; text?: strin
|
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
function failureReason(error: unknown): JevAbstainReason {
|
|
429
|
-
|
|
429
|
+
try {
|
|
430
|
+
if (isRecord(error) && (error.name === "TimeoutError" || error.name === "AbortError")) return "timeout";
|
|
431
|
+
} catch {
|
|
432
|
+
// A hostile error object is still just a transport failure.
|
|
433
|
+
}
|
|
430
434
|
return "transport";
|
|
431
435
|
}
|
|
432
436
|
|
|
437
|
+
function failureDiagnostic(error: unknown, failure: JevAbstainReason): JevFailureDiagnostic {
|
|
438
|
+
if (failure === "timeout") return { kind: "timeout" };
|
|
439
|
+
try {
|
|
440
|
+
const response = isRecord(error) && isRecord(error.response) ? error.response : undefined;
|
|
441
|
+
const message =
|
|
442
|
+
typeof error === "string"
|
|
443
|
+
? error
|
|
444
|
+
: error instanceof Error
|
|
445
|
+
? error.message
|
|
446
|
+
: isRecord(error) && typeof error.errorMessage === "string"
|
|
447
|
+
? error.errorMessage
|
|
448
|
+
: "";
|
|
449
|
+
const messageStatus = /^HTTP (\d{3}):/.exec(message)?.[1];
|
|
450
|
+
const httpStatus =
|
|
451
|
+
(isRecord(error) ? (error.status ?? error.statusCode ?? response?.status) : undefined) ??
|
|
452
|
+
(messageStatus ? Number(messageStatus) : undefined);
|
|
453
|
+
return {
|
|
454
|
+
kind: "provider-error",
|
|
455
|
+
...(typeof httpStatus === "number" && Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599
|
|
456
|
+
? { httpStatus }
|
|
457
|
+
: {}),
|
|
458
|
+
};
|
|
459
|
+
} catch {
|
|
460
|
+
return { kind: "provider-error" };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
433
464
|
interface JevAuth {
|
|
434
465
|
ok: boolean;
|
|
435
466
|
apiKey?: string;
|
|
@@ -494,10 +525,17 @@ export async function askJev(
|
|
|
494
525
|
return { choices, rejected, elapsedMs: result.elapsedMs };
|
|
495
526
|
}
|
|
496
527
|
|
|
528
|
+
/** Safe, content-free context for a failed Jev request. Never contains provider error text. */
|
|
529
|
+
export interface JevFailureDiagnostic {
|
|
530
|
+
kind: "timeout" | "provider-error" | "malformed-response";
|
|
531
|
+
httpStatus?: number;
|
|
532
|
+
}
|
|
533
|
+
|
|
497
534
|
/** Jev's raw `answers` envelope, or why the request produced none. */
|
|
498
535
|
export interface JevAnswers {
|
|
499
536
|
answers?: Record<string, unknown>;
|
|
500
537
|
failure?: JevAbstainReason;
|
|
538
|
+
diagnostic?: JevFailureDiagnostic;
|
|
501
539
|
elapsedMs: number;
|
|
502
540
|
}
|
|
503
541
|
|
|
@@ -510,7 +548,8 @@ export interface JevAnswers {
|
|
|
510
548
|
* was asked. Nothing beyond the envelope is validated here, so a caller that
|
|
511
549
|
* expects typed answers owns interpreting them. Every failure — no provider
|
|
512
550
|
* template, no credential, an oversized request, a timeout, a rejected call, or
|
|
513
|
-
* malformed JSON — is an absent decision, and this never throws.
|
|
551
|
+
* malformed JSON — is an absent decision, and this never throws. Failure diagnostics
|
|
552
|
+
* contain only a category and optional HTTP status, never provider error text.
|
|
514
553
|
*/
|
|
515
554
|
export async function askJevAnswers(
|
|
516
555
|
ctx: JevRuntime,
|
|
@@ -518,7 +557,11 @@ export async function askJevAnswers(
|
|
|
518
557
|
request: { payload: unknown; maxBytes: number },
|
|
519
558
|
): Promise<JevAnswers> {
|
|
520
559
|
const started = Date.now();
|
|
521
|
-
const abstained = (failure: JevAbstainReason): JevAnswers => ({
|
|
560
|
+
const abstained = (failure: JevAbstainReason, diagnostic?: JevFailureDiagnostic): JevAnswers => ({
|
|
561
|
+
failure,
|
|
562
|
+
...(diagnostic ? { diagnostic } : {}),
|
|
563
|
+
elapsedMs: Date.now() - started,
|
|
564
|
+
});
|
|
522
565
|
|
|
523
566
|
const access = await jevAccess(ctx, connection);
|
|
524
567
|
if ("failure" in access) return abstained(access.failure);
|
|
@@ -539,16 +582,21 @@ export async function askJevAnswers(
|
|
|
539
582
|
{ apiKey: auth.apiKey, headers: auth.headers, maxTokens: JEV_MAX_TOKENS, signal },
|
|
540
583
|
);
|
|
541
584
|
} catch (error) {
|
|
542
|
-
|
|
585
|
+
const failure = failureReason(error);
|
|
586
|
+
return abstained(failure, failureDiagnostic(error, failure));
|
|
543
587
|
}
|
|
544
588
|
// A provider failure or a deadline arrives as a message with an error stop
|
|
545
589
|
// reason rather than as a thrown error.
|
|
546
590
|
if (completion.stopReason === "error" || completion.stopReason === "aborted") {
|
|
547
|
-
|
|
591
|
+
const failure = signal.aborted ? "timeout" : "transport";
|
|
592
|
+
return abstained(
|
|
593
|
+
failure,
|
|
594
|
+
failure === "timeout" ? { kind: "timeout" } : failureDiagnostic(completion.errorMessage, failure),
|
|
595
|
+
);
|
|
548
596
|
}
|
|
549
597
|
|
|
550
598
|
const answers = parseAnswers(responseText(completion));
|
|
551
|
-
if (!answers) return abstained("malformed");
|
|
599
|
+
if (!answers) return abstained("malformed", { kind: "malformed-response" });
|
|
552
600
|
return { answers, elapsedMs: Date.now() - started };
|
|
553
601
|
}
|
|
554
602
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* assert what the agent observes — the state Jev was sent, the answers that come
|
|
7
7
|
* back, and what never leaves the process — never private helper order.
|
|
8
8
|
*/
|
|
9
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -32,8 +32,26 @@ vi.mock("@selesai/code", () => ({
|
|
|
32
32
|
}),
|
|
33
33
|
}));
|
|
34
34
|
|
|
35
|
-
import jevAskToolExtension, {
|
|
36
|
-
|
|
35
|
+
import jevAskToolExtension, {
|
|
36
|
+
askPath,
|
|
37
|
+
childDirectory,
|
|
38
|
+
FIND_BATCH,
|
|
39
|
+
FIND_MAX_REQUESTS,
|
|
40
|
+
FIND_MAX_SOURCE_BYTES,
|
|
41
|
+
FindRequests,
|
|
42
|
+
fitJudgePayloads,
|
|
43
|
+
MAX_ASK_QUESTIONS,
|
|
44
|
+
renderSourceBlocks,
|
|
45
|
+
runJevFind,
|
|
46
|
+
unitView,
|
|
47
|
+
} from "./jev-ask-tool.ts";
|
|
48
|
+
import { chunkUnits, loadTypeScript, splitSourceUnits } from "./jev-find-source.ts";
|
|
49
|
+
import {
|
|
50
|
+
JEV_ROUTING_EVENT,
|
|
51
|
+
type JevAbstainReason,
|
|
52
|
+
type JevFailureDiagnostic,
|
|
53
|
+
serializeJevRequest,
|
|
54
|
+
} from "./jev/decisions.ts";
|
|
37
55
|
import { jevResponse, providerTemplate } from "./jev/test-support.ts";
|
|
38
56
|
|
|
39
57
|
const FILE_SENTINEL = "FILE_BODY_MUST_RETURN_TO_JE ONLY";
|
|
@@ -425,14 +443,346 @@ describe("jev_find", () => {
|
|
|
425
443
|
return { text: result.content[0]?.text ?? "", details: result.details };
|
|
426
444
|
};
|
|
427
445
|
|
|
446
|
+
type Stage = "directories" | "files" | "units";
|
|
447
|
+
/**
|
|
448
|
+
* A fake Jev for `runJevFind`: it reads each request the way Jev would (state keyed by stage,
|
|
449
|
+
* one noul per quoted key) and answers with `decide`, refusing an oversized request exactly as
|
|
450
|
+
* the real transport does. `fail` scripts whole-request failures by stage and attempt.
|
|
451
|
+
*/
|
|
452
|
+
function fakeJev(
|
|
453
|
+
decide: (stage: Stage, key: string) => number | undefined,
|
|
454
|
+
options: {
|
|
455
|
+
maxBytes?: number;
|
|
456
|
+
fail?: (stage: Stage, attempt: number) => JevAbstainReason | undefined;
|
|
457
|
+
diagnostic?: JevFailureDiagnostic;
|
|
458
|
+
} = {},
|
|
459
|
+
) {
|
|
460
|
+
const calls: Array<{ stage: Stage; keys: string[]; bytes: number; payload: Record<string, unknown> }> = [];
|
|
461
|
+
const ask = async (payload: Record<string, unknown>) => {
|
|
462
|
+
const state = payload.state as Record<string, unknown>;
|
|
463
|
+
const stage = (["directories", "files", "units"] as const).find((key) => key in state) as Stage;
|
|
464
|
+
const questions = payload.questions as Record<string, { instructions: { question: string } }>;
|
|
465
|
+
const keys = Object.values(questions).map((q) => /"([^"]+)"/.exec(q.instructions.question)?.[1] ?? "");
|
|
466
|
+
const bytes = Buffer.byteLength(JSON.stringify(payload), "utf-8");
|
|
467
|
+
calls.push({ stage, keys, bytes, payload });
|
|
468
|
+
if (serializeJevRequest(payload, options.maxBytes ?? 32 * 1024) === undefined) {
|
|
469
|
+
return { failure: "overflow" as const, elapsedMs: 0 };
|
|
470
|
+
}
|
|
471
|
+
const failure = options.fail?.(stage, calls.filter((call) => call.stage === stage).length);
|
|
472
|
+
if (failure) return { failure, ...(options.diagnostic ? { diagnostic: options.diagnostic } : {}), elapsedMs: 1 };
|
|
473
|
+
const answers: Record<string, unknown> = {};
|
|
474
|
+
Object.keys(questions).forEach((name, index) => {
|
|
475
|
+
const noul = decide(stage, keys[index]);
|
|
476
|
+
if (noul !== undefined) answers[name] = { noul };
|
|
477
|
+
});
|
|
478
|
+
return { answers, elapsedMs: 1 };
|
|
479
|
+
};
|
|
480
|
+
return { ask, calls, keys: (stage: Stage) => calls.filter((call) => call.stage === stage).flatMap((call) => call.keys) };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
let tree: string;
|
|
484
|
+
const RETRY_SOURCE = [
|
|
485
|
+
'import { x } from "./x";',
|
|
486
|
+
"",
|
|
487
|
+
"/** Doubles the delay on every retry. */",
|
|
488
|
+
"export function retryBackoff(attempt: number) {",
|
|
489
|
+
"\treturn 100 * 2 ** attempt;",
|
|
490
|
+
"}",
|
|
491
|
+
"",
|
|
492
|
+
"export const unrelated = x; // not the retry path",
|
|
493
|
+
"",
|
|
494
|
+
].join("\n");
|
|
495
|
+
|
|
428
496
|
beforeAll(() => {
|
|
429
497
|
writeFileSync(join(repo, "src", "other.ts"), "// mentions round only in passing\nexport const x = 1;\n", "utf-8");
|
|
430
498
|
writeFileSync(join(repo, ".env"), "round=SECRET_ENV_VALUE\n", "utf-8");
|
|
499
|
+
// 86 files: alpha/ (30), beta/core/ (30), beta/extra/ (25), and README.md at the root.
|
|
500
|
+
tree = join(root, "tree");
|
|
501
|
+
for (const [dir, count] of [["alpha", 30], ["beta/core", 30], ["beta/extra", 25]] as const) {
|
|
502
|
+
mkdirSync(join(tree, dir), { recursive: true });
|
|
503
|
+
for (let i = 0; i < count; i += 1) writeFileSync(join(tree, dir, `f${i}.ts`), `export const v${i} = ${i};\n`, "utf-8");
|
|
504
|
+
}
|
|
505
|
+
writeFileSync(join(tree, "beta/core/f3.ts"), RETRY_SOURCE, "utf-8");
|
|
506
|
+
writeFileSync(join(tree, "README.md"), "# Tree\n\nA fixture.\n", "utf-8");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const QUESTION = "how is the retry backoff computed";
|
|
510
|
+
const TREE_VERDICTS = (stage: Stage, key: string): number | undefined => {
|
|
511
|
+
if (stage === "directories") return ({ "alpha/": 0.1, "beta/": 0.9, "beta/core/": 0.8, "beta/extra/": 0.2 } as Record<string, number>)[key];
|
|
512
|
+
if (stage === "files") return key === "beta/core/f3.ts" ? 0.95 : 0.05;
|
|
513
|
+
return key.includes("retryBackoff") ? 0.9 : 0.1;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
describe("directory descent", () => {
|
|
517
|
+
it("prunes a rejected directory and recurses into a kept one, judging root files alongside", async () => {
|
|
518
|
+
const jev = fakeJev(TREE_VERDICTS);
|
|
519
|
+
const result = await runJevFind({ rg: "rg", cwd: tree, root: ".", question: QUESTION, maxBytes: 32 * 1024, model: "jev-test", ask: jev.ask });
|
|
520
|
+
|
|
521
|
+
expect(jev.keys("directories")).toEqual(["beta/", "alpha/", "beta/core/", "beta/extra/"]);
|
|
522
|
+
// The kept directory is described by its most promising files and keyword hits.
|
|
523
|
+
const betaState = (jev.calls[0].payload.state as { directories: Record<string, string> }).directories["beta/"];
|
|
524
|
+
expect(betaState).toMatch(/^55 file\(s\): core\/f3\.ts/);
|
|
525
|
+
expect(betaState).toContain("Keyword hits:\ncore/f3.ts L3: /** Doubles the delay on every retry. */");
|
|
526
|
+
const judgedFiles = jev.keys("files");
|
|
527
|
+
expect(judgedFiles).toHaveLength(31);
|
|
528
|
+
expect(judgedFiles).toContain("README.md");
|
|
529
|
+
expect(judgedFiles.some((path) => path.startsWith("alpha/") || path.startsWith("beta/extra/"))).toBe(false);
|
|
530
|
+
|
|
531
|
+
expect(result.text).toMatch(/^jev_find: 1 relevant file\(s\) \(judged 31 of 86 candidates via jev-test in \d+ms\)$/m);
|
|
532
|
+
expect(result.text).toContain("Jev ruled out 2 directories holding 55 file(s).");
|
|
533
|
+
expect(result.text).toContain("- beta/core/f3.ts (0.95) — reading leads: retryBackoff lines 3-6");
|
|
534
|
+
expect(result.text.trimEnd().endsWith("End context.")).toBe(true);
|
|
535
|
+
expect(result.details).toMatchObject({ total: 86, judged: 31, relevant: 1 });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("keeps directories whose question failed, as unknown rather than rejected", async () => {
|
|
539
|
+
const jev = fakeJev(TREE_VERDICTS, { fail: (stage) => (stage === "directories" ? "timeout" : undefined) });
|
|
540
|
+
const result = await runJevFind({ rg: "rg", cwd: tree, root: ".", question: QUESTION, maxBytes: 32 * 1024, model: "jev-test", ask: jev.ask });
|
|
541
|
+
|
|
542
|
+
const judgedFiles = jev.keys("files");
|
|
543
|
+
expect(judgedFiles).toHaveLength(86);
|
|
544
|
+
expect(judgedFiles).toContain("alpha/f0.ts");
|
|
545
|
+
expect(result.text).not.toContain("ruled out");
|
|
546
|
+
expect(result.text).toMatch(/directories were kept unjudged \(timeout\)/);
|
|
547
|
+
expect(result.text).toContain('Source block "beta/core/f3.ts" lines 3-6:');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("stops free descent at the directory depth limit", async () => {
|
|
551
|
+
const deepTree = join(root, "deep-tree");
|
|
552
|
+
for (const branch of ["one", "two"]) {
|
|
553
|
+
const dir = join(deepTree, "a", "b", "c", "d", "e", branch);
|
|
554
|
+
mkdirSync(dir, { recursive: true });
|
|
555
|
+
for (let i = 0; i < 30; i += 1) writeFileSync(join(dir, `backoff${i}.ts`), `export const backoff${i} = ${i};\n`, "utf-8");
|
|
556
|
+
}
|
|
557
|
+
const jev = fakeJev(() => 0.9);
|
|
558
|
+
const result = await runJevFind({
|
|
559
|
+
rg: "rg",
|
|
560
|
+
cwd: deepTree,
|
|
561
|
+
root: ".",
|
|
562
|
+
question: "backoff",
|
|
563
|
+
maxBytes: 32 * 1024,
|
|
564
|
+
model: "jev-test",
|
|
565
|
+
ask: jev.ask,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
expect(jev.keys("directories")).toEqual([]);
|
|
569
|
+
expect(jev.keys("files")).toHaveLength(60);
|
|
570
|
+
expect(result.details.requests).toBeLessThanOrEqual(FIND_MAX_REQUESTS);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("names the next directory level below a root", () => {
|
|
574
|
+
expect(childDirectory("src/a/b.ts", ".")).toBe("src");
|
|
575
|
+
expect(childDirectory("src/a/b.ts", "src")).toBe("src/a");
|
|
576
|
+
expect(childDirectory("src/b.ts", "src")).toBeUndefined();
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
describe("source units", () => {
|
|
581
|
+
it("splits TypeScript into statements, merged imports, and class members that keep their header", async () => {
|
|
582
|
+
const ts = await loadTypeScript();
|
|
583
|
+
expect(ts).toBeDefined();
|
|
584
|
+
const filler = Array.from({ length: 40 }, (_, i) => `\t\tconst step${i} = ${i};`);
|
|
585
|
+
const text = [
|
|
586
|
+
'import { a } from "a";', // 1
|
|
587
|
+
'import { b } from "b";', // 2
|
|
588
|
+
"", // 3
|
|
589
|
+
"// ---- section banner ----", // 4
|
|
590
|
+
"", // 5
|
|
591
|
+
"/** Adds one. */", // 6
|
|
592
|
+
"export function add(x: number) {", // 7
|
|
593
|
+
"\treturn x + a + b;", // 8
|
|
594
|
+
"}", // 9
|
|
595
|
+
"", // 10
|
|
596
|
+
"export class Big {", // 11
|
|
597
|
+
"\t/** The first step. */", // 12
|
|
598
|
+
"\tfirst() {", // 13
|
|
599
|
+
...filler, // 14-53
|
|
600
|
+
"\t}", // 54
|
|
601
|
+
"\tsecond() { return 2; } // trailing note", // 55
|
|
602
|
+
"}", // 56
|
|
603
|
+
"",
|
|
604
|
+
].join("\n");
|
|
605
|
+
expect(splitSourceUnits("x.ts", text, ts)).toEqual([
|
|
606
|
+
{ name: "imports", start: 1, end: 2 },
|
|
607
|
+
{ name: "add", start: 6, end: 9 },
|
|
608
|
+
{ name: "Big.first", start: 12, end: 54, header: 11 },
|
|
609
|
+
{ name: "Big.second", start: 55, end: 55, header: 11 },
|
|
610
|
+
]);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("chunks other files, and scripts when TypeScript is missing, by blank lines into at most 40 lines", () => {
|
|
614
|
+
const long = Array.from({ length: 45 }, (_, i) => `line ${i}`);
|
|
615
|
+
const text = ["# Title", "intro", "", "## Setup", "run it", "", ...long, ""].join("\n");
|
|
616
|
+
expect(splitSourceUnits("README.md", text, undefined)).toEqual([
|
|
617
|
+
{ name: "# Title", start: 1, end: 5 },
|
|
618
|
+
{ name: "line 0", start: 7, end: 46 },
|
|
619
|
+
{ name: "line 40", start: 47, end: 51 },
|
|
620
|
+
]);
|
|
621
|
+
expect(splitSourceUnits("x.ts", RETRY_SOURCE, undefined)).toEqual(chunkUnits(RETRY_SOURCE));
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("returns accepted units verbatim with their own line numbers", async () => {
|
|
625
|
+
const jev = fakeJev(TREE_VERDICTS);
|
|
626
|
+
const result = await runJevFind({
|
|
627
|
+
rg: "rg",
|
|
628
|
+
cwd: tree,
|
|
629
|
+
root: "beta/core",
|
|
630
|
+
question: QUESTION,
|
|
631
|
+
maxBytes: 32 * 1024,
|
|
632
|
+
model: "jev-test",
|
|
633
|
+
ask: jev.ask,
|
|
634
|
+
});
|
|
635
|
+
const block = result.text.split('Source block "beta/core/f3.ts" lines 3-6:\n')[1]?.split("\n").slice(0, 4);
|
|
636
|
+
expect(block).toEqual(RETRY_SOURCE.split("\n").slice(2, 6).map((line, i) => `${i + 3}: ${line}`));
|
|
637
|
+
// Only the unit Jev accepted is returned; the rejected one is not.
|
|
638
|
+
expect(jev.keys("units")).toContain("beta/core/f3.ts lines 8-8 (unrelated)");
|
|
639
|
+
expect(result.text).not.toContain("8: export const unrelated");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("falls back to the matching source unit when Jev cannot judge the units", async () => {
|
|
643
|
+
const jev = fakeJev(TREE_VERDICTS, { fail: (stage) => (stage === "units" ? "timeout" : undefined) });
|
|
644
|
+
const result = await runJevFind({ rg: "rg", cwd: tree, root: "beta/core", question: QUESTION, maxBytes: 32 * 1024, model: "jev-test", ask: jev.ask });
|
|
645
|
+
|
|
646
|
+
expect(result.text).toContain("- beta/core/f3.ts (0.95) — reading leads: retryBackoff lines 3-6");
|
|
647
|
+
expect(result.text).toContain("Jev did not judge the source units of 1 file(s) (timeout); matching source units or keyword windows are shown instead.");
|
|
648
|
+
expect(result.text).toContain('Source block "beta/core/f3.ts" lines 3-6:\n3: /** Doubles the delay on every retry. */');
|
|
649
|
+
expect(result.text).not.toContain("1: import { x }");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("keeps the returned source within the output budget", async () => {
|
|
653
|
+
const big = join(root, "big");
|
|
654
|
+
mkdirSync(big, { recursive: true });
|
|
655
|
+
const fn = (n: number) => [
|
|
656
|
+
`export function retry${n}() {`,
|
|
657
|
+
...Array.from({ length: 58 }, (_, i) => `\tconst backoff${i} = "${"x".repeat(60)}";`),
|
|
658
|
+
"}",
|
|
659
|
+
"",
|
|
660
|
+
];
|
|
661
|
+
writeFileSync(join(big, "big.ts"), Array.from({ length: 8 }, (_, n) => fn(n)).flat().join("\n"), "utf-8");
|
|
662
|
+
const jev = fakeJev(() => 0.9);
|
|
663
|
+
const result = await runJevFind({ rg: "rg", cwd: big, root: ".", question: QUESTION, maxBytes: 32 * 1024, model: "jev-test", ask: jev.ask });
|
|
664
|
+
|
|
665
|
+
expect(result.details.sourceBytes).toBeLessThanOrEqual(FIND_MAX_SOURCE_BYTES);
|
|
666
|
+
expect(result.details.sourceBytes).toBeGreaterThan(FIND_MAX_SOURCE_BYTES / 2);
|
|
667
|
+
expect(result.text).toMatch(/source block\(s\) left out to stay within 16 KB/);
|
|
668
|
+
// Every numbered line is the file's own line under its own number.
|
|
669
|
+
const rows = readFileSync(join(big, "big.ts"), "utf-8").split("\n");
|
|
670
|
+
for (const match of result.text.matchAll(/^(\d+): (.*)$/gm)) expect(rows[Number(match[1]) - 1]).toBe(match[2]);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("clips a block to the byte budget and leaves out one that cannot show three lines", () => {
|
|
674
|
+
const rows = Array.from({ length: 20 }, (_, i) => `row ${i + 1}`);
|
|
675
|
+
const unit = { name: "u", start: 1, end: 20 };
|
|
676
|
+
const rendered = renderSourceBlocks(
|
|
677
|
+
[
|
|
678
|
+
{ path: "a.txt", unit, view: unitView(unit, undefined), rows },
|
|
679
|
+
{ path: "b.txt", unit, view: unitView(unit, undefined), rows },
|
|
680
|
+
],
|
|
681
|
+
60,
|
|
682
|
+
);
|
|
683
|
+
expect(rendered.bytes).toBeLessThanOrEqual(60);
|
|
684
|
+
expect(rendered.lines).toEqual(['', 'Source block "a.txt" lines 1-20:', "1: row 1", "2: row 2", "3: row 3", "4: row 4", "5: row 5", "6: row 6", "…"]);
|
|
685
|
+
expect(rendered.omitted).toBe(1);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("shows a long unit around its best keyword line", () => {
|
|
689
|
+
expect(unitView({ name: "f", start: 10, end: 200 }, 150, 20)).toEqual([10, 0, ...Array.from({ length: 19 }, (_, i) => 140 + i), 0]);
|
|
690
|
+
expect(unitView({ name: "m", start: 30, end: 31, header: 5 }, undefined)).toEqual([5, 0, 30, 31]);
|
|
691
|
+
});
|
|
431
692
|
});
|
|
432
693
|
|
|
433
|
-
|
|
694
|
+
describe("reliability", () => {
|
|
695
|
+
it("retries a transport failure once and then judges", async () => {
|
|
696
|
+
const jev = fakeJev((stage, key) => (stage === "files" ? (key.endsWith("round.ts") ? 0.9 : 0.1) : 0.9), {
|
|
697
|
+
fail: (stage, attempt) => (stage === "files" && attempt === 1 ? "transport" : undefined),
|
|
698
|
+
});
|
|
699
|
+
const result = await runJevFind({ rg: "rg", cwd: repo, root: "src", question: "rounding", maxBytes: 32 * 1024, model: "jev-test", ask: jev.ask });
|
|
700
|
+
|
|
701
|
+
expect(jev.calls.filter((call) => call.stage === "files")).toHaveLength(2);
|
|
702
|
+
expect(result.details).toMatchObject({ judged: 2, retried: 1 });
|
|
703
|
+
expect(result.text).toContain("- src/round.ts (0.90)");
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("retries only once, then still returns ripgrep-ranked source", async () => {
|
|
707
|
+
const jev = fakeJev(() => 0.9, { fail: () => "transport" });
|
|
708
|
+
const result = await runJevFind({ rg: "rg", cwd: repo, root: "src", question: "rounding", maxBytes: 32 * 1024, model: "jev-test", ask: jev.ask });
|
|
709
|
+
|
|
710
|
+
expect(jev.calls).toHaveLength(2);
|
|
711
|
+
expect(result.text).toMatch(/^jev_find: Jev did not judge \(transport\); 2 candidate file\(s\), ranked by ripgrep:/);
|
|
712
|
+
expect(result.text).toContain('Source block "src/round.ts" lines 1-1:');
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("reports sanitized provider status and returns the matching source unit on transport failure", async () => {
|
|
716
|
+
const jev = fakeJev(TREE_VERDICTS, {
|
|
717
|
+
fail: () => "transport",
|
|
718
|
+
diagnostic: { kind: "provider-error", httpStatus: 503 },
|
|
719
|
+
});
|
|
720
|
+
const result = await runJevFind({
|
|
721
|
+
rg: "rg",
|
|
722
|
+
cwd: tree,
|
|
723
|
+
root: "beta/core",
|
|
724
|
+
question: QUESTION,
|
|
725
|
+
pattern: "retry",
|
|
726
|
+
maxBytes: 32 * 1024,
|
|
727
|
+
model: "jev-test",
|
|
728
|
+
ask: jev.ask,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
expect(result.text).toContain("Diagnostic: Jev provider request failed (HTTP 503).");
|
|
732
|
+
expect(result.details).toMatchObject({ failure: "transport", diagnostic: { kind: "provider-error", httpStatus: 503 } });
|
|
733
|
+
expect(result.text).toContain("retryBackoff lines 3-6");
|
|
734
|
+
expect(result.text).toContain('Source block "beta/core/f3.ts" lines 3-6:\n3: /** Doubles the delay on every retry. */');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("never sends more than the request cap", async () => {
|
|
738
|
+
const asked: number[] = [];
|
|
739
|
+
const requests = new FindRequests(async () => {
|
|
740
|
+
asked.push(1);
|
|
741
|
+
return { failure: "transport", elapsedMs: 0 };
|
|
742
|
+
}, 3);
|
|
743
|
+
const results = await requests.sendAll([{}, {}, {}, {}, {}], 5);
|
|
744
|
+
expect(asked).toHaveLength(3);
|
|
745
|
+
expect(results.slice(3)).toEqual([undefined, undefined]);
|
|
746
|
+
expect(requests.left).toBe(0);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("fits every payload in every stage within maxBytes (the overflow regression)", async () => {
|
|
750
|
+
// The old fit gave up once snippets hit 64 bytes and sent the rest oversized: the questions, paths,
|
|
751
|
+
// and scaffolding of a full batch alone can outgrow a small budget.
|
|
752
|
+
const long = `src/${"deeply/nested/".repeat(12)}file.ts`;
|
|
753
|
+
const items = Array.from({ length: FIND_BATCH }, (_, i) => ({ key: `${long}${i}`, text: '"\n\t\\'.repeat(500), question: `Is "${long}${i}" relevant?` }));
|
|
754
|
+
const stage = { request: "q", stateKey: "files", criteria: { true: "yes", false: "no" } };
|
|
755
|
+
const fitted = fitJudgePayloads(stage, items, 4 * 1024);
|
|
756
|
+
expect(fitted.dropped).toEqual([]);
|
|
757
|
+
expect(fitted.payloads.flatMap((entry) => entry.items).sort((a, b) => a - b)).toEqual(items.map((_, i) => i));
|
|
758
|
+
for (const entry of fitted.payloads) expect(serializeJevRequest(entry.payload, 4 * 1024)).toBeDefined();
|
|
759
|
+
// An item that cannot fit alone is dropped, never sent oversized.
|
|
760
|
+
expect(fitJudgePayloads(stage, [{ key: "k", text: "", question: "x".repeat(5_000) }], 4 * 1024)).toEqual({ payloads: [], dropped: [0] });
|
|
761
|
+
|
|
762
|
+
const maxBytes = 6 * 1024;
|
|
763
|
+
const descent = fakeJev(TREE_VERDICTS, { maxBytes });
|
|
764
|
+
await runJevFind({ rg: "rg", cwd: tree, root: ".", question: `${QUESTION} ${"really ".repeat(2_000)}`, maxBytes, model: "jev-test", ask: descent.ask });
|
|
765
|
+
const units = fakeJev(TREE_VERDICTS, { maxBytes });
|
|
766
|
+
await runJevFind({ rg: "rg", cwd: tree, root: "beta/core", question: QUESTION, maxBytes, model: "jev-test", ask: units.ask });
|
|
767
|
+
const calls = [...descent.calls, ...units.calls];
|
|
768
|
+
expect(new Set(calls.map((call) => call.stage))).toEqual(new Set(["directories", "files", "units"]));
|
|
769
|
+
for (const call of calls) expect(call.bytes).toBeLessThanOrEqual(maxBytes);
|
|
770
|
+
expect(descent.calls.length).toBeLessThanOrEqual(FIND_MAX_REQUESTS);
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("registers jev_find as a finder that returns source, pointing subagents at it", () => {
|
|
775
|
+
const tool = harness().tools.jev_find;
|
|
776
|
+
expect(tool.description).toContain("verbatim");
|
|
777
|
+
expect(tool.description).not.toMatch(/never contents|pointers only/i);
|
|
778
|
+
expect(tool.description).toContain(".gitignore");
|
|
779
|
+
expect(tool.promptSnippet).toContain("source");
|
|
780
|
+
expect(tool.promptGuidelines?.join("\n")).toContain("subagent");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("ranks files, returns the accepted source verbatim, and never sends secret files", async () => {
|
|
434
784
|
const session = harness();
|
|
435
|
-
// Jev:
|
|
785
|
+
// Jev: anything naming round.ts is relevant, the rest are not.
|
|
436
786
|
session.complete.mockImplementation(async (_model: unknown, request: { messages: Array<{ content: string }> }) => {
|
|
437
787
|
const sent = JSON.parse(request.messages[0].content) as { questions: Record<string, { instructions: { question: string } }> };
|
|
438
788
|
const answers = Object.fromEntries(
|
|
@@ -442,33 +792,29 @@ describe("jev_find", () => {
|
|
|
442
792
|
});
|
|
443
793
|
const result = await find(session, { question: "where is rounding implemented", pattern: "round", ignoreCase: true });
|
|
444
794
|
|
|
445
|
-
expect(result.text).toMatch(
|
|
446
|
-
|
|
447
|
-
expect(result.text).
|
|
448
|
-
expect(result.text).
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
expect(
|
|
452
|
-
expect(
|
|
795
|
+
expect(result.text).toMatch(/^jev_find: 1 relevant file\(s\) \(judged 2 of 2 candidates via jev-1\.13 in \d+ms\)/);
|
|
796
|
+
// The sentinel line is not valid TypeScript; the parser recovers and still names the declaration.
|
|
797
|
+
expect(result.text).toMatch(/^- src\/round\.ts \(0\.93\) — reading leads: round\b.* lines 1-1$/m);
|
|
798
|
+
expect(result.text).toContain(`Source block "src/round.ts" lines 1-1:\n1: export const round = () => ${FILE_SENTINEL};`);
|
|
799
|
+
expect(result.text).not.toContain("- src/other.ts");
|
|
800
|
+
const everything = JSON.stringify(session.complete.mock.calls);
|
|
801
|
+
expect(everything).toContain("src/other.ts");
|
|
802
|
+
expect(everything).not.toContain("SECRET_ENV_VALUE");
|
|
803
|
+
expect(session.telemetry.at(-1)).toMatchObject({ route: "find", outcome: "jev" });
|
|
453
804
|
});
|
|
454
805
|
|
|
455
|
-
it("
|
|
806
|
+
it("still returns ripgrep-ranked files and their source when Jev is unreachable", async () => {
|
|
456
807
|
const session = harness({ credential: false });
|
|
457
808
|
const result = await find(session, { question: "rounding", path: "src", glob: "*.ts" });
|
|
458
809
|
|
|
459
810
|
expect(session.complete).not.toHaveBeenCalled();
|
|
460
|
-
|
|
811
|
+
const lines = result.text.split("\n");
|
|
812
|
+
expect(lines[0]).toBe("jev_find: Jev did not judge (no-credential); 2 candidate file(s), ranked by ripgrep:");
|
|
461
813
|
// The question word in the path orders the unjudged listing.
|
|
462
|
-
expect(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
// Quotes and newlines double under JSON escaping, which is what overflowed live.
|
|
467
|
-
const snippet = '"\n\t\\'.repeat(1_000);
|
|
468
|
-
const batch = Array.from({ length: FIND_BATCH }, (_, i) => ({ path: `src/f${i}.ts`, lines: [], snippet }));
|
|
469
|
-
const payload = fitFindPayload("q", batch, 32 * 1024);
|
|
470
|
-
expect(serializeJevRequest(payload, 32 * 1024)).toBeDefined();
|
|
471
|
-
expect(Object.keys(payload.questions as object)).toHaveLength(FIND_BATCH);
|
|
814
|
+
expect(lines[1]).toBe("- src/round.ts — file head lines 1-1");
|
|
815
|
+
expect(result.text).toContain(`Source block "src/round.ts" lines 1-1:\n1: export const round = () => ${FILE_SENTINEL};`);
|
|
816
|
+
expect(result.text.trimEnd().endsWith("End context.")).toBe(true);
|
|
817
|
+
expect(session.telemetry.at(-1)).toMatchObject({ route: "find", outcome: "fallback", reason: "no-credential" });
|
|
472
818
|
});
|
|
473
819
|
|
|
474
820
|
it("refuses a search root outside the working directory", async () => {
|