@inflexa-ai/harness 0.20.0 → 0.21.1

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.
Files changed (133) hide show
  1. package/dist/agents/conversation-agent.d.ts +26 -14
  2. package/dist/agents/conversation-agent.js +12 -23
  3. package/dist/agents/report-session-agent.d.ts +31 -5
  4. package/dist/agents/report-session-agent.js +35 -9
  5. package/dist/app/chat-turn.js +3 -1
  6. package/dist/app/message-assembly.d.ts +15 -1
  7. package/dist/app/message-assembly.js +22 -8
  8. package/dist/app/report-session-runtime.d.ts +21 -4
  9. package/dist/app/report-session-runtime.js +53 -6
  10. package/dist/app/spawn-report-session.d.ts +174 -17
  11. package/dist/app/spawn-report-session.js +233 -21
  12. package/dist/contracts/chat-events.d.ts +7 -2
  13. package/dist/contracts/message.d.ts +10 -0
  14. package/dist/contracts/report-blocks.d.ts +743 -19
  15. package/dist/contracts/report-blocks.js +186 -11
  16. package/dist/contracts/report-reference.d.ts +88 -0
  17. package/dist/contracts/report-reference.js +48 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +20 -0
  20. package/dist/lib/chrome.d.ts +26 -6
  21. package/dist/lib/chrome.js +99 -32
  22. package/dist/lib/eyes.d.ts +57 -0
  23. package/dist/lib/eyes.js +45 -0
  24. package/dist/lib/page-capture.d.ts +14 -0
  25. package/dist/lib/page-capture.js +56 -2
  26. package/dist/loop/run-agent.js +58 -23
  27. package/dist/loop/tool-detail.d.ts +19 -2
  28. package/dist/loop/tool-detail.js +35 -2
  29. package/dist/memory/content-to-cortex.d.ts +3 -2
  30. package/dist/memory/content-to-cortex.js +21 -12
  31. package/dist/memory/conversation-display-replay.js +14 -7
  32. package/dist/memory/thread-history.d.ts +60 -3
  33. package/dist/memory/thread-history.js +55 -16
  34. package/dist/memory/thread-store.d.ts +11 -1
  35. package/dist/memory/thread-store.js +12 -2
  36. package/dist/prompts/conversation.d.ts +1 -1
  37. package/dist/prompts/conversation.js +23 -6
  38. package/dist/prompts/report-session.d.ts +1 -1
  39. package/dist/prompts/report-session.js +190 -9
  40. package/dist/report-model/authoring-grammar.d.ts +463 -0
  41. package/dist/report-model/authoring-grammar.js +77 -0
  42. package/dist/report-model/block-walk.d.ts +57 -14
  43. package/dist/report-model/block-walk.js +116 -11
  44. package/dist/report-model/draft-finish.d.ts +22 -4
  45. package/dist/report-model/draft-finish.js +39 -7
  46. package/dist/report-model/draft-operations.d.ts +3 -0
  47. package/dist/report-model/draft-operations.js +19 -5
  48. package/dist/report-model/draft-read.d.ts +8 -5
  49. package/dist/report-model/draft-read.js +21 -5
  50. package/dist/report-model/fixture-resolver.js +15 -4
  51. package/dist/report-model/pin-snapshot.d.ts +24 -6
  52. package/dist/report-model/pin-snapshot.js +204 -6
  53. package/dist/report-model/production-resolver.js +15 -4
  54. package/dist/report-model/reference-resolver.d.ts +50 -1
  55. package/dist/report-model/reference-resolver.js +94 -1
  56. package/dist/report-model/reference-stamp.d.ts +45 -0
  57. package/dist/report-model/reference-stamp.js +173 -0
  58. package/dist/report-model/structural-validation.d.ts +4 -1
  59. package/dist/report-model/structural-validation.js +48 -3
  60. package/dist/report-model/validate.d.ts +1 -1
  61. package/dist/report-model/validate.js +1 -1
  62. package/dist/report-render/asset-lookup.d.ts +19 -0
  63. package/dist/report-render/asset-lookup.js +37 -0
  64. package/dist/report-render/assets.d.ts +45 -1
  65. package/dist/report-render/assets.js +65 -6
  66. package/dist/report-render/chart-presets.d.ts +102 -0
  67. package/dist/report-render/chart-presets.js +205 -0
  68. package/dist/report-render/chart.d.ts +112 -10
  69. package/dist/report-render/chart.js +1164 -41
  70. package/dist/report-render/design.d.ts +99 -0
  71. package/dist/report-render/design.js +289 -107
  72. package/dist/report-render/number-format.d.ts +117 -0
  73. package/dist/report-render/number-format.js +479 -0
  74. package/dist/report-render/page.d.ts +121 -5
  75. package/dist/report-render/page.js +689 -7
  76. package/dist/report-render/references.d.ts +70 -16
  77. package/dist/report-render/references.js +54 -22
  78. package/dist/report-render/render.d.ts +23 -5
  79. package/dist/report-render/render.js +81 -22
  80. package/dist/report-render/table-data.d.ts +92 -0
  81. package/dist/report-render/table-data.js +129 -0
  82. package/dist/report-render/types.d.ts +15 -0
  83. package/dist/report-render/value-bridge.js +6 -2
  84. package/dist/report-render/views/chart-view.d.ts +12 -5
  85. package/dist/report-render/views/chart-view.js +14 -8
  86. package/dist/report-render/views/page-view.d.ts +36 -15
  87. package/dist/report-render/views/page-view.js +45 -23
  88. package/dist/report-render/views/prose.d.ts +12 -2
  89. package/dist/report-render/views/prose.js +29 -7
  90. package/dist/report-render/views/references-view.d.ts +22 -8
  91. package/dist/report-render/views/references-view.js +101 -13
  92. package/dist/report-render/views/values.d.ts +128 -13
  93. package/dist/report-render/views/values.js +199 -18
  94. package/dist/runtime/assemble.d.ts +39 -5
  95. package/dist/runtime/assemble.js +47 -6
  96. package/dist/sandbox/client.d.ts +10 -1
  97. package/dist/sandbox/create-sandbox.d.ts +9 -4
  98. package/dist/sandbox/create-sandbox.js +22 -16
  99. package/dist/sandbox/docker-client.js +10 -7
  100. package/dist/sandbox/mount-plan.d.ts +45 -7
  101. package/dist/sandbox/mount-plan.js +43 -5
  102. package/dist/sandbox/types.d.ts +6 -0
  103. package/dist/state/init.js +38 -9
  104. package/dist/state/report-session-state.d.ts +42 -1
  105. package/dist/state/report-session-state.js +70 -2
  106. package/dist/state/report-versions.d.ts +22 -17
  107. package/dist/state/report-versions.js +48 -35
  108. package/dist/state/snapshot-parse.js +16 -1
  109. package/dist/tools/define-tool.d.ts +32 -0
  110. package/dist/tools/define-tool.js +4 -0
  111. package/dist/tools/detail-resolver.d.ts +7 -2
  112. package/dist/tools/detail-resolver.js +7 -2
  113. package/dist/tools/iterate-report.d.ts +1 -1
  114. package/dist/tools/report/preview-snapshot.js +1 -0
  115. package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
  116. package/dist/tools/report-authoring/authoring-tools.js +99 -18
  117. package/dist/tools/report-session/derive-table.d.ts +162 -0
  118. package/dist/tools/report-session/derive-table.js +459 -0
  119. package/dist/tools/report-session/examine-page.d.ts +48 -18
  120. package/dist/tools/report-session/examine-page.js +204 -24
  121. package/dist/tools/report-session/index.d.ts +2 -0
  122. package/dist/tools/report-session/index.js +2 -0
  123. package/dist/tools/report-session/list-artifacts.d.ts +97 -0
  124. package/dist/tools/report-session/list-artifacts.js +197 -0
  125. package/dist/tools/report-session/preview-report.d.ts +6 -0
  126. package/dist/tools/report-session/preview-report.js +180 -28
  127. package/dist/tools/report-session/record-version.d.ts +22 -10
  128. package/dist/tools/report-session/record-version.js +99 -14
  129. package/dist/tools/start-report-session.d.ts +115 -0
  130. package/dist/tools/start-report-session.js +179 -0
  131. package/dist/workspace/paths.d.ts +50 -0
  132. package/dist/workspace/paths.js +63 -0
  133. package/package.json +2 -1
@@ -1,23 +1,59 @@
1
1
  /**
2
- * Headless Chrome connection — process-lifetime singleton connected to the
3
- * Chrome sidecar reachable at `CHROME_BROWSER_URL`. Used by the iterate-report
4
- * preview-snapshot tool for visual validation.
2
+ * The connection to a headless Chrome endpoint.
5
3
  *
6
- * Connection is lazy and reconnect-on-disconnect; pages are gated through a
7
- * semaphore so the sidecar isn't overwhelmed by concurrent reports.
4
+ * Two callers name an endpoint. A composition names a standing sidecar at assembly, and a lease of the eyes
5
+ * seam names a browser for one look. Thus the module holds one connection for each endpoint, and it holds no
6
+ * connection for the process.
7
+ *
8
+ * A connection is lazy, and an endpoint connects again after a disconnect. Each endpoint also carries its own
9
+ * page gate. The gate bounds the pages that run at the same time against that one sidecar.
8
10
  */
9
11
  import puppeteer from "puppeteer-core";
10
12
  import { createNoopLogger } from "./console-logger.js";
13
+ /** Whether a value names an endpoint. An absent value and a blank value each name none. */
14
+ function namesEndpoint(browserUrl) {
15
+ return browserUrl !== undefined && browserUrl.trim().length > 0;
16
+ }
11
17
  /**
12
18
  * Whether the config names a browser to connect to. A composition with no endpoint has no eyes, and a
13
19
  * caller reads that up front instead of failing once for each attempted connection.
20
+ *
21
+ * The predicate narrows the config. Thus a caller that reads the endpoint after the gate needs no type
22
+ * assertion for a field that the gate proved.
14
23
  */
15
24
  export function hasBrowserUrl(cfg) {
16
- return cfg.browserUrl !== undefined && cfg.browserUrl.trim().length > 0;
25
+ return namesEndpoint(cfg.browserUrl);
26
+ }
27
+ /**
28
+ * The endpoint of a connection, or the refusal of a config that names none.
29
+ *
30
+ * The refusal and {@link hasBrowserUrl} read the same rule. Thus the gate that a caller reads up front is the
31
+ * gate that the connection applies.
32
+ */
33
+ function requireBrowserUrl(browserUrl) {
34
+ if (namesEndpoint(browserUrl))
35
+ return browserUrl;
36
+ throw new Error("CHROME_BROWSER_URL is not set — Cortex requires the chrome sidecar to be reachable");
37
+ }
38
+ const connectOverPuppeteer = (browserUrl) => puppeteer.connect({ browserURL: browserUrl });
39
+ let connector = connectOverPuppeteer;
40
+ /**
41
+ * Replace the connect operation, and give back the restore of the previous one.
42
+ *
43
+ * The cache keeps no state that a caller can read, because a connection is the only product of the module.
44
+ * Thus a real browser is the only other way to drive the cache, and a unit test of the keys needs this seam.
45
+ *
46
+ * Two facts make the seam safe. The module reads the binding at each connect, and no module under `src/`
47
+ * calls the setter. Thus a deployment always runs the connect of puppeteer. The caller restores the previous
48
+ * binding, thus a replacement never reaches a later file of the same test run.
49
+ */
50
+ export function setBrowserConnector(next) {
51
+ const previous = connector;
52
+ connector = next;
53
+ return () => {
54
+ connector = previous;
55
+ };
17
56
  }
18
- let browser;
19
- let connecting;
20
- let semaphore;
21
57
  function createSemaphore(max) {
22
58
  let active = 0;
23
59
  const queue = [];
@@ -42,50 +78,81 @@ function createSemaphore(max) {
42
78
  },
43
79
  };
44
80
  }
45
- function getSemaphore(maxPages) {
46
- if (!semaphore) {
47
- const max = maxPages && maxPages > 0 ? maxPages : 4;
48
- semaphore = createSemaphore(max);
49
- }
50
- return semaphore;
81
+ /** The cap on the pages that run at the same time against one endpoint, when a config names none. */
82
+ const DEFAULT_MAX_PAGES = 4;
83
+ /**
84
+ * The entry of each endpoint that the process reached, keyed by the endpoint URL. A disconnect evicts an
85
+ * entry, thus a browser that appears for one look leaves nothing behind.
86
+ *
87
+ * The eviction is event-driven, and no sweep runs over this map. The socket close of a dead browser raises
88
+ * the disconnect event, thus a browser that dies in the normal way evicts its own entry. A browser that dies
89
+ * with no event leaves one entry that nothing removes. The cost is one dead entry for one endpoint URL that
90
+ * no later look names again.
91
+ */
92
+ const endpoints = new Map();
93
+ /**
94
+ * The entry of one endpoint, made on the first call for that endpoint.
95
+ *
96
+ * The first caller fixes the cap of the gate. A later caller with a different cap reads the gate that is
97
+ * already in place, because one endpoint answers to one gate.
98
+ */
99
+ function entryFor(browserUrl, maxPages) {
100
+ const existing = endpoints.get(browserUrl);
101
+ if (existing)
102
+ return existing;
103
+ const max = maxPages && maxPages > 0 ? maxPages : DEFAULT_MAX_PAGES;
104
+ const fresh = { semaphore: createSemaphore(max) };
105
+ endpoints.set(browserUrl, fresh);
106
+ return fresh;
51
107
  }
52
108
  export async function getBrowser(browserUrl, injected) {
53
109
  const logger = (injected ?? createNoopLogger()).named("chrome");
54
- if (browser && browser.connected)
55
- return browser;
56
- if (connecting)
57
- return connecting;
58
- const browserURL = browserUrl;
59
- if (!browserURL) {
60
- throw new Error("CHROME_BROWSER_URL is not set — Cortex requires the chrome sidecar to be reachable");
61
- }
110
+ const browserURL = requireBrowserUrl(browserUrl);
111
+ const entry = entryFor(browserURL);
112
+ if (entry.browser?.connected)
113
+ return entry.browser;
114
+ if (entry.connecting)
115
+ return entry.connecting;
62
116
  logger.info("connecting to browser", { browserURL });
63
- connecting = puppeteer
64
- .connect({ browserURL })
117
+ entry.connecting = connector(browserURL)
65
118
  .then((b) => {
66
- browser = b;
119
+ entry.browser = b;
67
120
  b.on("disconnected", () => {
68
121
  logger.info("browser disconnected; will reconnect on next request");
69
- if (browser === b)
70
- browser = undefined;
122
+ // A reconnect can hold the key again by the time that the dead browser reports. The two
123
+ // guards keep the eviction on the entry of this browser alone.
124
+ if (endpoints.get(browserURL) === entry && entry.browser === b)
125
+ endpoints.delete(browserURL);
71
126
  });
72
127
  logger.info("connected", { wsEndpoint: b.wsEndpoint() });
73
128
  return b;
74
129
  })
75
130
  .catch((err) => {
76
131
  logger.error("failed to connect to browser", logger.errorFields(err));
132
+ // A failed connect leaves nothing to reuse. The entry goes, thus a run of looks that each name a
133
+ // new endpoint and each fail cannot grow the map. The two guards match the disconnect listener,
134
+ // thus a reconnect that already took the key survives.
135
+ //
136
+ // A waiter that already holds a slot of the evicted gate keeps running, and a later caller makes
137
+ // a fresh gate. Thus the page cap of this endpoint can be exceeded for a moment. That is
138
+ // acceptable, because the connect just failed and no browser exists for either gate to bound.
139
+ if (endpoints.get(browserURL) === entry && entry.browser === undefined)
140
+ endpoints.delete(browserURL);
77
141
  throw err;
78
142
  })
79
143
  .finally(() => {
80
- connecting = undefined;
144
+ entry.connecting = undefined;
81
145
  });
82
- return connecting;
146
+ return entry.connecting;
83
147
  }
84
148
  export async function withPage(cfg, fn) {
85
- const release = await getSemaphore(cfg.maxPages).acquire();
149
+ // The gate belongs to one endpoint, thus the endpoint must be known before the gate. A config that names
150
+ // none refuses here, the same as the connection below refuses it.
151
+ const browserURL = requireBrowserUrl(cfg.browserUrl);
152
+ const release = await entryFor(browserURL, cfg.maxPages).semaphore.acquire();
86
153
  let context;
87
154
  try {
88
- const b = await getBrowser(cfg.browserUrl, cfg.logger);
155
+ const b = await getBrowser(browserURL, cfg.logger);
89
156
  context = await b.createBrowserContext();
90
157
  const page = await context.newPage();
91
158
  return await fn(page, context);
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The eyes seam: where a browser comes from for one look at a report page.
3
+ *
4
+ * The seam answers that one question, and it answers nothing else. It gives an endpoint for one scope, and
5
+ * the harness keeps every page behavior: the `file://` navigation, the readiness wait, and the seen stamp.
6
+ * Thus a host with a standing sidecar and a host that starts a browser for one look share one page
7
+ * implementation.
8
+ *
9
+ * A realization must bound the life of what it provisions. A lease that no release ends must still end at
10
+ * that bound. A process can die between the acquire and the release, thus no release of a caller prevents
11
+ * every leak. The release of a caller is hygiene, and it is not the guarantee. This bound is the load-bearing
12
+ * contract of the seam, and each new realization obeys it.
13
+ *
14
+ * A realization must also bound how many browsers it provisions at one time. The page gate of the chrome
15
+ * module bounds one endpoint alone. Thus a realization that starts a browser at a new endpoint for each look
16
+ * meets no bound in the harness. Only the realization knows its own resource, and the count bound sits there.
17
+ *
18
+ * The seam speaks the throw protocol, the same as the capture seam beside it. A caller that needs a typed
19
+ * outcome guards the acquire and the release.
20
+ */
21
+ import { type ChromeConfig } from "./chrome.js";
22
+ /**
23
+ * What one acquire covers.
24
+ *
25
+ * A realization that starts a container mounts the workspace root at the identical host path, because the
26
+ * page navigation reads a `file://` URL of the host tree. The caller resolves the root already, thus the
27
+ * scope hands it over and a realization holds no second resolver.
28
+ */
29
+ export interface EyesScope {
30
+ readonly analysisId: string;
31
+ readonly workspaceRoot: string;
32
+ }
33
+ /**
34
+ * One acquired browser, and the end of the lease.
35
+ *
36
+ * `browserUrl` is the endpoint that a capture connects to. `release` ends the lease of the caller. The shape
37
+ * binds no duration, thus one look and a longer interactive hold read the same.
38
+ */
39
+ export interface EyesLease {
40
+ readonly browserUrl: string;
41
+ release(): Promise<void>;
42
+ }
43
+ /**
44
+ * The eyes seam. One call gives one lease over one scope.
45
+ *
46
+ * The embedder binds a realization at its composition root. The harness reads this type alone, and it never
47
+ * asks which realization answers.
48
+ */
49
+ export type AcquireEyes = (scope: EyesScope) => Promise<EyesLease>;
50
+ /**
51
+ * Make the static realization over a standing sidecar.
52
+ *
53
+ * Each look reads the configured endpoint, and the release does nothing, because the realization provisions
54
+ * nothing. The sidecar stands outside this process. Thus the deployment that runs the sidecar owns its life,
55
+ * and the no-leak bound of the seam holds with no work here.
56
+ */
57
+ export declare function createStaticEyes(chrome: ChromeConfig): AcquireEyes;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The eyes seam: where a browser comes from for one look at a report page.
3
+ *
4
+ * The seam answers that one question, and it answers nothing else. It gives an endpoint for one scope, and
5
+ * the harness keeps every page behavior: the `file://` navigation, the readiness wait, and the seen stamp.
6
+ * Thus a host with a standing sidecar and a host that starts a browser for one look share one page
7
+ * implementation.
8
+ *
9
+ * A realization must bound the life of what it provisions. A lease that no release ends must still end at
10
+ * that bound. A process can die between the acquire and the release, thus no release of a caller prevents
11
+ * every leak. The release of a caller is hygiene, and it is not the guarantee. This bound is the load-bearing
12
+ * contract of the seam, and each new realization obeys it.
13
+ *
14
+ * A realization must also bound how many browsers it provisions at one time. The page gate of the chrome
15
+ * module bounds one endpoint alone. Thus a realization that starts a browser at a new endpoint for each look
16
+ * meets no bound in the harness. Only the realization knows its own resource, and the count bound sits there.
17
+ *
18
+ * The seam speaks the throw protocol, the same as the capture seam beside it. A caller that needs a typed
19
+ * outcome guards the acquire and the release.
20
+ */
21
+ import { hasBrowserUrl } from "./chrome.js";
22
+ /**
23
+ * Make the static realization over a standing sidecar.
24
+ *
25
+ * Each look reads the configured endpoint, and the release does nothing, because the realization provisions
26
+ * nothing. The sidecar stands outside this process. Thus the deployment that runs the sidecar owns its life,
27
+ * and the no-leak bound of the seam holds with no work here.
28
+ */
29
+ export function createStaticEyes(chrome) {
30
+ // A realization with no endpoint can only fail at the first look, and a look sits deep inside a model
31
+ // turn. The refusal at construction moves the fault to the boot of the composition, where a programmer
32
+ // reads it. `getBrowser` in `chrome.ts` refuses the same condition with a throw, thus one absent endpoint
33
+ // gets one answer at both sites. The assembly constructs this realization behind `hasBrowserUrl` alone,
34
+ // thus a composition that names no browser never reaches the refusal.
35
+ if (!hasBrowserUrl(chrome)) {
36
+ throw new Error("static eyes need a browser endpoint, and the chrome config names none");
37
+ }
38
+ const browserUrl = chrome.browserUrl;
39
+ return async (_scope) => ({
40
+ browserUrl,
41
+ release: async () => {
42
+ // The standing sidecar outlives every look, thus nothing ends here.
43
+ },
44
+ });
45
+ }
@@ -10,6 +10,10 @@
10
10
  * imports them, thus a rename or a retime reaches the waiter at compile time. A second copy of the wait
11
11
  * script would instead degrade every capture to the readiness timeout, and no type would break.
12
12
  *
13
+ * The capture emulates the reduced-motion preference before the navigation. The design source collapses each
14
+ * transition under that preference, and it starts each reveal in its visible state. Thus the picture shows
15
+ * the final state, and no element appears mid-fade.
16
+ *
13
17
  * The capture speaks the throw protocol, because the chrome connection does. A caller that needs a typed
14
18
  * outcome guards the call.
15
19
  */
@@ -22,6 +26,12 @@ export interface FailedRequest {
22
26
  /** The picture and the faults of one page capture. */
23
27
  export interface PageCapture {
24
28
  screenshotBase64: string;
29
+ /**
30
+ * What the picture holds. `full` is the whole document, and it is the shape of a capture that passed at
31
+ * the first attempt. `viewport` appears when the full-page screenshot threw and the retry at the window
32
+ * passed, thus the picture shows the top window alone and a section below the fold is absent from it.
33
+ */
34
+ coverage: "full" | "viewport";
25
35
  consoleErrors: string[];
26
36
  failedRequests: FailedRequest[];
27
37
  }
@@ -41,6 +51,10 @@ export interface CaptureOptions {
41
51
  /**
42
52
  * Capture one page: the screenshot, the console errors, and the failed requests.
43
53
  *
54
+ * The picture holds the whole document, and not the window alone. Thus a caller can judge a section that a
55
+ * reader reaches by a scroll. A refused full-page bitmap degrades to the window, and the coverage of the
56
+ * result names which of the two pictures arrived.
57
+ *
44
58
  * The readiness wait is best-effort. A page that never signals still captures at the readiness budget, thus
45
59
  * a broken page gives a picture that shows what broke.
46
60
  */
@@ -10,6 +10,10 @@
10
10
  * imports them, thus a rename or a retime reaches the waiter at compile time. A second copy of the wait
11
11
  * script would instead degrade every capture to the readiness timeout, and no type would break.
12
12
  *
13
+ * The capture emulates the reduced-motion preference before the navigation. The design source collapses each
14
+ * transition under that preference, and it starts each reveal in its visible state. Thus the picture shows
15
+ * the final state, and no element appears mid-fade.
16
+ *
13
17
  * The capture speaks the throw protocol, because the chrome connection does. A caller that needs a typed
14
18
  * outcome guards the call.
15
19
  */
@@ -17,6 +21,14 @@ import { withPage } from "./chrome.js";
17
21
  import { PAGE_NAV_TIMEOUT_MS, PAGE_READY_TIMEOUT_MS, THEME_READY_EVENT, THEME_READY_SENTINEL } from "../report-render/page.js";
18
22
  /** The budget of the optional selector wait. The wait is best-effort, thus a miss captures the page anyway. */
19
23
  const SELECTOR_TIMEOUT_MS = 5_000;
24
+ /**
25
+ * The window of one capture, in CSS pixels.
26
+ *
27
+ * The width clears each breakpoint of the design source. Thus a multi-column band lays out at the width that a
28
+ * reader gets, and no band collapses to one column because the window was narrow.
29
+ */
30
+ const VIEWPORT_WIDTH = 1440;
31
+ const VIEWPORT_HEIGHT = 900;
20
32
  /**
21
33
  * The body of the readiness wait, in the browser context. The sentinel arm resolves a page that dispatched
22
34
  * the event before this wait registered, thus a plain listener never blocks forever. The timer arm bounds a
@@ -41,9 +53,42 @@ function waitForThemeReady(sentinel, event, timeout) {
41
53
  }, { once: true });
42
54
  });
43
55
  }
56
+ /** The connection gives base64 text or raw bytes, and a caller of the capture reads base64 text alone. */
57
+ function toBase64(shot) {
58
+ return typeof shot === "string" ? shot : Buffer.from(shot).toString("base64");
59
+ }
60
+ /**
61
+ * Take the picture of one settled page, at the whole document or at the window alone.
62
+ *
63
+ * The compositor can refuse a full-page bitmap of a tall page while the bitmap of the window is fine. A
64
+ * degraded picture beats a dead look, thus a refused full page retries one time at the window. A second throw
65
+ * names a broken browser and not a tall page, thus it propagates to the caller.
66
+ */
67
+ async function captureShot(page) {
68
+ try {
69
+ return { screenshotBase64: toBase64(await page.screenshot({ encoding: "base64", fullPage: true })), coverage: "full" };
70
+ }
71
+ catch (refusedFullPage) {
72
+ try {
73
+ return { screenshotBase64: toBase64(await page.screenshot({ encoding: "base64" })), coverage: "viewport" };
74
+ }
75
+ catch (refusedViewport) {
76
+ // The two refusals together are the account of a dead look, and the caller reports the second one
77
+ // alone. Thus the first one rides as the cause, and no reader of that report loses half of it.
78
+ if (refusedViewport instanceof Error && refusedViewport.cause === undefined) {
79
+ refusedViewport.cause = refusedFullPage;
80
+ }
81
+ throw refusedViewport;
82
+ }
83
+ }
84
+ }
44
85
  /**
45
86
  * Capture one page: the screenshot, the console errors, and the failed requests.
46
87
  *
88
+ * The picture holds the whole document, and not the window alone. Thus a caller can judge a section that a
89
+ * reader reaches by a scroll. A refused full-page bitmap degrades to the window, and the coverage of the
90
+ * result names which of the two pictures arrived.
91
+ *
47
92
  * The readiness wait is best-effort. A page that never signals still captures at the readiness budget, thus
48
93
  * a broken page gives a picture that shows what broke.
49
94
  */
@@ -59,6 +104,12 @@ export function capturePage(chrome, url, options = {}) {
59
104
  page.on("requestfailed", (req) => {
60
105
  failedRequests.push({ url: req.url(), reason: req.failure()?.errorText ?? "unknown" });
61
106
  });
107
+ // The size is set before the navigation, because a layout resolves at load time. The connection gives
108
+ // a small default window, and that window collapses each multi-column band of the design.
109
+ await page.setViewport({ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT });
110
+ // The preference is active before the navigation, because the page reveals its sections as it loads.
111
+ // A preference that arrives after the load reaches a page that already runs its transitions.
112
+ await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
62
113
  await page.goto(url, { waitUntil: "networkidle2", timeout: PAGE_NAV_TIMEOUT_MS });
63
114
  // The two names and the budget ride as arguments, because the browser context has no module scope.
64
115
  await page.evaluate(waitForThemeReady, THEME_READY_SENTINEL, THEME_READY_EVENT, PAGE_READY_TIMEOUT_MS).catch(() => {
@@ -73,9 +124,12 @@ export function capturePage(chrome, url, options = {}) {
73
124
  const settle = options.waitMs;
74
125
  await new Promise((resolve) => setTimeout(resolve, settle));
75
126
  }
76
- const screenshot = await page.screenshot({ encoding: "base64", fullPage: false });
127
+ // The picture must show the whole document at the layout that a reader gets. Thus a defect below the
128
+ // fold is visible, and a question about content that never appeared has an answer.
129
+ const shot = await captureShot(page);
77
130
  return {
78
- screenshotBase64: typeof screenshot === "string" ? screenshot : Buffer.from(screenshot).toString("base64"),
131
+ screenshotBase64: shot.screenshotBase64,
132
+ coverage: shot.coverage,
79
133
  consoleErrors,
80
134
  failedRequests,
81
135
  };
@@ -11,7 +11,7 @@ import { resultStep } from "./run-step.js";
11
11
  import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
12
12
  import { isToolError, readToolResultImage } from "../tools/define-tool.js";
13
13
  import { addChatUsage, hasReportedUsage, recordAgentRun } from "./metrics.js";
14
- import { computeDetail } from "./tool-detail.js";
14
+ import { computeDetail, computeResultDetail } from "./tool-detail.js";
15
15
  import { toolOutcomeForOutputType } from "./tool-outcome.js";
16
16
  const TRUNCATED_PROSE_STEER = "Your previous reply was cut off at the output-token limit; continue concisely, or finish via your terminal tool.";
17
17
  const TRUNCATED_TOOL_USE_ERROR = "Your previous tool call was cut off at the output-token limit and was not executed. Retry with a smaller payload, writing in incremental pieces.";
@@ -145,7 +145,8 @@ export async function runAgent(agent, initial, session, opts) {
145
145
  *
146
146
  * Computed once per round and carried onto both the `tool-started` and the
147
147
  * `tool-finished` event, so the pair a host renders as one chip cannot show
148
- * two different descriptions of the same call.
148
+ * two different descriptions of the same call. A tool that describes its own
149
+ * result replaces the second half of that pair with the outcome it produced.
149
150
  */
150
151
  const roundDetails = (calls) => calls.map((tu) => {
151
152
  const tool = toolsById.get(tu.toolName);
@@ -160,11 +161,11 @@ export async function runAgent(agent, initial, session, opts) {
160
161
  * it is not a thrown failure, so nothing reports it and the loop simply feeds it
161
162
  * back — and a run that ends badly is usually a run whose tool calls were failing.
162
163
  *
163
- * `details` and `durations` are positionally aligned with `calls`. `dispatchTools`
164
- * measures a duration around the call itself, because this sink emits every finish
165
- * event only after the whole round settles.
164
+ * `details`, `resultDetails` and `durations` are positionally aligned with `calls`.
165
+ * `dispatchTools` measures a duration around the call itself, because this sink emits
166
+ * every finish event only after the whole round settles.
166
167
  */
167
- const settleRound = async (calls, results, details, durations) => {
168
+ const settleRound = async (calls, results, details, resultDetails, durations) => {
168
169
  const errored = [];
169
170
  for (let idx = 0; idx < calls.length; idx++) {
170
171
  const tu = calls[idx];
@@ -183,7 +184,11 @@ export async function runAgent(agent, initial, session, opts) {
183
184
  toolUseId: tu.toolCallId,
184
185
  name: tu.toolName,
185
186
  outcome,
186
- ...detailField(details[idx]),
187
+ // The result detail wins where there is one, and the call detail
188
+ // stands otherwise. Only the ok branch of a dispatch computes a
189
+ // result detail, thus an error and a denial reach here with none
190
+ // and keep the line the `tool-started` carried.
191
+ ...detailField(resultDetails[idx] ?? details[idx]),
187
192
  ...durationField(durations[idx]),
188
193
  });
189
194
  }
@@ -242,8 +247,8 @@ export async function runAgent(agent, initial, session, opts) {
242
247
  for (const [idx, tu] of earlier.entries()) {
243
248
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(earlierDetails[idx]) });
244
249
  }
245
- const { results, durations } = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool, encoding);
246
- const errored = await settleRound(earlier, results, earlierDetails, durations);
250
+ const { results, durations, resultDetails } = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool, encoding);
251
+ const errored = await settleRound(earlier, results, earlierDetails, resultDetails, durations);
247
252
  results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
248
253
  // The trailing call was never dispatched, but it reaches the model as an error
249
254
  // result like any other — so it counts, or `toolErrors` reports a cleaner run
@@ -273,8 +278,8 @@ export async function runAgent(agent, initial, session, opts) {
273
278
  for (const [idx, tu] of toolCalls.entries()) {
274
279
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(details[idx]) });
275
280
  }
276
- const { results, durations } = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool, encoding);
277
- const errored = await settleRound(toolCalls, results, details, durations);
281
+ const { results, durations, resultDetails } = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool, encoding);
282
+ const errored = await settleRound(toolCalls, results, details, resultDetails, durations);
278
283
  if (errored.length > 0)
279
284
  log.debug("tool results returned errors", { iteration: i, tools: errored });
280
285
  messages.push({ role: "tool", content: results });
@@ -438,7 +443,7 @@ function appendDeferredImages(messages, results, deferredImages) {
438
443
  /**
439
444
  * Dispatch one round of tool calls, and measure the time of each call.
440
445
  *
441
- * `results` and `durations` are positionally aligned with `toolUses`.
446
+ * `results`, `durations` and `resultDetails` are positionally aligned with `toolUses`.
442
447
  *
443
448
  * Each measurement brackets the same unit that the loop awaits for that call. For a
444
449
  * step-mode call that unit is `runStep`, thus the figure includes the durable-step
@@ -451,6 +456,10 @@ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, run
451
456
  // says so, thus it agrees with what `settleRound` accepts. Every index is in
452
457
  // fact assigned, because the mode partition below covers each call.
453
458
  const durations = new Array(toolUses.length);
459
+ // A hole stays a hole for a call that described no result: an error, a
460
+ // denial, and a tool with no result hook each leave the index unassigned,
461
+ // and `settleRound` then reads the call detail.
462
+ const resultDetails = new Array(toolUses.length);
454
463
  const stepTools = [];
455
464
  const workflowTools = [];
456
465
  const inlineTools = [];
@@ -465,31 +474,53 @@ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, run
465
474
  }
466
475
  await Promise.all(stepTools.map(({ tu, idx }) => {
467
476
  const startedAt = performance.now();
468
- return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding)).then((r) => {
477
+ return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding)).then((settled) => {
469
478
  durations[idx] = elapsedMs(startedAt);
470
- results[idx] = r;
479
+ const dispatched = readDispatchedStep(settled);
480
+ results[idx] = dispatched.result;
481
+ if (dispatched.detail !== undefined)
482
+ resultDetails[idx] = dispatched.detail;
471
483
  });
472
484
  }));
473
485
  for (const { tu, idx } of workflowTools) {
474
486
  const startedAt = performance.now();
475
- results[idx] = await dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding);
487
+ const dispatched = await dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding);
488
+ results[idx] = dispatched.result;
489
+ if (dispatched.detail !== undefined)
490
+ resultDetails[idx] = dispatched.detail;
476
491
  durations[idx] = elapsedMs(startedAt);
477
492
  }
478
493
  for (const { tu, idx } of inlineTools) {
479
494
  const startedAt = performance.now();
480
- results[idx] = await dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding);
495
+ const dispatched = await dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding);
496
+ results[idx] = dispatched.result;
497
+ if (dispatched.detail !== undefined)
498
+ resultDetails[idx] = dispatched.detail;
481
499
  durations[idx] = elapsedMs(startedAt);
482
500
  }
483
- return { results, durations };
501
+ return { results, durations, resultDetails };
484
502
  }
485
503
  /** Whole milliseconds since `startedAt`, on the monotonic clock that took that mark. */
486
504
  function elapsedMs(startedAt) {
487
505
  return Math.round(performance.now() - startedAt);
488
506
  }
507
+ /**
508
+ * Read one settled step-mode dispatch, tolerating the bare result beside the pair.
509
+ *
510
+ * A step-mode call goes through `runStep`, which caches what the body returned and hands
511
+ * that cached value back on a replay. The durable step store therefore holds values that
512
+ * a DIFFERENT build of this file wrote: a workflow in flight when the shape of the return
513
+ * widened replays the bare `ToolResultPart` its own run cached, and it never re-enters the
514
+ * body that would produce the pair. The replay boundary is the whole reason for the test:
515
+ * a bare value carries no detail, thus the call keeps the line its `tool-started` showed.
516
+ */
517
+ function readDispatchedStep(settled) {
518
+ return "result" in settled ? settled : { result: settled };
519
+ }
489
520
  async function dispatchTool(tu, toolsById, ctx, isFatalLoopError, encoding) {
490
521
  const tool = toolsById.get(tu.toolName);
491
522
  if (tool === undefined) {
492
- return errorResult(tu, `unknown tool: ${tu.toolName}`);
523
+ return { result: errorResult(tu, `unknown tool: ${tu.toolName}`) };
493
524
  }
494
525
  const parsed = tool.inputSchema.safeParse(tu.input);
495
526
  if (parsed.success)
@@ -502,21 +533,25 @@ async function dispatchTool(tu, toolsById, ctx, isFatalLoopError, encoding) {
502
533
  const repaired = repairedInput === undefined ? undefined : tool.inputSchema.safeParse(repairedInput);
503
534
  if (repaired?.success === true)
504
535
  return execute(tu, tool, repaired.data, ctx, isFatalLoopError, encoding);
505
- return errorResult(tu, `input validation failed: ${formatZodIssues(parsed.error, tu.input)}`);
536
+ return { result: errorResult(tu, `input validation failed: ${formatZodIssues(parsed.error, tu.input)}`) };
506
537
  }
507
538
  async function execute(tu, tool, input, ctx, isFatalLoopError, encoding) {
508
539
  try {
509
540
  const output = await tool.execute(input, ctx);
510
541
  if (output.isErr())
511
- return errorResult(tu, toolErrorContent(output.error));
512
- return successResult(tu, output.value, encoding);
542
+ return { result: errorResult(tu, toolErrorContent(output.error)) };
543
+ // The one place a result description runs: the ok value in hand, the
544
+ // input already validated, and the guard inside the compute.
545
+ const detail = computeResultDetail(tool, input, output.value, encoding.log);
546
+ const result = successResult(tu, output.value, encoding);
547
+ return detail === undefined ? { result } : { result, detail };
513
548
  }
514
549
  catch (err) {
515
550
  if (isFatalLoopError(err))
516
551
  throw err;
517
552
  if (isAskRejected(err))
518
- return deniedResult(tu, err.feedback);
519
- return errorResult(tu, toolErrorContent(err));
553
+ return { result: deniedResult(tu, err.feedback) };
554
+ return { result: errorResult(tu, toolErrorContent(err)) };
520
555
  }
521
556
  }
522
557
  /**
@@ -2,8 +2,10 @@
2
2
  * The call detail: one line naming what a tool call is doing.
3
3
  *
4
4
  * A tool states it through its own `describeCall` hook, colocated with the Zod
5
- * `inputSchema` the compiler checks it against. Everything else about the
6
- * detail happens here, once, for every tool: validate, guard, normalize.
5
+ * `inputSchema` the compiler checks it against, and through its optional
6
+ * `describeResult` hook, which names what the call produced. Everything else
7
+ * about the detail happens here, once, for every tool: validate, guard,
8
+ * normalize.
7
9
  *
8
10
  * Two rules make this the only place that matters:
9
11
  *
@@ -92,3 +94,18 @@ export declare function normalizeDetail(raw: unknown): ToolCallDetail | undefine
92
94
  * second time from the repaired value costs more than the case is worth.
93
95
  */
94
96
  export declare function computeDetail(tool: Tool, rawInput: unknown, log: Logger): ToolCallDetail | undefined;
97
+ /**
98
+ * Compute a tool call's result detail, best-effort.
99
+ *
100
+ * `parsedInput` is the value the dispatch already validated, and `result` is the
101
+ * ok value the tool returned one line above. Thus neither is re-parsed here: a
102
+ * second parse of the input would repeat work the dispatch did, and the result
103
+ * satisfies no schema at all — the tool's own `Output` type is what the hook
104
+ * declares, and the tool is the author of both.
105
+ *
106
+ * The guard is the whole difference from a bare call. A hook that throws, returns
107
+ * a non-string, or returns nothing usable yields no result detail, and the
108
+ * finished event falls back on the call detail. A description must never be able
109
+ * to fail a call that already succeeded.
110
+ */
111
+ export declare function computeResultDetail(tool: Tool, parsedInput: unknown, result: unknown, log: Logger): ToolCallDetail | undefined;