@intentic/sandbox-contract 1.158.0 → 1.160.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/dist/agent-catalog.d.ts +10 -0
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +14 -1
- package/dist/agent-catalog.js.map +1 -1
- package/dist/contracts/agent.contract.d.ts +74 -4
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.js +2 -1
- package/dist/contracts/agent.contract.js.map +1 -1
- package/dist/contracts/agents.contract.d.ts +248 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +7 -2
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/automations.contract.d.ts +7 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/claude.contract.d.ts +26 -0
- package/dist/contracts/claude.contract.d.ts.map +1 -1
- package/dist/contracts/claude.contract.js +2 -1
- package/dist/contracts/claude.contract.js.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +1 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.d.ts +13 -0
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.js +2 -1
- package/dist/contracts/git.contract.js.map +1 -1
- package/dist/contracts/grok.contract.d.ts +2 -0
- package/dist/contracts/grok.contract.d.ts.map +1 -1
- package/dist/contracts/kimi.contract.d.ts +26 -0
- package/dist/contracts/kimi.contract.d.ts.map +1 -1
- package/dist/contracts/kimi.contract.js +2 -1
- package/dist/contracts/kimi.contract.js.map +1 -1
- package/dist/contracts/memory.contract.d.ts +34 -0
- package/dist/contracts/memory.contract.d.ts.map +1 -0
- package/dist/contracts/memory.contract.js +9 -0
- package/dist/contracts/memory.contract.js.map +1 -0
- package/dist/contracts/panels.contract.d.ts +1 -0
- package/dist/contracts/panels.contract.d.ts.map +1 -1
- package/dist/contracts/push.contract.d.ts +1 -1
- package/dist/contracts/push.contract.js +2 -2
- package/dist/contracts/push.contract.js.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +2 -0
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +32 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.js +2 -1
- package/dist/contracts/settings.contract.js.map +1 -1
- package/dist/contracts/system.contract.d.ts +35 -6
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +2 -1
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/translator.contract.d.ts +13 -3
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/contracts/translator.contract.js +1 -1
- package/dist/contracts/translator.contract.js.map +1 -1
- package/dist/contracts/workspace.contract.d.ts +43 -5
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +3 -1
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/events.d.ts +176 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +16 -5
- package/dist/events.js.map +1 -1
- package/dist/hostnames.d.ts +2 -0
- package/dist/hostnames.d.ts.map +1 -1
- package/dist/hostnames.js +27 -1
- package/dist/hostnames.js.map +1 -1
- package/dist/index.d.ts +566 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/model-order.d.ts +2 -0
- package/dist/model-order.d.ts.map +1 -1
- package/dist/model-order.js +3 -0
- package/dist/model-order.js.map +1 -1
- package/dist/path-refs.d.ts +5 -0
- package/dist/path-refs.d.ts.map +1 -0
- package/dist/path-refs.js +21 -0
- package/dist/path-refs.js.map +1 -0
- package/dist/quick-model.d.ts +13 -0
- package/dist/quick-model.d.ts.map +1 -0
- package/dist/quick-model.js +33 -0
- package/dist/quick-model.js.map +1 -0
- package/dist/routes.d.ts +8 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +39 -0
- package/dist/routes.js.map +1 -0
- package/dist/schemas.d.ts +351 -10
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +143 -13
- package/dist/schemas.js.map +1 -1
- package/dist/title.d.ts +6 -0
- package/dist/title.d.ts.map +1 -0
- package/dist/title.js +149 -0
- package/dist/title.js.map +1 -0
- package/package.json +2 -2
- package/src/agent-catalog.ts +51 -1
- package/src/contracts/agent.contract.ts +14 -1
- package/src/contracts/agents.contract.ts +25 -1
- package/src/contracts/claude.contract.ts +4 -1
- package/src/contracts/git.contract.ts +6 -0
- package/src/contracts/kimi.contract.ts +13 -2
- package/src/contracts/memory.contract.ts +13 -0
- package/src/contracts/push.contract.ts +2 -2
- package/src/contracts/settings.contract.ts +5 -1
- package/src/contracts/system.contract.ts +4 -0
- package/src/contracts/translator.contract.ts +3 -2
- package/src/contracts/workspace.contract.ts +12 -0
- package/src/events.ts +89 -11
- package/src/hostnames.test.ts +22 -0
- package/src/hostnames.ts +40 -1
- package/src/index.ts +20 -0
- package/src/model-order.test.ts +73 -1
- package/src/model-order.ts +31 -0
- package/src/path-refs.test.ts +68 -0
- package/src/path-refs.ts +59 -0
- package/src/quick-model.test.ts +88 -0
- package/src/quick-model.ts +103 -0
- package/src/routes.test.ts +83 -0
- package/src/routes.ts +84 -0
- package/src/schemas.test.ts +23 -1
- package/src/schemas.ts +461 -38
- package/src/title.test.ts +130 -0
- package/src/title.ts +254 -0
package/src/model-order.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
|
-
import { compareModelIds, familyOf, releaseOf, tierRankOf } from "./model-order.js";
|
|
2
|
+
import { compareCheapestFirst, compareModelIds, compareUnrankedModelIds, familyOf, releaseOf, tierRankOf } from "./model-order.js";
|
|
3
3
|
|
|
4
4
|
/* The order every provider's catalog is served and browsed in. The rule exists because only Anthropic publishes
|
|
5
5
|
* a ranking: the OpenAI-compatible endpoints behind Codex, Gemini, Kimi and Grok hand back a SET, and taking
|
|
@@ -26,6 +26,29 @@ test("lands the same models at the head and the tail whichever order the endpoin
|
|
|
26
26
|
}
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
test("an unranked catalog settles its own ties, so the SAME sibling opens the group on every refresh", () => {
|
|
30
|
+
// The measured failure: the translator's /v1/models hands sol/terra/luna back in a different order per
|
|
31
|
+
// request, and the rule above ranks all three equally — same tier, same 5.6 release. Under plain stability
|
|
32
|
+
// the catalog's head (i.e. the model a fresh conversation starts on) followed that reshuffling.
|
|
33
|
+
const arrivals = [
|
|
34
|
+
["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"],
|
|
35
|
+
["gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-sol"],
|
|
36
|
+
["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"],
|
|
37
|
+
];
|
|
38
|
+
const heads = arrivals.map((arrival) => arrival.toSorted(compareUnrankedModelIds)[0]);
|
|
39
|
+
|
|
40
|
+
expect(new Set(heads).size).toBe(1);
|
|
41
|
+
// Same rule, so ranking still outranks the tiebreak: the mini rung stays at the tail, under every sibling.
|
|
42
|
+
expect(["gpt-5.4-mini", ...arrivals[0]!].toSorted(compareUnrankedModelIds).at(-1)).toBe("gpt-5.4-mini");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("leaves a RANKED catalog's ties alone — the id tiebreak is for sets, and Anthropic publishes an opinion", () => {
|
|
46
|
+
// compareUnrankedModelIds would seat claude-fable-5 ahead of claude-opus-5 on the id alone. Anthropic's
|
|
47
|
+
// catalog arrives newest-first, so that order is a fact about the provider, not a leftover to be broken.
|
|
48
|
+
expect(["claude-opus-5", "claude-fable-5"].toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-fable-5"]);
|
|
49
|
+
expect(["claude-opus-5", "claude-fable-5"].toSorted(compareUnrankedModelIds)).toEqual(["claude-fable-5", "claude-opus-5"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
29
52
|
test("reads each vendor's tier vocabulary, not just Claude's", () => {
|
|
30
53
|
expect(["gemini-3-flash", "gemini-3-flash-lite", "gemini-3-pro"].toSorted(compareModelIds)).toEqual([
|
|
31
54
|
"gemini-3-pro",
|
|
@@ -57,6 +80,16 @@ test("leads with a family carrying no tier word at all, so a brand-new flagship
|
|
|
57
80
|
expect(["claude-sonnet-5", "claude-mythos-1", "claude-opus-5"].toSorted(compareModelIds)[0]).toBe("claude-mythos-1");
|
|
58
81
|
});
|
|
59
82
|
|
|
83
|
+
test("files a re-served open-weights model on the cheap rung, not at the head of the catalog it visits", () => {
|
|
84
|
+
// Google's channel vends gpt-oss beside Gemini and Claude. It carries no tier word of its own, so the
|
|
85
|
+
// lead-the-unknown rule would open that whole section on it — above Opus.
|
|
86
|
+
expect(["gpt-oss-120b-medium", "claude-opus-4-6-thinking", "gemini-pro-agent"].toSorted(compareModelIds)).toEqual([
|
|
87
|
+
"claude-opus-4-6-thinking",
|
|
88
|
+
"gemini-pro-agent",
|
|
89
|
+
"gpt-oss-120b-medium",
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
60
93
|
test("keeps the arrival order between ids the rule cannot separate — Anthropic's catalog IS ranked", () => {
|
|
61
94
|
// Same tier, same version: nothing here outranks the order the provider itself reported.
|
|
62
95
|
expect(["claude-opus-5", "claude-fable-5"].toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-fable-5"]);
|
|
@@ -102,3 +135,42 @@ test("sorts an unversioned rolling alias under the releases that name their vers
|
|
|
102
135
|
expect(releaseOf("kimi-latest").version).toEqual([]);
|
|
103
136
|
expect(["kimi-latest", "kimi-k2-0711-preview"].toSorted(compareModelIds)).toEqual(["kimi-k2-0711-preview", "kimi-latest"]);
|
|
104
137
|
});
|
|
138
|
+
|
|
139
|
+
// --- the cheap end (compareCheapestFirst) ---------------------------------------------------------------
|
|
140
|
+
// What the quick model behind a one-click helper resolves against: the same tier scale, read for the weakest
|
|
141
|
+
// row instead of the strongest.
|
|
142
|
+
|
|
143
|
+
test("opens on the efficient rung and buries the frontier one — the exact inverse of the picker's order", () => {
|
|
144
|
+
const claude = ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"];
|
|
145
|
+
|
|
146
|
+
expect(claude.toSorted(compareCheapestFirst)).toEqual(["claude-haiku-4-5-20251001", "claude-sonnet-5", "claude-opus-5"]);
|
|
147
|
+
expect(claude.toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("keeps an UNRANKED family off the cheap end, where a plain reversal would have seated it first", () => {
|
|
151
|
+
// The whole reason this is not `-compareModelIds`. An id with no tier word is the provider's base line, and
|
|
152
|
+
// an unheard-of family is likelier the next flagship than the next budget tier — so both orders agree it is
|
|
153
|
+
// not the efficient rung, and a helper never spends frontier money on a commit message.
|
|
154
|
+
expect(["gpt-5.6", "gpt-5.4-mini"].toSorted(compareCheapestFirst)).toEqual(["gpt-5.4-mini", "gpt-5.6"]);
|
|
155
|
+
expect(["claude-mythos-1", "claude-haiku-4-5", "claude-sonnet-5"].toSorted(compareCheapestFirst).at(-1)).toBe("claude-mythos-1");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("takes the NEWEST build of the cheap rung, not merely any of them", () => {
|
|
159
|
+
// Within one tier the release rule runs unchanged: cheap is a tier, not an excuse to serve a stale model.
|
|
160
|
+
expect(["claude-haiku-4-5-20251001", "claude-haiku-4-5-20260210"].toSorted(compareCheapestFirst)[0]).toBe("claude-haiku-4-5-20260210");
|
|
161
|
+
expect(["gemini-3-flash-lite", "gemini-2-flash-lite"].toSorted(compareCheapestFirst)[0]).toBe("gemini-3-flash-lite");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("finds each vendor's own cheap rung, including a re-served open-weights row", () => {
|
|
165
|
+
expect(["gemini-3-pro", "gemini-3-flash", "gemini-3-flash-lite"].toSorted(compareCheapestFirst)[0]).toBe("gemini-3-flash-lite");
|
|
166
|
+
// Google's channel vends gpt-oss beside Gemini's own line; it is there to be the cheap option, and `oss`
|
|
167
|
+
// is what says so — without that word the id carries no tier at all and would sink to the bottom.
|
|
168
|
+
expect(["claude-opus-4-6-thinking", "gpt-oss-120b-medium"].toSorted(compareCheapestFirst)[0]).toBe("gpt-oss-120b-medium");
|
|
169
|
+
expect(["grok-4", "grok-4-fast"].toSorted(compareCheapestFirst)[0]).toBe("grok-4-fast");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("falls back on the newest release for a catalog that publishes no cheap tier at all", () => {
|
|
173
|
+
// Kimi names no tier word anywhere, so every row is UNRANKED and the tier term cancels. Serving the newest
|
|
174
|
+
// of what it does publish is the honest answer — there is no cheaper rung to find.
|
|
175
|
+
expect(["kimi-k2-0711-preview", "kimi-k2-0905-preview"].toSorted(compareCheapestFirst)[0]).toBe("kimi-k2-0905-preview");
|
|
176
|
+
});
|
package/src/model-order.ts
CHANGED
|
@@ -97,6 +97,10 @@ const TIER_RANK: Readonly<Record<string, number>> = {
|
|
|
97
97
|
mini: 1,
|
|
98
98
|
// Efficient: the cheap/fast end, the rung whose whole purpose is to cost less than the one above it.
|
|
99
99
|
haiku: 2,
|
|
100
|
+
// An open-weights model a vendor re-serves beside its own (gpt-oss-120b on Google's channel) is that rung by
|
|
101
|
+
// construction: it is there to be the free/cheap option next to the frontier line, never the flagship. Without
|
|
102
|
+
// it the id carries no tier word at all and would LEAD the section it sits in.
|
|
103
|
+
oss: 2,
|
|
100
104
|
lite: 2,
|
|
101
105
|
nano: 2,
|
|
102
106
|
fast: 2,
|
|
@@ -122,3 +126,30 @@ export const tierRankOf = (family: string): number => {
|
|
|
122
126
|
// is stable, so two ids this rule cannot separate keep the order they arrived in (for Claude, the provider's own).
|
|
123
127
|
export const compareModelIds = (left: string, right: string): number =>
|
|
124
128
|
tierRankOf(familyOf(left)) - tierRankOf(familyOf(right)) || compareRelease(releaseOf(left), releaseOf(right));
|
|
129
|
+
|
|
130
|
+
/* The order for a catalog its endpoint published as a SET — Codex, Gemini, Kimi and Grok, i.e. everything but
|
|
131
|
+
* Anthropic's ranked list. Falling back on arrival order is what the rule above does with a tie, and for a RANKED
|
|
132
|
+
* catalog that is exactly right: the tie is the provider's own opinion, so claude-opus-5 stays ahead of
|
|
133
|
+
* claude-fable-5. For a set there is no opinion to keep, and the header of this file assumed the leftover order
|
|
134
|
+
* was at least alphabetical — it is not. A subscription vending sol/terra/luna (same tier, same 5.6 release, three
|
|
135
|
+
* ids this rule cannot separate) hands its rows back in whatever order its registry iterated THIS request, so the
|
|
136
|
+
* tie decided which model a fresh conversation opened on AND flipped between catalog refreshes.
|
|
137
|
+
*
|
|
138
|
+
* So a set breaks its own ties on the id. Which sibling that seats first is arbitrary — but it is the same
|
|
139
|
+
* arbitrary answer every refresh, which is the property `default` actually needs. */
|
|
140
|
+
export const compareUnrankedModelIds = (left: string, right: string): number => compareModelIds(left, right) || left.localeCompare(right);
|
|
141
|
+
|
|
142
|
+
/* THE SAME TIER SCALE READ FROM THE OTHER END, for the one caller that wants the WEAKEST model rather than the
|
|
143
|
+
* strongest: the quick model behind a one-click helper (the commit box's autofill). A picker orders a catalog by
|
|
144
|
+
* what a user reaches for; this orders it by what a helper should spend, and the two are exact opposites — so
|
|
145
|
+
* they share TIER_RANK rather than each naming its own list of cheap ids.
|
|
146
|
+
*
|
|
147
|
+
* The direction of UNRANKED is the reason this can't just be compareModelIds reversed. There, an unrecognized
|
|
148
|
+
* family LEADS, because an id carrying no tier word is the provider's base line and a family nobody here has
|
|
149
|
+
* heard of is likelier the next flagship than the next budget tier. Reversing would therefore seat exactly that
|
|
150
|
+
* unknown-probably-flagship id as the cheap pick. So unknown sinks to LAST here too — both orders agree it is
|
|
151
|
+
* not the efficient rung — and the cheap end is only ever a family whose tier word is actually recognized.
|
|
152
|
+
* Falling off the end of a catalog with no efficient tier at all (Kimi publishes none) is then honest: the
|
|
153
|
+
* newest of what it does publish, chosen by the release tiebreak below. */
|
|
154
|
+
export const compareCheapestFirst = (left: string, right: string): number =>
|
|
155
|
+
tierRankOf(familyOf(right)) - tierRankOf(familyOf(left)) || compareRelease(releaseOf(left), releaseOf(right));
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { isTestPath, rankRefCandidates, referenceTails } from "./path-refs.js";
|
|
3
|
+
|
|
4
|
+
describe("referenceTails", () => {
|
|
5
|
+
test("offers the reference itself first, then shorter tails of it", () => {
|
|
6
|
+
expect(referenceTails("_apps/web/src/foo.ts", "/work")).toEqual(["_apps/web/src/foo.ts", "web/src/foo.ts", "src/foo.ts"]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("anchors an absolute path under the workspace root", () => {
|
|
10
|
+
expect(referenceTails("/work/_apps/foo.ts", "/work")[0]).toBe("_apps/foo.ts");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("strips an isolated turn's worktree lead by dropping segments", () => {
|
|
14
|
+
// The worktree mirrors the workspace layout below /history/worktrees/<id>, so the real path is a tail.
|
|
15
|
+
expect(referenceTails("/history/worktrees/agent-7/_apps/foo.ts", "/work")).toContain("_apps/foo.ts");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("never cuts down to a bare filename — one `index.ts` is as good as another", () => {
|
|
19
|
+
expect(referenceTails("a/b/index.ts", "/work").at(-1)).toBe("b/index.ts");
|
|
20
|
+
expect(referenceTails("index.ts", "/work")).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("normalizes a ./ lead and windows separators", () => {
|
|
24
|
+
expect(referenceTails("./src/foo.ts", "/work")).toEqual(["src/foo.ts"]);
|
|
25
|
+
expect(referenceTails("src\\foo.ts", "/work")).toEqual(["src/foo.ts"]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("rankRefCandidates", () => {
|
|
30
|
+
test("keeps only matches that end in the tail on a segment boundary", () => {
|
|
31
|
+
// `mypages/` merely ends with the same characters — the daemon's glob can't tell, so this must.
|
|
32
|
+
expect(rankRefCandidates("pages/Foo.vue", ["app/mypages/Foo.vue", "app/pages/Foo.vue"])).toEqual(["app/pages/Foo.vue"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("ranks the shallowest match first — the app's file, not a copy in a fixture tree", () => {
|
|
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
|
+
});
|
|
38
|
+
|
|
39
|
+
test("matches the tail as a whole path too", () => {
|
|
40
|
+
expect(rankRefCandidates("src/foo.ts", ["src/foo.ts", "vendor/src/foo.ts"])).toEqual(["src/foo.ts", "vendor/src/foo.ts"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("isTestPath: test files, fixture dirs and runner configs — never product code that merely says 'test'", () => {
|
|
45
|
+
for (const path of [
|
|
46
|
+
`src/agents/land.test.ts`,
|
|
47
|
+
`src/pages/Foo.spec.tsx`,
|
|
48
|
+
`_apps/cli/src/cli.e2e.test.ts`,
|
|
49
|
+
`src/e2e-harness.ts`,
|
|
50
|
+
`pkg/__tests__/helper.ts`,
|
|
51
|
+
`_libs/iq-recall/src/__fixtures__/transcripts/a.jsonl`,
|
|
52
|
+
`vitest.config.ts`,
|
|
53
|
+
`_apps/web/vitest.workspace.config.mts`,
|
|
54
|
+
`playwright.config.ts`,
|
|
55
|
+
]) {
|
|
56
|
+
expect(isTestPath(path), path).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
for (const path of [
|
|
59
|
+
`src/pages/testimonials.vue`,
|
|
60
|
+
`src/latest.ts`,
|
|
61
|
+
`src/test-utils.ts`,
|
|
62
|
+
`contest/results.ts`,
|
|
63
|
+
`src/attestation.spec.md.bak`,
|
|
64
|
+
`docs/testing.md`,
|
|
65
|
+
]) {
|
|
66
|
+
expect(isTestPath(path), path).toBe(false);
|
|
67
|
+
}
|
|
68
|
+
});
|
package/src/path-refs.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* Which file a NAMED reference means. A path written in prose is only loosely anchored to the workspace: an
|
|
2
|
+
* agent that has been working in `_apps/web/src` writes `pages/workspace/Foo.vue`, and a turn running in an
|
|
3
|
+
* isolated worktree prints `/history/worktrees/<id>/_apps/web/src/foo.ts` — neither is the workspace-relative
|
|
4
|
+
* path the file routes speak, but both END in it.
|
|
5
|
+
*
|
|
6
|
+
* So a reference is resolved by matching progressively shorter TAILS of it against the real tree. The rules
|
|
7
|
+
* live here, in the contract package, because both sides run them: the browser first against the workspace
|
|
8
|
+
* tree it already has cached, then the daemon (/workspace/resolve) against the iq engine's full sweep, which
|
|
9
|
+
* sees the files the capped tree walk left out. Two matchers that disagreed would make a link's destination
|
|
10
|
+
* depend on which one answered. */
|
|
11
|
+
|
|
12
|
+
// How many leading segments a reference may carry that the workspace doesn't (the `/history/worktrees/<id>/`
|
|
13
|
+
// lead of a worktree path is 3; a foreign absolute root is rarely deeper).
|
|
14
|
+
const MAX_DROPS = 6;
|
|
15
|
+
// A tail is never cut down to a bare filename: `index.ts` names a hundred files in a monorepo and picking one
|
|
16
|
+
// at random is worse than not linking. The link grammar never emits a slash-less reference either.
|
|
17
|
+
const MIN_SEGMENTS = 2;
|
|
18
|
+
// Enough candidates for a picker; a reference matching more than this is ambiguous by any measure.
|
|
19
|
+
export const MAX_REF_CANDIDATES = 10;
|
|
20
|
+
|
|
21
|
+
// The tails worth matching, longest (most specific) first. `root` is the container workspace root: a path
|
|
22
|
+
// under it is already the answer minus that lead, and a path under any OTHER absolute root (a worktree) still
|
|
23
|
+
// mirrors the layout below its own lead, which the successive drops strip.
|
|
24
|
+
export const referenceTails = (raw: string, root: string): readonly string[] => {
|
|
25
|
+
const normalized = raw.replaceAll(`\\`, `/`).replace(/^\.\//, ``);
|
|
26
|
+
const anchored = normalized.startsWith(`${root}/`) ? normalized.slice(root.length + 1) : normalized.replace(/^\/+/, ``);
|
|
27
|
+
const segments = anchored.split(`/`).filter((segment) => segment !== `` && segment !== `.`);
|
|
28
|
+
const tails: string[] = [];
|
|
29
|
+
for (let drop = 0; drop <= MAX_DROPS && segments.length - drop >= MIN_SEGMENTS; drop++) {
|
|
30
|
+
tails.push(segments.slice(drop).join(`/`));
|
|
31
|
+
}
|
|
32
|
+
return tails;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The paths that genuinely END in `tail` on a segment boundary, best first — the shared ranking both matchers
|
|
36
|
+
// return their candidates in. Shallowest wins: `pages/Foo.vue` means the app's page, not the copy six
|
|
37
|
+
// directories down in a fixture tree. (The daemon's glob is anchored only at the string level — `**/pages/x.vue`
|
|
38
|
+
// also matches `mypages/x.vue` — so the boundary is enforced here rather than by the pattern.)
|
|
39
|
+
export const rankRefCandidates = (tail: string, paths: readonly string[]): readonly string[] =>
|
|
40
|
+
paths
|
|
41
|
+
.filter((path) => path === tail || path.endsWith(`/${tail}`))
|
|
42
|
+
.toSorted((a, b) => a.split(`/`).length - b.split(`/`).length || a.length - b.length || (a < b ? -1 : 1))
|
|
43
|
+
.slice(0, MAX_REF_CANDIDATES);
|
|
44
|
+
|
|
45
|
+
/* IS THIS FILE TEST CODE — the one classification rule for every surface that splits a diff into "the
|
|
46
|
+
* change" and "the proof". The agent review header answers "how much of this is tests?" with it; anything
|
|
47
|
+
* else that wants the split (fleet cards, commit summaries) must use this same predicate, because two
|
|
48
|
+
* classifiers that disagree turn the readout into a lie the user can't detect.
|
|
49
|
+
*
|
|
50
|
+
* Convention-based, matching what this monorepo (and the ecosystems it scaffolds) actually writes: a
|
|
51
|
+
* `.test.` / `.spec.` filename in any extension, a `__tests__` / `__fixtures__` directory anywhere on the
|
|
52
|
+
* path, an `e2e-harness`, or a test-runner config. Deliberately NOT "anything containing 'test'": a
|
|
53
|
+
* `testimonials/` page or a `latest.ts` is product code, and a false "tests" tag is worse than a missed one —
|
|
54
|
+
* it tells a reviewer not to look. */
|
|
55
|
+
const TEST_DIRS = new Set([`__tests__`, `__fixtures__`, `__mocks__`, `__snapshots__`]);
|
|
56
|
+
const TEST_FILE = /(?:^|\/)(?:[^/]+\.(?:test|spec)\.[^/.]+|[^/]*\.e2e\.[^/]+|e2e-harness\.[^/]+|(?:vitest|jest|playwright)(?:\.[\w-]+)*\.config\.[^/]+)$/;
|
|
57
|
+
|
|
58
|
+
export const isTestPath = (path: string): boolean =>
|
|
59
|
+
TEST_FILE.test(path) || path.split(`/`).some((segment) => TEST_DIRS.has(segment));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { type QuickModelSource, quickModelKey, resolveQuickModel } from "./quick-model.js";
|
|
3
|
+
|
|
4
|
+
/* Which model a one-click helper spends. The rule answers two surfaces at once — the daemon runs it, the
|
|
5
|
+
* browser names it in the sparkle's tooltip — so what these tests pin is that a sandbox's connections alone
|
|
6
|
+
* decide it, with no stored id to go stale. */
|
|
7
|
+
|
|
8
|
+
// Catalogs as their providers actually publish them: Claude's ranked list, the rest in registry order.
|
|
9
|
+
const CLAUDE: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`] };
|
|
10
|
+
const GOOGLE: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-flash`, `gemini-3-flash-lite`, `gemini-3-pro`] };
|
|
11
|
+
const CODEX: QuickModelSource = { provider: `codex`, ready: true, models: [`gpt-5.4-mini`, `gpt-5.6`] };
|
|
12
|
+
const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-0711-preview`, `kimi-k2-0905-preview`] };
|
|
13
|
+
|
|
14
|
+
const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
|
|
15
|
+
|
|
16
|
+
test("reaches for the efficient rung of the one connected provider, never its flagship", () => {
|
|
17
|
+
expect(resolveQuickModel([CLAUDE], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("spends the FREE channel over the subscription when both offer the same rung", () => {
|
|
21
|
+
// Both publish a cheap-tier row, so nothing separates them on capability — and one of them costs the user
|
|
22
|
+
// nothing while the other eats headroom they watch. Clicking sparkle should not quietly bill the Claude plan.
|
|
23
|
+
expect(resolveQuickModel([CLAUDE, GOOGLE], ``)).toEqual({ provider: `gemini`, model: `gemini-3-flash-lite` });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("puts tier ahead of cost — a free frontier model is still the wrong tool for a commit message", () => {
|
|
27
|
+
// Google connected but publishing only its Pro line. Ordering on price first would seat a flagship here,
|
|
28
|
+
// which is the exact outcome the feature exists to avoid.
|
|
29
|
+
const proOnly: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
|
|
30
|
+
|
|
31
|
+
expect(resolveQuickModel([CLAUDE, proOnly], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("prefers a subscription to a metered key at equal tier, since only one of them charges per click", () => {
|
|
35
|
+
const kimiCheap: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-mini`] };
|
|
36
|
+
const claudeCheap: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-haiku-4-5`] };
|
|
37
|
+
|
|
38
|
+
expect(resolveQuickModel([kimiCheap, claudeCheap], ``)?.provider).toBe(`claude`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("answers the same thing however the connected providers happen to be listed", () => {
|
|
42
|
+
// The daemon assembles these from live stores and the browser from its own refs; neither order is a fact.
|
|
43
|
+
const answers = [
|
|
44
|
+
resolveQuickModel([CLAUDE, GOOGLE, CODEX], ``),
|
|
45
|
+
resolveQuickModel([CODEX, CLAUDE, GOOGLE], ``),
|
|
46
|
+
resolveQuickModel([GOOGLE, CODEX, CLAUDE], ``),
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
expect(new Set(answers.map((answer) => quickModelKey(answer!))).size).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("honours a pinned model verbatim, including an id no catalog lists yet", () => {
|
|
53
|
+
expect(resolveQuickModel([CLAUDE, GOOGLE], `claude:claude-opus-5`)).toEqual({ provider: `claude`, model: `claude-opus-5` });
|
|
54
|
+
// The picker's custom-id escape hatch reaches here too: a catalog can lag a release, and running something
|
|
55
|
+
// other than what the settings row names would be the worse failure.
|
|
56
|
+
expect(resolveQuickModel([CLAUDE], `claude:claude-haiku-9`)).toEqual({ provider: `claude`, model: `claude-haiku-9` });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("falls back to Auto when the pinned provider is no longer connected", () => {
|
|
60
|
+
// Rather than failing every click with a credential error while the sandbox can plainly still answer.
|
|
61
|
+
expect(resolveQuickModel([offline(CLAUDE), GOOGLE], `claude:claude-haiku-4-5-20251001`)).toEqual({
|
|
62
|
+
provider: `gemini`,
|
|
63
|
+
model: `gemini-3-flash-lite`,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("ignores a malformed pin instead of running an empty model id", () => {
|
|
68
|
+
for (const pinned of [`claude`, `claude:`, `:claude-haiku-4-5`, ` `]) {
|
|
69
|
+
expect(resolveQuickModel([CLAUDE], pinned)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("serves the newest of a catalog that publishes no cheap tier at all", () => {
|
|
74
|
+
// Kimi names no tier word anywhere. There is no cheaper rung to find, so the newest row is the honest answer.
|
|
75
|
+
expect(resolveQuickModel([KIMI], ``)).toEqual({ provider: `kimi`, model: `kimi-k2-0905-preview` });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("reports nothing when no account is connected, so the button can say so instead of failing on click", () => {
|
|
79
|
+
expect(resolveQuickModel([offline(CLAUDE), offline(GOOGLE)], ``)).toBeUndefined();
|
|
80
|
+
expect(resolveQuickModel([], `claude:claude-haiku-4-5`)).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("skips a connected provider whose catalog has not loaded yet", () => {
|
|
84
|
+
const unloaded: QuickModelSource = { provider: `grok`, ready: true, models: [] };
|
|
85
|
+
|
|
86
|
+
expect(resolveQuickModel([unloaded, CLAUDE], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
|
|
87
|
+
expect(resolveQuickModel([unloaded], ``)).toBeUndefined();
|
|
88
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ACCESS_COST, accessFor, PROVIDERS } from "./agent-catalog.js";
|
|
2
|
+
import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
|
|
3
|
+
import type { NativeProvider } from "./schemas.js";
|
|
4
|
+
|
|
5
|
+
/* THE QUICK MODEL — the cheap, fast model a one-click helper spends instead of the frontier model the chat runs
|
|
6
|
+
* on. Today that is the commit box's autofill; anything else of that shape (a branch name, a PR description)
|
|
7
|
+
* reads the same answer, which is the reason this is a `quickModel` setting rather than a commit-message one.
|
|
8
|
+
*
|
|
9
|
+
* The rule lives in the contract because BOTH sides need the same answer for different jobs: the daemon runs
|
|
10
|
+
* the model, and the browser has to NAME it — in the sparkle's tooltip and in the settings row's "Auto (…)"
|
|
11
|
+
* label — before anything has been run. Two implementations would drift precisely where it matters most, since
|
|
12
|
+
* a tooltip promising Haiku while the daemon bills Opus is worse than no tooltip.
|
|
13
|
+
*
|
|
14
|
+
* The default is DERIVED, NEVER STORED. `quickModel` ships as "" and that empty string means "work it out from
|
|
15
|
+
* whatever is connected right now", so connecting a Google account tomorrow improves the default by itself and
|
|
16
|
+
* disconnecting the pinned provider degrades to Auto instead of to a dead button. Same instinct as the rest of
|
|
17
|
+
* this repo's model handling: model-order.ts derives tier and recency from the id and curates nothing, and the
|
|
18
|
+
* web's defaultModelFor reads the live catalog rather than naming an id that a release will falsify. */
|
|
19
|
+
|
|
20
|
+
// One provider's standing in the decision: whether a turn on it can be sent at all, and what its catalog holds.
|
|
21
|
+
// ACP agents are deliberately not expressible here — an ACP row's model id is empty because the agent owns its
|
|
22
|
+
// own model, so there is no cheap rung to point it at.
|
|
23
|
+
export interface QuickModelSource {
|
|
24
|
+
readonly provider: NativeProvider;
|
|
25
|
+
// The same connection predicate every other surface gates on (access.ts web-side, the daemon's own account
|
|
26
|
+
// stores daemon-side). A catalog is never empty by construction, so "has rows" says nothing about "can send".
|
|
27
|
+
readonly ready: boolean;
|
|
28
|
+
readonly models: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QuickModelChoice {
|
|
32
|
+
readonly provider: NativeProvider;
|
|
33
|
+
readonly model: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A pinned selection on the wire: `${provider}:${modelId}`, the same key shape the model picker already mints
|
|
37
|
+
// for its entries (PickerEntry.key). Empty ⇒ Auto.
|
|
38
|
+
export const quickModelKey = (choice: QuickModelChoice): string => `${choice.provider}:${choice.model}`;
|
|
39
|
+
|
|
40
|
+
// Split on the FIRST colon only: a provider id never contains one and a model id might.
|
|
41
|
+
const parsePinned = (pinned: string): QuickModelChoice | undefined => {
|
|
42
|
+
const separator = pinned.indexOf(`:`);
|
|
43
|
+
if (separator <= 0 || separator === pinned.length - 1) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return { provider: pinned.slice(0, separator) as NativeProvider, model: pinned.slice(separator + 1) };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// The cheapest row a provider publishes — its whole catalog read from the cheap end. Undefined for a catalog
|
|
50
|
+
// that hasn't loaded yet, which is a real state: every provider serves a floor, but only once something has
|
|
51
|
+
// asked it.
|
|
52
|
+
const cheapestOf = (source: QuickModelSource): string | undefined => source.models.toSorted(compareCheapestFirst)[0];
|
|
53
|
+
|
|
54
|
+
// Where a provider's cheapest row sits on the shared tier scale, and therefore how well it answers the question
|
|
55
|
+
// this whole module asks. UNRANKED (-1) is a genuine last place: it means the id carries no tier word we know,
|
|
56
|
+
// so the row is the provider's base line rather than its budget one.
|
|
57
|
+
const tierOf = (model: string): number => tierRankOf(familyOf(model));
|
|
58
|
+
|
|
59
|
+
// PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read — the property
|
|
60
|
+
// compareUnrankedModelIds exists to guarantee, and the one a default actually needs.
|
|
61
|
+
const providerOrder = (provider: NativeProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
|
|
62
|
+
|
|
63
|
+
// How much a call on this provider costs at the margin. Every native provider declares an access kind, so the
|
|
64
|
+
// fallback is unreachable — it exists because AgentProvider is a bare string on the wire.
|
|
65
|
+
const costOf = (provider: NativeProvider): number => {
|
|
66
|
+
const access = accessFor(provider);
|
|
67
|
+
return access === undefined ? ACCESS_COST.key : ACCESS_COST[access.kind];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/* Which model a quick helper runs, given what this sandbox has connected. `pinned` is the stored setting.
|
|
71
|
+
*
|
|
72
|
+
* A pin only holds while its provider is READY: an account the user disconnected would otherwise leave the
|
|
73
|
+
* button failing on every click with a credential error, when the sandbox can plainly still answer. Auto taking
|
|
74
|
+
* over is the same move the composer already makes when a live catalog stops offering the selected model.
|
|
75
|
+
*
|
|
76
|
+
* Auto ranks candidates on TIER FIRST, then cost. That order is the point of the feature: the helper exists to
|
|
77
|
+
* not be the frontier model, so a free flagship is still the wrong tool, while a free Haiku-class row and a
|
|
78
|
+
* subscription Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the
|
|
79
|
+
* channel the user is not paying per token for — and against the one they are.
|
|
80
|
+
*
|
|
81
|
+
* Undefined when nothing is connected: the caller renders a disabled control that says so, rather than a live
|
|
82
|
+
* button that fails on click. */
|
|
83
|
+
export const resolveQuickModel = (sources: readonly QuickModelSource[], pinned: string): QuickModelChoice | undefined => {
|
|
84
|
+
const requested = parsePinned(pinned);
|
|
85
|
+
if (requested !== undefined && sources.some((source) => source.provider === requested.provider && source.ready)) {
|
|
86
|
+
// Taken verbatim, unvalidated against the catalog on purpose: the picker already offers a custom-id
|
|
87
|
+
// escape hatch for a model a catalog hasn't caught up with, and second-guessing the user's own id here
|
|
88
|
+
// would silently run a different model than the settings row names.
|
|
89
|
+
return requested;
|
|
90
|
+
}
|
|
91
|
+
const candidates = sources
|
|
92
|
+
.filter((source) => source.ready)
|
|
93
|
+
.flatMap((source) => {
|
|
94
|
+
const model = cheapestOf(source);
|
|
95
|
+
return model === undefined ? [] : [{ provider: source.provider, model }];
|
|
96
|
+
});
|
|
97
|
+
return candidates.toSorted(
|
|
98
|
+
(left, right) =>
|
|
99
|
+
tierOf(right.model) - tierOf(left.model) ||
|
|
100
|
+
costOf(left.provider) - costOf(right.provider) ||
|
|
101
|
+
providerOrder(left.provider) - providerOrder(right.provider),
|
|
102
|
+
)[0];
|
|
103
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { oc } from "@orpc/contract";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTES, sandboxRouteName } from "./index.js";
|
|
4
|
+
import { contractRoutes, routeNameForRequest } from "./routes.js";
|
|
5
|
+
|
|
6
|
+
const fixture = {
|
|
7
|
+
vpn: {
|
|
8
|
+
list: oc.route({ method: "GET", path: "/vpn" }),
|
|
9
|
+
connect: oc.route({ method: "POST", path: "/vpn/{id}/connect" }),
|
|
10
|
+
},
|
|
11
|
+
system: {
|
|
12
|
+
killTerminal: oc.route({ method: "DELETE", path: "/system/terminals/{name}" }),
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe(`contractRoutes`, () => {
|
|
17
|
+
it(`names every procedure <group>.<route>, sorted`, () => {
|
|
18
|
+
expect(contractRoutes(fixture).map((route) => route.name)).toEqual([`system.killTerminal`, `vpn.connect`, `vpn.list`]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it(`carries the wire method and path template`, () => {
|
|
22
|
+
expect(contractRoutes(fixture).find((route) => route.name === `vpn.connect`)).toEqual({
|
|
23
|
+
name: `vpn.connect`,
|
|
24
|
+
method: `POST`,
|
|
25
|
+
path: `/vpn/{id}/connect`,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it(`ignores non-procedure members rather than inventing routes for them`, () => {
|
|
30
|
+
expect(contractRoutes({ vpn: { list: fixture.vpn.list, NOT_A_ROUTE: { hello: true } } }).map((r) => r.name)).toEqual([`vpn.list`]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe(`routeNameForRequest`, () => {
|
|
35
|
+
const routes = contractRoutes(fixture);
|
|
36
|
+
|
|
37
|
+
it(`matches a literal path`, () => {
|
|
38
|
+
expect(routeNameForRequest(routes, `GET`, `/vpn`)).toBe(`vpn.list`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it(`matches a templated segment`, () => {
|
|
42
|
+
expect(routeNameForRequest(routes, `POST`, `/vpn/corp-gw/connect`)).toBe(`vpn.connect`);
|
|
43
|
+
expect(routeNameForRequest(routes, `DELETE`, `/system/terminals/web-1`)).toBe(`system.killTerminal`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it(`strips the query string before matching`, () => {
|
|
47
|
+
expect(routeNameForRequest(routes, `GET`, `/vpn?refresh=1`)).toBe(`vpn.list`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it(`is method-sensitive`, () => {
|
|
51
|
+
expect(routeNameForRequest(routes, `POST`, `/vpn`)).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it(`never matches a longer or shorter path than the template`, () => {
|
|
55
|
+
expect(routeNameForRequest(routes, `POST`, `/vpn/corp-gw/connect/extra`)).toBeUndefined();
|
|
56
|
+
expect(routeNameForRequest(routes, `POST`, `/vpn/connect`)).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it(`does not let an empty segment stand in for a param`, () => {
|
|
60
|
+
expect(routeNameForRequest(routes, `DELETE`, `/system/terminals/`)).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it(`returns undefined for the daemon's hand-written non-contract routes`, () => {
|
|
64
|
+
expect(routeNameForRequest(routes, `GET`, `/health`)).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe(`the real sandbox contract`, () => {
|
|
69
|
+
it(`derives a route table with no duplicate names`, () => {
|
|
70
|
+
expect(SANDBOX_ROUTE_NAMES.length).toBe(SANDBOX_ROUTES.length);
|
|
71
|
+
expect(new Set(SANDBOX_ROUTE_NAMES).size).toBe(SANDBOX_ROUTE_NAMES.length);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it(`covers every oc.route in the contract`, () => {
|
|
75
|
+
// Guards the walk against a future contract nesting deeper than group → procedure, which would
|
|
76
|
+
// silently advertise fewer routes than the daemon serves.
|
|
77
|
+
expect(SANDBOX_ROUTES.length).toBeGreaterThan(100);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it(`resolves a known concrete request back to its contract name`, () => {
|
|
81
|
+
expect(sandboxRouteName(`GET`, `/vpn`)).toBe(`vpn.list`);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* The daemon's route surface, named. A sandbox daemon is baked into an image, so the browser talking to it is
|
|
2
|
+
* routinely NEWER than the daemon: a released app plane serves every user's sandbox, whatever image they last
|
|
3
|
+
* pulled, and in local development the web app is always ahead of the last `pnpm build:sandbox`. Both are
|
|
4
|
+
* normal and neither should force an update.
|
|
5
|
+
*
|
|
6
|
+
* What must not happen is the failure being SILENT. A route the daemon predates answers 404, which the browser
|
|
7
|
+
* has no way to tell apart from "you asked for a file that isn't there" — so a missing feature reads as a
|
|
8
|
+
* broken one, and diagnosing it costs an hour of "did the image rebuild?".
|
|
9
|
+
*
|
|
10
|
+
* So the daemon ADVERTISES the routes it implements (the /events hello frame) and the browser compares that
|
|
11
|
+
* against the contract it was itself built with. Everything present on both sides works exactly as before;
|
|
12
|
+
* anything the daemon lacks is a KNOWN, NAMED gap the UI can gate a feature on or explain in an error, instead
|
|
13
|
+
* of a mystery 404. Old daemon + new browser stays fully supported — it just stops being confusing.
|
|
14
|
+
*
|
|
15
|
+
* Route names are `<group>.<route>` (`vpn.list`, `kimi.models`), derived from the contract object both sides
|
|
16
|
+
* import. Nothing is generated and nothing is hand-maintained: adding a route to the contract adds it here.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is a pure function of a contract passed in — index.ts binds them to `sandboxContract` once it
|
|
19
|
+
* is assembled, which is what keeps this module out of an import cycle with it. */
|
|
20
|
+
|
|
21
|
+
// The shape we read off an oRPC contract procedure. `~orpc.route` is the contract metadata oRPC attaches to
|
|
22
|
+
// every `oc.route(...)` procedure; we only need the wire method + path, so this is deliberately structural
|
|
23
|
+
// rather than an import of oRPC's internal types (which are not part of its public surface).
|
|
24
|
+
interface ContractProcedureLike {
|
|
25
|
+
readonly "~orpc": { readonly route?: { readonly method?: string; readonly path?: string } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const procedureRoute = (value: unknown): { method: string; path: string } | undefined => {
|
|
29
|
+
if (typeof value !== "object" || value === null || !("~orpc" in value)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const { route } = (value as ContractProcedureLike)["~orpc"];
|
|
33
|
+
if (route?.method === undefined || route.path === undefined) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return { method: route.method, path: route.path };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// One advertised route: its contract name plus the wire shape, so a concrete request path can be matched back
|
|
40
|
+
// to the name it came from (see routeNameForRequest).
|
|
41
|
+
export interface ContractRoute {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly method: string;
|
|
44
|
+
// The oRPC path template, with `{param}` placeholders — e.g. `/system/terminals/{name}`.
|
|
45
|
+
readonly path: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Walk a contract object (two levels: group → procedure) into its flat route list, sorted by name so the
|
|
49
|
+
// advertised array is stable and diffable.
|
|
50
|
+
export const contractRoutes = (contract: Record<string, unknown>): ContractRoute[] => {
|
|
51
|
+
const routes: ContractRoute[] = [];
|
|
52
|
+
for (const [group, procedures] of Object.entries(contract)) {
|
|
53
|
+
if (typeof procedures !== "object" || procedures === null) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const [name, procedure] of Object.entries(procedures as Record<string, unknown>)) {
|
|
57
|
+
const route = procedureRoute(procedure);
|
|
58
|
+
if (route !== undefined) {
|
|
59
|
+
routes.push({ name: `${group}.${name}`, method: route.method, path: route.path });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return routes.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Does a concrete request path match this route's template? Segment-wise, with `{param}` matching exactly one
|
|
67
|
+
// segment — the same shape oRPC mounts, so a template can never match a longer or shorter path.
|
|
68
|
+
const pathMatches = (template: string, path: string): boolean => {
|
|
69
|
+
const wanted = template.split("/");
|
|
70
|
+
const actual = path.split("/");
|
|
71
|
+
if (wanted.length !== actual.length) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return wanted.every((segment, index) => (segment.startsWith("{") && segment.endsWith("}") ? actual[index] !== "" : segment === actual[index]));
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// The contract route a concrete request belongs to, or undefined when the path is not a contract route at all
|
|
78
|
+
// (the daemon also serves hand-written Hono routes like /health and /workspace/raw — those are never gated).
|
|
79
|
+
// The query string is stripped first; callers pass whatever they handed to fetch.
|
|
80
|
+
export const routeNameForRequest = (routes: readonly ContractRoute[], method: string, pathWithQuery: string): string | undefined => {
|
|
81
|
+
const path = pathWithQuery.split("?")[0] ?? pathWithQuery;
|
|
82
|
+
const upper = method.toUpperCase();
|
|
83
|
+
return routes.find((route) => route.method.toUpperCase() === upper && pathMatches(route.path, path))?.name;
|
|
84
|
+
};
|