@reticlehq/server 2.7.0 → 2.8.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 -5
- package/SKILL.md +64 -29
- package/dist/capsule/causal-summary.d.ts +20 -0
- package/dist/capsule/causal-summary.js +31 -6
- package/dist/capsule/divergence.js +7 -1
- package/dist/cli/chromium-hint.d.ts +33 -5
- package/dist/cli/chromium-hint.js +82 -12
- package/dist/cli/cli-doctor.js +28 -25
- package/dist/cli/cli-kill.d.ts +58 -0
- package/dist/cli/cli-kill.js +162 -0
- package/dist/cli/cli-launch.d.ts +17 -0
- package/dist/cli/cli-launch.js +28 -0
- package/dist/cli/cli-parse.d.ts +16 -1
- package/dist/cli/cli-parse.js +32 -9
- package/dist/cli/cli-port.d.ts +8 -0
- package/dist/cli/cli-port.js +16 -0
- package/dist/cli/cli-verify.js +2 -2
- package/dist/cli/doctor-sessions-line.d.ts +12 -0
- package/dist/cli/doctor-sessions-line.js +66 -0
- package/dist/cli/port-holder.d.ts +10 -0
- package/dist/cli/port-holder.js +26 -2
- package/dist/cli.js +99 -14
- package/dist/crawl/crawl-tools.js +12 -2
- package/dist/crawl/crawl.d.ts +13 -0
- package/dist/crawl/crawl.js +50 -2
- package/dist/events/contradictions.d.ts +14 -0
- package/dist/events/contradictions.js +26 -17
- package/dist/events/predicate-eval.d.ts +20 -1
- package/dist/events/predicate-eval.js +125 -3
- package/dist/events/predicate.js +170 -18
- package/dist/flows/flow-classify.js +1 -1
- package/dist/flows/flow-tools.js +12 -4
- package/dist/flows/heal-run.js +1 -1
- package/dist/honesty/blind-spots.js +6 -2
- package/dist/honesty/unsettled.d.ts +40 -0
- package/dist/honesty/unsettled.js +78 -0
- package/dist/honesty/verified.d.ts +7 -0
- package/dist/honesty/verified.js +3 -3
- package/dist/index.js +3 -0
- package/dist/init/agent-rules.d.ts +16 -0
- package/dist/init/agent-rules.js +96 -22
- package/dist/init/astro-patch.d.ts +2 -1
- package/dist/init/astro-patch.js +11 -5
- package/dist/init/capabilities.d.ts +22 -0
- package/dist/init/capabilities.js +76 -0
- package/dist/init/confirm.d.ts +96 -0
- package/dist/init/confirm.js +138 -0
- package/dist/init/cra.js +3 -2
- package/dist/init/non-js-project.js +29 -5
- package/dist/init/plan-framework.js +53 -15
- package/dist/init/plan.d.ts +40 -4
- package/dist/init/plan.js +163 -40
- package/dist/init/run.d.ts +24 -2
- package/dist/init/run.js +148 -42
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +46 -6
- package/dist/init/snippets.d.ts +44 -4
- package/dist/init/snippets.js +161 -37
- package/dist/init/vite-config.js +29 -3
- package/dist/input/network-mock-tools.d.ts +19 -0
- package/dist/input/network-mock-tools.js +29 -4
- package/dist/license/license.js +1 -1
- package/dist/mcp/mcp-proxy.d.ts +69 -3
- package/dist/mcp/mcp-proxy.js +205 -20
- package/dist/mcp/mcp.js +23 -7
- package/dist/mcp/proxy-lifecycle.d.ts +1 -1
- package/dist/mcp/proxy-lifecycle.js +1 -1
- package/dist/pool/browser-pool.d.ts +22 -2
- package/dist/pool/browser-pool.js +44 -4
- package/dist/project/learned-routes.d.ts +24 -0
- package/dist/project/learned-routes.js +63 -0
- package/dist/project/project-store.d.ts +6 -0
- package/dist/project/project-store.js +25 -1
- package/dist/session/no-session-diagnosis.d.ts +19 -0
- package/dist/session/no-session-diagnosis.js +71 -18
- package/dist/session/no-session-watch.js +12 -0
- package/dist/session/ref-provenance.d.ts +54 -0
- package/dist/session/ref-provenance.js +61 -0
- package/dist/session/session-lease.js +1 -1
- package/dist/telemetry/automation-hint.d.ts +17 -0
- package/dist/telemetry/automation-hint.js +71 -0
- package/dist/telemetry/bug-found.d.ts +0 -20
- package/dist/telemetry/bug-found.js +38 -21
- package/dist/telemetry/cli-telemetry.js +7 -1
- package/dist/telemetry/feedback.js +1 -1
- package/dist/telemetry/init-telemetry.js +9 -1
- package/dist/telemetry/install-source.d.ts +36 -0
- package/dist/telemetry/install-source.js +40 -0
- package/dist/telemetry/run-telemetry.js +4 -1
- package/dist/telemetry/session-metrics.js +5 -0
- package/dist/telemetry/telemetry.d.ts +5 -1
- package/dist/telemetry/telemetry.js +15 -2
- package/dist/telemetry/tool-refused.d.ts +19 -0
- package/dist/telemetry/tool-refused.js +55 -0
- package/dist/telemetry/verification-of.js +46 -7
- package/dist/temp-dir.d.ts +27 -0
- package/dist/temp-dir.js +46 -0
- package/dist/tools/act-tools.js +45 -1
- package/dist/tools/assert-grade.d.ts +1 -1
- package/dist/tools/assert-grade.js +27 -5
- package/dist/tools/assert-verdict.js +18 -1
- package/dist/tools/contract-tools.js +4 -0
- package/dist/tools/dynamic-tools.js +1 -1
- package/dist/tools/error-recovery.d.ts +10 -0
- package/dist/tools/error-recovery.js +64 -4
- package/dist/tools/feedback-invite.js +1 -1
- package/dist/tools/invoke-tool.js +139 -23
- package/dist/tools/lease-tools.d.ts +29 -1
- package/dist/tools/lease-tools.js +58 -5
- package/dist/tools/navigate-result.js +1 -1
- package/dist/tools/observe-tools.js +1 -1
- package/dist/tools/observed-nothing.js +1 -1
- package/dist/tools/react-grace.d.ts +48 -0
- package/dist/tools/react-grace.js +87 -0
- package/dist/tools/read-tools.js +20 -4
- package/dist/tools/settle-in-flight.d.ts +79 -0
- package/dist/tools/settle-in-flight.js +106 -0
- package/dist/tools/snapshot-delta.d.ts +7 -0
- package/dist/tools/snapshot-delta.js +130 -6
- package/dist/tools/tool-surface.js +19 -0
- package/dist/tools/tools.js +16 -4
- package/docs/README.md +56 -11
- package/docs/actions.mdx +87 -0
- package/docs/agent-cheatsheet.md +93 -29
- package/docs/architecture.md +46 -35
- package/docs/benchmarks.md +76 -72
- package/docs/best-practices.mdx +211 -0
- package/docs/capabilities.mdx +387 -0
- package/docs/cli/affected.mdx +51 -0
- package/docs/cli/capsules.mdx +69 -0
- package/docs/cli/cloud.mdx +127 -0
- package/docs/cli/doctor.mdx +76 -0
- package/docs/cli/drive.mdx +55 -0
- package/docs/cli/feedback.mdx +67 -0
- package/docs/cli/gate.mdx +64 -0
- package/docs/cli/help.mdx +92 -0
- package/docs/cli/hunt.mdx +57 -0
- package/docs/cli/identify.mdx +62 -0
- package/docs/cli/init.mdx +94 -0
- package/docs/cli/kill.mdx +91 -0
- package/docs/cli/license.mdx +48 -0
- package/docs/cli/mcp.mdx +61 -0
- package/docs/cli/open.mdx +68 -0
- package/docs/cli/restart.mdx +60 -0
- package/docs/cli/rollback.mdx +35 -0
- package/docs/cli/serve.mdx +62 -0
- package/docs/cli/status.mdx +64 -0
- package/docs/cli/stop.mdx +56 -0
- package/docs/cli/telemetry.mdx +57 -0
- package/docs/cli/update.mdx +52 -0
- package/docs/cli/verify.mdx +57 -0
- package/docs/cli/version.mdx +39 -0
- package/docs/cli/watch.mdx +55 -0
- package/docs/cli.mdx +219 -0
- package/docs/debugging.md +13 -9
- package/docs/deploy-checks.md +15 -11
- package/docs/desktop-apps.md +49 -40
- package/docs/desktop.mdx +188 -0
- package/docs/docs.json +363 -0
- package/docs/enterprise.md +20 -16
- package/docs/faq.mdx +88 -0
- package/docs/fixtures.md +11 -7
- package/docs/flows.md +26 -22
- package/docs/for-agents.md +71 -0
- package/docs/frameworks.mdx +199 -0
- package/docs/gate-plan.md +45 -41
- package/docs/gates.md +22 -18
- package/docs/getting-started.md +76 -65
- package/docs/human-control.md +22 -18
- package/docs/index.mdx +101 -0
- package/docs/install-agentic.mdx +215 -0
- package/docs/install-manual.mdx +194 -0
- package/docs/instrumentation.mdx +232 -0
- package/docs/integration-patterns.md +38 -34
- package/docs/local-registry.md +16 -12
- package/docs/multi-agent-testing.md +16 -12
- package/docs/packages/babel-plugin.mdx +70 -0
- package/docs/packages/browser.mdx +111 -0
- package/docs/packages/core.mdx +106 -0
- package/docs/packages/electron.mdx +87 -0
- package/docs/packages/eslint-plugin.mdx +84 -0
- package/docs/packages/next.mdx +91 -0
- package/docs/packages/react.mdx +91 -0
- package/docs/packages/server.mdx +98 -0
- package/docs/packages/tauri.mdx +63 -0
- package/docs/packages/test.mdx +108 -0
- package/docs/packages/vite-plugin.mdx +96 -0
- package/docs/packages.mdx +217 -0
- package/docs/platform-integration.md +33 -26
- package/docs/predicates.mdx +179 -0
- package/docs/quickstart.mdx +211 -0
- package/docs/recipes.mdx +242 -0
- package/docs/skill-file.mdx +105 -0
- package/docs/state-management.mdx +197 -0
- package/docs/style.css +14 -0
- package/docs/system-map.md +47 -32
- package/docs/telemetry-contract.md +93 -50
- package/docs/telemetry-events.mdx +430 -0
- package/docs/telemetry.md +57 -52
- package/docs/testing.md +10 -6
- package/docs/token-efficiency.md +30 -26
- package/docs/tools-act-and-wait.mdx +123 -0
- package/docs/tools-act-sequence.mdx +120 -0
- package/docs/tools-act.mdx +82 -0
- package/docs/tools-assert.mdx +70 -0
- package/docs/tools-console.mdx +59 -0
- package/docs/tools-inspect.mdx +85 -0
- package/docs/tools-navigate.mdx +63 -0
- package/docs/tools-network.mdx +86 -0
- package/docs/tools-observe.mdx +89 -0
- package/docs/tools-overview.mdx +157 -0
- package/docs/tools-query.mdx +82 -0
- package/docs/tools-session-and-feedback.mdx +91 -0
- package/docs/tools-sessions.mdx +65 -0
- package/docs/tools-snapshot.mdx +75 -0
- package/docs/tools-state.mdx +81 -0
- package/docs/tools-tools-and-run.mdx +125 -0
- package/docs/tools-wait-for.mdx +66 -0
- package/docs/troubleshooting.mdx +193 -0
- package/docs/usage.md +175 -153
- package/docs/vs-chrome-devtools-mcp.mdx +82 -0
- package/docs/vs-playwright-mcp.mdx +85 -0
- package/docs/vs-screenshots.mdx +96 -0
- package/docs/what-is-reticle.mdx +63 -0
- package/docs/why-reticle.mdx +107 -0
- package/package.json +2 -2
- package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
- package/docs/matrix/2.5.0/codex-machine.json +0 -20
- package/docs/matrix/2.5.0/cursor-machine.json +0 -22
- package/docs/matrix/2.5.0/gemini-machine.json +0 -22
- package/docs/matrix/2.5.0/opencode-machine.json +0 -22
- package/docs/matrix/2.5.0/vscode-machine.json +0 -22
- package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
- package/docs/matrix/MATRIX.md +0 -26
- package/docs/matrix/README.md +0 -59
- package/docs/mint.json +0 -61
|
@@ -69,7 +69,7 @@ export function buildDynamicTools(allTools, profile) {
|
|
|
69
69
|
// `full` advertises all 46 tools DIRECTLY and carries no meta-tools at all, so
|
|
70
70
|
// reticle_run does not exist there. Saying otherwise sends the agent to a tool the
|
|
71
71
|
// profile does not have.
|
|
72
|
-
note: `The profile is read once at daemon startup
|
|
72
|
+
note: `The profile is read once at daemon startup: change ${TOOL_PROFILE_ENV} and restart the daemon, or it has no effect. Under this profile every unadvertised tool is reachable through reticle_run; under \`full\` there are no meta-tools because every tool is advertised directly.`,
|
|
73
73
|
},
|
|
74
74
|
};
|
|
75
75
|
const reticleTools = {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* on stable, human-authored substrings of the thrown messages (see session-manager.ts, the flow/
|
|
9
9
|
* baseline stores). No clock, no IO — unit-testable in isolation.
|
|
10
10
|
*/
|
|
11
|
+
import { RefusalReason } from '@reticlehq/core';
|
|
11
12
|
/** The recovery hints, named so they are not free strings and can be asserted in tests. */
|
|
12
13
|
export declare const RECOVERY: {
|
|
13
14
|
readonly NO_SESSION: string;
|
|
@@ -40,6 +41,15 @@ export declare const RECOVERY: {
|
|
|
40
41
|
* saying a server is running and the recovery saying to go start one.
|
|
41
42
|
*/
|
|
42
43
|
export declare function recoveryFor(message: string): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* WHY a tool refused, as one of the six buckets in core's `RefusalReason`.
|
|
46
|
+
*
|
|
47
|
+
* Reads the SAME rules `buildErrorPayload` reads, in the same order, so the reason we report and the
|
|
48
|
+
* advice the agent got can never describe different things. Anything unrecognised is `other`, which
|
|
49
|
+
* is the bucket that says the classifier has a blind spot — and a growing `other` is a finding about
|
|
50
|
+
* this table rather than about anybody's app.
|
|
51
|
+
*/
|
|
52
|
+
export declare function refusalReasonFor(rawMessage: string): RefusalReason;
|
|
43
53
|
/**
|
|
44
54
|
* A serialized zod issue array, rendered as a sentence. Anything else is returned untouched.
|
|
45
55
|
*
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* on stable, human-authored substrings of the thrown messages (see session-manager.ts, the flow/
|
|
9
9
|
* baseline stores). No clock, no IO — unit-testable in isolation.
|
|
10
10
|
*/
|
|
11
|
-
import { TRANSPORT_LIMITS } from '@reticlehq/core';
|
|
11
|
+
import { RefusalReason, TRANSPORT_LIMITS } from '@reticlehq/core';
|
|
12
12
|
import { SELF_RECOVERING_MARKER } from '../session/no-session-diagnosis.js';
|
|
13
13
|
import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
|
|
14
14
|
/** Marks where an over-long message had its middle removed. Named so tests assert it by value. */
|
|
@@ -42,17 +42,17 @@ function capMessage(message) {
|
|
|
42
42
|
* the agent precisely what to do.
|
|
43
43
|
*/
|
|
44
44
|
const SELF_RECOVERING = [
|
|
45
|
-
/cannot drive native input/i,
|
|
45
|
+
{ match: /cannot drive native input/i, reason: RefusalReason.UNSUPPORTED },
|
|
46
46
|
// The unknown-session refusal now lists the live ids inline. Appending "call reticle_sessions for
|
|
47
47
|
// the current ids" to a message that just named them contradicts the shorter path it offers, and
|
|
48
48
|
// a contradicted instruction is how one agent came to retry a dead id twelve times.
|
|
49
|
-
/Connected right now:/i,
|
|
49
|
+
{ match: /Connected right now:/i, reason: RefusalReason.NO_SESSION },
|
|
50
50
|
];
|
|
51
51
|
/** A message that already carries its own concrete next action, so nothing should be appended. */
|
|
52
52
|
function isSelfRecovering(message) {
|
|
53
53
|
if (message.includes(SELF_RECOVERING_MARKER))
|
|
54
54
|
return true;
|
|
55
|
-
return SELF_RECOVERING.some((
|
|
55
|
+
return SELF_RECOVERING.some((rule) => rule.match.test(message));
|
|
56
56
|
}
|
|
57
57
|
/** The recovery hints, named so they are not free strings and can be asserted in tests. */
|
|
58
58
|
export const RECOVERY = {
|
|
@@ -137,6 +137,39 @@ export const RECOVERY = {
|
|
|
137
137
|
'` if that is what is missing. Meanwhile drive ' +
|
|
138
138
|
'a tab the human already has open — reticle_sessions lists them.',
|
|
139
139
|
};
|
|
140
|
+
/**
|
|
141
|
+
* Which OWNER each recovery belongs to, for `tool_refused`.
|
|
142
|
+
*
|
|
143
|
+
* A `Record` over the recovery keys rather than a second table of patterns, and that is the point:
|
|
144
|
+
* adding a recovery without classifying it does not compile. A parallel regex list would have been
|
|
145
|
+
* correct on the day it was written and silently wrong at the next addition — the drift that has
|
|
146
|
+
* already cost this repo a published number twice, and telemetry drift is not recoverable after the
|
|
147
|
+
* fact.
|
|
148
|
+
*/
|
|
149
|
+
const REASON_OF = {
|
|
150
|
+
NO_SESSION: RefusalReason.NO_SESSION,
|
|
151
|
+
MULTIPLE_SESSIONS: RefusalReason.NO_SESSION,
|
|
152
|
+
UNKNOWN_SESSION: RefusalReason.NO_SESSION,
|
|
153
|
+
SCOPE_MISMATCH: RefusalReason.NO_SESSION,
|
|
154
|
+
SESSION_GONE: RefusalReason.NO_SESSION,
|
|
155
|
+
THROTTLED: RefusalReason.NOT_READY,
|
|
156
|
+
COMMAND_TIMEOUT: RefusalReason.NOT_READY,
|
|
157
|
+
NO_POOL: RefusalReason.NOT_READY,
|
|
158
|
+
TOKEN_REQUIRED: RefusalReason.NOT_READY,
|
|
159
|
+
MISSING_BASELINE: RefusalReason.NO_MATCH,
|
|
160
|
+
MISSING_RECORDING: RefusalReason.NO_MATCH,
|
|
161
|
+
STALE_REF: RefusalReason.NO_MATCH,
|
|
162
|
+
NO_SUCH_OPTION: RefusalReason.NO_MATCH,
|
|
163
|
+
FLOW_STEP_MISSING: RefusalReason.NO_MATCH,
|
|
164
|
+
UNSUPPORTED_SURFACE: RefusalReason.UNSUPPORTED,
|
|
165
|
+
NOT_EDITABLE: RefusalReason.UNSUPPORTED,
|
|
166
|
+
CONFIRM_DANGEROUS: RefusalReason.UNSUPPORTED,
|
|
167
|
+
WRONG_TARGET: RefusalReason.UNSUPPORTED,
|
|
168
|
+
BAD_ARGUMENTS: RefusalReason.BAD_ARGS,
|
|
169
|
+
INVALID_NAME: RefusalReason.BAD_ARGS,
|
|
170
|
+
};
|
|
171
|
+
/** Hint text back to its reason. The hints are distinct strings, so this inverts cleanly. */
|
|
172
|
+
const REASON_BY_HINT = new Map(Object.entries(RECOVERY).map(([key, hint]) => [hint, REASON_OF[key]]));
|
|
140
173
|
/** Ordered match rules; the first hit wins. Substrings track the thrown messages they recover. */
|
|
141
174
|
const RULES = [
|
|
142
175
|
{ match: /no browser session connected/i, hint: RECOVERY.NO_SESSION },
|
|
@@ -216,6 +249,33 @@ export function recoveryFor(message) {
|
|
|
216
249
|
}
|
|
217
250
|
return undefined;
|
|
218
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* WHY a tool refused, as one of the six buckets in core's `RefusalReason`.
|
|
254
|
+
*
|
|
255
|
+
* Reads the SAME rules `buildErrorPayload` reads, in the same order, so the reason we report and the
|
|
256
|
+
* advice the agent got can never describe different things. Anything unrecognised is `other`, which
|
|
257
|
+
* is the bucket that says the classifier has a blind spot — and a growing `other` is a finding about
|
|
258
|
+
* this table rather than about anybody's app.
|
|
259
|
+
*/
|
|
260
|
+
export function refusalReasonFor(rawMessage) {
|
|
261
|
+
const message = zodArrayAsSentence(rawMessage);
|
|
262
|
+
// The no-session diagnosis inspected the machine and named the cause itself, so it never reaches
|
|
263
|
+
// the recovery table. It is the single largest refusal there is; missing it would gut the metric.
|
|
264
|
+
if (message.includes(SELF_RECOVERING_MARKER))
|
|
265
|
+
return RefusalReason.NO_SESSION;
|
|
266
|
+
for (const rule of SELF_RECOVERING) {
|
|
267
|
+
if (rule.match.test(message))
|
|
268
|
+
return rule.reason;
|
|
269
|
+
}
|
|
270
|
+
// Same order as buildErrorPayload: a schema rejection is the agent's own call, whatever else in
|
|
271
|
+
// the message might also match.
|
|
272
|
+
if (ARGUMENT_REJECTION.test(message))
|
|
273
|
+
return RefusalReason.BAD_ARGS;
|
|
274
|
+
if (INVALID_NAME_REJECTION.test(message))
|
|
275
|
+
return RefusalReason.BAD_ARGS;
|
|
276
|
+
const hint = recoveryFor(message);
|
|
277
|
+
return (hint === undefined ? undefined : REASON_BY_HINT.get(hint)) ?? RefusalReason.OTHER;
|
|
278
|
+
}
|
|
219
279
|
/**
|
|
220
280
|
* A schema rejection: the call named a parameter that does not exist, or gave one the wrong type.
|
|
221
281
|
*
|
|
@@ -38,7 +38,7 @@ export const FrictionKind = {
|
|
|
38
38
|
const REPEAT_INVITE_AT = 3;
|
|
39
39
|
const LINES = {
|
|
40
40
|
[FrictionKind.UNKNOWN_TOOL]: 'no such tool — reticle_feedback tells us what you expected it to do, which is how it gets built',
|
|
41
|
-
[FrictionKind.UNKNOWN_VERDICT]: 'Reticle could not tell what happened here. If you expected otherwise, reticle_feedback
|
|
41
|
+
[FrictionKind.UNKNOWN_VERDICT]: 'Reticle could not tell what happened here. If you expected otherwise, reticle_feedback. An ' +
|
|
42
42
|
'unknown verdict is our defect, not yours',
|
|
43
43
|
[FrictionKind.REPEATING]: 'stuck on the same call? reticle_feedback — say what you were trying to do and it gets fixed',
|
|
44
44
|
[FrictionKind.REFUSED]: 'if that diagnosis was wrong or unhelpful, reticle_feedback — error messages are a product ' +
|
|
@@ -6,12 +6,16 @@ import { takeUpdateNudge } from '../update/update-nudge.js';
|
|
|
6
6
|
import { takeVersionSkew } from '../version/version-nudge.js';
|
|
7
7
|
import { noteToolCall } from '../daemon/daemon-usefulness.js';
|
|
8
8
|
import { bugsInResult } from '../telemetry/bug-found.js';
|
|
9
|
+
import { noteToolServed, reportToolRefused } from '../telemetry/tool-refused.js';
|
|
10
|
+
import { buildErrorPayload, refusalReasonFor } from './error-recovery.js';
|
|
11
|
+
import { resultIsError } from '../mcp/mcp-is-error.js';
|
|
9
12
|
import { verificationOf } from '../telemetry/verification-of.js';
|
|
10
13
|
import { asString } from './tools-helpers.js';
|
|
11
14
|
import { EnvelopeKey } from './tool-kit.js';
|
|
12
15
|
import { ReticleTool } from './tool-names.js';
|
|
13
16
|
import { takeFeedbackPrompt } from './feedback-tools.js';
|
|
14
17
|
import { takeFeedbackUndelivered } from '../telemetry/feedback-delivery.js';
|
|
18
|
+
import { noteRefsMinted, wrongTabRefusal } from '../session/ref-provenance.js';
|
|
15
19
|
import { span } from '../trace.js';
|
|
16
20
|
import { frictionOf, inviteFor } from './feedback-invite.js';
|
|
17
21
|
/**
|
|
@@ -30,6 +34,20 @@ const ACTION_TOOLS = new Set([
|
|
|
30
34
|
ReticleTool.ACT_SEQUENCE,
|
|
31
35
|
ReticleTool.ACT_AND_WAIT,
|
|
32
36
|
]);
|
|
37
|
+
/**
|
|
38
|
+
* Tools whose result hands the agent refs. The tab they were minted in is remembered, so the call
|
|
39
|
+
* that spends one cannot be routed to a different tab without saying so — see ref-provenance.ts.
|
|
40
|
+
*
|
|
41
|
+
* Over-inclusion is the safe direction here and under-inclusion is not dangerous either: both ends
|
|
42
|
+
* produce a REFUSAL naming the two tabs, never a silent drive against the wrong one.
|
|
43
|
+
*/
|
|
44
|
+
const REF_MINTING_TOOLS = new Set([
|
|
45
|
+
ReticleTool.SNAPSHOT,
|
|
46
|
+
ReticleTool.QUERY,
|
|
47
|
+
ReticleTool.INSPECT,
|
|
48
|
+
ReticleTool.EXPLORE,
|
|
49
|
+
ReticleTool.CRAWL,
|
|
50
|
+
]);
|
|
33
51
|
export const SESSION_BOUND_TOOLS = new Set([
|
|
34
52
|
ReticleTool.SNAPSHOT,
|
|
35
53
|
ReticleTool.QUERY,
|
|
@@ -134,6 +152,54 @@ function reportBugsFound(toolName, result) {
|
|
|
134
152
|
});
|
|
135
153
|
}
|
|
136
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Report a refused call, at the one place both dispatch paths cross.
|
|
157
|
+
*
|
|
158
|
+
* A refusal arrives two ways and they are the same event: the handler THROWS (the common case — no
|
|
159
|
+
* session, a stale ref, a schema rejection), or it RETURNS `{ error }`, which is this codebase's
|
|
160
|
+
* refusal convention and just as much a refusal. Both are classified by the same table the agent's
|
|
161
|
+
* recovery advice comes from, so the reason we record and the advice it got cannot describe
|
|
162
|
+
* different things.
|
|
163
|
+
*
|
|
164
|
+
* `reticle_run` is skipped for the same reason `bugsInResult` skips it: it is a WRAPPER whose
|
|
165
|
+
* handler calls `runTool` on the real tool, which already reported that refusal under the real
|
|
166
|
+
* tool's name, and then hands the same failure back out to be reported a second time as
|
|
167
|
+
* `reticle_run`. A count that doubles because of HOW a tool was reached is not a measurement.
|
|
168
|
+
*/
|
|
169
|
+
function reportRefusal(toolName, message) {
|
|
170
|
+
if (toolName === ReticleTool.RUN)
|
|
171
|
+
return;
|
|
172
|
+
reportToolRefused(toolName, refusalReasonFor(message));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Which friction this result represents, counting the invitation if there is one.
|
|
176
|
+
*
|
|
177
|
+
* Feedback is the highest-yield channel this product has, and `feedbackPrompted` is the denominator
|
|
178
|
+
* that says whether inviting mid-task works or is decoration. It read as near-empty because the
|
|
179
|
+
* count sat behind the session-bound early return, so a session-EXEMPT tool never invited and a tool
|
|
180
|
+
* that THREW never got there — and a throw is the commonest refusal there is.
|
|
181
|
+
*
|
|
182
|
+
* `reticle_run` is skipped for the same reason it is skipped for refusals and defects: the wrapper's
|
|
183
|
+
* handler calls `runTool` on the real tool, which already invited under the real tool's name, and
|
|
184
|
+
* the failure is then handed back out to be counted a second time. A doubled denominator makes the
|
|
185
|
+
* feedback rate read half.
|
|
186
|
+
*/
|
|
187
|
+
function frictionInviteFor(toolName, raw) {
|
|
188
|
+
if (!isPlainObject(raw) || toolName === ReticleTool.RUN)
|
|
189
|
+
return undefined;
|
|
190
|
+
const friction = frictionOf({
|
|
191
|
+
unknownTool: false,
|
|
192
|
+
verifiedUnknown: 'unknown' === raw['verified'],
|
|
193
|
+
repeatRun: getSessionMetrics().currentRun,
|
|
194
|
+
errored: resultIsError(raw),
|
|
195
|
+
// A verdict is progress. Without this, three successful act_and_wait calls in a row — the exact
|
|
196
|
+
// loop this release exists to encourage — get told they are stuck.
|
|
197
|
+
producedVerdict: 'verified' in raw,
|
|
198
|
+
});
|
|
199
|
+
if (friction !== undefined)
|
|
200
|
+
getSessionMetrics().recordFeedbackPrompt();
|
|
201
|
+
return friction;
|
|
202
|
+
}
|
|
137
203
|
/**
|
|
138
204
|
* The single entry point both the MCP server and the programmatic invoker call instead of
|
|
139
205
|
* `tool.handler` directly. Runs the handler, then — for a live-session tool returning a plain
|
|
@@ -189,11 +255,13 @@ export async function runTool(tool, deps, args) {
|
|
|
189
255
|
// prevents: the agent learns its argument was too big, which is something it can fix.
|
|
190
256
|
const oversized = firstOversizedArg(args);
|
|
191
257
|
if (oversized !== undefined) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
258
|
+
const error = `argument '${oversized}' is larger than ${String(TRANSPORT_LIMITS.MAX_STRING_LENGTH)} ` +
|
|
259
|
+
'characters, which is the most Reticle moves in one call. Nothing ran. Send a smaller ' +
|
|
260
|
+
'value — a selector or an id rather than a document, or a slice of the text you need.';
|
|
261
|
+
// Refused before the handler was ever entered, and still a refusal the agent has to recover
|
|
262
|
+
// from. Reported here rather than at the return below, which this branch never reaches.
|
|
263
|
+
reportRefusal(tool.name, error);
|
|
264
|
+
return { error };
|
|
197
265
|
}
|
|
198
266
|
// An ACTION is what gets abandoned. Counted here, against verifications, so "the agent drove the
|
|
199
267
|
// page and then wandered off" becomes a number instead of an impression.
|
|
@@ -218,6 +286,25 @@ export async function runTool(tool, deps, args) {
|
|
|
218
286
|
session = undefined;
|
|
219
287
|
}
|
|
220
288
|
}
|
|
289
|
+
// A ref that was handed out by a DIFFERENT tab, on a call that named no session. Refused here, at
|
|
290
|
+
// the one point that knows both the ref and the tab resolution picked — the handler only learns
|
|
291
|
+
// that the ref missed, and answers that with "the DOM re-rendered", which is a confident diagnosis
|
|
292
|
+
// of the wrong thing. The other half is worse: the same ref can resolve to a different element in
|
|
293
|
+
// the guessed tab and return a green about a page nobody asked about.
|
|
294
|
+
if (session !== undefined) {
|
|
295
|
+
const wrongTab = wrongTabRefusal({
|
|
296
|
+
ref: asString(args['ref']),
|
|
297
|
+
explicitSessionId: rawSessionId,
|
|
298
|
+
chosenSessionId: session.id,
|
|
299
|
+
connected: () => deps.sessions.list(),
|
|
300
|
+
});
|
|
301
|
+
if (wrongTab !== undefined) {
|
|
302
|
+
// Refused before the handler was entered, so it is reported here — the same shape as the
|
|
303
|
+
// oversized-argument refusal above.
|
|
304
|
+
reportRefusal(tool.name, wrongTab);
|
|
305
|
+
return { error: wrongTab };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
221
308
|
const leaseId = session?.id ?? rawSessionId;
|
|
222
309
|
if (leaseId !== undefined)
|
|
223
310
|
deps.pool?.touch(leaseId);
|
|
@@ -228,6 +315,22 @@ export async function runTool(tool, deps, args) {
|
|
|
228
315
|
// nests under it. Free when off — see trace.ts.
|
|
229
316
|
raw = await span('tool.handler', { tool: tool.name, session: session?.id }, () => tool.handler(deps, args));
|
|
230
317
|
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
// The commonest refusal shape by far, and the one nothing could see: the message is built, handed
|
|
320
|
+
// to the agent by the MCP boundary, and discarded. Reported here rather than at that boundary
|
|
321
|
+
// because there are two of them (mcp.ts and the reticle_run hatch) and this is the one place both
|
|
322
|
+
// go through — a third would otherwise be invisible from the day it was added.
|
|
323
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
324
|
+
reportRefusal(tool.name, message);
|
|
325
|
+
// The invitation on THIS path is the one `buildErrorPayload` attaches when it does not recognise
|
|
326
|
+
// the error, and it was never counted — so the denominator excluded the commonest friction there
|
|
327
|
+
// is. Gated on the payload actually carrying the ask, because a recognised error gets a recovery
|
|
328
|
+
// instead and counting an invitation nobody was shown breaks the ratio in the other direction.
|
|
329
|
+
if (tool.name !== ReticleTool.RUN && buildErrorPayload(message).feedback !== undefined) {
|
|
330
|
+
getSessionMetrics().recordFeedbackPrompt();
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
231
334
|
finally {
|
|
232
335
|
// In a `finally` so a THROWN call still settles: otherwise every failing tool would leak a
|
|
233
336
|
// concurrency slot and peakConcurrentTools would climb forever on an unhealthy session.
|
|
@@ -244,6 +347,20 @@ export async function runTool(tool, deps, args) {
|
|
|
244
347
|
recordVerification(tool.name, raw, Date.now() - startedAt, session?.brand);
|
|
245
348
|
reportBugsFound(tool.name, raw);
|
|
246
349
|
}
|
|
350
|
+
// The other half of the refusal surface. A top-level `error` string IS this codebase's refusal
|
|
351
|
+
// convention, so half the tools refuse by RETURNING one rather than throwing — and reading only the
|
|
352
|
+
// throw path would have measured half the wall and called it the whole of it, the same way `isError`
|
|
353
|
+
// once did. A call that was served clears the retry chain, so the next refusal after it is a first
|
|
354
|
+
// refusal rather than a retry of something unrelated.
|
|
355
|
+
if (resultIsError(raw))
|
|
356
|
+
reportRefusal(tool.name, raw.error);
|
|
357
|
+
else {
|
|
358
|
+
noteToolServed();
|
|
359
|
+
// Which tab these refs belong to. Recorded on the way OUT, so the next call that spends one is
|
|
360
|
+
// measured against the tab that actually produced it.
|
|
361
|
+
if (session !== undefined && REF_MINTING_TOOLS.has(tool.name))
|
|
362
|
+
noteRefsMinted(session.id);
|
|
363
|
+
}
|
|
247
364
|
const prompt = isPlainObject(raw) ? takeFeedbackPrompt(tool.name) : undefined;
|
|
248
365
|
// Same one-shot channel as the feedback ask: the agent is mid-task, so anything it would have to go
|
|
249
366
|
// and ASK for is something it will never ask for. Spliced on ANY tool result, not just a
|
|
@@ -259,10 +376,26 @@ export async function runTool(tool, deps, args) {
|
|
|
259
376
|
// report announced as accepted and then silently lost is the failure the awaited send existed to
|
|
260
377
|
// prevent. See feedback-delivery.ts.
|
|
261
378
|
const undelivered = isPlainObject(raw) ? takeFeedbackUndelivered() : undefined;
|
|
262
|
-
|
|
379
|
+
// Invite feedback at the moment of friction, worded for what just happened. Counted, because
|
|
380
|
+
// `feedback_submitted / feedbackPrompted` is the only thing that says whether the invitation works
|
|
381
|
+
// at all rather than being decoration.
|
|
382
|
+
//
|
|
383
|
+
// This used to sit below the session-bound early return, which put it behind two conditions that
|
|
384
|
+
// exclude most friction there is. A session-EXEMPT tool never reached it, and neither did a tool
|
|
385
|
+
// that THREW — and throwing is the commonest refusal shape by a wide margin, i.e. exactly the
|
|
386
|
+
// `refused` friction the line is written for. `errored` could only ever see the handlers that
|
|
387
|
+
// RETURN an error. So the denominator was counting a small, unrepresentative slice of the
|
|
388
|
+
// invitations and reading near-empty, which looks identical to a nudge that never fires.
|
|
389
|
+
const friction = frictionInviteFor(tool.name, raw);
|
|
390
|
+
const result = prompt === undefined &&
|
|
391
|
+
update === undefined &&
|
|
392
|
+
skew === undefined &&
|
|
393
|
+
undelivered === undefined &&
|
|
394
|
+
friction === undefined
|
|
263
395
|
? raw
|
|
264
396
|
: {
|
|
265
397
|
...raw,
|
|
398
|
+
...(friction !== undefined ? { [EnvelopeKey.FEEDBACK_INVITE]: inviteFor(friction) } : {}),
|
|
266
399
|
...(prompt !== undefined ? { [EnvelopeKey.FEEDBACK_PROMPT]: prompt } : {}),
|
|
267
400
|
...(update !== undefined ? { [EnvelopeKey.UPDATE_AVAILABLE]: update } : {}),
|
|
268
401
|
...(skew !== undefined ? { [EnvelopeKey.VERSION_SKEW]: skew } : {}),
|
|
@@ -298,22 +431,5 @@ export async function runTool(tool, deps, args) {
|
|
|
298
431
|
const unverified = getSessionMetrics().takeUnverifiedNudge();
|
|
299
432
|
if (unverified !== undefined)
|
|
300
433
|
envelope[EnvelopeKey.VERIFY_NEXT] = unverified;
|
|
301
|
-
// Invite feedback at the moment of friction, worded for what just happened. Feedback produced
|
|
302
|
-
// ~11 of this release's fixes from 14 reports; the handshake instruction is what got those 14,
|
|
303
|
-
// and this closes the gap it leaves — the agent mid-problem, not thinking about feedback tooling.
|
|
304
|
-
// Counted so `feedback_submitted / feedbackPrompted` can tell us whether it works at all.
|
|
305
|
-
const friction = frictionOf({
|
|
306
|
-
unknownTool: false,
|
|
307
|
-
verifiedUnknown: 'unknown' === result.verified,
|
|
308
|
-
repeatRun: getSessionMetrics().currentRun,
|
|
309
|
-
errored: 'error' in result,
|
|
310
|
-
// A verdict is progress. Without this, three successful act_and_wait calls in a row — the exact
|
|
311
|
-
// loop this release exists to encourage — get told they are stuck.
|
|
312
|
-
producedVerdict: 'verified' in result,
|
|
313
|
-
});
|
|
314
|
-
if (friction !== undefined) {
|
|
315
|
-
envelope[EnvelopeKey.FEEDBACK_INVITE] = inviteFor(friction);
|
|
316
|
-
getSessionMetrics().recordFeedbackPrompt();
|
|
317
|
-
}
|
|
318
434
|
return Object.keys(envelope).length > 0 ? { ...result, ...envelope } : result;
|
|
319
435
|
}
|
|
@@ -20,6 +20,26 @@ export declare function appendReticleParams(url: string, session: string, projec
|
|
|
20
20
|
* of an internals-leaking wall of text.
|
|
21
21
|
*/
|
|
22
22
|
export declare function cleanNavError(err: unknown): string;
|
|
23
|
+
/**
|
|
24
|
+
* The session a lease's tab actually registered as, or undefined while nothing has.
|
|
25
|
+
*
|
|
26
|
+
* Normally that is the lease id: `appendReticleParams` stamps `__reticle_session` on the URL and the
|
|
27
|
+
* SDK adopts it. An app that passes an explicit `session` to `connect()` keeps its own name instead,
|
|
28
|
+
* which is legitimate — a single-app fixture does it deliberately so a battery can address it by a
|
|
29
|
+
* known id. Matching on the lease id alone could never see those tabs, so the lease reported
|
|
30
|
+
* `ready: false` and a hint blaming a port mismatch while the session was connected and driveable.
|
|
31
|
+
*
|
|
32
|
+
* The URL marker is the evidence, and it is what makes this safe: two concurrent leases on one origin
|
|
33
|
+
* carry different markers, so neither can adopt the other's tab. Parsed rather than substring-matched,
|
|
34
|
+
* so `lease-abc` cannot claim `lease-abcdef`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveLeasedSessionId(sessions: {
|
|
37
|
+
get: (id: string) => unknown;
|
|
38
|
+
all: () => {
|
|
39
|
+
id: string;
|
|
40
|
+
url?: string;
|
|
41
|
+
}[];
|
|
42
|
+
}, leaseId: string): string | undefined;
|
|
23
43
|
/**
|
|
24
44
|
* Poll until the leased tab's SDK has actually registered on the bridge, so the sessionId we return
|
|
25
45
|
* is immediately usable (the page navigates first, then connects a moment later — without this wait
|
|
@@ -38,7 +58,15 @@ export declare function acquireLeasedSession(pool: {
|
|
|
38
58
|
sessionId: string;
|
|
39
59
|
release: () => Promise<void>;
|
|
40
60
|
}>;
|
|
41
|
-
|
|
61
|
+
/** Optional so a test double need not implement it; the real pool always does. */
|
|
62
|
+
alias?: (registeredId: string, leaseId: string) => void;
|
|
63
|
+
}, sessions: {
|
|
64
|
+
get: (id: string) => unknown;
|
|
65
|
+
all: () => {
|
|
66
|
+
id: string;
|
|
67
|
+
url?: string;
|
|
68
|
+
}[];
|
|
69
|
+
}, url: string, projectId?: string): Promise<{
|
|
42
70
|
sessionId: string;
|
|
43
71
|
release: () => Promise<void>;
|
|
44
72
|
}>;
|
|
@@ -64,6 +64,34 @@ function newLeaseId() {
|
|
|
64
64
|
: `${String(Date.now())}-${String(performance.now())}`;
|
|
65
65
|
return `lease-${uuid}`;
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The session a lease's tab actually registered as, or undefined while nothing has.
|
|
69
|
+
*
|
|
70
|
+
* Normally that is the lease id: `appendReticleParams` stamps `__reticle_session` on the URL and the
|
|
71
|
+
* SDK adopts it. An app that passes an explicit `session` to `connect()` keeps its own name instead,
|
|
72
|
+
* which is legitimate — a single-app fixture does it deliberately so a battery can address it by a
|
|
73
|
+
* known id. Matching on the lease id alone could never see those tabs, so the lease reported
|
|
74
|
+
* `ready: false` and a hint blaming a port mismatch while the session was connected and driveable.
|
|
75
|
+
*
|
|
76
|
+
* The URL marker is the evidence, and it is what makes this safe: two concurrent leases on one origin
|
|
77
|
+
* carry different markers, so neither can adopt the other's tab. Parsed rather than substring-matched,
|
|
78
|
+
* so `lease-abc` cannot claim `lease-abcdef`.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveLeasedSessionId(sessions, leaseId) {
|
|
81
|
+
if (sessions.get(leaseId) !== undefined)
|
|
82
|
+
return leaseId;
|
|
83
|
+
return sessions.all().find((s) => sessionParamOf(s.url) === leaseId)?.id;
|
|
84
|
+
}
|
|
85
|
+
function sessionParamOf(url) {
|
|
86
|
+
if (url === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
try {
|
|
89
|
+
return new URL(url).searchParams.get(RETICLE_URL_PARAM.SESSION) ?? undefined;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
67
95
|
const LEASE_READY_ATTEMPTS = 100;
|
|
68
96
|
const LEASE_READY_POLL_MS = 100;
|
|
69
97
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -78,11 +106,20 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
78
106
|
* Returns the session id plus its release; the caller must release in a finally so a crashed flow never
|
|
79
107
|
* leaks a pool slot and starves the rest of the suite.
|
|
80
108
|
*/
|
|
81
|
-
export async function acquireLeasedSession(pool,
|
|
109
|
+
export async function acquireLeasedSession(pool, sessions, url, projectId) {
|
|
82
110
|
const sessionId = newLeaseId();
|
|
83
111
|
const lease = await pool.acquire(appendReticleParams(url, sessionId, projectId), { sessionId });
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
// Resolved, not assumed — see resolveLeasedSessionId. The parallel suite replays against whatever
|
|
113
|
+
// id comes back, so handing it the lease id for an app that named its own session sent every flow
|
|
114
|
+
// in the run at a session that does not exist.
|
|
115
|
+
let registeredId;
|
|
116
|
+
await waitForLeasedSession(() => {
|
|
117
|
+
registeredId = resolveLeasedSessionId(sessions, lease.sessionId);
|
|
118
|
+
return registeredId !== undefined;
|
|
119
|
+
});
|
|
120
|
+
if (registeredId !== undefined)
|
|
121
|
+
pool.alias?.(registeredId, lease.sessionId);
|
|
122
|
+
return { sessionId: registeredId ?? lease.sessionId, release: () => lease.release() };
|
|
86
123
|
}
|
|
87
124
|
export async function waitForLeasedSession(isConnected, sleeper = sleep, attempts = LEASE_READY_ATTEMPTS, pollMs = LEASE_READY_POLL_MS) {
|
|
88
125
|
for (let i = 0; i < attempts; i++) {
|
|
@@ -111,6 +148,9 @@ export const LEASE_TOOLS = [
|
|
|
111
148
|
ready: z
|
|
112
149
|
.boolean()
|
|
113
150
|
.describe('Whether the leased tab connected — false ⇒ the app may not embed @reticlehq/core.'),
|
|
151
|
+
expiresInMs: z
|
|
152
|
+
.number()
|
|
153
|
+
.describe('Milliseconds until this lease expires if untouched. Each tool call that targets this session resets the clock. Plan your work to finish or re-acquire before this runs out.'),
|
|
114
154
|
leased: z.number().describe('How many contexts are currently leased from the pool.'),
|
|
115
155
|
queued: z.number().describe('How many acquires are waiting for a free slot.'),
|
|
116
156
|
hint: z.string().optional(),
|
|
@@ -135,11 +175,24 @@ export const LEASE_TOOLS = [
|
|
|
135
175
|
throw new Error(`could not open ${url} — is the app running there? (${cleanNavError(err)})`);
|
|
136
176
|
}
|
|
137
177
|
// Wait for the leased tab's SDK to connect so the returned sessionId is usable right away.
|
|
138
|
-
|
|
178
|
+
// Resolved rather than assumed: an app that names its own session registers under that name,
|
|
179
|
+
// and the id we hand back has to be the one the agent can actually drive.
|
|
180
|
+
let registeredId;
|
|
181
|
+
const ready = await waitForLeasedSession(() => {
|
|
182
|
+
registeredId = resolveLeasedSessionId(deps.sessions, lease.sessionId);
|
|
183
|
+
return registeredId !== undefined;
|
|
184
|
+
});
|
|
185
|
+
// Tell the pool the other name this lease answers to. Every later touch and release arrives
|
|
186
|
+
// under the id we are about to hand back, and the pool is keyed by the id it navigated with;
|
|
187
|
+
// without this the touches miss, the lease ages out despite continuous activity, and the
|
|
188
|
+
// reaper closes the context mid-flow. See BrowserPool.alias and #157.
|
|
189
|
+
if (registeredId !== undefined)
|
|
190
|
+
pool.alias(registeredId, lease.sessionId);
|
|
139
191
|
return {
|
|
140
|
-
sessionId: lease.sessionId,
|
|
192
|
+
sessionId: registeredId ?? lease.sessionId,
|
|
141
193
|
url,
|
|
142
194
|
ready,
|
|
195
|
+
expiresInMs: pool.leaseTtlMs(),
|
|
143
196
|
leased: pool.activeCount(),
|
|
144
197
|
queued: pool.queuedCount(),
|
|
145
198
|
...(ready
|
|
@@ -30,7 +30,7 @@ arrival = null) {
|
|
|
30
30
|
return {
|
|
31
31
|
...base,
|
|
32
32
|
confirmed: false,
|
|
33
|
-
note: 'ok means the navigation was DISPATCHED, not that the page arrived
|
|
33
|
+
note: 'ok means the navigation was DISPATCHED, not that the page arrived. The SDK is torn down by ' +
|
|
34
34
|
'the navigation itself, so nothing here can see the new document. Call reticle_sessions to ' +
|
|
35
35
|
'confirm a session reconnected at the new URL before acting; if none appears, the page did ' +
|
|
36
36
|
'not load or is not instrumented.',
|
|
@@ -179,7 +179,7 @@ export const OBSERVE_TOOLS = [
|
|
|
179
179
|
// never asked about.
|
|
180
180
|
const actCursor = session.lastAct.cursor();
|
|
181
181
|
const judgingTheAct = actCursor !== undefined && actCursor >= since;
|
|
182
|
-
const contradictions = findContradictions(filtered, judgingTheAct ? session.lastAct.effect() : {});
|
|
182
|
+
const contradictions = findContradictions(filtered, judgingTheAct ? { ...session.lastAct.effect(), actionSince: actCursor } : {});
|
|
183
183
|
// carry session health — a throttled tab means the observed timeline may be incomplete.
|
|
184
184
|
return withControl(session, {
|
|
185
185
|
...report,
|
|
@@ -28,6 +28,6 @@ export function noteEmptyRead(result, key, context) {
|
|
|
28
28
|
...result,
|
|
29
29
|
// The distinction the field sweep could not make: this is a RESULT, not a failure to look.
|
|
30
30
|
observed: true,
|
|
31
|
-
note: `no ${context.noun}${window}
|
|
31
|
+
note: `no ${context.noun}${window}; the observation ran and found none, which is a result, not a missing reading`,
|
|
32
32
|
};
|
|
33
33
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "The app ignored this response" is only sayable once the app has had a chance to read it.
|
|
3
|
+
*
|
|
4
|
+
* `response-ignored` fires when a write succeeded on the server and nothing on the client moved. It
|
|
5
|
+
* is a real and valuable finding — a lost write, a response parsed into the void, a render that never
|
|
6
|
+
* happened. It is also an ACCUSATION, and it is decided by where a window happens to end.
|
|
7
|
+
*
|
|
8
|
+
* Waiting for in-flight requests before taking the verdict made that boundary dangerous in a way it
|
|
9
|
+
* was not before. Previously the write was still pending when the window closed, so it never reached
|
|
10
|
+
* the settled list and this rule could not fire at all. Now the response lands inside the window BY
|
|
11
|
+
* DESIGN — and the app's re-render happens in a later task, a few milliseconds after it. Close the
|
|
12
|
+
* window in that gap and every channel agrees that the app took a successful write and did nothing.
|
|
13
|
+
*
|
|
14
|
+
* The verdict is then `verified:"no"` on a completely correct application, produced entirely by where
|
|
15
|
+
* we chose to stop looking. For a verification tool that is the more damaging direction of error: a
|
|
16
|
+
* false green is a missed catch, but a false accusation sends someone to fix code that is not broken,
|
|
17
|
+
* and it is how the instrument stops being believed.
|
|
18
|
+
*
|
|
19
|
+
* So the response is not the end of the window — the app's REACTION to it is. The grace is paid only
|
|
20
|
+
* in the exact case that would otherwise be accused: a successful mutating write with nothing moved
|
|
21
|
+
* after it. Every other action returns without a single poll.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately short. This covers a task-boundary re-render, not a slow app: if nothing has moved
|
|
24
|
+
* within the grace, "ignored" has become an honest description and the finding should stand.
|
|
25
|
+
*/
|
|
26
|
+
import type { SettleSource } from './settle-in-flight.js';
|
|
27
|
+
/**
|
|
28
|
+
* Is this a window that would be wrongly accused if it ended right now?
|
|
29
|
+
*
|
|
30
|
+
* True only for the accusable shape: a SUCCESSFUL MUTATING request settled, and nothing has moved. A
|
|
31
|
+
* read that changed nothing is a prefetch, a failed write is a different finding entirely, and an
|
|
32
|
+
* action that already moved the UI has nothing to wait for — none of them buy a grace period.
|
|
33
|
+
*/
|
|
34
|
+
export declare function awaitsReaction(events: readonly {
|
|
35
|
+
type: string;
|
|
36
|
+
data: Record<string, unknown>;
|
|
37
|
+
}[]): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Wait, bounded by BOTH the grace and the caller's remaining budget, for the app to react to a write
|
|
40
|
+
* it has just been told succeeded.
|
|
41
|
+
*
|
|
42
|
+
* Returns whether the app moved. A `false` is not a verdict — it hands the question back to the
|
|
43
|
+
* contradiction engine, which will raise `response-ignored` exactly as it does today. The point is
|
|
44
|
+
* only that by then the app has been given its chance, so the finding means what it says.
|
|
45
|
+
*/
|
|
46
|
+
export declare function waitForReaction(session: SettleSource, since: number, budgetMs: number, opts: {
|
|
47
|
+
sleep(ms: number): Promise<void>;
|
|
48
|
+
}): Promise<boolean>;
|