@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
|
@@ -26,7 +26,7 @@ import type { AgentHarness, AgentProvider, PermissionMode } from "./schemas.js";
|
|
|
26
26
|
* drops the field. The record is what fixed that, and this is what keeps it honest.
|
|
27
27
|
*
|
|
28
28
|
* By SHAPE, not by a list: PROVIDERS × HARNESSES comes from the catalog itself, so a provider added tomorrow is
|
|
29
|
-
* covered the day it is added. If a pair fails here, the answer is a row in capabilitiesOf
|
|
29
|
+
* covered the day it is added. If a pair fails here, the answer is a row in capabilitiesOf: never a special
|
|
30
30
|
* case in the surface that asked. */
|
|
31
31
|
|
|
32
32
|
const pairs: { provider: AgentProvider; harness: AgentHarness }[] = PROVIDERS.flatMap((provider) =>
|
|
@@ -42,20 +42,29 @@ describe("every provider/harness pair declares what it can do", () => {
|
|
|
42
42
|
expect(["claude-code", "codex", "opencode", "opencode-gemini", "acp", "pi"]).toContain(capabilities.runtime);
|
|
43
43
|
expect(["modes", "plan"]).toContain(capabilities.permissions);
|
|
44
44
|
expect(["full", "browser", "http", "none"]).toContain(capabilities.mcp);
|
|
45
|
-
// Every runtime executes SOMETHING
|
|
45
|
+
// Every runtime executes SOMETHING: a record listing no backend would hide the shell every loop has.
|
|
46
46
|
expect(capabilities.execution).toContain("shell");
|
|
47
47
|
for (const backend of capabilities.execution) {
|
|
48
48
|
expect(["shell", "js"]).toContain(backend);
|
|
49
49
|
}
|
|
50
50
|
expect(["namespace", "cwd"]).toContain(capabilities.isolation);
|
|
51
51
|
expect(["replace", "append", "none"]).toContain(capabilities.instructions);
|
|
52
|
-
|
|
52
|
+
expect(["hooks", "approval", "refuse-only", "none"]).toContain(capabilities.rulebook);
|
|
53
|
+
expect(["masked", "none"]).toContain(capabilities.secrets);
|
|
54
|
+
/* Masking is a PostToolUse hook and nothing else can edit what a model reads, so a record claiming
|
|
55
|
+
* masked without hooks would be claiming a seam that does not exist (see the axis doc). The one
|
|
56
|
+
* direction that IS legal is hooks without masking, which is why this is an implication and not
|
|
57
|
+
* equality. */
|
|
58
|
+
if (capabilities.secrets === "masked") {
|
|
59
|
+
expect(capabilities.rulebook).toBe("hooks");
|
|
60
|
+
}
|
|
61
|
+
// The permission modes offered must include the mode a clamp falls back to: a floor that isn't in the
|
|
53
62
|
// list would leave the composer showing a posture the runtime can't hold.
|
|
54
63
|
expect(modesFor(capabilities)).toContain(clampMode("default", capabilities));
|
|
55
64
|
});
|
|
56
65
|
});
|
|
57
66
|
|
|
58
|
-
// An ACP provider is an installed capability's id
|
|
67
|
+
// An ACP provider is an installed capability's id: any string that isn't a native provider. It gets the ACP
|
|
59
68
|
// floor whatever harness the client happened to send, because the agent IS its own loop.
|
|
60
69
|
test("an unknown provider id is an ACP agent, on either harness", () => {
|
|
61
70
|
for (const harness of HARNESSES) {
|
|
@@ -63,12 +72,12 @@ test("an unknown provider id is an ACP agent, on either harness", () => {
|
|
|
63
72
|
}
|
|
64
73
|
});
|
|
65
74
|
|
|
66
|
-
/* THE `pi` ID IS RESERVED for the Pi coding agent's own RPC runtime
|
|
75
|
+
/* THE `pi` ID IS RESERVED for the Pi coding agent's own RPC runtime: an `agent`-kind capability like any ACP
|
|
67
76
|
* agent, but served over Pi's JSONL protocol, which carries abilities the ACP floor cannot: real mid-turn
|
|
68
77
|
* steering, the thinking-level scale, a published command list. Falling to the ACP record instead would strip
|
|
69
78
|
* exactly the abilities that justify a fifth runtime. */
|
|
70
79
|
describe("the pi provider", () => {
|
|
71
|
-
it("runs the pi runtime on either harness
|
|
80
|
+
it("runs the pi runtime on either harness: pi is its own loop", () => {
|
|
72
81
|
for (const harness of HARNESSES) {
|
|
73
82
|
expect(capabilitiesOf("pi", harness.value).runtime).toBe("pi");
|
|
74
83
|
}
|
|
@@ -96,7 +105,7 @@ describe("the pi provider", () => {
|
|
|
96
105
|
* "there is nothing to choose". Claude is always its own Claude Code loop. Kimi has no native runtime at all
|
|
97
106
|
* (Moonshot speaks the Anthropic protocol directly), so it runs Claude Code whatever the client sent. Gemini is
|
|
98
107
|
* Kimi's mirror: it has no CLAUDE CODE road, because that loop announces itself in every request and Google's
|
|
99
|
-
* Antigravity channel refuses on the announcement
|
|
108
|
+
* Antigravity channel refuses on the announcement: every account, every time, reported as a spent quota it
|
|
100
109
|
* never was. */
|
|
101
110
|
test("only codex and grok change runtime with the harness", () => {
|
|
102
111
|
const switched = PROVIDERS.filter((provider) => capabilitiesOf(provider.value, "native") !== capabilitiesOf(provider.value, "claude-code"));
|
|
@@ -105,26 +114,26 @@ test("only codex and grok change runtime with the harness", () => {
|
|
|
105
114
|
});
|
|
106
115
|
|
|
107
116
|
/* THE RULE THAT KEEPS CLAUDE CODE TRAFFIC AWAY FROM GOOGLE, asserted where it is decided rather than at each of
|
|
108
|
-
* the surfaces that obey it. Everything downstream
|
|
109
|
-
* quick helper's choice of loop
|
|
117
|
+
* the surfaces that obey it. Everything downstream: the adapter that serves a turn, the transcript store, the
|
|
118
|
+
* quick helper's choice of loop: reads the runtime off this record, so a Gemini turn asking for Claude Code and
|
|
110
119
|
* getting it back would put the refused loop on the road again everywhere at once. */
|
|
111
120
|
test("gemini answers with its own runtime whatever harness is asked for", () => {
|
|
112
121
|
const native = capabilitiesOf("gemini", "native");
|
|
113
122
|
|
|
114
123
|
expect(native.runtime).toBe("opencode-gemini");
|
|
115
124
|
expect(capabilitiesOf("gemini", "claude-code")).toEqual(native);
|
|
116
|
-
// Same abilities as the Grok loop it shares
|
|
125
|
+
// Same abilities as the Grok loop it shares: only the runtime id, which keys adapter health, differs.
|
|
117
126
|
expect({ ...native, runtime: "opencode" }).toEqual(capabilitiesOf("grok", "native"));
|
|
118
127
|
});
|
|
119
128
|
|
|
120
|
-
test("codex and grok under the Claude Code harness get the full ceiling
|
|
129
|
+
test("codex and grok under the Claude Code harness get the full ceiling, it is the same loop", () => {
|
|
121
130
|
for (const provider of ["codex", "grok"] as const) {
|
|
122
131
|
expect(capabilitiesOf(provider, "claude-code")).toEqual(capabilitiesOf("claude", "native"));
|
|
123
132
|
}
|
|
124
133
|
});
|
|
125
134
|
|
|
126
135
|
// The composer's four modes describe behaviours the Claude Code loop actually has. A runtime whose every tool
|
|
127
|
-
// call is pre-approved has two postures, so it is offered two names
|
|
136
|
+
// call is pre-approved has two postures, so it is offered two names, not four names for two behaviours.
|
|
128
137
|
test("a plan-only runtime offers the two postures it has", () => {
|
|
129
138
|
expect(modesFor(capabilitiesOf("codex", "native"))).toEqual(["plan", "bypassPermissions"]);
|
|
130
139
|
expect(modesFor(capabilitiesOf("claude", "native"))).toEqual(["default", "acceptEdits", "plan", "bypassPermissions"]);
|
|
@@ -138,7 +147,7 @@ test("a mode the runtime can't hold falls back to the one it runs; one it can ho
|
|
|
138
147
|
expect(clampMode("acceptEdits", capabilitiesOf("claude", "native"))).toBe("acceptEdits");
|
|
139
148
|
});
|
|
140
149
|
|
|
141
|
-
/* The user-facing half. The full ceiling says nothing
|
|
150
|
+
/* The user-facing half. The full ceiling says nothing: an empty list is what hides the picker's block, and
|
|
142
151
|
* every axis the record carries has a sentence here, because a capability nobody can read is how this started. */
|
|
143
152
|
test("the ceiling has nothing to disclose; a floor names what it lacks", () => {
|
|
144
153
|
expect(limitationsOf(capabilitiesOf("claude", "native"))).toEqual([]);
|
|
@@ -149,15 +158,15 @@ test("the ceiling has nothing to disclose; a floor names what it lacks", () => {
|
|
|
149
158
|
expect(grok).toContain("no effort control");
|
|
150
159
|
expect(grok).toContain("worktree by working directory only");
|
|
151
160
|
|
|
152
|
-
// ACP takes our http MCP tools when it advertises them, so its line is a narrowing rather than an absence
|
|
161
|
+
// ACP takes our http MCP tools when it advertises them, so its line is a narrowing rather than an absence:
|
|
153
162
|
// and it publishes commands and terminals, which must NOT be listed as missing.
|
|
154
163
|
const acp = limitationsOf(capabilitiesOf("some-installed-agent", "native"));
|
|
155
|
-
expect(acp).toContain("MCP tools only
|
|
164
|
+
expect(acp).toContain("MCP tools only: no plugins or browser");
|
|
156
165
|
expect(acp).not.toContain("no slash commands");
|
|
157
166
|
expect(acp).not.toContain("no terminal panel");
|
|
158
167
|
|
|
159
168
|
const codex = limitationsOf(capabilitiesOf("codex", "native"));
|
|
160
|
-
expect(codex).toContain("browser tools only
|
|
169
|
+
expect(codex).toContain("browser tools only: no plugins or other MCP tools");
|
|
161
170
|
expect(codex).not.toContain("no MCP tools or plugins");
|
|
162
171
|
});
|
|
163
172
|
|
|
@@ -176,16 +185,52 @@ test("every axis a record can lack has words for it", () => {
|
|
|
176
185
|
terminals: false,
|
|
177
186
|
recovery: false,
|
|
178
187
|
instructions: "none",
|
|
188
|
+
rulebook: "none",
|
|
189
|
+
secrets: "none",
|
|
179
190
|
};
|
|
180
191
|
|
|
181
|
-
//
|
|
182
|
-
// never be disclosed. fastMode is the deliberate
|
|
192
|
+
// Thirteen DISCLOSABLE axes, thirteen sentences: an axis added to the interface without one would silently
|
|
193
|
+
// never be disclosed. fastMode is the deliberate exception: a record alone can't tell the truth about it (a
|
|
183
194
|
// translator-routed turn reads true here and still can't go fast), so it is answered by fastAllowed
|
|
184
195
|
// instead. Anything else added to the interface has to move this number.
|
|
185
|
-
expect(limitationsOf(nothing)).toHaveLength(
|
|
196
|
+
expect(limitationsOf(nothing)).toHaveLength(13);
|
|
186
197
|
expect(limitationsOf(nothing).join(" ")).not.toContain("fast");
|
|
187
198
|
});
|
|
188
199
|
|
|
200
|
+
/* The two safety axes, checked the same way the instruction axis is below and for the same reason: `rulebook`
|
|
201
|
+
* has a middle value, and disclosing the floor's words for it would tell a Codex user their rules are ignored
|
|
202
|
+
* when they are in fact being applied to everything Codex asks about. */
|
|
203
|
+
test("the safety axes disclose the middle answer differently from the floor", () => {
|
|
204
|
+
const claude = limitationsOf(capabilitiesOf("claude", "native")).join(" ");
|
|
205
|
+
const codex = limitationsOf(capabilitiesOf("codex", "native")).join(" ");
|
|
206
|
+
const pi = limitationsOf(capabilitiesOf("pi", "native")).join(" ");
|
|
207
|
+
|
|
208
|
+
// The ceiling says nothing about either axis.
|
|
209
|
+
expect(claude).not.toContain("command rules");
|
|
210
|
+
expect(claude).not.toContain("stored secrets");
|
|
211
|
+
|
|
212
|
+
// The middle: rules DO apply, to what the vendor raises. Never the floor's flat "aren't applied".
|
|
213
|
+
expect(codex).toContain("only to calls this agent asks about");
|
|
214
|
+
expect(codex).not.toContain("aren't applied");
|
|
215
|
+
|
|
216
|
+
// The floor: no seam at all, said plainly.
|
|
217
|
+
expect(pi).toContain("your command rules aren't applied");
|
|
218
|
+
|
|
219
|
+
/* The third answer, which exists because OpenCode's watchdog aborts a turn that pauses. Its sentence must
|
|
220
|
+
* say the rules DO bite (unlike Pi's) and that a hold cannot ask (unlike Codex's). */
|
|
221
|
+
const grok = limitationsOf(capabilitiesOf("grok", "native")).join(" ");
|
|
222
|
+
expect(grok).toContain("a rule set to hold refuses instead");
|
|
223
|
+
expect(grok).not.toContain("aren't applied");
|
|
224
|
+
expect(grok).not.toContain("only to calls this agent asks about");
|
|
225
|
+
// Gemini rides the same loop, so it must read the same way.
|
|
226
|
+
expect(limitationsOf(capabilitiesOf("gemini", "native")).join(" ")).toBe(grok);
|
|
227
|
+
|
|
228
|
+
// Masking is binary and structural, so every non-Claude runtime says the same thing.
|
|
229
|
+
for (const provider of ["codex", "grok", "gemini", "pi", "some-installed-agent"] as const) {
|
|
230
|
+
expect(limitationsOf(capabilitiesOf(provider, "native")).join(" ")).toContain("stored secrets reach the model unmasked");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
189
234
|
/* The instruction axis has THREE values and only two of them are worth a sentence, which is the one shape the
|
|
190
235
|
* count above cannot check: a middle value that discloses the same words as the floor would tell a Grok user
|
|
191
236
|
* their prompt is ignored when it is in fact being sent. */
|
|
@@ -196,12 +241,12 @@ test("the instruction axis discloses its two weaker answers, differently", () =>
|
|
|
196
241
|
expect(grok).toContain("added to theirs");
|
|
197
242
|
expect(grok).not.toContain("isn't applied");
|
|
198
243
|
expect(acp).toContain("isn't applied");
|
|
199
|
-
// Codex on its own runtime replaces, like the Claude Code loop
|
|
244
|
+
// Codex on its own runtime replaces, like the Claude Code loop, so it has nothing to disclose here.
|
|
200
245
|
expect(limitationsOf(capabilitiesOf("codex", "native")).join(" ")).not.toContain("system prompt");
|
|
201
246
|
});
|
|
202
247
|
|
|
203
248
|
/* The JS backend is hosted by the one loop the daemon can put its own execution seam through. Pinned as a test
|
|
204
|
-
* rather than left to the records because turn planning gates the backend on this axis
|
|
249
|
+
* rather than left to the records because turn planning gates the backend on this axis: a runtime gaining it
|
|
205
250
|
* here without a seam behind it would advertise code runs that can never execute. */
|
|
206
251
|
test("only the Claude Code loop hosts the js execution backend", () => {
|
|
207
252
|
for (const { provider, harness } of pairs) {
|
|
@@ -230,7 +275,7 @@ describe("the max-effort rule", () => {
|
|
|
230
275
|
expect(effortAllowed("high", "kimi", false)).toBe(true);
|
|
231
276
|
});
|
|
232
277
|
|
|
233
|
-
it("is repaired, not refused, on the way to the API
|
|
278
|
+
it("is repaired, not refused, on the way to the API: the tier drops, the user's thinking choice does not", () => {
|
|
234
279
|
expect(sendableEffort("max", false)).toBe("high");
|
|
235
280
|
expect(sendableEffort("max", undefined)).toBe("high");
|
|
236
281
|
expect(sendableEffort("max", true)).toBe("max");
|
|
@@ -241,7 +286,7 @@ describe("the max-effort rule", () => {
|
|
|
241
286
|
|
|
242
287
|
/* AN `endpoint/<id>` PROVIDER is a model API the user configured, and the one thing this record has to get right
|
|
243
288
|
* about it is that it is NOT an ACP agent. Both are minted by installing a capability and both are unknown to
|
|
244
|
-
* NATIVE_PROVIDERS, so the id is the only thing that tells them apart
|
|
289
|
+
* NATIVE_PROVIDERS, so the id is the only thing that tells them apart, and they want opposite records: an ACP
|
|
245
290
|
* agent brings its own loop and gets the documented floor, while an endpoint is driven BY the Claude Code loop
|
|
246
291
|
* and gets its full ceiling. Getting this backwards would strip steering, per-tool approvals, MCP and the mount
|
|
247
292
|
* namespace from every turn on a user's own model. */
|
|
@@ -262,7 +307,7 @@ describe("a configured model endpoint", () => {
|
|
|
262
307
|
expect(endpointProvider("gpu-box")).toBe("endpoint/gpu-box");
|
|
263
308
|
expect(endpointIdOf(endpointProvider("gpu-box"))).toBe("gpu-box");
|
|
264
309
|
expect(isEndpointProvider("endpoint/gpu-box")).toBe(true);
|
|
265
|
-
// A bare id that merely starts with the word is not one
|
|
310
|
+
// A bare id that merely starts with the word is not one: the separator is what makes the namespace.
|
|
266
311
|
expect(isEndpointProvider("endpoints-r-us")).toBe(false);
|
|
267
312
|
expect(endpointIdOf("claude")).toBeUndefined();
|
|
268
313
|
// Its credential was configured with the endpoint, so there is nothing left for a connect gate to offer.
|
|
@@ -271,7 +316,7 @@ describe("a configured model endpoint", () => {
|
|
|
271
316
|
});
|
|
272
317
|
|
|
273
318
|
/* FAST SPEED IS OFFERED ON THREE CONDITIONS AT ONCE, and the interesting cases are the ones where two of them
|
|
274
|
-
* hold. A translator-routed provider runs the Claude Code loop
|
|
319
|
+
* hold. A translator-routed provider runs the Claude Code loop: same record, same ceiling, and still cannot go
|
|
275
320
|
* fast, because the harness refuses a non-Anthropic endpoint; a Claude model that publishes no `fast` badge
|
|
276
321
|
* cannot either. Both would be silent failures if the composer offered the control anyway: the turn runs, the
|
|
277
322
|
* answer arrives, and only the bill says it was standard speed. */
|
|
@@ -281,7 +326,7 @@ describe("offering fast speed", () => {
|
|
|
281
326
|
});
|
|
282
327
|
|
|
283
328
|
it("is refused for a routed provider on the Claude Code loop, whose endpoint is not first-party", () => {
|
|
284
|
-
// Grok under the claude-code harness reads the FULL Claude Code record
|
|
329
|
+
// Grok under the claude-code harness reads the FULL Claude Code record: the capability alone would say
|
|
285
330
|
// yes. It is served through the sandbox's translator, so the harness would report `not_first_party`.
|
|
286
331
|
expect(capabilitiesOf("grok", "claude-code").fastMode).toBe(true);
|
|
287
332
|
expect(fastAllowed(capabilitiesOf("grok", "claude-code"), "grok", ["fast"])).toBe(false);
|
|
@@ -291,7 +336,7 @@ describe("offering fast speed", () => {
|
|
|
291
336
|
|
|
292
337
|
it("is refused for a Claude model whose catalog row doesn't publish it", () => {
|
|
293
338
|
expect(fastAllowed(capabilitiesOf("claude", "native"), "claude", ["reasoning"])).toBe(false);
|
|
294
|
-
// The seed floor and any provider that reports ids only
|
|
339
|
+
// The seed floor and any provider that reports ids only: no capabilities published, nothing claimed.
|
|
295
340
|
expect(fastAllowed(capabilitiesOf("claude", "native"), "claude", undefined)).toBe(false);
|
|
296
341
|
});
|
|
297
342
|
|
package/src/agent-catalog.ts
CHANGED
|
@@ -286,6 +286,49 @@ export interface AgentCapabilities {
|
|
|
286
286
|
* only the Claude Code loop's: Codex's own base describes Codex's own tools, so swapping it for a prompt
|
|
287
287
|
* written about another harness is the owner's deliberate act (their custom text), never ours. */
|
|
288
288
|
readonly instructions: "replace" | "append" | "none";
|
|
289
|
+
/* WHETHER THE OWNER'S COMMAND RULEBOOK REACHES THIS RUNTIME (SandboxSettings.commandRules, decided by
|
|
290
|
+
* guard/actions.ts commandRun, delivered by guard/command-gate.ts).
|
|
291
|
+
*
|
|
292
|
+
* It exists because the rulebook was silently a Claude Code rulebook. The gate is a PreToolUse hook, which
|
|
293
|
+
* is an Agent SDK seam, so an owner who set `files.destructive: hold` was asked on a Claude turn and never
|
|
294
|
+
* on a Codex, Grok, Gemini, Pi or ACP one, with nothing on screen saying so. Same failure mode as the
|
|
295
|
+
* `instructions` axis above, and the same fix: name it once, let every surface read it.
|
|
296
|
+
*
|
|
297
|
+
* "hooks" , the runtime's own pre-execution hook carries the verdict and a HOLD can park the call.
|
|
298
|
+
* The Claude Code loop, whose PreToolUse hook fires even under bypassPermissions.
|
|
299
|
+
* "approval" , the vendor publishes a per-call approval channel the daemon answers from the same
|
|
300
|
+
* rulebook, and a hold parks on a card because the vendor is blocked on the answer
|
|
301
|
+
* (Codex's `item/commandExecution/requestApproval`, ACP's `session/request_permission`).
|
|
302
|
+
* Weaker than "hooks" in one stated way: the vendor decides WHICH calls it asks about, so a
|
|
303
|
+
* class it never raises is a class the rulebook cannot see. What it does raise is judged by
|
|
304
|
+
* the same decide fn.
|
|
305
|
+
* "refuse-only", the same channel, but the vendor puts a CLOCK on the wait, so a hold cannot park and
|
|
306
|
+
* arrives as a refusal instead. OpenCode's turn has an inactivity watchdog that reads a
|
|
307
|
+
* paused approval as a stalled turn; a card there would break the turn rather than gate it.
|
|
308
|
+
* `deny` rules work fully; `hold` rules stop the command and say they could not ask.
|
|
309
|
+
* "none" , the runtime publishes no seam before it runs a command, so no rule can apply. Pi runs its
|
|
310
|
+
* bash in-process with no approval channel at all.
|
|
311
|
+
*
|
|
312
|
+
* The taint floor rides this axis too: a runtime with no consult has no place to apply it, which is why
|
|
313
|
+
* `conversationTainted` must read a "none" runtime as tainted rather than as clean (guard/turn-taint.ts). */
|
|
314
|
+
readonly rulebook: "hooks" | "approval" | "refuse-only" | "none";
|
|
315
|
+
/* WHETHER A STORED CREDENTIAL IS MASKED IN WHAT THIS RUNTIME'S MODEL READS (secrets/secret-registry.ts and
|
|
316
|
+
* the two seams around it).
|
|
317
|
+
*
|
|
318
|
+
* "masked" is the full round trip: every stored value is replaced by its `{{secret:name}}` reference on the
|
|
319
|
+
* way into the model's context, and the same reference resolves back to the value at the two exits that
|
|
320
|
+
* spend it (a shell command, a script). The Claude Code loop, via PostToolUse and PreToolUse.
|
|
321
|
+
*
|
|
322
|
+
* "none" is a STRUCTURAL limit, not an unfinished wire, and it is the reason this axis is honest rather
|
|
323
|
+
* than aspirational. On every other runtime the tool runs inside the VENDOR'S own loop: the model has read
|
|
324
|
+
* the result before the daemon sees any frame about it, so there is no seam left to rewrite. A PostToolUse
|
|
325
|
+
* hook is the only thing that can edit what a model reads, and only the Claude Code loop has one. Nothing
|
|
326
|
+
* about wiring more transports changes that, which is why the answer here is a disclosure and the real fix
|
|
327
|
+
* is to stop putting credentials where a vendor's tool can read them at all.
|
|
328
|
+
*
|
|
329
|
+
* Read by limitationsOf, and by agent/system-prompt.ts, which must not teach the reference language to a
|
|
330
|
+
* runtime that has no exit for it. */
|
|
331
|
+
readonly secrets: "masked" | "none";
|
|
289
332
|
}
|
|
290
333
|
|
|
291
334
|
// The Claude Code Agent SDK loop, the ceiling every other runtime is measured against, and the only one that
|
|
@@ -307,6 +350,10 @@ const CLAUDE_CODE: AgentCapabilities = {
|
|
|
307
350
|
terminals: true,
|
|
308
351
|
recovery: true,
|
|
309
352
|
instructions: "replace",
|
|
353
|
+
// The only runtime with a pre-execution hook of its own, which is why it is the only one where a HOLD can
|
|
354
|
+
// park the call and wait for a card rather than having to refuse it.
|
|
355
|
+
rulebook: "hooks",
|
|
356
|
+
secrets: "masked",
|
|
310
357
|
};
|
|
311
358
|
|
|
312
359
|
/* Codex app-server: item-level events, process-backed MCP servers, and the four interactive seams its protocol
|
|
@@ -336,6 +383,13 @@ const CODEX: AgentCapabilities = {
|
|
|
336
383
|
* message ahead of its skills and team blocks. Verified against codex-cli 0.147 by reading what actually
|
|
337
384
|
* reached the wire, the keys are undocumented, and a strings dump proves only that they parse. */
|
|
338
385
|
instructions: "replace",
|
|
386
|
+
/* App-server publishes `item/commandExecution/requestApproval`, whose params carry the command text, and
|
|
387
|
+
* takes `accept`/`decline` back (codex-cli 0.147's own generated JSON Schema, read with
|
|
388
|
+
* `codex app-server generate-json-schema`). The daemon only asks Codex to raise those requests when the
|
|
389
|
+
* owner has written command rules, so an unconfigured workspace keeps `approvalPolicy: "never"` and pays
|
|
390
|
+
* nothing (codex/codex-agent.ts threadOptions). */
|
|
391
|
+
rulebook: "approval",
|
|
392
|
+
secrets: "none",
|
|
339
393
|
};
|
|
340
394
|
|
|
341
395
|
// OpenCode (the Grok runtime): its own agentic loop, its own tools, allow-all permissions. It takes a model id,
|
|
@@ -357,6 +411,16 @@ const OPENCODE: AgentCapabilities = {
|
|
|
357
411
|
// replacing that, so a custom prompt lands here as extra instructions, and the settings page says so
|
|
358
412
|
// rather than letting "replaces everything" quietly mean something else on two providers.
|
|
359
413
|
instructions: "append",
|
|
414
|
+
/* OpenCode asks over its own permission channel (`permission.updated`, replied on
|
|
415
|
+
* `/session/{id}/permissions/{permissionID}`, vocabulary once/always/reject), and the daemon judges what it
|
|
416
|
+
* raises with the same decide fn every other runtime uses.
|
|
417
|
+
*
|
|
418
|
+
* REFUSE-ONLY because of its watchdog, not because of its protocol. A Grok/Gemini turn is aborted after two
|
|
419
|
+
* minutes without an event for its session (grok/grok-agent.ts GROK_INACTIVITY_MS), and a permission paused
|
|
420
|
+
* on a person is exactly that silence, so a parked card would turn "ask me" into a broken turn. A `deny`
|
|
421
|
+
* rule is enforced in full; a `hold` stops the command and tells the agent it could not be asked about. */
|
|
422
|
+
rulebook: "refuse-only",
|
|
423
|
+
secrets: "none",
|
|
360
424
|
};
|
|
361
425
|
|
|
362
426
|
/* The same OpenCode loop, serving Gemini instead of xAI, identical abilities, which is the point of giving it
|
|
@@ -396,6 +460,12 @@ const ACP: AgentCapabilities = {
|
|
|
396
460
|
// ACP's `session/new` and `session/prompt` carry no system field: the agent owns its own instructions the
|
|
397
461
|
// same way it owns its model and its permission posture. The persona note takes the user message instead.
|
|
398
462
|
instructions: "none",
|
|
463
|
+
/* `session/request_permission` is in the protocol floor, so every conforming agent has the channel and the
|
|
464
|
+
* daemon answers it from the rulebook (acp/acp-permissions.ts). The caveat the "approval" value already
|
|
465
|
+
* carries is at its widest here: WHICH calls an agent asks about is entirely the agent's choice, and one
|
|
466
|
+
* that never asks is one no rule can reach. */
|
|
467
|
+
rulebook: "approval",
|
|
468
|
+
secrets: "none",
|
|
399
469
|
};
|
|
400
470
|
|
|
401
471
|
/* THE PI CAPABILITY ID IS RESERVED, the same way the five native ids are: an `agent`-kind capability installed
|
|
@@ -426,6 +496,12 @@ const PI: AgentCapabilities = {
|
|
|
426
496
|
// Pi's RPC opens a session with a prompt and steers it; nothing in that protocol sets standing
|
|
427
497
|
// instructions, so like ACP it hears the persona note through the user message.
|
|
428
498
|
instructions: "none",
|
|
499
|
+
/* THE ONE RUNTIME WITH NO SEAM AT ALL. Pi runs bash in-process and its RPC publishes no approval request,
|
|
500
|
+
* so there is nothing to consult before a command runs and no rule the owner writes can apply here. Said
|
|
501
|
+
* out loud rather than left to be discovered: limitationsOf renders it, and the taint floor treats a "none"
|
|
502
|
+
* runtime as permanently tainted, because a bit nobody can act on is worse than no bit. */
|
|
503
|
+
rulebook: "none",
|
|
504
|
+
secrets: "none",
|
|
429
505
|
};
|
|
430
506
|
|
|
431
507
|
// The pair → its record. An `endpoint/<id>` provider is a model API the user configured, driven BY the Claude
|
|
@@ -486,11 +562,11 @@ export const limitationsOf = (capabilities: AgentCapabilities): string[] => [
|
|
|
486
562
|
...(capabilities.mcp === "none"
|
|
487
563
|
? ["no MCP tools or plugins"]
|
|
488
564
|
: capabilities.mcp === "http"
|
|
489
|
-
? ["MCP tools only
|
|
565
|
+
? ["MCP tools only: no plugins or browser"]
|
|
490
566
|
: capabilities.mcp === "browser"
|
|
491
|
-
? ["browser tools only
|
|
567
|
+
? ["browser tools only: no plugins or other MCP tools"]
|
|
492
568
|
: []),
|
|
493
|
-
...(capabilities.execution.includes("js") ? [] : ["no code runs
|
|
569
|
+
...(capabilities.execution.includes("js") ? [] : ["no code runs, its shell is the one way to execute"]),
|
|
494
570
|
...(capabilities.effort ? [] : ["no effort control"]),
|
|
495
571
|
...(capabilities.commands ? [] : ["no slash commands"]),
|
|
496
572
|
...(capabilities.terminals ? [] : ["no terminal panel"]),
|
|
@@ -501,6 +577,18 @@ export const limitationsOf = (capabilities: AgentCapabilities): string[] => [
|
|
|
501
577
|
* mechanism, because that is the thing they wrote and the thing that will or will not be in force. */
|
|
502
578
|
...(capabilities.instructions === "append" ? ["your system prompt is added to theirs, not replacing it"] : []),
|
|
503
579
|
...(capabilities.instructions === "none" ? ["your system prompt isn't applied"] : []),
|
|
580
|
+
/* THE TWO SAFETY AXES, phrased as what the OWNER loses rather than as which seam is missing, because both
|
|
581
|
+
* describe something they configured on a settings page and would otherwise assume was in force everywhere.
|
|
582
|
+
*
|
|
583
|
+
* "hooks" and "masked" are the ceiling and disclose nothing. The "approval" middle answer discloses the one
|
|
584
|
+
* thing that genuinely differs from a hook: the vendor picks which calls it asks about, so a rule can only
|
|
585
|
+
* reach what it chose to raise. */
|
|
586
|
+
...(capabilities.rulebook === "approval" ? ["your command rules apply only to calls this agent asks about"] : []),
|
|
587
|
+
...(capabilities.rulebook === "refuse-only"
|
|
588
|
+
? ["your command rules can stop a command here but not pause to ask: a rule set to hold refuses instead"]
|
|
589
|
+
: []),
|
|
590
|
+
...(capabilities.rulebook === "none" ? ["your command rules aren't applied"] : []),
|
|
591
|
+
...(capabilities.secrets === "none" ? ["stored secrets reach the model unmasked, and `{{secret:name}}` isn't substituted"] : []),
|
|
504
592
|
];
|
|
505
593
|
|
|
506
594
|
// Claude's compile-time model floor, shared by the daemon's catalog (claude-models.ts, its last rung, reached
|
|
@@ -3,7 +3,7 @@ import { resolveAgentRunModels } from "./agent-run-model.js";
|
|
|
3
3
|
import type { QuickModelSource } from "./quick-model.js";
|
|
4
4
|
|
|
5
5
|
/* Which model a run somebody's BUTTON started opens on. The rule answers the same two surfaces its quick-model
|
|
6
|
-
* sibling does
|
|
6
|
+
* sibling does: the daemon walks it, the settings row names it, so what these pin is the pair of properties
|
|
7
7
|
* that separate the two: an account this sandbox cannot reach never sits at the head of the chain, and an empty
|
|
8
8
|
* answer stays empty rather than being filled in with a tier nobody chose. */
|
|
9
9
|
|
|
@@ -13,7 +13,7 @@ const GOOGLE: QuickModelSource = { provider: `gemini`, ready: true, models: [`ge
|
|
|
13
13
|
|
|
14
14
|
const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
|
|
15
15
|
|
|
16
|
-
test("keeps the user's own order
|
|
16
|
+
test("keeps the user's own order, this list is read, never ranked", () => {
|
|
17
17
|
// The opposite of the quick chain, which sorts by tier and cost. Here the order IS the setting: someone who
|
|
18
18
|
// put Opus above GPT wants Opus first, and a resolver that knew better would spend the wrong account.
|
|
19
19
|
expect(resolveAgentRunModels([CLAUDE, CODEX], [`codex:gpt-5.6`, `claude:claude-opus-5`])).toEqual([
|
|
@@ -30,7 +30,7 @@ test("steps over a provider this sandbox has no credential for", () => {
|
|
|
30
30
|
]);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
test("resolves to nothing when no pin is reachable
|
|
33
|
+
test("resolves to nothing when no pin is reachable: it does NOT fall back to whatever is connected", () => {
|
|
34
34
|
// The deliberate difference from resolveQuickModels, which lands on its Auto ladder here. An agent run is
|
|
35
35
|
// billed in whole sessions, so an unreachable list hands the choice back to the caller's floor (the user's
|
|
36
36
|
// own composer pick) rather than spending an account they never pointed at.
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { type AgentCapabilities, capabilitiesOf, limitationsOf } from "./agent-catalog.js";
|
|
3
3
|
|
|
4
|
-
/* THE CAPABILITY LEDGER
|
|
4
|
+
/* THE CAPABILITY LEDGER, which claims in the record are backed by machinery, and which by prose.
|
|
5
5
|
*
|
|
6
6
|
* agent-catalog.test.ts asks two other questions: does every provider/harness pair HAVE a row, and does every
|
|
7
7
|
* axis a record can lack have words for it. A row can pass both and still be a lie. The ability is declared,
|
|
8
|
-
* the sentence renders in the picker's footer, and nothing anywhere behaves differently
|
|
8
|
+
* the sentence renders in the picker's footer, and nothing anywhere behaves differently, so the day the
|
|
9
9
|
* runtime gains the ability (or loses it), the declaration keeps its old value and the footer keeps telling the
|
|
10
10
|
* user something that stopped being true. Over-promising is the direction that hurts: told a runtime can be
|
|
11
11
|
* steered, someone types a mid-turn correction into a queue that does not exist.
|
|
12
12
|
*
|
|
13
|
-
* The record's header already states the rule
|
|
13
|
+
* The record's header already states the rule: "a capability is listed here only if something READS it". This
|
|
14
14
|
* is the rule with teeth. Every field is ENFORCED (a seam opens or closes on it, and the entry cites where) or
|
|
15
15
|
* DESCRIPTIVE (it gates nothing; its whole job is that one sentence). `Record<keyof AgentCapabilities, …>`
|
|
16
16
|
* makes the split exhaustive at COMPILE time: a field added to the interface without an entry here does not
|
|
@@ -19,7 +19,7 @@ import { type AgentCapabilities, capabilitiesOf, limitationsOf } from "./agent-c
|
|
|
19
19
|
*
|
|
20
20
|
* WHAT THIS DOES NOT DO, deliberately: prove an ENFORCED gate exists. Those gates live in the daemon and the
|
|
21
21
|
* web, which sit ABOVE this package and must not be imported back into it. So an enforced entry's citation is a
|
|
22
|
-
* comment a reader checks, and the honest reading is "someone verified this once"
|
|
22
|
+
* comment a reader checks, and the honest reading is "someone verified this once", not "CI verifies it". The
|
|
23
23
|
* descriptive half IS machine-checked below, because it can be: those claims reach the user through
|
|
24
24
|
* `limitationsOf`, which is right here.
|
|
25
25
|
*
|
|
@@ -31,7 +31,7 @@ const LEDGER: Record<keyof AgentCapabilities, Backing> = {
|
|
|
31
31
|
// adapter-registry.ts maps runtime → the adapter that serves the turn; getting it wrong runs the turn on
|
|
32
32
|
// the wrong loop entirely. Also keys the daemon's per-runtime health probes (useSandboxVersion.ts).
|
|
33
33
|
runtime: "enforced",
|
|
34
|
-
// conversation.ts's `steerable
|
|
34
|
+
// conversation.ts's `steerable`: the composer offers mid-turn injection only where there is a queue.
|
|
35
35
|
steering: "enforced",
|
|
36
36
|
// turn-plan.ts forwards `permissionMode` only under "modes" (bar plan); modesFor/clampMode build the
|
|
37
37
|
// composer's mode list from it, so a plan-only runtime is offered two postures rather than four names.
|
|
@@ -47,27 +47,40 @@ const LEDGER: Record<keyof AgentCapabilities, Backing> = {
|
|
|
47
47
|
// turn-plan.ts tells a "cwd" turn where its worktree is, because an absolute /work path still reaches the
|
|
48
48
|
// shared checkout there. Under "namespace" the mount does it and the note would be noise.
|
|
49
49
|
isolation: "enforced",
|
|
50
|
-
/* system-prompt.ts composes a turn's standing instructions AGAINST this value
|
|
50
|
+
/* system-prompt.ts composes a turn's standing instructions AGAINST this value: a replacement where one may
|
|
51
51
|
* be sent, an addition where only that is possible, and the user-message door for the persona note where
|
|
52
|
-
* there is no system seam at all
|
|
52
|
+
* there is no system seam at all, and each adapter reads the field the composition set (codex-agent.ts's
|
|
53
53
|
* two config keys, grok-agent.ts's per-message `system`). It is also the one axis whose absence was the bug
|
|
54
54
|
* that produced it: before the field existed, every runtime was composed for as though it were the Claude
|
|
55
55
|
* Code loop, and five of the six silently dropped the owner's prompt. */
|
|
56
56
|
instructions: "enforced",
|
|
57
57
|
// turn-plan.ts (honoured) plans the JS backend only where "js" is declared, so a runtime without it is
|
|
58
|
-
// handed no `jsExecution` and mounts no Code tool
|
|
58
|
+
// handed no `jsExecution` and mounts no Code tool: the same drop-what-you-can't-honour rule as `effort`.
|
|
59
59
|
execution: "enforced",
|
|
60
|
+
/* turn-plan.ts (honoured) carries this onto every request and guard/turn-gate.ts DERIVES the gate's shape
|
|
61
|
+
* from it: "none" gets no consult and a permanently-set taint bit, "refuse-only" cannot park so a hold
|
|
62
|
+
* refuses, the other two park on a card. So a row that lies about itself changes how its turns behave.
|
|
63
|
+
* It also decides whether Codex is asked to raise approvals at all (codex-agent.ts threadOptions). */
|
|
64
|
+
rulebook: "enforced",
|
|
60
65
|
|
|
61
|
-
/* DESCRIPTIVE
|
|
66
|
+
/* DESCRIPTIVE: true of the runtime, and nothing consults them. Each describes behaviour that is emergent
|
|
62
67
|
* rather than gated: an agent that never emits `question` frames simply never asks, one that publishes no
|
|
63
68
|
* commands leaves the `/` popover empty. Nothing has to switch off, which is why nothing does. That also
|
|
64
69
|
* means the declaration and the behaviour are two independent facts, and only the reader below keeps them
|
|
65
70
|
* from disagreeing in the one place a user can see. Wiring one up (hiding the terminal tab, skipping
|
|
66
|
-
* auto-resume) moves it to ENFORCED
|
|
71
|
+
* auto-resume) moves it to ENFORCED: in the change that wires it. */
|
|
67
72
|
questions: "descriptive",
|
|
68
73
|
commands: "descriptive",
|
|
69
74
|
terminals: "descriptive",
|
|
70
75
|
recovery: "descriptive",
|
|
76
|
+
/* DESCRIPTIVE, and the one entry here whose gap cannot be closed by wiring. Masking a stored credential in
|
|
77
|
+
* what the model READS needs a seam that edits a tool result before the model sees it, and only the Claude
|
|
78
|
+
* Code loop has one (a PostToolUse hook). Every other runtime runs its tools inside the VENDOR'S loop: the
|
|
79
|
+
* model has read the result before the daemon sees any frame about it. So nothing consults this field, and
|
|
80
|
+
* nothing can: it exists so limitationsOf can say out loud what is true, instead of letting an owner who
|
|
81
|
+
* stored a credential assume it is hidden everywhere. Moving it to ENFORCED would take a runtime that
|
|
82
|
+
* publishes a result-rewriting seam, not a change here. */
|
|
83
|
+
secrets: "descriptive",
|
|
71
84
|
};
|
|
72
85
|
|
|
73
86
|
// The full ceiling: every ability real, so `limitationsOf` has nothing to say about it. Read from the catalog
|
|
@@ -86,25 +99,31 @@ it("classifies exactly the fields a live record carries", () => {
|
|
|
86
99
|
/* A DESCRIPTIVE field earns its place by reaching the user, since by definition it does nothing else. Checked
|
|
87
100
|
* per field rather than by counting sentences: an aggregate is satisfied by one axis contributing two lines
|
|
88
101
|
* while another contributes none, which is exactly the case worth catching. Anything that survives here without
|
|
89
|
-
* a sentence is decoration, and the fix is to delete the field or wire it up
|
|
102
|
+
* a sentence is decoration, and the fix is to delete the field or wire it up, not to add an exemption. */
|
|
90
103
|
describe("a descriptive claim reaches the user", () => {
|
|
91
104
|
it("has something to disclose against: the ceiling discloses nothing", () => {
|
|
92
105
|
expect(limitationsOf(CEILING)).toEqual([]);
|
|
93
106
|
});
|
|
94
107
|
|
|
108
|
+
/* What "lacking this axis" MEANS for a field that is not a boolean. A boolean diminishes to false; anything
|
|
109
|
+
* else has to name its own floor here, because only the axis knows which of its values is the weak one.
|
|
110
|
+
* Adding a non-boolean descriptive field without an entry fails below rather than silently testing nothing. */
|
|
111
|
+
const DIMINISHED: Partial<Record<keyof AgentCapabilities, unknown>> = { secrets: "none" };
|
|
112
|
+
|
|
95
113
|
it.each(fieldsWhere("descriptive"))("%s puts its own sentence in the picker when the runtime lacks it", (field) => {
|
|
96
|
-
|
|
114
|
+
const floor = typeof CEILING[field] === "boolean" ? false : DIMINISHED[field];
|
|
115
|
+
expect(floor, `${field} is descriptive and not a boolean: add its weakest value to DIMINISHED above`).toBeDefined();
|
|
97
116
|
|
|
98
|
-
const lacking: AgentCapabilities = { ...CEILING, [field]:
|
|
117
|
+
const lacking: AgentCapabilities = { ...CEILING, [field]: floor };
|
|
99
118
|
|
|
100
119
|
expect(limitationsOf(lacking)).toHaveLength(1);
|
|
101
120
|
});
|
|
102
121
|
});
|
|
103
122
|
|
|
104
123
|
/* The ledger is a split, not a label: a set that swallowed everything would typecheck and prove nothing. Both
|
|
105
|
-
* sides being occupied is what makes reading an entry informative
|
|
124
|
+
* sides being occupied is what makes reading an entry informative, and if the descriptive side ever empties
|
|
106
125
|
* because every axis got wired up, that is a real event, and deleting this file is the right response to it. */
|
|
107
|
-
it("is a real split
|
|
126
|
+
it("is a real split: neither side is empty", () => {
|
|
108
127
|
expect(fieldsWhere("enforced").length).toBeGreaterThan(0);
|
|
109
128
|
expect(fieldsWhere("descriptive").length).toBeGreaterThan(0);
|
|
110
129
|
});
|
|
@@ -16,7 +16,7 @@ describe(`the chore book's order`, () => {
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
it(`keeps every chore in the book`, () => {
|
|
19
|
-
// The sort cannot drop an entry
|
|
19
|
+
// The sort cannot drop an entry, but a future refactor to a filter-into-groups could, so the count is
|
|
20
20
|
// pinned to the ids rather than to a number, which says which one went missing.
|
|
21
21
|
expect(new Set(CHORES.map((chore) => chore.id)).size).toBe(CHORES.length);
|
|
22
22
|
expect(CHORES.length).toBe(17);
|