@intentic/sandbox-contract 1.224.0 → 1.226.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -14
- package/dist/agent-catalog.d.ts +2 -0
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +19 -3
- package/dist/agent-catalog.js.map +1 -1
- package/dist/chores/chores.js +42 -42
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/extension-update.js +2 -2
- package/dist/chores/extension-update.js.map +1 -1
- package/dist/chores/fix-deps.js +1 -1
- package/dist/chores/fix-deps.js.map +1 -1
- package/dist/chores/probes.js +1 -1
- package/dist/chores/probes.js.map +1 -1
- package/dist/chores/verdict.js +2 -2
- package/dist/chores/verdict.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +41 -0
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/exit.contract.d.ts +80 -0
- package/dist/contracts/exit.contract.d.ts.map +1 -0
- package/dist/contracts/exit.contract.js +13 -0
- package/dist/contracts/exit.contract.js.map +1 -0
- package/dist/contracts/settings.contract.d.ts +12 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/events.d.ts +4 -4
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +5 -5
- package/dist/events.js.map +1 -1
- package/dist/fast-tier.d.ts +9 -0
- package/dist/fast-tier.d.ts.map +1 -0
- package/dist/fast-tier.js +19 -0
- package/dist/fast-tier.js.map +1 -0
- package/dist/history-state.js +3 -3
- package/dist/history-state.js.map +1 -1
- package/dist/index.d.ts +203 -68
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/model-order.d.ts +1 -0
- package/dist/model-order.d.ts.map +1 -1
- package/dist/model-order.js +5 -0
- package/dist/model-order.js.map +1 -1
- package/dist/output-fields.d.ts.map +1 -1
- package/dist/output-fields.js +2 -2
- package/dist/output-fields.js.map +1 -1
- package/dist/prompt-complexity.d.ts +19 -0
- package/dist/prompt-complexity.d.ts.map +1 -0
- package/dist/prompt-complexity.js +83 -0
- package/dist/prompt-complexity.js.map +1 -0
- package/dist/publish-drafts.js +2 -2
- package/dist/publish-drafts.js.map +1 -1
- package/dist/schemas.d.ts +214 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +113 -2
- package/dist/schemas.js.map +1 -1
- package/dist/workflow-faults.js +3 -3
- package/dist/workflow-faults.js.map +1 -1
- package/dist/workspace-state.d.ts +20 -20
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +20 -20
- package/dist/workspace-state.js.map +1 -1
- package/package.json +5 -5
- package/src/agent-catalog.test.ts +72 -27
- package/src/agent-catalog.ts +91 -3
- package/src/agent-run-model.test.ts +3 -3
- package/src/capability-ledger.test.ts +34 -15
- package/src/chores/chores.test.ts +1 -1
- package/src/chores/chores.ts +42 -42
- package/src/chores/digest.test.ts +1 -1
- package/src/chores/extension-update.ts +2 -2
- package/src/chores/fix-deps.ts +1 -1
- package/src/chores/probes.test.ts +6 -6
- package/src/chores/probes.ts +1 -1
- package/src/chores/stack.test.ts +3 -3
- package/src/chores/verdict.test.ts +20 -20
- package/src/chores/verdict.ts +2 -2
- package/src/contract-lock.test.ts +1 -1
- package/src/contracts/exit.contract.ts +42 -0
- package/src/contracts/providers.contract.ts +1 -1
- package/src/conversation-ids.ts +1 -1
- package/src/events.test.ts +3 -3
- package/src/events.ts +6 -6
- package/src/fast-tier.test.ts +88 -0
- package/src/fast-tier.ts +72 -0
- package/src/history-state.ts +3 -3
- package/src/hostnames.test.ts +1 -1
- package/src/index.ts +5 -0
- package/src/model-order.test.ts +11 -11
- package/src/model-order.ts +22 -0
- package/src/output-fields.ts +2 -2
- package/src/path-refs.test.ts +4 -4
- package/src/prompt-complexity.test.ts +160 -0
- package/src/prompt-complexity.ts +271 -0
- package/src/publish-drafts.ts +2 -2
- package/src/quick-model.test.ts +11 -11
- package/src/routes.test.ts +11 -5
- package/src/runtime-state.test.ts +1 -1
- package/src/schemas.test.ts +8 -8
- package/src/schemas.ts +311 -6
- package/src/search-globs.test.ts +2 -2
- package/src/share-paths.test.ts +1 -1
- package/src/title.test.ts +9 -9
- package/src/title.ts +1 -1
- package/src/tunnel-ids.test.ts +3 -3
- package/src/versions.test.ts +3 -3
- package/src/versions.ts +1 -1
- package/src/workflow-faults.test.ts +6 -6
- package/src/workflow-faults.ts +3 -3
- package/src/workspace-state.test.ts +32 -32
- package/src/workspace-state.ts +20 -20
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { eventIterator, oc } from "@orpc/contract";
|
|
2
|
+
import { IntenticLineSchema } from "../events.js";
|
|
3
|
+
import { ExitCountriesSchema, ExitIdParamSchema, ExitListSchema, ExitObservationSchema, ExitUseInputSchema, OkSchema } from "../schemas.js";
|
|
4
|
+
|
|
5
|
+
// The live GEO EXIT surface. An exit is ADDED as an `exit` capability (which provider, a resting country,
|
|
6
|
+
// whether it comes up on boot); it is STARTED, MOVED and ROTATED here. Same split as the vpn contract and for
|
|
7
|
+
// the same reason: switching country is a runtime operation performed many times over one stored pool, by the
|
|
8
|
+
// operator from the Status card and by the agent through `exit` on its PATH, which calls these very routes.
|
|
9
|
+
//
|
|
10
|
+
// The difference from vpn is what "success" means. A dial succeeds when the tunnel is up; a country switch
|
|
11
|
+
// succeeds only when the egress ADDRESS has moved, which is why `use` and `rotate` end by fetching an
|
|
12
|
+
// ExitObservation through the exit's own proxy and fail when it does not agree with what was asked for.
|
|
13
|
+
//
|
|
14
|
+
// Nothing here changes the sandbox's default route, ever. Each exit publishes a SOCKS proxy and callers opt
|
|
15
|
+
// in; the daemon's own traffic, the model endpoint and the tunnel that makes this sandbox reachable stay on
|
|
16
|
+
// the plain uplink no matter what is up.
|
|
17
|
+
export const exitContract = {
|
|
18
|
+
// Every configured exit with its live state, where it was asked to come out and where it actually does.
|
|
19
|
+
// Drives the Status card, the browser account picker and `geo list` (the CLI is `geo`: `exit` is a shell
|
|
20
|
+
// builtin, so a binary of that name is unreachable from a command line).
|
|
21
|
+
list: oc.route({ method: "GET", path: "/exit" }).output(ExitListSchema),
|
|
22
|
+
// What this exit can reach, ranked by how much capacity is actually there. Live from the provider when it
|
|
23
|
+
// answers (Tor's directory, VPN Gate's CSV, the pasted confs), from the baked fallback when it does not,
|
|
24
|
+
// and `live` says which. This is what auto-fills the country picker instead of a user hunting hostnames.
|
|
25
|
+
countries: oc.route({ method: "GET", path: "/exit/{id}/countries" }).input(ExitIdParamSchema).output(ExitCountriesSchema),
|
|
26
|
+
// Bring the exit up at its stored country. Streams, because a first start pulls a catalog, brings up a
|
|
27
|
+
// tunnel and then verifies the address, which is tens of seconds on the free providers and can fail at
|
|
28
|
+
// each step with something the user has to read. Idempotent: starting an up exit reports it and stops.
|
|
29
|
+
start: oc.route({ method: "POST", path: "/exit/{id}/start" }).input(ExitIdParamSchema).output(eventIterator(IntenticLineSchema)),
|
|
30
|
+
// Move to another country, starting the exit first if it was down. Streams for the same reason as start,
|
|
31
|
+
// and THROWS when the observed country does not end up matching: a switch that silently left traffic where
|
|
32
|
+
// it was is the one failure mode this whole feature exists to make impossible.
|
|
33
|
+
use: oc.route({ method: "POST", path: "/exit/{id}/use" }).input(ExitUseInputSchema).output(eventIterator(IntenticLineSchema)),
|
|
34
|
+
// A different address in the SAME country. Cheap on tor (a control-port signal), a re-dial to another
|
|
35
|
+
// server on the rest. Fails when the address does not actually change, which on a small pool it can't.
|
|
36
|
+
rotate: oc.route({ method: "POST", path: "/exit/{id}/rotate" }).input(ExitIdParamSchema).output(eventIterator(IntenticLineSchema)),
|
|
37
|
+
// What the world sees through this exit right now. Cheap, unstreamed, and the honest answer to "am I
|
|
38
|
+
// actually in Germany", which is the question every other route here is judged against.
|
|
39
|
+
check: oc.route({ method: "POST", path: "/exit/{id}/check" }).input(ExitIdParamSchema).output(ExitObservationSchema),
|
|
40
|
+
// Take it down. Tolerates an already-down exit: "make it not be up" is the contract, not "it was up".
|
|
41
|
+
stop: oc.route({ method: "POST", path: "/exit/{id}/stop" }).input(ExitIdParamSchema).output(OkSchema),
|
|
42
|
+
};
|
|
@@ -3,7 +3,7 @@ import { ModelsSchema, NativeProviderParamSchema } from "../schemas.js";
|
|
|
3
3
|
|
|
4
4
|
// EVERY NATIVE PROVIDER'S PICKER CATALOG, ON ONE ROUTE.
|
|
5
5
|
//
|
|
6
|
-
// There were five of these
|
|
6
|
+
// There were five of these: /claude/models, /codex/models, /grok/models, /kimi/models, /gemini/models, one per
|
|
7
7
|
// provider, each a contract entry, a route factory and a service field wired separately. They differed in
|
|
8
8
|
// nothing: same method, same output schema, same "the daemon's catalog for this provider, never empty". Three of
|
|
9
9
|
// the five route files were the same fifteen lines with a name swapped, and the daemon paid for that shape twice
|
package/src/conversation-ids.ts
CHANGED
|
@@ -165,7 +165,7 @@ const randomBelow = (upperBound: number): number => {
|
|
|
165
165
|
// browser tabs opened in the same instant are exactly the case this must not produce the same name for.
|
|
166
166
|
const pick = <T>(values: readonly T[]): T => values[randomBelow(values.length)]!;
|
|
167
167
|
|
|
168
|
-
// Lowercase base36, one character per draw
|
|
168
|
+
// Lowercase base36, one character per draw: 0-9a-z, all of which the id guard accepts.
|
|
169
169
|
const tail = (): string => Array.from({ length: TAIL_LENGTH }, () => randomBelow(36).toString(36)).join("");
|
|
170
170
|
|
|
171
171
|
/* A fresh conversation id: `<adjective>-<noun>-<tail>`, e.g. `swift-otter-k9m2`. Sixteen characters or so
|
package/src/events.test.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { RESUME_NOTES, resumeDisclosure, withResumeNote, withoutResumeNote } fro
|
|
|
3
3
|
|
|
4
4
|
/* The resume note is a round trip across the wire: the daemon wraps a prompt to tell the model what interrupted
|
|
5
5
|
* it, and the client unwraps the SAME prompt off an attach head to tell whether it already has that bubble. A
|
|
6
|
-
* mismatch between the two halves fails silently and cosmetically
|
|
7
|
-
* something the user typed
|
|
6
|
+
* mismatch between the two halves fails silently and cosmetically: a paragraph of machine prose rendered as
|
|
7
|
+
* something the user typed, which is exactly the kind of drift that stays broken. */
|
|
8
8
|
test("a resume note round-trips back to the user's own words", () => {
|
|
9
9
|
for (const note of Object.values(RESUME_NOTES)) {
|
|
10
10
|
expect(withoutResumeNote(withResumeNote("ship the parser", note))).toBe("ship the parser");
|
|
@@ -39,7 +39,7 @@ test("a re-run discloses as a notice, and the answered case as a note on the mes
|
|
|
39
39
|
const disclosure = resumeDisclosure(withResumeNote("ship the parser", RESUME_NOTES[reason]));
|
|
40
40
|
expect(disclosure?.kind).toBe("notice");
|
|
41
41
|
}
|
|
42
|
-
// The answer is new words the user really did type, so nothing is dropped
|
|
42
|
+
// The answer is new words the user really did type, so nothing is dropped: the interruption rides them.
|
|
43
43
|
const answered = resumeDisclosure(withResumeNote("option two", RESUME_NOTES.answered));
|
|
44
44
|
expect(answered).toEqual({ kind: "note", note: { title: expect.any(String), text: RESUME_NOTES.answered } });
|
|
45
45
|
});
|
package/src/events.ts
CHANGED
|
@@ -737,7 +737,7 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
737
737
|
* is, so nothing is re-run and nothing asks the user to reconnect, the one recovery that looks
|
|
738
738
|
* plausible and is guaranteed to waste their time. */
|
|
739
739
|
"claude-not-entitled",
|
|
740
|
-
// The model provider itself failed transiently
|
|
740
|
+
// The model provider itself failed transiently: 500/502/503, a 529 at capacity, a dropped
|
|
741
741
|
// socket, and the harness's own in-turn retries did not outlast it. Nothing about the workspace
|
|
742
742
|
// or the request is wrong, so the daemon remembers the turn and re-runs it on an escalating
|
|
743
743
|
// backoff (provider-health.ts): the frame is a notice about a turn that is coming back, and
|
|
@@ -813,7 +813,7 @@ export type AttachFrame = z.infer<typeof AttachFrameSchema>;
|
|
|
813
813
|
// and telling the model to "continue from that point instead of starting over" about words it has never seen
|
|
814
814
|
// is how a resume reads as the user contradicting themselves.
|
|
815
815
|
const REPEATED =
|
|
816
|
-
"The interrupted request is repeated below
|
|
816
|
+
"The interrupted request is repeated below, where part of it was already completed in this session, continue from that point instead of starting over.";
|
|
817
817
|
export const RESUME_NOTES = {
|
|
818
818
|
auth: `The Claude credential that interrupted this conversation has been renewed, and this turn resumed automatically. ${REPEATED}`,
|
|
819
819
|
outage: `The model provider was briefly unavailable and interrupted this conversation; this turn resumed automatically. ${REPEATED}`,
|
|
@@ -822,7 +822,7 @@ export const RESUME_NOTES = {
|
|
|
822
822
|
// instead, and this is the turn their answer starts (turn-resume.ts). What rides below the note is the
|
|
823
823
|
// answer itself, so the model picks the session back up at exactly the decision it had handed over.
|
|
824
824
|
answered:
|
|
825
|
-
"The sandbox restarted while this conversation was waiting for the user to respond; it is back, and their response follows below
|
|
825
|
+
"The sandbox restarted while this conversation was waiting for the user to respond; it is back, and their response follows below: continue from where the session left off.",
|
|
826
826
|
} as const;
|
|
827
827
|
|
|
828
828
|
// The prompt a resume actually sends: the note (each carries its own account of what the words below are),
|
|
@@ -860,9 +860,9 @@ export type ResumeReason = keyof typeof RESUME_NOTES;
|
|
|
860
860
|
export type ResumeDisclosure = { readonly kind: "notice"; readonly text: string } | { readonly kind: "note"; readonly note: TurnNote };
|
|
861
861
|
|
|
862
862
|
const RESUME_DISCLOSURES: Record<ResumeReason, ResumeDisclosure> = {
|
|
863
|
-
auth: { kind: "notice", text: "Claude sign-in renewed
|
|
864
|
-
outage: { kind: "notice", text: "The model provider came back
|
|
865
|
-
restart: { kind: "notice", text: "The sandbox came back
|
|
863
|
+
auth: { kind: "notice", text: "Claude sign-in renewed, this turn picked up where it left off." },
|
|
864
|
+
outage: { kind: "notice", text: "The model provider came back, this turn picked up where it left off." },
|
|
865
|
+
restart: { kind: "notice", text: "The sandbox came back, this turn picked up where it left off." },
|
|
866
866
|
answered: { kind: "note", note: { title: "Picked back up after a sandbox restart", text: RESUME_NOTES.answered } },
|
|
867
867
|
};
|
|
868
868
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { fastTierModel } from "./fast-tier.js";
|
|
3
|
+
|
|
4
|
+
/* WHERE A DOWNGRADED TURN ACTUALLY LANDS. The judge (prompt-complexity.ts) says a turn could be cheaper; this
|
|
5
|
+
* says whether there is anywhere cheaper to put it, on the provider it is already on.
|
|
6
|
+
*
|
|
7
|
+
* Two properties carry the whole feature, and everything below is one of them: a downgrade is only ever to a
|
|
8
|
+
* genuinely CHEAPER rung of the same catalog (so "cheaper" can never quietly become "older", or "the same model
|
|
9
|
+
* with less thinking"), and it never crosses PROVIDER (because that retires the conversation's session, which
|
|
10
|
+
* throws away the context that made the follow-up cheap to answer in the first place). */
|
|
11
|
+
|
|
12
|
+
const CLAUDE = [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`];
|
|
13
|
+
const GOOGLE = [`gemini-3-pro`, `gemini-3-flash`, `gemini-3-flash-lite`];
|
|
14
|
+
|
|
15
|
+
const fastFor = (model: string, over: { models?: readonly string[]; pinned?: readonly string[]; provider?: string } = {}) =>
|
|
16
|
+
fastTierModel({ provider: over.provider ?? `claude`, model, models: over.models ?? CLAUDE, pinned: over.pinned ?? [] });
|
|
17
|
+
|
|
18
|
+
// --- Auto: the cheapest rung the provider publishes ----------------------------------------------------
|
|
19
|
+
|
|
20
|
+
test("sends a frontier turn to the provider's own cheap rung", () => {
|
|
21
|
+
expect(fastFor(`claude-opus-5`)).toBe(`claude-haiku-4-5-20251001`);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("reads the cheap end the same way the quick model does, on every vendor's vocabulary", () => {
|
|
25
|
+
expect(fastFor(`gemini-3-pro`, { provider: `gemini`, models: GOOGLE })).toBe(`gemini-3-flash-lite`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// --- the ceiling: the user's own pick -------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
test("a user already on the cheap rung has nowhere to be sent", () => {
|
|
31
|
+
// The common case worth being exact about rather than an edge case. Undefined means "run what they asked
|
|
32
|
+
// for", which is the honest answer and the one that costs nothing to be wrong about.
|
|
33
|
+
expect(fastFor(`claude-haiku-4-5-20251001`)).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("never swaps a model for an older build of the same tier", () => {
|
|
37
|
+
// A downgrade has to be legible AS a downgrade. Last year's Sonnet under this year's Sonnet is not a
|
|
38
|
+
// saving the feature promised, it is a different turn wearing the user's own model name.
|
|
39
|
+
expect(fastFor(`claude-sonnet-5`, { models: [`claude-sonnet-5`, `claude-sonnet-4`] })).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("never downgrades a model whose family this build does not recognise", () => {
|
|
43
|
+
// An id carrying no tier word is a provider's base line or a family nobody here has heard of, and betting
|
|
44
|
+
// a user's turn on the guess that an unknown id is the budget one is the wrong direction to be wrong in.
|
|
45
|
+
expect(fastFor(`claude-opus-5`, { models: [`some-new-thing`] })).toBeUndefined();
|
|
46
|
+
expect(fastFor(`some-new-thing`, { models: CLAUDE })).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("an unloaded catalog resolves to no downgrade rather than to a guess", () => {
|
|
50
|
+
expect(fastFor(`claude-opus-5`, { models: [] })).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no pick yet means nothing to be cheaper than", () => {
|
|
54
|
+
expect(fastFor(``)).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// --- pins -----------------------------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
test("a pin on this provider wins over the catalog's own cheap end", () => {
|
|
60
|
+
expect(fastFor(`claude-opus-5`, { pinned: [`claude:claude-sonnet-5`] })).toBe(`claude-sonnet-5`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("takes a pinned id verbatim, so a model the static catalog has not caught up with is still pinnable", () => {
|
|
64
|
+
// The same call resolveQuickModels makes, and for the same reason: the picker offers a custom-id escape
|
|
65
|
+
// hatch, and second-guessing the id here would run a different model than the settings row names.
|
|
66
|
+
expect(fastFor(`claude-opus-5`, { pinned: [`claude:claude-haiku-9`] })).toBe(`claude-haiku-9`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("drops a pin naming another provider rather than crossing to it", () => {
|
|
70
|
+
// Switching provider retires the conversation's session (turnRequest.ts `resumes`). Starting the
|
|
71
|
+
// conversation over to save a fraction of a cent is not a saving, so the pin is ignored and Auto answers.
|
|
72
|
+
expect(fastFor(`claude-opus-5`, { pinned: [`gemini:gemini-3-flash-lite`] })).toBe(`claude-haiku-4-5-20251001`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("skips a pin that is not actually cheaper than what the user picked", () => {
|
|
76
|
+
// A pin is a preference about WHICH cheap rung, never a licence to swap a model for its equal or better.
|
|
77
|
+
expect(fastFor(`claude-sonnet-5`, { pinned: [`claude:claude-opus-5`] })).toBe(`claude-haiku-4-5-20251001`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("walks past an unusable pin to the next one that names this provider", () => {
|
|
81
|
+
const pinned = [`gemini:gemini-3-flash`, `claude:claude-opus-5`, `claude:claude-haiku-4-5`];
|
|
82
|
+
|
|
83
|
+
expect(fastFor(`claude-opus-5`, { pinned })).toBe(`claude-haiku-4-5`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("falls back to Auto when no pin survives, rather than to no downgrade at all", () => {
|
|
87
|
+
expect(fastFor(`claude-opus-5`, { pinned: [`nonsense`, `gemini:gemini-3-flash`] })).toBe(`claude-haiku-4-5-20251001`);
|
|
88
|
+
});
|
package/src/fast-tier.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { compareCheapestFirst, isCheaperRung } from "./model-order.js";
|
|
2
|
+
import { parsePinned } from "./quick-model.js";
|
|
3
|
+
import type { AgentProvider } from "./schemas.js";
|
|
4
|
+
|
|
5
|
+
/* WHICH MODEL A DOWNGRADED TURN ACTUALLY RUNS ON, the second half of automatic tier selection.
|
|
6
|
+
* prompt-complexity.ts decides a turn COULD be cheaper; this decides whether there is anything cheaper to put
|
|
7
|
+
* it on, and names it.
|
|
8
|
+
*
|
|
9
|
+
* THERE IS NO "STANDARD TIER" SETTING, and its absence is the design rather than an omission. The standard tier
|
|
10
|
+
* is whatever the user already picked for this conversation. So this mechanism can only ever route DOWN, from a
|
|
11
|
+
* model somebody chose to a cheaper rung of the same catalog, which means the worst case of a wrong answer is
|
|
12
|
+
* one turn's quality on a model the user can see and correct, never a bill they did not ask for. Every
|
|
13
|
+
* ambiguous case in this file therefore resolves to `undefined`, which the caller reads as "run what they
|
|
14
|
+
* asked for".
|
|
15
|
+
*
|
|
16
|
+
* THE PROVIDER IS NEVER CROSSED, and this is the constraint the rest of the app imposes rather than one this
|
|
17
|
+
* file would have chosen. A conversation's provider session is resumed only while the selection still matches
|
|
18
|
+
* the runtime and account that minted it (turnRequest.ts `resumes`, which compares provider, account and
|
|
19
|
+
* harness, and pointedly NOT model). A model swap inside one provider is free, and the session carries on. A
|
|
20
|
+
* provider swap RETIRES the session and cuts a new segment, which throws away the very context that made the
|
|
21
|
+
* follow-up cheap to answer. So a cross-provider pin here is dropped rather than honoured: saving a fraction of
|
|
22
|
+
* a cent by starting the conversation over is not a saving.
|
|
23
|
+
*
|
|
24
|
+
* THE ORDER IS A LADDER, for the reason quick-model.ts is one, but a shorter one: the caller spends the head
|
|
25
|
+
* and falls back to the user's own pick, rather than walking rungs. A downgrade that cannot be started is not
|
|
26
|
+
* worth a second attempt when the honest answer (their model) is sitting right there. */
|
|
27
|
+
|
|
28
|
+
export interface FastTierInput {
|
|
29
|
+
// The provider this turn is on. Both the pick and every candidate belong to it; see above.
|
|
30
|
+
readonly provider: AgentProvider;
|
|
31
|
+
// The model the user picked, i.e. the standard tier and the ceiling. Empty when the composer has not
|
|
32
|
+
// resolved one yet, which resolves to no downgrade: there is nothing to be cheaper THAN.
|
|
33
|
+
readonly model: string;
|
|
34
|
+
// That provider's catalog as the caller can see it. Empty is a real state (a catalog that has not loaded),
|
|
35
|
+
// and it resolves to no downgrade rather than to a guess.
|
|
36
|
+
readonly models: readonly string[];
|
|
37
|
+
// settings.autoFastModels: an ordered list of `${provider}:${modelId}` keys (quickModelKey), or empty for
|
|
38
|
+
// Auto. Entries naming another provider are dropped, not honoured.
|
|
39
|
+
readonly pinned: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* THE CHEAPER MODEL TO RUN THIS TURN ON, or undefined for "there isn't one, use their pick".
|
|
43
|
+
*
|
|
44
|
+
* A PIN IS TAKEN VERBATIM against the catalog, the same call resolveQuickModels makes and for the same reason:
|
|
45
|
+
* the model picker offers a custom-id escape hatch for a model the static catalog has not caught up with, and
|
|
46
|
+
* second-guessing the id here would run a different model than the settings row names. It is still checked for
|
|
47
|
+
* being CHEAPER, because that is not a fact about the catalog, it is a fact about the id, and a pin that is not
|
|
48
|
+
* cheaper than the pick is not a downgrade at all.
|
|
49
|
+
*
|
|
50
|
+
* AUTO IS THE CHEAPEST ROW THE PROVIDER PUBLISHES, read through the same cheap-end order the quick model uses,
|
|
51
|
+
* so the two features cannot disagree about which rung is the cheap one. Derived, never stored: connect an
|
|
52
|
+
* account tomorrow and the answer improves by itself, exactly as quickModel's empty default does.
|
|
53
|
+
*
|
|
54
|
+
* NOTHING CHEAPER THAN THE PICK ⇒ UNDEFINED, and that is the common case worth being exact about rather than
|
|
55
|
+
* the edge case: a user already working on the cheap rung has nowhere to be sent, and a user on a model whose
|
|
56
|
+
* family this build does not recognize is not going to be downgraded on a guess (isCheaperRung). */
|
|
57
|
+
export const fastTierModel = (input: FastTierInput): string | undefined => {
|
|
58
|
+
if (input.model === "") {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const pinned = input.pinned
|
|
62
|
+
.flatMap((key) => {
|
|
63
|
+
const choice = parsePinned(key);
|
|
64
|
+
return choice === undefined || choice.provider !== input.provider ? [] : [choice.model];
|
|
65
|
+
})
|
|
66
|
+
.find((model) => isCheaperRung(model, input.model));
|
|
67
|
+
if (pinned !== undefined) {
|
|
68
|
+
return pinned;
|
|
69
|
+
}
|
|
70
|
+
const cheapest = input.models.toSorted(compareCheapestFirst)[0];
|
|
71
|
+
return cheapest !== undefined && isCheaperRung(cheapest, input.model) ? cheapest : undefined;
|
|
72
|
+
};
|
package/src/history-state.ts
CHANGED
|
@@ -95,7 +95,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
95
95
|
{
|
|
96
96
|
path: "ssh-hosts/",
|
|
97
97
|
portability: "secret",
|
|
98
|
-
note: "Re-add each ssh host on the Capabilities view
|
|
98
|
+
note: "Re-add each ssh host on the Capabilities view, its key does not travel.",
|
|
99
99
|
},
|
|
100
100
|
// The cli-proxy's config, which holds the routed subscriptions' provider tokens.
|
|
101
101
|
{
|
|
@@ -108,7 +108,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
108
108
|
|
|
109
109
|
/* Signs every browser session cookie. Carrying it would let a bundle's holder mint sessions against the
|
|
110
110
|
* target, an export becomes a credential, and the target minting its own costs exactly one sign-in. */
|
|
111
|
-
{ path: "session-secret", portability: "identity", note: "Sign in again
|
|
111
|
+
{ path: "session-secret", portability: "identity", note: "Sign in again, the target signs its own sessions." },
|
|
112
112
|
{
|
|
113
113
|
path: "browser-access-disabled",
|
|
114
114
|
portability: "identity",
|
|
@@ -117,7 +117,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
|
|
|
117
117
|
{
|
|
118
118
|
path: "push.json",
|
|
119
119
|
portability: "identity",
|
|
120
|
-
note: "Re-enable notifications in the browsers you use
|
|
120
|
+
note: "Re-enable notifications in the browsers you use, a push subscription is bound to the sandbox that minted it.",
|
|
121
121
|
},
|
|
122
122
|
{ path: "sync-enrollments.json", portability: "identity", note: "Re-pair desktop sync from the Sync tab." },
|
|
123
123
|
{ path: "sync-pair-consumed.json", portability: "identity" },
|
package/src/hostnames.test.ts
CHANGED
|
@@ -42,7 +42,7 @@ test("the local sync folder carries the very id the sandbox's URL does", () => {
|
|
|
42
42
|
const url = `https://${sandboxHostname(ID, "intentic.dev")}`;
|
|
43
43
|
expect(sandboxIdFromUrl(url)).toBe(ID);
|
|
44
44
|
expect(syncFolder("Radarsu Local", url)).toBe(`~/intentic/radarsu-local-${ID}`);
|
|
45
|
-
// A bare host (no scheme) and a port are both accepted
|
|
45
|
+
// A bare host (no scheme) and a port are both accepted: the daemon/CLI and the browser pass different shapes.
|
|
46
46
|
expect(sandboxIdFromUrl(`${sandboxHostname(ID, "intentic.dev")}:443`)).toBe(ID);
|
|
47
47
|
// Own-Cloudflare: the owner's chosen subdomain IS the id, so the folder still matches what they browse to.
|
|
48
48
|
expect(syncFolder("shop", "https://dev-box.example.com")).toBe("~/intentic/shop-dev-box");
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { ciContract } from "./contracts/ci.contract.js";
|
|
|
10
10
|
import { claudeContract } from "./contracts/claude.contract.js";
|
|
11
11
|
import { draftsContract } from "./contracts/drafts.contract.js";
|
|
12
12
|
import { endpointsContract } from "./contracts/endpoints.contract.js";
|
|
13
|
+
import { exitContract } from "./contracts/exit.contract.js";
|
|
13
14
|
import { extensionsContract } from "./contracts/extensions.contract.js";
|
|
14
15
|
import { personasContract } from "./contracts/personas.contract.js";
|
|
15
16
|
import { gitContract } from "./contracts/git.contract.js";
|
|
@@ -47,6 +48,7 @@ export { ciContract } from "./contracts/ci.contract.js";
|
|
|
47
48
|
export { claudeContract } from "./contracts/claude.contract.js";
|
|
48
49
|
export { draftsContract } from "./contracts/drafts.contract.js";
|
|
49
50
|
export { endpointsContract, type TrialHealth, TrialStatusSchema, type TrialStatusResponse } from "./contracts/endpoints.contract.js";
|
|
51
|
+
export { exitContract } from "./contracts/exit.contract.js";
|
|
50
52
|
export { extensionsContract } from "./contracts/extensions.contract.js";
|
|
51
53
|
export { personasContract } from "./contracts/personas.contract.js";
|
|
52
54
|
export { gitContract } from "./contracts/git.contract.js";
|
|
@@ -99,11 +101,13 @@ export * from "./agent-run-model.js";
|
|
|
99
101
|
export * from "./capability-env.js";
|
|
100
102
|
export * from "./capability-secrets.js";
|
|
101
103
|
export * from "./conversation-ids.js";
|
|
104
|
+
export * from "./fast-tier.js";
|
|
102
105
|
export * from "./host-protocol.js";
|
|
103
106
|
export * from "./listener-protocol.js";
|
|
104
107
|
export * from "./hostnames.js";
|
|
105
108
|
export * from "./model-order.js";
|
|
106
109
|
export * from "./path-refs.js";
|
|
110
|
+
export * from "./prompt-complexity.js";
|
|
107
111
|
export * from "./quick-model.js";
|
|
108
112
|
export * from "./output-fields.js";
|
|
109
113
|
export * from "./publish-drafts.js";
|
|
@@ -153,6 +157,7 @@ export const sandboxContract = {
|
|
|
153
157
|
translator: translatorContract,
|
|
154
158
|
usage: usageContract,
|
|
155
159
|
vpn: vpnContract,
|
|
160
|
+
exit: exitContract,
|
|
156
161
|
workflows: workflowsContract,
|
|
157
162
|
};
|
|
158
163
|
|
package/src/model-order.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ const CODEX = ["gpt-5.1-codex", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-
|
|
|
11
11
|
|
|
12
12
|
test("ranks the frontier line above the cheap one and the newest release above its predecessors", () => {
|
|
13
13
|
// The base line (no tier word) leads, newest first; the mini rung sinks under all of it regardless of how
|
|
14
|
-
// recently it shipped
|
|
14
|
+
// recently it shipped, which is the whole decision a user makes in this list.
|
|
15
15
|
expect(CODEX.toSorted(compareModelIds)).toEqual(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.1-codex", "gpt-5.4-mini"]);
|
|
16
16
|
});
|
|
17
17
|
|
|
@@ -47,7 +47,7 @@ test("the Codex release-tier order is stable across catalog refreshes", () => {
|
|
|
47
47
|
expect(["gpt-5.4-mini", ...arrivals[0]!].toSorted(compareUnrankedModelIds).at(-1)).toBe("gpt-5.4-mini");
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
test("leaves a RANKED catalog's ties alone
|
|
50
|
+
test("leaves a RANKED catalog's ties alone: the id tiebreak is for sets, and Anthropic publishes an opinion", () => {
|
|
51
51
|
// compareUnrankedModelIds would seat claude-fable-5 ahead of claude-opus-5 on the id alone. Anthropic's
|
|
52
52
|
// catalog arrives newest-first, so that order is a fact about the provider, not a leftover to be broken.
|
|
53
53
|
expect(["claude-opus-5", "claude-fable-5"].toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-fable-5"]);
|
|
@@ -82,7 +82,7 @@ test("reads Kimi's k-prefixed generation so K3 leads the K2.x catalog", () => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
test("the rightmost tier word wins, because tier words compose", () => {
|
|
85
|
-
// flash-lite is the cheap end of Flash, codex-max the frontier end of Codex
|
|
85
|
+
// flash-lite is the cheap end of Flash, codex-max the frontier end of Codex: reading the leftmost word
|
|
86
86
|
// instead would file both under the tier they modify.
|
|
87
87
|
expect(tierRankOf(familyOf("gemini-3-flash-lite"))).toBe(tierRankOf("lite"));
|
|
88
88
|
expect(tierRankOf(familyOf("gpt-5.1-codex-max"))).toBe(tierRankOf("max"));
|
|
@@ -95,7 +95,7 @@ test("leads with a family carrying no tier word at all, so a brand-new flagship
|
|
|
95
95
|
|
|
96
96
|
test("files a re-served open-weights model on the cheap rung, not at the head of the catalog it visits", () => {
|
|
97
97
|
// Google's channel vends gpt-oss beside Gemini and Claude. It carries no tier word of its own, so the
|
|
98
|
-
// lead-the-unknown rule would open that whole section on it
|
|
98
|
+
// lead-the-unknown rule would open that whole section on it: above Opus.
|
|
99
99
|
expect(["gpt-oss-120b-medium", "claude-opus-4-6-thinking", "gemini-pro-agent"].toSorted(compareModelIds)).toEqual([
|
|
100
100
|
"claude-opus-4-6-thinking",
|
|
101
101
|
"gemini-pro-agent",
|
|
@@ -103,7 +103,7 @@ test("files a re-served open-weights model on the cheap rung, not at the head of
|
|
|
103
103
|
]);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
test("keeps the arrival order between ids the rule cannot separate
|
|
106
|
+
test("keeps the arrival order between ids the rule cannot separate: Anthropic's catalog IS ranked", () => {
|
|
107
107
|
// Same tier, same version: nothing here outranks the order the provider itself reported.
|
|
108
108
|
expect(["claude-opus-5", "claude-fable-5"].toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-fable-5"]);
|
|
109
109
|
expect(["claude-fable-5", "claude-opus-5"].toSorted(compareModelIds)).toEqual(["claude-fable-5", "claude-opus-5"]);
|
|
@@ -124,7 +124,7 @@ test("stands an id with nothing but numbers (and an ACP row's empty one) as its
|
|
|
124
124
|
|
|
125
125
|
test("holds date stamps apart from version components, or a dated build outranks the point release after it", () => {
|
|
126
126
|
// The failure this prevents: claude-opus-4-1-20250805 (Opus 4.1) read as (4,1,20250805) loses to
|
|
127
|
-
// claude-opus-4-20250514 (Opus 4.0) read as (4,20250514)
|
|
127
|
+
// claude-opus-4-20250514 (Opus 4.0) read as (4,20250514): the older model, by six digits.
|
|
128
128
|
expect(releaseOf("claude-opus-4-1-20250805")).toEqual({ version: [4, 1], date: 20250805 });
|
|
129
129
|
expect(["claude-opus-4-20250514", "claude-opus-4-1-20250805"].toSorted(compareModelIds)).toEqual([
|
|
130
130
|
"claude-opus-4-1-20250805",
|
|
@@ -153,7 +153,7 @@ test("sorts an unversioned rolling alias under the releases that name their vers
|
|
|
153
153
|
// What the quick model behind a one-click helper resolves against: the same tier scale, read for the weakest
|
|
154
154
|
// row instead of the strongest.
|
|
155
155
|
|
|
156
|
-
test("opens on the efficient rung and buries the frontier one
|
|
156
|
+
test("opens on the efficient rung and buries the frontier one: the exact inverse of the picker's order", () => {
|
|
157
157
|
const claude = ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"];
|
|
158
158
|
|
|
159
159
|
expect(claude.toSorted(compareCheapestFirst)).toEqual(["claude-haiku-4-5-20251001", "claude-sonnet-5", "claude-opus-5"]);
|
|
@@ -162,7 +162,7 @@ test("opens on the efficient rung and buries the frontier one — the exact inve
|
|
|
162
162
|
|
|
163
163
|
test("keeps an UNRANKED family off the cheap end, where a plain reversal would have seated it first", () => {
|
|
164
164
|
// The whole reason this is not `-compareModelIds`. An id with no tier word is the provider's base line, and
|
|
165
|
-
// an unheard-of family is likelier the next flagship than the next budget tier
|
|
165
|
+
// an unheard-of family is likelier the next flagship than the next budget tier, so both orders agree it is
|
|
166
166
|
// not the efficient rung, and a helper never spends frontier money on a commit message.
|
|
167
167
|
expect(["gpt-5.6", "gpt-5.4-mini"].toSorted(compareCheapestFirst)).toEqual(["gpt-5.4-mini", "gpt-5.6"]);
|
|
168
168
|
expect(["claude-mythos-1", "claude-haiku-4-5", "claude-sonnet-5"].toSorted(compareCheapestFirst).at(-1)).toBe("claude-mythos-1");
|
|
@@ -177,7 +177,7 @@ test("takes the NEWEST build of the cheap rung, not merely any of them", () => {
|
|
|
177
177
|
test("finds each vendor's own cheap rung, including a re-served open-weights row", () => {
|
|
178
178
|
expect(["gemini-3-pro", "gemini-3-flash", "gemini-3-flash-lite"].toSorted(compareCheapestFirst)[0]).toBe("gemini-3-flash-lite");
|
|
179
179
|
// Google's channel vends gpt-oss beside Gemini's own line; it is there to be the cheap option, and `oss`
|
|
180
|
-
// is what says so
|
|
180
|
+
// is what says so: without that word the id carries no tier at all and would sink to the bottom.
|
|
181
181
|
expect(["claude-opus-4-6-thinking", "gpt-oss-120b-medium"].toSorted(compareCheapestFirst)[0]).toBe("gpt-oss-120b-medium");
|
|
182
182
|
expect(["grok-4", "grok-4-fast"].toSorted(compareCheapestFirst)[0]).toBe("grok-4-fast");
|
|
183
183
|
});
|
|
@@ -188,7 +188,7 @@ test("reads a release-local tier ladder from the cheap end too", () => {
|
|
|
188
188
|
|
|
189
189
|
test("refuses the thinking variant of a model, however new it is", () => {
|
|
190
190
|
/* The bug this rule exists for, in the shape the live catalog actually publishes it: a routed channel vends
|
|
191
|
-
* one row per thinking LEVEL, and the newest row of the cheapest model was the high one
|
|
191
|
+
* one row per thinking LEVEL, and the newest row of the cheapest model was the high one, so the ladder
|
|
192
192
|
* whose whole job is to be the cheap rung reached for the most expensive reading of it, and a commit
|
|
193
193
|
* message that takes 2s took closer to 30. */
|
|
194
194
|
expect(["gemini-3.6-flash-high", "gemini-3.5-flash-extra-low"].toSorted(compareCheapestFirst)[0]).toBe("gemini-3.5-flash-extra-low");
|
|
@@ -219,6 +219,6 @@ test("names the thinking rows, and only those", () => {
|
|
|
219
219
|
|
|
220
220
|
test("falls back on the newest release for a catalog that publishes no cheap tier at all", () => {
|
|
221
221
|
// Kimi names no tier word anywhere, so every row is UNRANKED and the tier term cancels. Serving the newest
|
|
222
|
-
// of what it does publish is the honest answer
|
|
222
|
+
// of what it does publish is the honest answer: there is no cheaper rung to find.
|
|
223
223
|
expect(["kimi-k2-0711-preview", "kimi-k2-0905-preview"].toSorted(compareCheapestFirst)[0]).toBe("kimi-k2-0905-preview");
|
|
224
224
|
});
|
package/src/model-order.ts
CHANGED
|
@@ -230,6 +230,28 @@ export const compareCheapestFirst = (left: string, right: string): number => {
|
|
|
230
230
|
);
|
|
231
231
|
};
|
|
232
232
|
|
|
233
|
+
/* IS `candidate` A STRICTLY CHEAPER RUNG THAN `pick`, on the tier ladder alone. The predicate automatic tier
|
|
234
|
+
* selection runs on (prompt-complexity.ts judges the turn, fast-tier.ts spends this to find something cheaper
|
|
235
|
+
* to run it on), and it lives here because it is a question about the tier vocabulary, which is this file's
|
|
236
|
+
* one curated fact.
|
|
237
|
+
*
|
|
238
|
+
* TIER ONLY, deliberately: not release, not thinking level. A downgrade has to be legible as a downgrade, and
|
|
239
|
+
* "the same model one release older" or "the same model with less thinking" are not the saving this mechanism
|
|
240
|
+
* promises, they are a different turn wearing the user's model name. Haiku under Sonnet is; Sonnet 4 under
|
|
241
|
+
* Sonnet 5 is not.
|
|
242
|
+
*
|
|
243
|
+
* AN UNRANKED FAMILY IS FALSE ON EITHER SIDE, and both directions matter. A candidate carrying no tier word is
|
|
244
|
+
* a provider's base line or a family nobody here has heard of, so calling it the budget option is a guess, and
|
|
245
|
+
* the scale already says the likelier reading is "next flagship". The PICK being unranked is the subtler half:
|
|
246
|
+
* the safety argument for automatic tier selection is that it can only ever route DOWN, and against an id whose
|
|
247
|
+
* tier is unknown nothing can be shown to be down. So an unrecognised pick is left alone rather than swapped
|
|
248
|
+
* for something merely known to be cheap, which is how a saving turns into a bill. */
|
|
249
|
+
export const isCheaperRung = (candidate: string, pick: string): boolean => {
|
|
250
|
+
const candidateRank = tierRankOf(familyOf(candidate));
|
|
251
|
+
const pickRank = tierRankOf(familyOf(pick));
|
|
252
|
+
return candidateRank !== UNRANKED && pickRank !== UNRANKED && candidateRank > pickRank;
|
|
253
|
+
};
|
|
254
|
+
|
|
233
255
|
/* WOULD RUNNING THIS ID MAKE THE MODEL THINK, as far as its name admits, which for a routed catalog is as far
|
|
234
256
|
* as anyone can tell without running it. True only for an id that spells out a level ABOVE the quiet end, so an
|
|
235
257
|
* ordinary id nobody has annotated (claude-haiku-4-5, kimi-k2) is never accused of it.
|
package/src/output-fields.ts
CHANGED
|
@@ -92,7 +92,7 @@ export const fieldsValidator = (fields: readonly OutputField[]): z.ZodType =>
|
|
|
92
92
|
export const fieldsExample = (fields: readonly OutputField[]): Record<string, unknown> =>
|
|
93
93
|
Object.fromEntries(
|
|
94
94
|
fields.map((field) => {
|
|
95
|
-
const hint = `${field.description}${field.required ? "" : " (optional
|
|
95
|
+
const hint = `${field.description}${field.required ? "" : " (optional, omit if it does not apply)"}`;
|
|
96
96
|
if (field.type === "number") {
|
|
97
97
|
return [field.name, 0];
|
|
98
98
|
}
|
|
@@ -108,4 +108,4 @@ export const fieldsExample = (fields: readonly OutputField[]): Record<string, un
|
|
|
108
108
|
|
|
109
109
|
// One line per field, for surfaces with no room to render an example: "risk (string, required), how likely …".
|
|
110
110
|
export const describeFields = (fields: readonly OutputField[]): string =>
|
|
111
|
-
fields.map((field) => `- \`${field.name}\` (${field.type}${field.required ? ", required" : ", optional"})
|
|
111
|
+
fields.map((field) => `- \`${field.name}\` (${field.type}${field.required ? ", required" : ", optional"}), ${field.description}`).join(`\n`);
|
package/src/path-refs.test.ts
CHANGED
|
@@ -15,7 +15,7 @@ describe("referenceTails", () => {
|
|
|
15
15
|
expect(referenceTails("/history/worktrees/agent-7/_apps/foo.ts", "/work")).toContain("_apps/foo.ts");
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
test("never cuts down to a bare filename
|
|
18
|
+
test("never cuts down to a bare filename: one `index.ts` is as good as another", () => {
|
|
19
19
|
expect(referenceTails("a/b/index.ts", "/work").at(-1)).toBe("b/index.ts");
|
|
20
20
|
expect(referenceTails("index.ts", "/work")).toEqual([]);
|
|
21
21
|
});
|
|
@@ -28,11 +28,11 @@ describe("referenceTails", () => {
|
|
|
28
28
|
|
|
29
29
|
describe("rankRefCandidates", () => {
|
|
30
30
|
test("keeps only matches that end in the tail on a segment boundary", () => {
|
|
31
|
-
// `mypages/` merely ends with the same characters
|
|
31
|
+
// `mypages/` merely ends with the same characters: the daemon's glob can't tell, so this must.
|
|
32
32
|
expect(rankRefCandidates("pages/Foo.vue", ["app/mypages/Foo.vue", "app/pages/Foo.vue"])).toEqual(["app/pages/Foo.vue"]);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
test("ranks the shallowest match first
|
|
35
|
+
test("ranks the shallowest match first: the app's file, not a copy in a fixture tree", () => {
|
|
36
36
|
expect(rankRefCandidates("pages/Foo.vue", ["a/b/c/pages/Foo.vue", "a/pages/Foo.vue"])).toEqual(["a/pages/Foo.vue", "a/b/c/pages/Foo.vue"]);
|
|
37
37
|
});
|
|
38
38
|
|
|
@@ -41,7 +41,7 @@ describe("rankRefCandidates", () => {
|
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
test("isTestPath: test files, fixture dirs and runner configs
|
|
44
|
+
test("isTestPath: test files, fixture dirs and runner configs: never product code that merely says 'test'", () => {
|
|
45
45
|
for (const path of [
|
|
46
46
|
`src/agents/land.test.ts`,
|
|
47
47
|
`src/pages/Foo.spec.tsx`,
|