@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,160 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { type ComplexityInput, FAST_CEILING, judgeComplexity } from "./prompt-complexity.js";
|
|
3
|
+
|
|
4
|
+
/* WHETHER A TURN COULD HAVE RUN ON THE CHEAP RUNG, judged before anything is spent.
|
|
5
|
+
*
|
|
6
|
+
* What these tests pin is the ASYMMETRY, not the accuracy: the judge can only ever route down, so every one of
|
|
7
|
+
* its mistakes in the "standard" direction costs a fraction of a cent and every mistake in the "fast" direction
|
|
8
|
+
* costs a user their turn. So the properties worth nailing down are that silence resolves to standard, that a
|
|
9
|
+
* gate or an escalating rule ends the question whatever else the sentence says, and that no rule ORDER can
|
|
10
|
+
* change an answer. The weights themselves are a hypothesis with a ledger under it (settings.autoTier
|
|
11
|
+
* "shadow"), and a test that froze them would be a test that made them impossible to fit. */
|
|
12
|
+
|
|
13
|
+
const turn = (prompt: string, over: Partial<ComplexityInput> = {}): ComplexityInput => ({
|
|
14
|
+
prompt,
|
|
15
|
+
attachments: 0,
|
|
16
|
+
hasImages: false,
|
|
17
|
+
editorContext: false,
|
|
18
|
+
unattended: false,
|
|
19
|
+
planMode: false,
|
|
20
|
+
afterHardTurn: false,
|
|
21
|
+
...over,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const tierOf = (prompt: string, over: Partial<ComplexityInput> = {}) => judgeComplexity(turn(prompt, over)).tier;
|
|
25
|
+
|
|
26
|
+
// --- silence, and what it is read as -----------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
test("a prompt matching nothing at all stays on the model the user picked", () => {
|
|
29
|
+
// The single most important default in the file. An unrecognised request is MEDIUM, never simple: the
|
|
30
|
+
// conservative reading of silence is the only safe one when a wrong downgrade costs a retry and a
|
|
31
|
+
// user who stops trusting the feature.
|
|
32
|
+
const verdict = judgeComplexity(turn(`Have another go at the thing we were discussing yesterday afternoon`));
|
|
33
|
+
|
|
34
|
+
expect(verdict.tier).toBe(`standard`);
|
|
35
|
+
expect(verdict.score).toBeGreaterThan(FAST_CEILING);
|
|
36
|
+
// Nothing in the sentence made a positive claim in either direction, so only the two absence features
|
|
37
|
+
// fired, and the pair of them cannot reach the ceiling by design.
|
|
38
|
+
expect(verdict.rules).toEqual([`short-prompt`, `no-workspace-reference`]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a short vague request is not read as an easy one", () => {
|
|
42
|
+
// Absence of complexity is not evidence of simplicity. Weighted the obvious way, "short and naming no
|
|
43
|
+
// file" reached the ceiling by itself and downgraded every terse request in the product.
|
|
44
|
+
expect(tierOf(`fix the bug`)).toBe(`standard`);
|
|
45
|
+
expect(tierOf(`have a look at the thing we discussed`)).toBe(`standard`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an empty-ish prompt is not mistaken for an easy one", () => {
|
|
49
|
+
expect(tierOf(` `)).toBe(`standard`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// --- the fast end ------------------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
test("a short question about nothing in the workspace is the case this feature exists for", () => {
|
|
55
|
+
expect(tierOf(`what is a closure?`)).toBe(`fast`);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("naming a file keeps an otherwise easy question on the user's own model", () => {
|
|
59
|
+
// "explain" is the easiest word in the lexicon, but the turn is now about real code in this repo rather
|
|
60
|
+
// than about a concept, and the cheap rung's failures on real code are the silent kind.
|
|
61
|
+
expect(tierOf(`explain what is a closure`)).toBe(`fast`);
|
|
62
|
+
expect(tierOf(`explain what src/agent/turn-plan.ts does`)).toBe(`standard`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a trivial aside inside a hard conversation still gets through", () => {
|
|
66
|
+
// The reason afterHardTurn is a weight and not a lock: a conversation that has been doing hard work is
|
|
67
|
+
// still allowed to be asked an easy question, and locking it out is a mechanism that saves nothing.
|
|
68
|
+
expect(tierOf(`what is a closure?`, { afterHardTurn: true })).toBe(`fast`);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a workspace-adjacent errand stops being cheap once the conversation has done hard work", () => {
|
|
72
|
+
// The contrast with the test above is the whole rule. A pure knowledge question survives the penalty
|
|
73
|
+
// because it earns every easing feature there is; an errand about this repo does not, and after hard work
|
|
74
|
+
// it is far likelier to be the deceptive follow-up than a genuine aside.
|
|
75
|
+
expect(tierOf(`list the exports`, { afterHardTurn: false })).toBe(`fast`);
|
|
76
|
+
expect(tierOf(`list the exports`, { afterHardTurn: true })).toBe(`standard`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// --- gates: the turn's situation, whatever its words say ---------------------------------------------
|
|
80
|
+
|
|
81
|
+
test("a screenshot is never sent to the cheap rung, however simple the question about it", () => {
|
|
82
|
+
// The tier most likely to misread an image, on the turn least likely to notice that it did.
|
|
83
|
+
expect(tierOf(`what is this?`, { hasImages: true, attachments: 1 })).toBe(`standard`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("plan mode is a request to think, so it is never answered by the model that thinks least", () => {
|
|
87
|
+
expect(tierOf(`what is a closure?`, { planMode: true })).toBe(`standard`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a surface-started run is never downgraded, because nobody is watching it fail", () => {
|
|
91
|
+
// Same call agentRunModels already makes in the other direction: a run billed whole, with a worktree in
|
|
92
|
+
// it, is not the place to spend a guess.
|
|
93
|
+
expect(tierOf(`what is a closure?`, { unattended: true })).toBe(`standard`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a gate reports itself and scores 1, so the ledger can tell a gate from a hard sentence", () => {
|
|
97
|
+
const verdict = judgeComplexity(turn(`hi`, { unattended: true }));
|
|
98
|
+
|
|
99
|
+
expect(verdict.score).toBe(1);
|
|
100
|
+
expect(verdict.rules).toContain(`unattended`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// --- escalating rules: any one ends the question ------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
test.each([
|
|
106
|
+
[`pasted code`, "explain this\n```ts\nconst x = 1;\n```"],
|
|
107
|
+
[`a stack trace`, "it broke\n at Object.run (/work/x.ts:12:3)"],
|
|
108
|
+
[`a thrown error`, "help\nTypeError: cannot read properties of undefined"],
|
|
109
|
+
[`a hard word`, `why does the picker reset`],
|
|
110
|
+
[`another hard word`, `refactor this`],
|
|
111
|
+
[`a second job`, `rename it and then update the tests`],
|
|
112
|
+
[`a checklist`, `- rename it\n- update the tests`],
|
|
113
|
+
[`a cross-cutting scope`, `rename it across the codebase`],
|
|
114
|
+
])("%s forces the user's own model even in an otherwise tiny prompt", (_name, prompt) => {
|
|
115
|
+
expect(tierOf(prompt)).toBe(`standard`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("an escalating rule beats every easing feature at once, so rule order cannot change an answer", () => {
|
|
119
|
+
// Short, easy-worded, no workspace reference, a bare question: every negative weight in the file, plus one
|
|
120
|
+
// escalating rule. Monotone escalation means the rule wins, which is what makes adding a rule tomorrow a
|
|
121
|
+
// safe change: it can only ever move turns UP a tier.
|
|
122
|
+
const verdict = judgeComplexity(turn(`what is a race condition?`));
|
|
123
|
+
|
|
124
|
+
expect(verdict.tier).toBe(`standard`);
|
|
125
|
+
expect(verdict.score).toBe(1);
|
|
126
|
+
expect(verdict.rules).toEqual([`hard-words`]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a long brief is standard on its length alone, whatever words it happens to use", () => {
|
|
130
|
+
expect(tierOf(`explain `.repeat(400))).toBe(`standard`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("three files in, the job is about a shape rather than about a file", () => {
|
|
134
|
+
expect(tierOf(`have a look`, { attachments: 3 })).toBe(`standard`);
|
|
135
|
+
expect(tierOf(`have a look`, { attachments: 1 })).toBe(`standard`);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// --- the verdict as a record --------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
test("names every rule that fired, because a score alone cannot say which feature did the work", () => {
|
|
141
|
+
// The ledger stores these. Re-fitting the weights against real traffic needs to know WHICH feature moved a
|
|
142
|
+
// turn, not merely that a threshold was crossed.
|
|
143
|
+
const verdict = judgeComplexity(turn(`what is this?`));
|
|
144
|
+
|
|
145
|
+
expect(verdict.rules).toEqual([`short-prompt`, `easy-words`, `bare-question`, `no-workspace-reference`]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("scores are rounded, so two turns the same rules judged compare equal on the ledger", () => {
|
|
149
|
+
const score = judgeComplexity(turn(`what is this?`)).score;
|
|
150
|
+
|
|
151
|
+
expect(score).toBe(Number(score.toFixed(3)));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("the score never leaves 0..1, so a stored row is always comparable against the ceiling", () => {
|
|
155
|
+
const floor = judgeComplexity(turn(`what is this?`));
|
|
156
|
+
const ceiling = judgeComplexity(turn(`refactor everything`));
|
|
157
|
+
|
|
158
|
+
expect(floor.score).toBeGreaterThanOrEqual(0);
|
|
159
|
+
expect(ceiling.score).toBeLessThanOrEqual(1);
|
|
160
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/* HOW HARD A TURN LOOKS BEFORE ANYTHING HAS BEEN SPENT ON IT, the judge behind automatic tier selection.
|
|
2
|
+
*
|
|
3
|
+
* The job is narrow on purpose: decide whether this turn could have run on the cheap rung of the provider the
|
|
4
|
+
* user is already on. Nothing here picks a model, nothing here reads a catalog, and nothing here calls
|
|
5
|
+
* anything. It is a pure function over the turn's own words and shape, so the daemon and the composer can both
|
|
6
|
+
* ask it and get the same answer, which is the same reason quick-model.ts lives in the contract rather than in
|
|
7
|
+
* either of them.
|
|
8
|
+
*
|
|
9
|
+
* IT CAN ONLY EVER ROUTE DOWN. The standard tier is not a setting: it is whatever the user already picked. So
|
|
10
|
+
* the question this file answers is never "which of two models" but "may we substitute something cheaper for
|
|
11
|
+
* the one they chose", and every ambiguous answer is NO. That asymmetry is the whole safety argument, and it is
|
|
12
|
+
* why there is no fail-up branch to get wrong: failing up means doing exactly what was asked.
|
|
13
|
+
*
|
|
14
|
+
* WHY RULES AND NOT A MODEL. Two findings decide this. Across the routing literature nothing sophisticated
|
|
15
|
+
* beats a simple predictor over decent features (a tuned kNN and a linear head tie, and both beat graph and
|
|
16
|
+
* attention routers costing 13-14x more), and a router that spends an LLM call to save an LLM call has spent
|
|
17
|
+
* the saving. So the layer is deliberately narrow, transparent, and free. See docs/model-routing-design.md.
|
|
18
|
+
*
|
|
19
|
+
* WHY EVERY RULE IS NAMED. A verdict carries the rules that fired, not just a number. That is what lets a
|
|
20
|
+
* screen say WHY a turn was downgraded, what makes a bad call reportable rather than mysterious, and what makes
|
|
21
|
+
* the shadow ledger analysable later: a score alone tells you a threshold was crossed, the rules tell you which
|
|
22
|
+
* feature is doing the work. The interpretable-router literature (Routesplain, COLM 2026) reaches the same
|
|
23
|
+
* conclusion from the accuracy side, but the operational one is enough on its own. */
|
|
24
|
+
|
|
25
|
+
// The named features. A verdict lists these, the shadow ledger stores them, and a screen renders them, so they
|
|
26
|
+
// are a vocabulary rather than debug strings: renaming one is a breaking change to what past rows mean.
|
|
27
|
+
export type ComplexityRule =
|
|
28
|
+
// Gates. Any one of these ends the question: the turn is standard and no score is computed.
|
|
29
|
+
| "images"
|
|
30
|
+
| "plan-mode"
|
|
31
|
+
| "unattended"
|
|
32
|
+
// Escalating rules. Any one forces standard. Order between them cannot matter, which is the point.
|
|
33
|
+
| "code-block"
|
|
34
|
+
| "stack-trace"
|
|
35
|
+
| "hard-words"
|
|
36
|
+
| "multi-step"
|
|
37
|
+
| "cross-cutting"
|
|
38
|
+
| "long-prompt"
|
|
39
|
+
| "many-attachments"
|
|
40
|
+
// Graded features. These only move the score.
|
|
41
|
+
| "medium-prompt"
|
|
42
|
+
| "attachment"
|
|
43
|
+
| "editor-context"
|
|
44
|
+
| "paths"
|
|
45
|
+
| "many-verbs"
|
|
46
|
+
| "after-hard-turn"
|
|
47
|
+
| "short-prompt"
|
|
48
|
+
| "easy-words"
|
|
49
|
+
| "bare-question"
|
|
50
|
+
| "no-workspace-reference";
|
|
51
|
+
|
|
52
|
+
export type ComplexityTier = "fast" | "standard";
|
|
53
|
+
|
|
54
|
+
/* Everything the judge is allowed to know. Deliberately primitive: counts, flags and the prompt itself, no
|
|
55
|
+
* objects owned by either side, so the daemon's turn and the composer's draft can each build one without
|
|
56
|
+
* agreeing about anything else. The same "compress route state into cheap primitive fields" shape the serving
|
|
57
|
+
* literature converges on. */
|
|
58
|
+
export interface ComplexityInput {
|
|
59
|
+
readonly prompt: string;
|
|
60
|
+
// Uploaded files plus @-mentioned workspace paths; the daemon resolves both into one list, so one count.
|
|
61
|
+
readonly attachments: number;
|
|
62
|
+
// Any attachment the model will read as an image. A cheap rung reading a screenshot is the worst cell in
|
|
63
|
+
// the matrix: it is the tier most likely to misread it and the turn least likely to notice.
|
|
64
|
+
readonly hasImages: boolean;
|
|
65
|
+
// The opt-in editor chip: the user pointed at a file and a selection, so the turn is about real code.
|
|
66
|
+
readonly editorContext: boolean;
|
|
67
|
+
// A surface started this, not a person at a composer (AgentTurn.unattended).
|
|
68
|
+
readonly unattended: boolean;
|
|
69
|
+
// The turn opens in plan mode: it is being asked to think before it acts, which is the request itself.
|
|
70
|
+
readonly planMode: boolean;
|
|
71
|
+
/* THE TURN BEFORE THIS ONE, IN THIS CONVERSATION, WAS JUDGED STANDARD.
|
|
72
|
+
*
|
|
73
|
+
* The most important field, and the one a prompt-only judge cannot derive. "now do the same for the other
|
|
74
|
+
* file" is nine easy words carrying the whole weight of the task before it, and a judge reading only the
|
|
75
|
+
* words will downgrade it every time. The routing work closest to this product (SWE-Router, over SWE-bench)
|
|
76
|
+
* states the general form: prompt-only routers inherit an information-theoretic error floor because the
|
|
77
|
+
* difficulty lives in the trajectory rather than in the request. Their answer is a 7B value model reading
|
|
78
|
+
* partial trajectories; ours is one boolean, which costs nothing and catches the case that actually bites.
|
|
79
|
+
*
|
|
80
|
+
* IT RAISES THE BAR, IT DOES NOT LOCK THE DOOR, and that is a deliberate softening of the rule this was
|
|
81
|
+
* designed as. A hard "once standard, always standard" lock reads well and is nearly useless: opening
|
|
82
|
+
* messages are substantive, so almost every conversation would take standard on turn one and never be
|
|
83
|
+
* eligible again, which is a mechanism that saves nothing while carrying all of the risk. As a weight it
|
|
84
|
+
* still stops the deceptive follow-up (which scores near the base and cannot afford the penalty) while a
|
|
85
|
+
* genuinely trivial aside inside a hard conversation still gets through, and that aside is a real and
|
|
86
|
+
* common turn.
|
|
87
|
+
*
|
|
88
|
+
* The JUDGEMENT, not what ran: a turn judged fast that ran standard anyway (nothing cheaper in the catalog,
|
|
89
|
+
* or the feature switched off) says nothing about the difficulty of the work, and reading it as escalation
|
|
90
|
+
* would make the sandbox's configuration leak into its opinion about a sentence. */
|
|
91
|
+
readonly afterHardTurn: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ComplexityVerdict {
|
|
95
|
+
readonly tier: ComplexityTier;
|
|
96
|
+
// 0..1, rounded to three places so ledger rows compare exactly and do not carry float noise. 1 whenever a
|
|
97
|
+
// gate or an escalating rule fired: those do not produce a degree of difficulty, they produce an answer.
|
|
98
|
+
readonly score: number;
|
|
99
|
+
// Every rule that fired, in declaration order. Empty is legal and means "nothing distinctive": the score is
|
|
100
|
+
// the base, which sits above the fast ceiling, so an unremarkable turn stays on the user's own pick.
|
|
101
|
+
readonly rules: readonly ComplexityRule[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* WHERE AN UNREMARKABLE TURN STARTS, and it starts ABOVE the fast ceiling on purpose: a prompt that matches no
|
|
105
|
+
* rule at all is medium, not simple. That is the reference keyword-router convention and it is the conservative
|
|
106
|
+
* reading of silence, which is the only safe one when the downside of a wrong downgrade is a retry, an
|
|
107
|
+
* escalation, and a user who stops trusting the feature. */
|
|
108
|
+
const BASE_SCORE = 0.5;
|
|
109
|
+
|
|
110
|
+
// Fast iff the score lands at or below this. Exported because the shadow ledger's whole purpose is to let this
|
|
111
|
+
// be re-fitted against real traffic rather than argued about, and a reader of a stored score needs the ceiling
|
|
112
|
+
// that score was judged against.
|
|
113
|
+
export const FAST_CEILING = 0.25;
|
|
114
|
+
|
|
115
|
+
// Characters, not tokens: nothing here can tokenize, and for a threshold the constant cancels. ~600 chars is
|
|
116
|
+
// where a request stops being a sentence and starts being a brief; ~2400 is where it is carrying pasted
|
|
117
|
+
// material it has not fenced.
|
|
118
|
+
const MEDIUM_PROMPT_CHARS = 600;
|
|
119
|
+
const LONG_PROMPT_CHARS = 2400;
|
|
120
|
+
const SHORT_PROMPT_CHARS = 140;
|
|
121
|
+
// Three files in is a job about a shape rather than about a file, whatever the words say.
|
|
122
|
+
const MANY_ATTACHMENTS = 3;
|
|
123
|
+
// Three or more distinct imperatives is a list of jobs wearing the grammar of one.
|
|
124
|
+
const MANY_VERBS = 3;
|
|
125
|
+
|
|
126
|
+
/* THE WORD LISTS. Kept short and boring, because a hand-written lexicon is the part of a router that rots: it
|
|
127
|
+
* is inflexible by construction and every addition is a guess about traffic nobody has measured yet. It exists
|
|
128
|
+
* to catch the unambiguous ends of the distribution and to hand everything else to the score.
|
|
129
|
+
*
|
|
130
|
+
* Both lists are matched on word boundaries and case-insensitively. `easy` only ever lowers a score; `hard`
|
|
131
|
+
* forces standard outright, which is the asymmetry the rest of this file is built on. */
|
|
132
|
+
const EASY_WORDS =
|
|
133
|
+
/\b(?:what(?:'s| is| are)|explain|describe|summari[sz]e|list|show me|where(?:'s| is| are)|rename|typo|reword|reformat|format this|tidy|define|translate|spell|abbreviat)/i;
|
|
134
|
+
|
|
135
|
+
const HARD_WORDS =
|
|
136
|
+
/\b(?:refactor|redesign|architect|architecture|migrat|root cause|debug|investigat|diagnos|optimi[sz]|race condition|deadlock|memory leak|regression|security|threat model|benchmark|profil|audit|design a|plan (?:a|the|out)|why (?:does|is|are|did|would|can't|cannot))/i;
|
|
137
|
+
|
|
138
|
+
// "Do this, and also that." The strongest cheap signal of a job that is several jobs, and the one an easy-
|
|
139
|
+
// sounding sentence hides behind most often.
|
|
140
|
+
const MULTI_STEP = /\b(?:and then|after that|once (?:that|you)|followed by|as well as|then also)\b/i;
|
|
141
|
+
|
|
142
|
+
// A job whose subject is the shape of the codebase rather than a place in it. A cheap rung asked to be
|
|
143
|
+
// consistent across twenty files is being asked the one thing it is worst at.
|
|
144
|
+
const CROSS_CUTTING =
|
|
145
|
+
/\b(?:across (?:the|all|every)|every(?: single)? (?:file|module|package|component|usage|call ?site)|all (?:the|of the) (?:files|usages|call ?sites|places)|everywhere|throughout the|codebase-wide|repo-wide)\b/i;
|
|
146
|
+
|
|
147
|
+
// Fenced code, a unified diff, or an inline patch. Pasted code is not proof of difficulty by itself, but it is
|
|
148
|
+
// proof the turn is about real code rather than about a word, and the cheap rung's failures there are silent.
|
|
149
|
+
const CODE_BLOCK = /```|^diff --git |^@@ .* @@|^[+-]{3} [ab]\//m;
|
|
150
|
+
|
|
151
|
+
// A thrown error the user has pasted in. Debugging from a trace is the canonical case where the expensive tier
|
|
152
|
+
// earns its price, and it is trivially detectable.
|
|
153
|
+
const STACK_TRACE =
|
|
154
|
+
/(?:^|\n)\s*(?:at [\w$.<>]+ \(|Traceback \(most recent call last\)|Caused by:|panic:|thread '.*' panicked|Unhandled|[A-Z]\w*(?:Error|Exception): )/;
|
|
155
|
+
|
|
156
|
+
// A workspace path or an @-mention in the prose. Distinguishes "explain closures" from "explain what this file
|
|
157
|
+
// does" without claiming either is hard.
|
|
158
|
+
const PATH_LIKE =
|
|
159
|
+
/(?:^|\s)(?:@[\w./-]+|[\w-]+\/[\w./-]+\.[a-z]{1,5}\b|\b[\w-]+\.(?:ts|tsx|js|jsx|vue|py|go|rs|java|rb|css|scss|json|ya?ml|md|sql|sh)\b)/i;
|
|
160
|
+
|
|
161
|
+
// One sentence, ending in a question mark, with no second clause. Knowledge, not work.
|
|
162
|
+
const BARE_QUESTION = /^[^.!?]{0,200}\?\s*$/;
|
|
163
|
+
|
|
164
|
+
// Imperatives that start a request. Counted, not matched: one is a task, three is a list.
|
|
165
|
+
const VERBS =
|
|
166
|
+
/\b(?:add|remove|delete|fix|write|create|make|update|change|move|rename|split|merge|extract|inline|wire|hook|test|check|run|build|deploy|document|implement|replace|convert|handle|support|expose|log|render|validate|parse|sort|filter|cache)\b/gi;
|
|
167
|
+
|
|
168
|
+
// Bullets and numbered steps, which is a checklist however casually it is written.
|
|
169
|
+
const LIST_LINES = /^\s*(?:[-*+]\s|\d+[.)]\s)/gm;
|
|
170
|
+
|
|
171
|
+
// Every rule that ends the question on its own, paired with the test that fires it. Split from the graded
|
|
172
|
+
// features below because the two are read differently: these are answers, those are evidence.
|
|
173
|
+
const forcing = (input: ComplexityInput, text: string): ComplexityRule[] => {
|
|
174
|
+
const rules: ComplexityRule[] = [];
|
|
175
|
+
// Gates first, cheapest and least arguable. They are about the turn's SITUATION rather than its words.
|
|
176
|
+
if (input.hasImages) {
|
|
177
|
+
rules.push("images");
|
|
178
|
+
}
|
|
179
|
+
if (input.planMode) {
|
|
180
|
+
rules.push("plan-mode");
|
|
181
|
+
}
|
|
182
|
+
/* An unattended run is billed whole and nobody is watching it fail. The settings this repo already ships
|
|
183
|
+
* make the same call in the other direction: agentRunModels resolves to NOTHING when empty precisely
|
|
184
|
+
* because "nothing here can judge whether a job is worth the frontier tier". This file does judge, but not
|
|
185
|
+
* for the runs where a wrong guess costs a whole session with a worktree in it. */
|
|
186
|
+
if (input.unattended) {
|
|
187
|
+
rules.push("unattended");
|
|
188
|
+
}
|
|
189
|
+
// Then the words. Every one of these is a claim that the turn is about real code doing something real.
|
|
190
|
+
if (CODE_BLOCK.test(text)) {
|
|
191
|
+
rules.push("code-block");
|
|
192
|
+
}
|
|
193
|
+
if (STACK_TRACE.test(text)) {
|
|
194
|
+
rules.push("stack-trace");
|
|
195
|
+
}
|
|
196
|
+
if (HARD_WORDS.test(text)) {
|
|
197
|
+
rules.push("hard-words");
|
|
198
|
+
}
|
|
199
|
+
if (MULTI_STEP.test(text) || (text.match(LIST_LINES)?.length ?? 0) >= 2) {
|
|
200
|
+
rules.push("multi-step");
|
|
201
|
+
}
|
|
202
|
+
if (CROSS_CUTTING.test(text)) {
|
|
203
|
+
rules.push("cross-cutting");
|
|
204
|
+
}
|
|
205
|
+
if (text.length > LONG_PROMPT_CHARS) {
|
|
206
|
+
rules.push("long-prompt");
|
|
207
|
+
}
|
|
208
|
+
if (input.attachments >= MANY_ATTACHMENTS) {
|
|
209
|
+
rules.push("many-attachments");
|
|
210
|
+
}
|
|
211
|
+
return rules;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/* The graded half: what nudges an otherwise ordinary request either way. Weights are a starting shape fitted to
|
|
215
|
+
* nothing, which is exactly why the mechanism ships in shadow first — see docs/model-routing-design.md §4. They
|
|
216
|
+
* are not a claim, they are a hypothesis with a ledger under it.
|
|
217
|
+
*
|
|
218
|
+
* ONE PROPERTY IS NOT A HYPOTHESIS AND MUST SURVIVE ANY REFIT: the two ABSENCE features (`short-prompt`,
|
|
219
|
+
* `no-workspace-reference`) are too light to reach the ceiling together. Absence of complexity is not evidence
|
|
220
|
+
* of simplicity — "fix the bug" is four words naming no file and is not a cheap turn — so a downgrade always
|
|
221
|
+
* requires something POSITIVE to have been said, which in practice means `easy-words` or `bare-question`
|
|
222
|
+
* carrying it. Weighted the obvious way instead, the judge downgraded every short vague request in the
|
|
223
|
+
* product, which is the single worst population to be wrong about. */
|
|
224
|
+
const GRADED: readonly { readonly rule: ComplexityRule; readonly weight: number; readonly of: (input: ComplexityInput, text: string) => boolean }[] =
|
|
225
|
+
[
|
|
226
|
+
{ rule: "medium-prompt", weight: +0.2, of: (_input, text) => text.length > MEDIUM_PROMPT_CHARS },
|
|
227
|
+
{ rule: "attachment", weight: +0.15, of: (input) => input.attachments > 0 },
|
|
228
|
+
{ rule: "editor-context", weight: +0.1, of: (input) => input.editorContext },
|
|
229
|
+
// Enough on its own to hold an easy-worded question at standard: "explain what this file does" is a
|
|
230
|
+
// question about real code in this repo, and the cheap rung's failures on real code are the silent kind.
|
|
231
|
+
{ rule: "paths", weight: +0.15, of: (_input, text) => PATH_LIKE.test(text) },
|
|
232
|
+
{
|
|
233
|
+
rule: "many-verbs",
|
|
234
|
+
weight: +0.15,
|
|
235
|
+
of: (_input, text) => new Set((text.match(VERBS) ?? []).map((verb) => verb.toLowerCase())).size >= MANY_VERBS,
|
|
236
|
+
},
|
|
237
|
+
// The heaviest single weight, because it is the only feature that can see past the words. See
|
|
238
|
+
// ComplexityInput.afterHardTurn for why it is a weight rather than the gate it was designed as.
|
|
239
|
+
{ rule: "after-hard-turn", weight: +0.25, of: (input) => input.afterHardTurn },
|
|
240
|
+
// The two absence features, deliberately light; see the note above the list.
|
|
241
|
+
{ rule: "short-prompt", weight: -0.1, of: (_input, text) => text.length <= SHORT_PROMPT_CHARS },
|
|
242
|
+
{ rule: "easy-words", weight: -0.25, of: (_input, text) => EASY_WORDS.test(text) },
|
|
243
|
+
{ rule: "bare-question", weight: -0.15, of: (_input, text) => BARE_QUESTION.test(text) },
|
|
244
|
+
{
|
|
245
|
+
rule: "no-workspace-reference",
|
|
246
|
+
weight: -0.1,
|
|
247
|
+
of: (input, text) => input.attachments === 0 && !input.editorContext && !PATH_LIKE.test(text),
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
// Three places, so a stored score is a value rather than a float artefact and two rows written by the same
|
|
252
|
+
// rules compare equal.
|
|
253
|
+
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
|
|
254
|
+
|
|
255
|
+
/* THE VERDICT. Gates and escalating rules first, and if any fired the answer is standard at score 1 — no
|
|
256
|
+
* partial credit, no weight to tune, and no way for rule ORDER to change the outcome. That monotone-escalation
|
|
257
|
+
* property is worth more than the accuracy it costs: it means adding a rule tomorrow can only ever move turns
|
|
258
|
+
* up a tier, never silently move a different set of turns down.
|
|
259
|
+
*
|
|
260
|
+
* Only what survives all of that gets scored, which keeps the graded layer doing the one job it is good at:
|
|
261
|
+
* separating "explain this" from "wire this up" among requests that look alike. */
|
|
262
|
+
export const judgeComplexity = (input: ComplexityInput): ComplexityVerdict => {
|
|
263
|
+
const text = input.prompt.trim();
|
|
264
|
+
const forced = forcing(input, text);
|
|
265
|
+
if (forced.length > 0) {
|
|
266
|
+
return { tier: "standard", score: 1, rules: forced };
|
|
267
|
+
}
|
|
268
|
+
const hits = GRADED.filter((feature) => feature.of(input, text));
|
|
269
|
+
const score = Math.min(1, Math.max(0, BASE_SCORE + hits.reduce((total, feature) => total + feature.weight, 0)));
|
|
270
|
+
return { tier: score <= FAST_CEILING ? "fast" : "standard", score: round3(score), rules: hits.map((feature) => feature.rule) };
|
|
271
|
+
};
|
package/src/publish-drafts.ts
CHANGED
|
@@ -47,7 +47,7 @@ export const publishTurnPrompt = (drafts: readonly { readonly id: string; readon
|
|
|
47
47
|
``,
|
|
48
48
|
`Take them ONE AT A TIME, and for each:`,
|
|
49
49
|
`1. Read the file. Set "status":"posting" BEFORE you act, so a turn that dies here cannot double-post.`,
|
|
50
|
-
`2. Post exactly its "content"
|
|
50
|
+
`2. Post exactly its "content": with its "title", "target" and "media", using that platform's skill.`,
|
|
51
51
|
` A "target" that is a URL means this draft is a REPLY to whatever is at it: open that exact URL and`,
|
|
52
52
|
` reply where it lands. On reddit a comment permalink (.../comments/<post>/<slug>/<comment>/) has to`,
|
|
53
53
|
` nest under that comment rather than becoming a new top-level comment on the thread.`,
|
|
@@ -55,5 +55,5 @@ export const publishTurnPrompt = (drafts: readonly { readonly id: string; readon
|
|
|
55
55
|
` If it failed, set "status":"failed" plus an "error" saying what went wrong in plain words the owner`,
|
|
56
56
|
` can act on, then move to the next draft instead of retrying in a loop.`,
|
|
57
57
|
``,
|
|
58
|
-
`Never rewrite the content
|
|
58
|
+
`Never rewrite the content: the owner approved these exact words. Never touch a draft not listed above.`,
|
|
59
59
|
].join(`\n`);
|
package/src/quick-model.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
2
|
import { type QuickModelChoice, type QuickModelSource, quickModelKey, resolveQuickModels } from "./quick-model.js";
|
|
3
3
|
|
|
4
|
-
/* Which models a small automatic helper spends, and in which order. The rule answers two surfaces at once
|
|
5
|
-
* the daemon walks it, the browser names its head in the settings row
|
|
4
|
+
/* Which models a small automatic helper spends, and in which order. The rule answers two surfaces at once:
|
|
5
|
+
* the daemon walks it, the browser names its head in the settings row, so what these tests pin is that a
|
|
6
6
|
* sandbox's connections alone decide it, with no stored id to go stale, and that there is always a rung
|
|
7
7
|
* underneath the first one whenever the sandbox has another account to reach for. */
|
|
8
8
|
|
|
@@ -14,7 +14,7 @@ const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k
|
|
|
14
14
|
|
|
15
15
|
const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
|
|
16
16
|
|
|
17
|
-
// The model that answers when nothing goes wrong
|
|
17
|
+
// The model that answers when nothing goes wrong: the head of the chain, which is what most of what follows is
|
|
18
18
|
// about and what every surface naming the spend up front reads.
|
|
19
19
|
const head = (sources: readonly QuickModelSource[], pinned: readonly string[]): QuickModelChoice | undefined =>
|
|
20
20
|
resolveQuickModels(sources, pinned)[0];
|
|
@@ -24,12 +24,12 @@ test("reaches for the efficient rung of the one connected provider, never its fl
|
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
test("spends the FREE channel over the subscription when both offer the same rung", () => {
|
|
27
|
-
// Both publish a cheap-tier row, so nothing separates them on capability
|
|
27
|
+
// Both publish a cheap-tier row, so nothing separates them on capability, and one of them costs the user
|
|
28
28
|
// nothing while the other eats headroom they watch. A background helper should not quietly bill the Claude plan.
|
|
29
29
|
expect(head([CLAUDE, GOOGLE], [])).toEqual({ provider: `gemini`, model: `gemini-3-flash-lite` });
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
test("puts tier ahead of cost
|
|
32
|
+
test("puts tier ahead of cost: a free frontier model is still the wrong tool for a commit message", () => {
|
|
33
33
|
// Google connected but publishing only its Pro line. Ordering on price first would seat a flagship here,
|
|
34
34
|
// which is the exact outcome the feature exists to avoid.
|
|
35
35
|
const proOnly: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
|
|
@@ -90,7 +90,7 @@ test("skips a connected provider whose catalog has not loaded yet", () => {
|
|
|
90
90
|
expect(head([unloaded], [])).toBeUndefined();
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
/* THE CHAIN
|
|
93
|
+
/* THE CHAIN: what the daemon walks when the model at the top of it refuses. A spent allowance is the ordinary
|
|
94
94
|
* case, not the exotic one: the account a helper shares with the chat runs out mid-afternoon, and the whole
|
|
95
95
|
* point of the list is that the click still lands on the next rung down. */
|
|
96
96
|
|
|
@@ -111,7 +111,7 @@ test("drops a pin whose provider went away and keeps the rest of the order intac
|
|
|
111
111
|
|
|
112
112
|
test("stops at the end of a pinned list rather than reaching for an account the user left out", () => {
|
|
113
113
|
// Google and Kimi are connected and cheaper. The user wrote down one model, so one model is what this may
|
|
114
|
-
// spend
|
|
114
|
+
// spend: a pin exists precisely to keep a helper off the accounts it does not name.
|
|
115
115
|
expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [`claude:claude-haiku-4-5`])).toEqual([{ provider: `claude`, model: `claude-haiku-4-5` }]);
|
|
116
116
|
});
|
|
117
117
|
|
|
@@ -122,7 +122,7 @@ test("names each model once, however many times the list repeats it", () => {
|
|
|
122
122
|
]);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
test("Auto is a ladder too
|
|
125
|
+
test("Auto is a ladder too, every connected provider's cheap rung, best first", () => {
|
|
126
126
|
expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [])).toEqual([
|
|
127
127
|
{ provider: `gemini`, model: `gemini-3-flash-lite` },
|
|
128
128
|
{ provider: `claude`, model: `claude-haiku-4-5-20251001` },
|
|
@@ -135,11 +135,11 @@ test("Auto is a ladder too — every connected provider's cheap rung, best first
|
|
|
135
135
|
* dropped would print one model's name in the settings row and spend a different account entirely. */
|
|
136
136
|
const OLLAMA: QuickModelSource = { provider: `endpoint/ollama`, ready: true, models: [`qwen3-coder`, `gemma3-27b`] };
|
|
137
137
|
|
|
138
|
-
test("honours a pin on a configured endpoint
|
|
138
|
+
test("honours a pin on a configured endpoint: the whole id, not the half before its slash", () => {
|
|
139
139
|
expect(head([CLAUDE, OLLAMA], [`endpoint/ollama:qwen3-coder`])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
|
|
140
140
|
// And it round-trips through the key shape the picker mints, which is where the slash-not-colon rule earns
|
|
141
141
|
// itself: parsePinned splits on the FIRST colon, so an `endpoint:ollama` id would have parsed the provider
|
|
142
|
-
// as "endpoint" and the model as "ollama:qwen3-coder"
|
|
142
|
+
// as "endpoint" and the model as "ollama:qwen3-coder": a pin that silently resolves to nothing.
|
|
143
143
|
expect(quickModelKey({ provider: `endpoint/ollama`, model: `qwen3-coder` })).toBe(`endpoint/ollama:qwen3-coder`);
|
|
144
144
|
});
|
|
145
145
|
|
|
@@ -152,7 +152,7 @@ test("leaves Auto to the providers whose price is known, rather than reaching fo
|
|
|
152
152
|
|
|
153
153
|
test("still answers from an endpoint when it is the only thing configured", () => {
|
|
154
154
|
// No tier word in either id, so the shared id-derived ordering decides between them exactly as it does for
|
|
155
|
-
// Kimi above
|
|
155
|
+
// Kimi above: the point here is that a sandbox whose only model API is its owner's still gets an answer
|
|
156
156
|
// rather than the disabled "nothing connected" button.
|
|
157
157
|
expect(head([offline(CLAUDE), OLLAMA], [])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
|
|
158
158
|
});
|
package/src/routes.test.ts
CHANGED
|
@@ -87,14 +87,14 @@ describe(`routeShapes`, () => {
|
|
|
87
87
|
expect(after).not.toBe(before);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
it(`is blind to the order fields are declared in
|
|
90
|
+
it(`is blind to the order fields are declared in: a reordered object is not a wire change`, () => {
|
|
91
91
|
const one = routeShapes(shaped(z.object({ a: z.string(), b: z.number() })))[`vpn.list`];
|
|
92
92
|
const other = routeShapes(shaped(z.object({ b: z.number(), a: z.string() })))[`vpn.list`];
|
|
93
93
|
expect(other).toBe(one);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
it(`reads a defaulted field differently on the way in than on the way out`, () => {
|
|
97
|
-
// `.default()` makes a field optional going in and required coming out
|
|
97
|
+
// `.default()` makes a field optional going in and required coming out: the same declaration, two
|
|
98
98
|
// wire shapes. Reading both directions the same way would call them identical.
|
|
99
99
|
const one = z.object({ a: z.string().default(`x`) });
|
|
100
100
|
const asOutput = routeShapes(shaped(one))[`vpn.list`];
|
|
@@ -114,7 +114,7 @@ describe(`routeShapes`, () => {
|
|
|
114
114
|
|
|
115
115
|
it(`omits a route whose shape cannot be expressed rather than failing the walk`, () => {
|
|
116
116
|
// An oRPC event iterator wraps its output in an opaque type with no schema underneath. The route keeps
|
|
117
|
-
// its NAME (it is still advertised as existing) and simply carries no shape
|
|
117
|
+
// its NAME (it is still advertised as existing) and simply carries no shape, which reads downstream as
|
|
118
118
|
// "assume compatible". The two neighbours still get fingerprinted, which is the part that matters: one
|
|
119
119
|
// unexpressable route must not cost the whole walk.
|
|
120
120
|
const withStream = {
|
|
@@ -135,12 +135,18 @@ describe(`routeShapes`, () => {
|
|
|
135
135
|
describe(`the real sandbox contract`, () => {
|
|
136
136
|
it(`fingerprints all but the streaming routes`, () => {
|
|
137
137
|
const unshaped = SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES));
|
|
138
|
-
// oRPC wraps an event iterator's output in an opaque type with no schema under it, so these
|
|
138
|
+
// oRPC wraps an event iterator's output in an opaque type with no schema under it, so these ten
|
|
139
139
|
// cannot be fingerprinted and are assumed compatible. Named rather than counted: a NEW entry here is
|
|
140
140
|
// a route that quietly lost its shape check, which is worth failing a test over.
|
|
141
141
|
expect(unshaped.toSorted()).toEqual([
|
|
142
142
|
`agent.attach`,
|
|
143
143
|
`capabilities.add`,
|
|
144
|
+
// The three geo-exit moves, streaming for the same reason vpn.connect does: bringing an exit up
|
|
145
|
+
// pulls a catalog, dials, and then verifies the address it landed on, which is tens of seconds on
|
|
146
|
+
// the free providers and can fail with something the user has to read at each step.
|
|
147
|
+
`exit.rotate`,
|
|
148
|
+
`exit.start`,
|
|
149
|
+
`exit.use`,
|
|
144
150
|
`intentic.applyEvents`,
|
|
145
151
|
`intentic.run`,
|
|
146
152
|
`system.events`,
|
|
@@ -151,7 +157,7 @@ describe(`the real sandbox contract`, () => {
|
|
|
151
157
|
|
|
152
158
|
it(`fingerprints every other route exactly once`, () => {
|
|
153
159
|
expect(Object.keys(SANDBOX_ROUTE_SHAPES).every((name) => SANDBOX_ROUTE_NAMES.includes(name))).toBe(true);
|
|
154
|
-
expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length -
|
|
160
|
+
expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 10);
|
|
155
161
|
});
|
|
156
162
|
|
|
157
163
|
it(`derives a route table with no duplicate names`, () => {
|
|
@@ -39,7 +39,7 @@ describe(`runtimeBoundQueryKeys`, () => {
|
|
|
39
39
|
expect(joined(runtimeBoundQueryKeys())).toEqual(joined(RUNTIME_DOMAIN_BINDINGS.flatMap((binding) => binding.invalidates)));
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
it(`leaves no domain declaring no keys
|
|
42
|
+
it(`leaves no domain declaring no keys: a domain nothing renders has no reason to be pushed`, () => {
|
|
43
43
|
expect(RUNTIME_DOMAIN_BINDINGS.filter((binding) => binding.invalidates.length === 0)).toEqual([]);
|
|
44
44
|
});
|
|
45
45
|
});
|