@intentic/sandbox-contract 1.157.0 → 1.159.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 +9 -0
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +13 -1
- package/dist/agent-catalog.js.map +1 -1
- package/dist/contracts/agent.contract.d.ts +55 -2
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +53 -0
- package/dist/contracts/agents.contract.d.ts.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/system.contract.d.ts +20 -4
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.d.ts +5 -0
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +2 -1
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/events.d.ts +76 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +5 -3
- 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 +143 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/model-order.d.ts +10 -0
- package/dist/model-order.d.ts.map +1 -0
- package/dist/model-order.js +62 -0
- package/dist/model-order.js.map +1 -0
- package/dist/path-refs.d.ts +4 -0
- package/dist/path-refs.d.ts.map +1 -0
- package/dist/path-refs.js +18 -0
- package/dist/path-refs.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 +60 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +27 -3
- package/dist/schemas.js.map +1 -1
- package/package.json +2 -2
- package/src/agent-catalog.ts +45 -1
- package/src/contracts/workspace.contract.ts +6 -0
- package/src/events.ts +42 -5
- package/src/hostnames.test.ts +22 -0
- package/src/hostnames.ts +40 -1
- package/src/index.ts +15 -0
- package/src/model-order.test.ts +137 -0
- package/src/model-order.ts +140 -0
- package/src/path-refs.test.ts +42 -0
- package/src/path-refs.ts +43 -0
- package/src/routes.test.ts +83 -0
- package/src/routes.ts +84 -0
- package/src/schemas.ts +63 -5
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { compareModelIds, compareUnrankedModelIds, familyOf, releaseOf, tierRankOf } from "./model-order.js";
|
|
3
|
+
|
|
4
|
+
/* The order every provider's catalog is served and browsed in. The rule exists because only Anthropic publishes
|
|
5
|
+
* a ranking: the OpenAI-compatible endpoints behind Codex, Gemini, Kimi and Grok hand back a SET, and taking
|
|
6
|
+
* their registry order for a preference is what opened the Codex group on GPT 5.4 Mini and started fresh Codex
|
|
7
|
+
* conversations on whichever id sorted first. */
|
|
8
|
+
|
|
9
|
+
// A Codex catalog exactly as an OpenAI-compatible /v1/models hands it over: alphabetical, i.e. meaningless.
|
|
10
|
+
const CODEX = ["gpt-5.1-codex", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra"];
|
|
11
|
+
|
|
12
|
+
test("ranks the frontier line above the cheap one and the newest release above its predecessors", () => {
|
|
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 — which is the whole decision a user makes in this list.
|
|
15
|
+
expect(CODEX.toSorted(compareModelIds)).toEqual(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.5", "gpt-5.1-codex", "gpt-5.4-mini"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("lands the same models at the head and the tail whichever order the endpoint listed them in", () => {
|
|
19
|
+
// Arrival order survives only as the tiebreak between two ids the rule ranks equally (the 5.6 siblings), so
|
|
20
|
+
// an alphabetical registry and a reversed one can no longer disagree about which model the group opens on.
|
|
21
|
+
for (const arrival of [CODEX.toSorted(), CODEX.toReversed()]) {
|
|
22
|
+
const ordered = arrival.toSorted(compareModelIds);
|
|
23
|
+
|
|
24
|
+
expect(ordered.slice(0, 2).toSorted()).toEqual(["gpt-5.6-sol", "gpt-5.6-terra"]);
|
|
25
|
+
expect(ordered.at(-1)).toBe("gpt-5.4-mini");
|
|
26
|
+
}
|
|
27
|
+
});
|
|
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
|
+
|
|
52
|
+
test("reads each vendor's tier vocabulary, not just Claude's", () => {
|
|
53
|
+
expect(["gemini-3-flash", "gemini-3-flash-lite", "gemini-3-pro"].toSorted(compareModelIds)).toEqual([
|
|
54
|
+
"gemini-3-pro",
|
|
55
|
+
"gemini-3-flash",
|
|
56
|
+
"gemini-3-flash-lite",
|
|
57
|
+
]);
|
|
58
|
+
expect(["grok-4-fast", "grok-code-fast-1", "grok-4", "grok-3"].toSorted(compareModelIds)).toEqual([
|
|
59
|
+
"grok-4",
|
|
60
|
+
"grok-3",
|
|
61
|
+
"grok-4-fast",
|
|
62
|
+
"grok-code-fast-1",
|
|
63
|
+
]);
|
|
64
|
+
expect(["kimi-k2-turbo-preview", "kimi-k2-0711-preview", "kimi-k2-mini"].toSorted(compareModelIds)).toEqual([
|
|
65
|
+
"kimi-k2-0711-preview",
|
|
66
|
+
"kimi-k2-turbo-preview",
|
|
67
|
+
"kimi-k2-mini",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the rightmost tier word wins, because tier words compose", () => {
|
|
72
|
+
// flash-lite is the cheap end of Flash, codex-max the frontier end of Codex — reading the leftmost word
|
|
73
|
+
// instead would file both under the tier they modify.
|
|
74
|
+
expect(tierRankOf(familyOf("gemini-3-flash-lite"))).toBe(tierRankOf("lite"));
|
|
75
|
+
expect(tierRankOf(familyOf("gpt-5.1-codex-max"))).toBe(tierRankOf("max"));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("leads with a family carrying no tier word at all, so a brand-new flagship is never buried by its novelty", () => {
|
|
79
|
+
// The precise inverse of the ranking this replaced, which sank unrecognized ids BELOW the everyday tier.
|
|
80
|
+
expect(["claude-sonnet-5", "claude-mythos-1", "claude-opus-5"].toSorted(compareModelIds)[0]).toBe("claude-mythos-1");
|
|
81
|
+
});
|
|
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
|
+
|
|
93
|
+
test("keeps the arrival order between ids the rule cannot separate — Anthropic's catalog IS ranked", () => {
|
|
94
|
+
// Same tier, same version: nothing here outranks the order the provider itself reported.
|
|
95
|
+
expect(["claude-opus-5", "claude-fable-5"].toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-fable-5"]);
|
|
96
|
+
expect(["claude-fable-5", "claude-opus-5"].toSorted(compareModelIds)).toEqual(["claude-fable-5", "claude-opus-5"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("groups every version of a family under one key, whatever shape its id takes", () => {
|
|
100
|
+
expect(familyOf("claude-opus-4-8")).toBe(familyOf("claude-opus-5"));
|
|
101
|
+
expect(familyOf("claude-haiku-4-5-20251001")).toBe(familyOf("claude-haiku-4-5"));
|
|
102
|
+
expect(familyOf("gpt-5.1")).toBe(familyOf("gpt-5"));
|
|
103
|
+
// Distinct lines stay distinct: a variant word is part of the family, not a version of the base one.
|
|
104
|
+
expect(familyOf("gpt-5.1-codex")).not.toBe(familyOf("gpt-5.1"));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("stands an id with nothing but numbers (and an ACP row's empty one) as its own family", () => {
|
|
108
|
+
expect(familyOf("4-5")).toBe("4-5");
|
|
109
|
+
expect(familyOf("")).toBe("");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("holds date stamps apart from version components, or a dated build outranks the point release after it", () => {
|
|
113
|
+
// The failure this prevents: claude-opus-4-1-20250805 (Opus 4.1) read as (4,1,20250805) loses to
|
|
114
|
+
// claude-opus-4-20250514 (Opus 4.0) read as (4,20250514) — the older model, by six digits.
|
|
115
|
+
expect(releaseOf("claude-opus-4-1-20250805")).toEqual({ version: [4, 1], date: 20250805 });
|
|
116
|
+
expect(["claude-opus-4-20250514", "claude-opus-4-1-20250805"].toSorted(compareModelIds)).toEqual([
|
|
117
|
+
"claude-opus-4-1-20250805",
|
|
118
|
+
"claude-opus-4-20250514",
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("breaks a version tie by date stamp, so two builds of one release still order", () => {
|
|
123
|
+
expect(["claude-haiku-4-5-20251001", "claude-haiku-4-5-20260210"].toSorted(compareModelIds)).toEqual([
|
|
124
|
+
"claude-haiku-4-5-20260210",
|
|
125
|
+
"claude-haiku-4-5-20251001",
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("reads a longer version as the newer one, so 5.1 outranks 5", () => {
|
|
130
|
+
expect(["gpt-5", "gpt-5.1"].toSorted(compareModelIds)).toEqual(["gpt-5.1", "gpt-5"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("sorts an unversioned rolling alias under the releases that name their version", () => {
|
|
134
|
+
// `kimi-latest` claims no release; guessing one for it would seat it above models that do say what they are.
|
|
135
|
+
expect(releaseOf("kimi-latest").version).toEqual([]);
|
|
136
|
+
expect(["kimi-latest", "kimi-k2-0711-preview"].toSorted(compareModelIds)).toEqual(["kimi-k2-0711-preview", "kimi-latest"]);
|
|
137
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/* HOW A MODEL CATALOG IS ORDERED — one rule for every provider, because only one provider publishes an order
|
|
2
|
+
* worth keeping.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic's REST /v1/models answers newest-first: that IS a provider opinion, and Claude's catalog rides it
|
|
5
|
+
* (claude-models.ts). Every other provider here is read through an OpenAI-compatible /v1/models — Codex and
|
|
6
|
+
* Gemini via the bundled translator, Kimi via Moonshot — or out of xAI's "Did you mean" rejection, and those
|
|
7
|
+
* endpoints publish a SET, not a ranking: they hand the ids back in whatever order their registry iterates,
|
|
8
|
+
* which in practice is alphabetical. Reading that as a preference is what put "GPT 5.4 Mini" at the head of the
|
|
9
|
+
* Codex group with GPT 5.6 below it, and what made a fresh Codex conversation start on whichever id happened to
|
|
10
|
+
* sort first — models[0] is the provider default.
|
|
11
|
+
*
|
|
12
|
+
* So for those providers the order is DERIVED from the id, out of the only two facts an id reliably carries:
|
|
13
|
+
* which TIER the model is (the adjective) and which RELEASE it is (the numbers). Both are provider-agnostic —
|
|
14
|
+
* every vendor names its models the same way — which is what lets the daemon's four catalog services and the
|
|
15
|
+
* web's picker share one rule instead of each inventing a local one. */
|
|
16
|
+
|
|
17
|
+
// A version-ish segment: digits and dots, optionally v-prefixed (`4`, `5.1`, `v2`, `20251001`). Everything else
|
|
18
|
+
// is a NAME segment and belongs to the family — which is what makes the split below exhaustive.
|
|
19
|
+
const VERSION_SEGMENT = /^v?[\d.]+$/;
|
|
20
|
+
|
|
21
|
+
// A date stamp rather than a version component: six digits or more (20251001, 250514). The distinction is not
|
|
22
|
+
// cosmetic — claude-opus-4-1-20250805 (Opus 4.1) and claude-opus-4-20250514 (Opus 4.0) compare as (4,1) vs (4)
|
|
23
|
+
// with the stamps held apart, and as (4,1,20250805) vs (4,20250514) — the OLDER model winning — without.
|
|
24
|
+
const DATE_SEGMENT = /^\d{6,}$/;
|
|
25
|
+
|
|
26
|
+
const segmentsOf = (id: string): string[] => id.split(/[-_]/);
|
|
27
|
+
|
|
28
|
+
// A model's FAMILY — its id with every version-ish segment dropped, so claude-opus-5 and claude-opus-4-8 land
|
|
29
|
+
// together (as do gpt-5.1/gpt-5, and claude-haiku-4-5-20251001 with its date suffix). Derived, never listed: a
|
|
30
|
+
// family that ships tomorrow groups itself. The id is the stable key here — labels get renamed, ids don't.
|
|
31
|
+
export const familyOf = (id: string): string => {
|
|
32
|
+
const stem = segmentsOf(id)
|
|
33
|
+
.filter((segment) => !VERSION_SEGMENT.test(segment))
|
|
34
|
+
.join("-");
|
|
35
|
+
// An all-numeric id (and an ACP row's empty one) has no stem to speak of; it stands as its own family.
|
|
36
|
+
return stem === "" ? id : stem;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface ModelRelease {
|
|
40
|
+
// The version components in id order: gpt-5.1 → [5, 1], claude-opus-4-8 → [4, 8]. EMPTY for an unversioned
|
|
41
|
+
// id (kimi-latest, gemini-pro-agent), which therefore reads as the oldest of its tier: a rolling alias names
|
|
42
|
+
// no release, and inventing one for it would outrank the models that do name theirs.
|
|
43
|
+
readonly version: readonly number[];
|
|
44
|
+
// The id's date stamp, 0 for none — the tiebreak between two builds of the SAME version.
|
|
45
|
+
readonly date: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const releaseOf = (id: string): ModelRelease => {
|
|
49
|
+
const numeric = segmentsOf(id)
|
|
50
|
+
.filter((segment) => VERSION_SEGMENT.test(segment))
|
|
51
|
+
.map((segment) => segment.replace(/^v/, ""));
|
|
52
|
+
const stamps = numeric.filter((segment) => DATE_SEGMENT.test(segment)).map(Number);
|
|
53
|
+
return {
|
|
54
|
+
version: numeric
|
|
55
|
+
.filter((segment) => !DATE_SEGMENT.test(segment))
|
|
56
|
+
.flatMap((segment) => segment.split(".").map(Number))
|
|
57
|
+
.filter((component) => Number.isFinite(component)),
|
|
58
|
+
date: Math.max(0, ...stamps),
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Newest first. A missing component reads as -1, so gpt-5 sorts under gpt-5.1 and an unversioned id sorts under
|
|
63
|
+
// every versioned one; the date stamp breaks what is left.
|
|
64
|
+
const compareRelease = (left: ModelRelease, right: ModelRelease): number => {
|
|
65
|
+
for (let index = 0; index < Math.max(left.version.length, right.version.length); index += 1) {
|
|
66
|
+
const diff = (right.version[index] ?? -1) - (left.version[index] ?? -1);
|
|
67
|
+
if (diff !== 0) {
|
|
68
|
+
return diff;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return right.date - left.date;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/* THE ONE CURATED FACT in this file, and the only one the providers publish nowhere the app can read: which tier
|
|
75
|
+
* is the frontier and which is the cheap one. It ranks FAMILIES, never models, and it is a vocabulary of tier
|
|
76
|
+
* ADJECTIVES rather than a table of ids — that scoping is the whole point, because a per-model ranking table
|
|
77
|
+
* failed here once already. The words are the ones every vendor reaches for, so a release that ships tomorrow
|
|
78
|
+
* ranks itself as long as it is named like its predecessors, and a release named some other way ranks as unknown.
|
|
79
|
+
*
|
|
80
|
+
* An UNKNOWN family LEADS rather than sinks, and that direction is the point: the ranking this replaced sank
|
|
81
|
+
* unrecognized ids to a floor below the everyday tier, so a brand-new flagship sorted beneath the model it
|
|
82
|
+
* replaced. An id carrying no tier word at all is the provider's BASE line (gpt-5.6, grok-4, kimi-k2) — which is
|
|
83
|
+
* exactly the line a user reaches for — and a family nobody here has heard of is far likelier to be the next
|
|
84
|
+
* flagship than the next budget tier. Being wrong costs one row's position; being wrong the other way hides a
|
|
85
|
+
* launch. */
|
|
86
|
+
const TIER_RANK: Readonly<Record<string, number>> = {
|
|
87
|
+
// Frontier: the tier a vendor ships last and charges most for.
|
|
88
|
+
opus: 0,
|
|
89
|
+
fable: 0,
|
|
90
|
+
pro: 0,
|
|
91
|
+
max: 0,
|
|
92
|
+
ultra: 0,
|
|
93
|
+
heavy: 0,
|
|
94
|
+
// Everyday: the workhorse a step below the frontier.
|
|
95
|
+
sonnet: 1,
|
|
96
|
+
flash: 1,
|
|
97
|
+
mini: 1,
|
|
98
|
+
// Efficient: the cheap/fast end, the rung whose whole purpose is to cost less than the one above it.
|
|
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,
|
|
104
|
+
lite: 2,
|
|
105
|
+
nano: 2,
|
|
106
|
+
fast: 2,
|
|
107
|
+
small: 2,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const UNRANKED = -1;
|
|
111
|
+
|
|
112
|
+
// The LAST recognized word wins, because tier words compose and the rightmost is the most specific one:
|
|
113
|
+
// gemini-flash-lite is the cheap end of Flash, gpt-codex-max the frontier end of Codex.
|
|
114
|
+
export const tierRankOf = (family: string): number => {
|
|
115
|
+
let rank = UNRANKED;
|
|
116
|
+
for (const segment of family.split("-")) {
|
|
117
|
+
const found = TIER_RANK[segment];
|
|
118
|
+
if (found !== undefined) {
|
|
119
|
+
rank = found;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return rank;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// The canonical order of two model ids: tier first, then release. Hand it straight to Array#toSorted — that sort
|
|
126
|
+
// is stable, so two ids this rule cannot separate keep the order they arrived in (for Claude, the provider's own).
|
|
127
|
+
export const compareModelIds = (left: string, right: string): number =>
|
|
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);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { 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
|
+
});
|
package/src/path-refs.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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);
|
|
@@ -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
|
+
};
|