diff --git a/.claude/skills/openbot-screen-layout/SKILL.md b/.claude/skills/openbot-screen-layout/SKILL.md index 958b5a120..9ceafff9f 100644 --- a/.claude/skills/openbot-screen-layout/SKILL.md +++ b/.claude/skills/openbot-screen-layout/SKILL.md @@ -143,7 +143,7 @@ itself when the dialog changes something. ## The rem Trap -`app/src/styles.css` sets `html { font-size: 15px }`. Every rem-based Tailwind token is therefore +`app/src/theme.css` sets `html { font-size: 15px }`. Every rem-based Tailwind token is therefore **0.9375×** its documented pixel value: | Token | Documented | Actual here | diff --git a/.env.example b/.env.example index acc971401..675b6bc41 100644 --- a/.env.example +++ b/.env.example @@ -166,6 +166,11 @@ COPILOTKIT_LICENSE_TOKEN= # CPK_INTELLIGENCE_SKILLS_REVISION=exact-revision-id # See docs/automatic-learning.md for managed/self-hosted setup and permissions. +# OpenBot accepts the OpenBot Recorder extension only from its own extension id. A fork, or a build +# signed with another key, has another id: list it here, comma separated, each 32 letters from a to +# p. Any other value stops the server at boot. +# OPENBOT_RECORDER_EXTRA_EXTENSION_IDS= + # How long a Bot's stream may say nothing before this deployment gives up on the turn, in # milliseconds. A Bot is any AG-UI endpoint, which means it will be redeployed mid-answer, its own # upstream will time out, and it will sometimes accept a connection and then write nothing at all. @@ -207,6 +212,11 @@ OPENAI_API_KEY= # BOT_MODEL=openai/gpt-5.5 # # OPENAI_BASE_URL= +# Behind an OPENAI_BASE_URL that is not OpenAI or Azure, built-in Bots call the endpoint's Chat +# Completions API, which is what most compatible endpoints serve. A gateway that also serves the +# Responses API keeps Responses-only features, such as reasoning summaries, with `responses`. +# `chat` forces Chat Completions; any other value is an error when a built-in Bot runs. +# COPILOTKIT_OPENAI_API=responses # Optional dictation, independent of every agent/chat provider. Set provider, base URL, # and model together. The endpoint must implement POST /audio/transcriptions under this diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index f6e652795..01b6e14f1 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -9,10 +9,10 @@ name: Desktop # it at runtime with an `expect`, so a bad row panics the window rather than failing a type check. on: pull_request: - paths: ["desktop/**", "shared/harnesses.json", "shared/marks/**", "package.json", ".github/workflows/desktop.yml"] + paths: ["desktop/**", "shared/harnesses.json", "shared/marks/**", "shared/recorder-extension.json", "package.json", ".github/workflows/desktop.yml"] push: branches: [main] - paths: ["desktop/**", "shared/harnesses.json", "shared/marks/**", "package.json", ".github/workflows/desktop.yml"] + paths: ["desktop/**", "shared/harnesses.json", "shared/marks/**", "shared/recorder-extension.json", "package.json", ".github/workflows/desktop.yml"] workflow_call: secrets: OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET: @@ -143,6 +143,18 @@ jobs: - name: Rust regression tests run: cargo test --locked --lib --bins working-directory: desktop/src-tauri + # The binary run as the Recorder's browsers run it: the native messaging host Chrome starts, and + # the install that writes its manifests. The host name and extension id come from + # shared/recorder-extension.json, which the Rust reads at compile time. + - name: Recorder native host tests + run: cargo test --locked --test recorder_host + working-directory: desktop/src-tauri + # Only a release build is a GUI-subsystem executable on Windows, and only a release build proves + # the debug-only test seam is absent. + - name: Recorder native host tests (release) + if: matrix.platform.name == 'windows' + run: cargo test --locked --release --test recorder_host + working-directory: desktop/src-tauri # Hosted Windows runners are administrators. Exercise setup again with an actual Users-only # account: DISM's feature reads worked as CI's administrator but refused ordinary app users. - name: Windows standard-user setup regression diff --git a/.gitignore b/.gitignore index 4a7ca18ab..5b6adc786 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ venv/ .pytest_cache/ .ruff_cache/ .mypy_cache/ + +# The Recorder extension's signing key never enters the repository (Decision 9). +extension/**/*.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1e6a8af..5c9490e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -**Before upgrading.** Five things change for an existing deployment: +**Before upgrading.** Seven things change for an existing deployment: - A hosted computer's browser is now headed by default. It uses a little more memory than headless did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old behaviour. @@ -31,6 +31,73 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0056_sign_in_code_step` widens the check on `sign_in_requests.status` to allow `code_required` (below). A server and a computer must be upgraded together: an older computer has no `/sign-in/code`. +- Migration `0057_recorder_extension` adds six tables for the OpenBot Recorder and each person's own + Learning space (below): `recorder_connect_codes`, `recorder_connections`, + `recorder_trajectory_bindings`, `learning_person_spaces`, `learning_skill_sharing` and + `learning_open_skills`. Nothing existing changes, and the tables stay empty until somebody + connects a browser or changes Settings → Learning. +- The CopilotKit runtime moves to 1.79.1. A built-in Bot whose `openai/...` model is reached + through an `OPENAI_BASE_URL` that is not OpenAI or Azure now calls that endpoint's Chat + Completions API (`/chat/completions`) instead of the Responses API. That is what most + OpenAI-compatible endpoints serve, so Bots behind one of them now work; a gateway that also serves + the Responses API loses Responses-only features, such as reasoning summaries, without an error. + `COPILOTKIT_OPENAI_API=responses` keeps the Responses API (`chat` forces Chat Completions); any + other value is an error when a built-in Bot runs. Deployments on OpenAI or Azure are unchanged. + +### Teach a Bot by doing the task in your own browser: the OpenBot Recorder + +The OpenBot Recorder is a Chrome and Edge extension. A person names the task, does it in their own +browser, and the Recorder sends what they did, step by step (each click, entry, choice, submit, +Enter and page change, with what an agent needs to find that element again, and the one request a +step sent when it saved something), masked in the browser first, straight to Intelligence as a +trajectory in that person's own Learning space; the page's own traffic is never recorded, except the +agent's AG-UI events (below). Every Bot +they use then gets the Skills learned there, beside the Bot's own. OpenBot connects the browser and +hands out each capture pass; it receives nothing that is recorded. + +- **The agent's side too.** On a page whose agent speaks AG-UI (over SSE or the Intelligence + WebSocket, OpenBot's own chat included), the Recorder also sends the agent's side, in exactly the + shape the CopilotKit SDK emits so Intelligence cannot tell the two apart: `agent.run`, + `agent.message` (the person's own messages too), `tool.call` and `thread.linked`, a click inside a + chat message with its Thread and message, and `agent.event` for what those leave out (a tool + call's arguments and result, state, reasoning, errors), never one row per streamed token. + CopilotKit's suggestion runs are left out, as its own capture leaves them out. +- **Clicks and typing come from CopilotKit main's own capture code**, carried as a Bun patch over + `@copilotkit/learning` 0.0.1 (`patches/`, which the images and OpenBot Desktop copy) until main is + published. +- **Settings → Browsers** connects the Recorder in this browser and lists every browser connected + as you, each with a Disconnect. OpenBot Desktop can connect the Recorder on its own computer too. +- **Only a connected browser gets a capture pass.** The runtime's `trajectory//connect` route + answers only the Recorder's own bearer, and binds the trajectory to that person; no Bot is + picked or named. A signed-in session alone gets no pass, and the Recorder's bearer cannot run a + Bot or read a conversation. +- **One Learning space per person.** Every recording joins the person's own Intelligence Learning + container, `user-` and 32 hex digits derived from their OpenBot user id. OpenBot makes it when + the browser connects (and again on a capture pass if it is still missing) with the same + credential that makes the `openbot` container: a service token on a CopilotKit local evaluation + install, or a CopilotKit CLI login against managed Intelligence. Intelligence's runtime key + cannot make one. With neither, a missing space cannot be made, and the Recorder's popup says + Learning will not see the recording; the recording still goes to Intelligence. +- **Every Bot a person uses draws on their space.** A run gets the Bot's own learned Skills, then + the person's own, then any another person opened (below). On a name clash the Bot's own wins, + then the person's own; two people's opened Skills with one name are both kept, each named + `/`. A person's space that is missing or unreachable never fails a run: it + goes on with the Skills it has. Admin → Automatic Learning off, or a Bot excluded there, still + means no learned Skills at all. +- **Settings → Learning** chooses who else's Bots use a person's Skills: only their own (the + default), every Bot in the deployment, or the Skills they open one by one. Runs read what is + open from memory, refreshed at once on the replica that saved a change and within 30 seconds on + the others. +- **Intelligence must allow trajectories:** the `trajectories` licence feature, + `FF_LEARNING_TRAJECTORIES=true`, a realtime gateway that can ask Intelligence whether an + organization may capture, and an `INTELLIGENCE_GATEWAY_WS_URL` that people's browsers can reach. +- **`OPENBOT_RECORDER_EXTRA_EXTENSION_IDS`** lists other extension ids to trust besides the + Recorder's own, for a fork or a build signed with another key. Each must be 32 letters from a to + p, comma separated; **a value that is not stops the server at boot.** +- Connecting, disconnecting and refusals are audited as `recorder.connected`, `recorder.revoked`, + `recorder.connect_refused`, `recorder.bearer_refused` and `recorder.trajectory_refused`; changing + Settings → Learning as `learning.sharing_changed`, `learning.skill_opened` and + `learning.skill_closed`. ### A new desktop install starts its server again diff --git a/Dockerfile b/Dockerfile index 0152296c5..c966c49cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,12 +51,15 @@ WORKDIR /src # Manifests first, so editing a source file does not reinstall the world. COPY package.json bun.lock ./ +# The dependency patches package.json names in patchedDependencies: the install stops without them. +COPY patches patches COPY tsconfig.base.json bunfig.toml ./ COPY app/package.json app/package.json COPY server/package.json server/package.json COPY worker/package.json worker/package.json COPY shared/package.json shared/package.json COPY toolkit/package.json toolkit/package.json +COPY extension/package.json extension/package.json RUN bun install --frozen-lockfile # The lockfile travels with the manifest, because `--frozen-lockfile` with no lockfile in the context @@ -69,13 +72,14 @@ RUN cd agent-computer && bun install --frozen-lockfile # A second tree with the build-time dependencies left out, for the runtime stage to take. Vite, # biome and the test tooling are a gigabyte that nothing in a running container imports. -RUN mkdir -p /prod && cp package.json bun.lock /prod/ \ - && cd /prod && mkdir -p app server worker shared toolkit \ +RUN mkdir -p /prod && cp package.json bun.lock /prod/ && cp -R patches /prod/patches \ + && cd /prod && mkdir -p app server worker shared toolkit extension \ && cp /src/app/package.json app/package.json \ && cp /src/server/package.json server/package.json \ && cp /src/worker/package.json worker/package.json \ && cp /src/shared/package.json shared/package.json \ && cp /src/toolkit/package.json toolkit/package.json \ + && cp /src/extension/package.json extension/package.json \ && bun install --frozen-lockfile --production @@ -127,6 +131,8 @@ WORKDIR /app COPY --from=deps /prod/node_modules node_modules COPY --from=deps /prod/server/node_modules server/node_modules COPY --from=deps /prod/toolkit/node_modules toolkit/node_modules +# shared files import zod at runtime, and the isolated install resolves it from shared/node_modules +COPY --from=deps /prod/shared/node_modules shared/node_modules COPY --from=deps /src/package.json package.json COPY --from=deps /src/bun.lock bun.lock # The browser's tree is a separate install root with its own lockfile rather than a workspace of the diff --git a/README.md b/README.md index 7d5b93677..ed2b9a316 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), ## Requirements - Docker, for PostgreSQL and the shipped Bots. -- [Bun](https://bun.sh) 1.3+, for the app and API server. +- [Bun](https://bun.sh) 1.4+, for the app, the API server and the tests (some tests fail on 1.3). - A CopilotKit Intelligence project and license. A free plan is available, and Intelligence can be self-hosted. - A model key. The proof-of-concept Bot uses OpenAI; the LangGraph Bot can use OpenAI, Anthropic, or Google. diff --git a/agent-computer/Dockerfile b/agent-computer/Dockerfile index 42bf6da34..94a41bb89 100644 --- a/agent-computer/Dockerfile +++ b/agent-computer/Dockerfile @@ -29,8 +29,10 @@ RUN bun install --frozen-lockfile COPY agent-computer/src ./src # index.ts imports ../../shared/file-download at runtime (the other shared imports are type-only and -# erased), so the one file it needs goes where that path resolves from /app/src. +# erased), and demonstration.ts imports ../../shared/demonstration-rules at runtime too, so the files +# they need go where those paths resolve from /app/src. COPY shared/file-download.ts /shared/file-download.ts +COPY shared/demonstration-rules.ts /shared/demonstration-rules.ts # Durable working directory. Bot-produced files that must survive replacement live here. RUN mkdir -p /workspace diff --git a/agent-computer/src/demonstration.ts b/agent-computer/src/demonstration.ts index b55c274a3..3cba4cdea 100644 --- a/agent-computer/src/demonstration.ts +++ b/agent-computer/src/demonstration.ts @@ -1,63 +1,11 @@ import type { Page } from "playwright"; +import { + describeDemonstrationTarget, + gestureForRecording, + safeDemonstrationUrl, +} from "../../shared/demonstration-rules"; import type { InputMessage } from "./screencast"; -export type RecordedGesture = { - kind: "click" | "type" | "key" | "scroll"; - key?: string; - deltaY?: number; -}; -export function gestureForRecording( - input: InputMessage, -): RecordedGesture | null { - if (input.type === "mouse") - return input.event === "released" && input.button !== "right" - ? { kind: "click" } - : null; - if (input.type === "wheel") - return { - kind: "scroll", - deltaY: Math.max(-2000, Math.min(2000, input.deltaY)), - }; - if (input.type === "text") return { kind: "type" }; - if (input.event !== "down") return null; - if (input.key.length === 1 || (input.text && input.key !== "Enter")) - return { kind: "type" }; - if ( - [ - "Enter", - "Tab", - "Escape", - "Backspace", - "Delete", - "ArrowDown", - "ArrowUp", - "ArrowLeft", - "ArrowRight", - "Home", - "End", - "PageDown", - "PageUp", - ].includes(input.key) - ) - return { kind: "key", key: input.key }; - return null; -} -export function safeDemonstrationUrl(raw: string): string { - try { - const url = new URL(raw); - if (!["http:", "https:"].includes(url.protocol)) return "about:blank"; - const path = url.pathname - .split("/") - .map((part) => - part.length > 40 || /token|secret|password|credential/i.test(part) - ? "[redacted]" - : part, - ) - .join("/"); - return `${url.origin}${path}`; - } catch { - return "about:blank"; - } -} + /** Never reads a field's value, page body, clipboard or image. All text entry is parameterized. */ export async function describeHumanGesture(page: Page, input: InputMessage) { const gesture = gestureForRecording(input); @@ -66,77 +14,6 @@ export async function describeHumanGesture(page: Page, input: InputMessage) { input.type === "mouse" || input.type === "wheel" ? { x: input.x, y: input.y } : null; - const target = await page.evaluate((point) => { - const hit = point - ? document.elementFromPoint(point.x, point.y) - : document.activeElement; - const closest = - hit?.closest( - "button,a,input,textarea,select,label,[role],[contenteditable=true]", - ) ?? hit; - // A click on a label's text is a click on the control it names: "Small", a radio, not a label. - const element = - closest instanceof HTMLLabelElement - ? (closest.control ?? closest) - : closest; - if (!element) return { role: "page", name: "Page", sensitive: false }; - const tag = element.tagName.toLowerCase(); - const inputType = - element instanceof HTMLInputElement ? element.type.toLowerCase() : ""; - const role = - element.getAttribute("role") || - ( - { - checkbox: "checkbox", - radio: "radio", - button: "button", - submit: "button", - reset: "button", - image: "button", - range: "slider", - file: "button", - } as Record - )[inputType] || - ( - { - button: "button", - a: "link", - input: "textbox", - textarea: "textbox", - select: "combobox", - } as Record - )[tag] || - tag; - const label = - element.getAttribute("aria-label") || - (element instanceof HTMLInputElement || - element instanceof HTMLTextAreaElement || - element instanceof HTMLSelectElement - ? Array.from(element.labels ?? []) - .map((entry) => entry.textContent ?? "") - .join(" ") - : "") || - (tag === "button" || tag === "a" ? element.textContent : "") || - element.getAttribute("placeholder") || - role; - const sensitive = - element.getAttribute("type") === "password" || - // Short words only as whole words: "pin" is in "topping" and "shipping", "otp" in "hotpot". - /password|secret|token|one.time|verification|credit.card|security.code|passcode|(?:^|[^a-z])(?:cc-|(?:cvc|cvv|pin|otp)(?![a-z]))/i.test( - [ - label, - element.getAttribute("name"), - element.getAttribute("autocomplete"), - ].join(" "), - ); - const name = sensitive - ? "[sensitive field]" - : label - .replace(/[A-Za-z0-9_+/=-]{32,}/g, "[redacted]") - .replace(/[^\s@]+@[^\s@]+/g, "[redacted]") - .trim() - .slice(0, 120); - return { role: role.slice(0, 40), name: name || role, sensitive }; - }, point); + const target = await page.evaluate(describeDemonstrationTarget, { point }); return { ...gesture, url: safeDemonstrationUrl(page.url()), target }; } diff --git a/agent-computer/tests/demonstration.test.ts b/agent-computer/tests/demonstration.test.ts deleted file mode 100644 index f35ab554b..000000000 --- a/agent-computer/tests/demonstration.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, test } from "bun:test"; -import { - gestureForRecording, - safeDemonstrationUrl, -} from "../src/demonstration"; - -test("passwords, pasted text and printable key values are never part of a recorded gesture", () => { - expect( - gestureForRecording({ type: "text", text: "secret-password" }), - ).toEqual({ kind: "type" }); - expect( - gestureForRecording({ - type: "key", - event: "down", - key: "s", - code: "KeyS", - text: "s", - }), - ).toEqual({ kind: "type" }); -}); -test("pointer movement and releases are not false completed actions", () => { - expect( - gestureForRecording({ type: "mouse", event: "moved", x: 1, y: 2 }), - ).toBeNull(); - expect( - gestureForRecording({ - type: "key", - event: "up", - key: "Enter", - code: "Enter", - }), - ).toBeNull(); -}); -test("URL provenance omits credentials, query tokens and fragments", () => { - expect( - safeDemonstrationUrl( - "https://user:password@example.test/form?token=secret#secret", - ), - ).toBe("https://example.test/form"); -}); diff --git a/app/package.json b/app/package.json index f741d78e5..0fdbcf6ad 100644 --- a/app/package.json +++ b/app/package.json @@ -14,11 +14,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@ag-ui/core": "1.0.1", + "@ag-ui/core": "1.0.2", "@base-ui/react": "^1.6.0", "@better-auth/sso": "^1.7.1", - "@copilotkit/a2ui-renderer": "1.77.0", - "@copilotkit/react-core": "1.77.0", + "@copilotkit/a2ui-renderer": "1.79.1", + "@copilotkit/react-core": "1.79.1", "@fontsource-variable/inter": "^5.3.0", "@openbot/shared": "workspace:*", "@shadcn/react": "^0.3.0", diff --git a/app/src/components/layout/page-parts.tsx b/app/src/components/layout/page-parts.tsx new file mode 100644 index 000000000..1d52b4942 --- /dev/null +++ b/app/src/components/layout/page-parts.tsx @@ -0,0 +1,176 @@ +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +/** + * `prose` is the default because configuration is mostly reading. A measure of around 65 characters + * is where prose stays comfortable, and a row of label-and-control has no business being wider than + * the sentence explaining it. + * + * `wide` exists for one page. An audit log is a table you scan, and forcing it into the prose column + * would wrap every row. It is not a licence for anything else to be wide. + */ +export type ShellWidth = "prose" | "wide"; + +const WIDTHS: Record = { + prose: "max-w-2xl", + wide: "max-w-5xl", +}; + +/** + * The scroller and the centred column every configuration screen sits in, without the bar. The bar + * needs the router, so `PageShell` draws it and passes `bar` to say whether it did; this file + * imports nothing from the router so the extension can render the same column. + */ +export function PageColumn({ + bar, + children, + className, + width = "prose", +}: { + bar: boolean; + children: React.ReactNode; + className?: string; + width?: ShellWidth; +}) { + return ( + <> + {/* + * The scroller, and it has to live here rather than in either shell, because the two shells + * this frame is used under disagree about who scrolls. + * + * Under `_authed` — admin, settings — the document scrolls, so a page taller than the window + * has always just worked. Under `_authed/_app` it does not: that shell is `h-svh + * overflow-hidden` on purpose ("one viewport, never scrolls: panes scroll inside it") and its + * `main` is `overflow-hidden` too, so a PageShell taller than the window was silently CLIPPED + * — 246px of Skills sat below the fold with no way to reach it, by keyboard or otherwise. + * + * `min-h-0` is the load-bearing half of `min-h-0 flex-1`: a flex item's default `min-height: + * auto` refuses to shrink below its content, so `flex-1` alone would grow this past the pane + * and clip exactly as before. Under `_authed` both are inert — the parent is not a flex + * container — and `overflow-y-auto` on an auto-height element shows no scrollbar, so those + * eighteen screens are unaffected. + * + * Separate from the centred column below so the scrollbar rides the pane's edge instead of + * appearing inside a 630px measure with content either side of it. + */} +
+
+ {children} +
+
+ + ); +} + +/** The page's title, its one primary action and its description. */ +export function PageHeader({ + action, + description, + title, +}: { + action?: React.ReactNode; + description?: React.ReactNode; + title: string; +}) { + return ( +
+
+

{title}

+ {action} +
+ {description ? ( +

+ {description} +

+ ) : null} +
+ ); +} + +/** + * A titled group of rows. + * + * The gap above a section is deliberately large. These pages are lists of unrelated decisions, and + * the space is what stops the eye running two of them together — it is doing the work a divider + * would otherwise do, without drawing a line. + */ +export function PageSection({ + action, + children, + className, + description, + title, +}: { + action?: React.ReactNode; + children?: React.ReactNode; + className?: string; + description?: React.ReactNode; + title?: string; +}) { + return ( +
+ {title ? ( +
+

{title}

+ {action} +
+ ) : null} + {description ? ( +

+ {description} +

+ ) : null} + {children} +
+ ); +} + +/** + * The card that rows live in. + * + * Rows are `` with a `` between them; this is only the border around + * them. Rendering nothing when there is nothing is deliberate — an empty bordered box reads as a + * thing that failed to load, where absence reads as nothing to see yet. + */ +export function PageRows({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + /* + * The rows are squared off and the card clips them. `Item` carries `rounded-lg` of its own, which + * inside a card of divided rows painted a hover as a floating pill: a middle row has no corners, + * and the first and last cannot be concentric with the card while sitting a border inside it. + */ +
+ {children} +
+ ); +} + +/** + * What a section says when it has nothing to list. + * + * Said in a sentence rather than drawn as an illustration with a heading: on a configuration screen + * "nothing here yet" is a fact, not an event, and the page already told you what the section is for. + */ +export function PageEmpty({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx index 15d9b4e00..652f3c546 100644 --- a/app/src/components/layout/page-shell.tsx +++ b/app/src/components/layout/page-shell.tsx @@ -1,8 +1,8 @@ import { IconChevronLeft } from "@tabler/icons-react"; import { Link, type LinkProps } from "@tanstack/react-router"; import type * as React from "react"; -import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; +import { PageColumn, PageHeader, type ShellWidth } from "./page-parts"; import { SidebarToggle, useSidebarToggleVisible } from "./sidebar-toggle"; /** @@ -21,21 +21,6 @@ import { SidebarToggle, useSidebarToggleVisible } from "./sidebar-toggle"; * whichever it happened to see first. */ -/** - * `prose` is the default because configuration is mostly reading. A measure of around 65 characters - * is where prose stays comfortable, and a row of label-and-control has no business being wider than - * the sentence explaining it. - * - * `wide` exists for one page. An audit log is a table you scan, and forcing it into the prose column - * would wrap every row. It is not a licence for anything else to be wide. - */ -type ShellWidth = "prose" | "wide"; - -const WIDTHS: Record = { - prose: "max-w-2xl", - wide: "max-w-5xl", -}; - export function PageShell({ action, children, @@ -91,129 +76,12 @@ export function PageShell({ )} ) : null} - {/* - * The scroller, and it has to live here rather than in either shell, because the two shells - * this frame is used under disagree about who scrolls. - * - * Under `_authed` — admin, settings — the document scrolls, so a page taller than the window - * has always just worked. Under `_authed/_app` it does not: that shell is `h-svh - * overflow-hidden` on purpose ("one viewport, never scrolls: panes scroll inside it") and its - * `main` is `overflow-hidden` too, so a PageShell taller than the window was silently CLIPPED - * — 246px of Skills sat below the fold with no way to reach it, by keyboard or otherwise. - * - * `min-h-0` is the load-bearing half of `min-h-0 flex-1`: a flex item's default `min-height: - * auto` refuses to shrink below its content, so `flex-1` alone would grow this past the pane - * and clip exactly as before. Under `_authed` both are inert — the parent is not a flex - * container — and `overflow-y-auto` on an auto-height element shows no scrollbar, so those - * eighteen screens are unaffected. - * - * Separate from the centred column below so the scrollbar rides the pane's edge instead of - * appearing inside a 630px measure with content either side of it. - */} -
-
-
-
-

{title}

- {action} -
- {description ? ( -

- {description} -

- ) : null} -
- {children} -
-
+ + + {children} + ); } -/** - * A titled group of rows. - * - * The gap above a section is deliberately large. These pages are lists of unrelated decisions, and - * the space is what stops the eye running two of them together — it is doing the work a divider - * would otherwise do, without drawing a line. - */ -export function PageSection({ - action, - children, - className, - description, - title, -}: { - action?: React.ReactNode; - children?: React.ReactNode; - className?: string; - description?: React.ReactNode; - title?: string; -}) { - return ( -
- {title ? ( -
-

{title}

- {action} -
- ) : null} - {description ? ( -

- {description} -

- ) : null} - {children} -
- ); -} - -/** - * The card that rows live in. - * - * Rows are `` with a `` between them; this is only the border around - * them. Rendering nothing when there is nothing is deliberate — an empty bordered box reads as a - * thing that failed to load, where absence reads as nothing to see yet. - */ -export function PageRows({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - /* - * The rows are squared off and the card clips them. `Item` carries `rounded-lg` of its own, which - * inside a card of divided rows painted a hover as a floating pill: a middle row has no corners, - * and the first and last cannot be concentric with the card while sitting a border inside it. - */ -
- {children} -
- ); -} - -/** - * What a section says when it has nothing to list. - * - * Said in a sentence rather than drawn as an illustration with a heading: on a configuration screen - * "nothing here yet" is a fact, not an event, and the page already told you what the section is for. - */ -export function PageEmpty({ children }: { children: React.ReactNode }) { - return

{children}

; -} +export { PageEmpty, PageRows, PageSection } from "./page-parts"; diff --git a/app/src/components/settings/browsers/browser-sections.tsx b/app/src/components/settings/browsers/browser-sections.tsx new file mode 100644 index 000000000..62eeb6564 --- /dev/null +++ b/app/src/components/settings/browsers/browser-sections.tsx @@ -0,0 +1,14 @@ +import { useState } from "react"; +import { ConnectedBrowsers } from "@/components/settings/browsers/connected-browsers"; +import { ThisBrowser } from "@/components/settings/browsers/this-browser"; + +/** This browser's Recorder, then every browser connected as you, the row of this browser's own copy marked. */ +export function BrowserSections() { + const [thisInstallId, setThisInstallId] = useState(); + return ( + <> + + + + ); +} diff --git a/app/src/components/settings/browsers/connected-browsers.tsx b/app/src/components/settings/browsers/connected-browsers.tsx new file mode 100644 index 000000000..b8c3e8ed1 --- /dev/null +++ b/app/src/components/settings/browsers/connected-browsers.tsx @@ -0,0 +1,102 @@ +import { IconBrowser } from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + PageEmpty, + PageRows, + PageSection, +} from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { revokeRecorderConnectionMutationOptions } from "@/lib/recorder/mutations"; +import { recorderConnectionsQueryOptions } from "@/lib/recorder/queries"; +import { relativeTime } from "@/lib/relative-time"; +import { queryClient } from "@/query-client"; + +/** When a browser connected, to the minute: two copies with the same name (two Chrome profiles) stay told apart. */ +const connectedAt = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +/** The browsers signed in to this OpenBot as you, each with a Disconnect that revokes its sign-in. The row of the copy + * in this browser (`thisInstallId`, from This browser) says so. */ +export function ConnectedBrowsers({ + thisInstallId, +}: { + thisInstallId?: string; +} = {}) { + const connections = useQuery(recorderConnectionsQueryOptions()); + const revoke = useMutation( + revokeRecorderConnectionMutationOptions(queryClient), + ); + return ( + + {connections.isPending ? null : connections.error ? ( +

+ Could not load connected browsers. +

+ ) : connections.data.connections.length === 0 ? ( + No browsers are connected. + ) : ( + + {connections.data.connections.map((connection, index) => { + const here = + thisInstallId !== undefined && + connection.installId === thisInstallId; + const name = here + ? `${connection.label} · This browser` + : connection.label; + return ( +
+ {index > 0 ? : null} + + + + + + {name} + + Connected{" "} + {connectedAt.format(new Date(connection.createdAt))} from{" "} + {connection.via === "desktop" + ? "OpenBot Desktop" + : "OpenBot"} + ,{" "} + {connection.lastUsedAt + ? `last used ${relativeTime(connection.lastUsedAt)}` + : "never used"} + + + + + + +
+ ); + })} +
+ )} + {revoke.error ? ( +

+ {revoke.error.message} +

+ ) : null} +
+ ); +} diff --git a/app/src/components/settings/browsers/this-browser.tsx b/app/src/components/settings/browsers/this-browser.tsx new file mode 100644 index 000000000..6b1b04276 --- /dev/null +++ b/app/src/components/settings/browsers/this-browser.tsx @@ -0,0 +1,191 @@ +import { IconBrowser } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { PageRows, PageSection } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { + type DetectedRecorder, + detectRecorder, + sendConnectCode, +} from "@/lib/recorder/bridge"; +import { mintRecorderConnectCode } from "@/lib/recorder/mutations"; +import { recorderConnectionsQueryOptions } from "@/lib/recorder/queries"; + +/** How long a connect request waits for the person to confirm it in the Recorder's tab. */ +const CONFIRM_WINDOW_MS = 5 * 60_000; + +const REFUSALS: Record = { + "not-https": "The Recorder only connects to OpenBot over HTTPS.", + "pinned-elsewhere": "Your organization set the Recorder to another OpenBot.", + "policy-invalid": + "Your organization's Recorder policy names an OpenBot address the Recorder cannot use. Ask your IT team.", + busy: "The Recorder is finishing another connect request. Try again in a moment.", +}; +/** The Recorder can also answer `unreachable` or `invalid`, which have no sentence of their own. */ +const REFUSAL_FALLBACK = "The Recorder did not accept the request. Try again."; + +/** The Recorder takes only so many connects from one OpenBot in ten minutes; its refusal says how long is left. */ +function throttledSentence(retryAfterMs: number | undefined): string { + const lead = "Too many connect requests in a short time."; + if (retryAfterMs === undefined) return `${lead} Try again in a few minutes.`; + const minutes = Math.max(1, Math.ceil(retryAfterMs / 60_000)); + return `${lead} Try again in ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`; +} + +function refusalSentence(error: string, retryAfterMs?: number): string { + if (error === "throttled") return throttledSentence(retryAfterMs); + return REFUSALS[error] ?? REFUSAL_FALLBACK; +} + +type Attempt = + | { state: "idle" } + | { state: "sending" } + | { state: "awaiting" } + | { state: "expired" } + | { state: "failed"; message: string }; + +/** Whether the Recorder in this browser is installed, and connected to this OpenBot as you. `onInstallId` hears this + * browser's own copy once it is known (it answers with it only once connected), so the list of connected browsers can + * say which row is this one. */ +export function ThisBrowser({ + onInstallId, +}: { + onInstallId?: (installId: string | undefined) => void; +} = {}) { + const [attempt, setAttempt] = useState({ state: "idle" }); + // The install id the connect answer carried: a copy only reports its own once connected. + const [connectedAs, setConnectedAs] = useState(); + const awaiting = attempt.state === "awaiting"; + const connections = useQuery(recorderConnectionsQueryOptions({ awaiting })); + const ids = connections.data?.extensionIds; + const idsKey = ids?.join(","); + // `undefined` while asking, `null` when no Recorder answered. + const [detected, setDetected] = useState< + DetectedRecorder | null | undefined + >(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: the key stands for the id list, so a refetch with the same ids does not ask again + useEffect(() => { + if (!ids) return; + let current = true; + void detectRecorder(ids).then((found) => { + if (current) setDetected(found); + }); + return () => { + current = false; + }; + }, [idsKey]); + + const installId = detected?.installId ?? connectedAs; + const connected = + installId !== undefined && + (connections.data?.connections.some((c) => c.installId === installId) ?? + false); + + useEffect(() => { + onInstallId?.(installId); + }, [installId, onInstallId]); + + // Once the connection appears the wait is over, so polling stops. + useEffect(() => { + if (connected && awaiting) setAttempt({ state: "idle" }); + }, [connected, awaiting]); + + useEffect(() => { + if (!awaiting) return; + const timer = setTimeout( + () => setAttempt({ state: "expired" }), + CONFIRM_WINDOW_MS, + ); + return () => clearTimeout(timer); + }, [awaiting]); + + async function connect() { + if (!detected) return; + setAttempt({ state: "sending" }); + try { + const { code } = await mintRecorderConnectCode(); + const answer = await sendConnectCode(detected.id, code); + if (!answer.ok) { + setAttempt({ + state: "failed", + message: refusalSentence(answer.error, answer.retryAfterMs), + }); + return; + } + setConnectedAs(answer.installId); + setAttempt({ state: "awaiting" }); + } catch (error) { + setAttempt({ + state: "failed", + message: + error instanceof Error + ? error.message + : "Could not connect this browser.", + }); + } + } + + return ( + + {connections.error ? ( +

+ Could not check whether this browser is connected. +

+ ) : detected === undefined ? null : ( + + + + + + + OpenBot Recorder + + {describe(detected, connected, attempt)} + + + {detected && !connected ? ( + + + + ) : null} + + + )} +
+ ); +} + +function describe( + detected: DetectedRecorder | null, + connected: boolean, + attempt: Attempt, +): string { + if (!detected) + return "The OpenBot Recorder is not installed in this browser."; + if (connected) return "Connected"; + switch (attempt.state) { + case "awaiting": + return "Confirm in the OpenBot Recorder tab that opened."; + case "expired": + return "The request expired. Try again."; + case "failed": + return attempt.message; + default: + return "Installed. Connect it to record tasks for your Bots."; + } +} diff --git a/app/src/components/settings/learning-sharing.tsx b/app/src/components/settings/learning-sharing.tsx new file mode 100644 index 000000000..500e7f87e --- /dev/null +++ b/app/src/components/settings/learning-sharing.tsx @@ -0,0 +1,198 @@ +import type { + LearningSharingMode, + PersonalLearningSharing, +} from "@openbot/shared/learning"; +import { + IconCheck, + IconListCheck, + IconLock, + IconSparkles, + IconUsersGroup, +} from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import React from "react"; +import { + PageEmpty, + PageRows, + PageSection, +} from "@/components/layout/page-shell"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { + setLearnedSkillOpenMutationOptions, + setLearningSharingModeMutationOptions, +} from "@/lib/learning/mutations"; +import { personalLearningQueryOptions } from "@/lib/learning/queries"; +import { queryClient } from "@/query-client"; + +/** The three choices, each with the one plain line that says who sees what. */ +export const SHARING_CHOICES: { + mode: LearningSharingMode; + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; +}[] = [ + { + mode: "closed", + icon: IconLock, + title: "Only my Bots", + description: "Only the Bots you use draw on your Skills.", + }, + { + mode: "open", + icon: IconUsersGroup, + title: "Everyone's Bots", + description: "Every Bot in this OpenBot draws on all of your Skills.", + }, + { + mode: "choose", + icon: IconListCheck, + title: "The Skills I choose", + description: + "Every Bot draws on the Skills you open below; your own Bots use all of them.", + }, +]; + +/** What the Skills list says when it has none to show. */ +function emptySentence(data: PersonalLearningSharing): string { + if (data.unavailable === "off") + return "Automatic Learning is off in this OpenBot, so no Skills are learned from your recordings."; + if (data.skills === null) + return "Your Skills could not be read from Intelligence just now. Your choice above still applies to them."; + return "No Skills have been learned from your recordings yet. Each one you open here reaches every Bot once it is."; +} + +/** + * Settings, Learning: every recording a person makes goes into their own Learning space, and the Skills learned there + * reach every Bot they use. Here they choose who else's Bots may draw on them: none (the default), all, or the Skills + * they open one by one. Each change takes effect when it is made; there is no save. + */ +export function LearningSharing() { + const sharing = useQuery(personalLearningQueryOptions()); + const setMode = useMutation( + setLearningSharingModeMutationOptions(queryClient), + ); + const setOpen = useMutation(setLearnedSkillOpenMutationOptions(queryClient)); + if (sharing.isPending) return null; + if (sharing.error) { + return ( +

+ {sharing.error.message} +

+ ); + } + const data = sharing.data; + // Shown at once while the save is on its way, as a switch is. + const mode = (setMode.isPending ? setMode.variables : undefined) ?? data.mode; + const failure = setMode.error ?? setOpen.error; + + return ( + <> + + + {/* One pressed button of three, as Admin, Boundaries draws its enforcement choice. */} + {SHARING_CHOICES.map((choice, index) => ( + + {index > 0 ? : null} + { + if (mode !== choice.mode) setMode.mutate(choice.mode); + }} + type="button" + /> + } + > + + + + + {choice.title} + + {choice.description} + + + + {mode === choice.mode ? ( + + + + ))} + + + {mode === "choose" ? ( + + {data.skills === null || data.skills.length === 0 ? ( + {emptySentence(data)} + ) : ( + + {data.skills.map((skill, index) => { + const open = + (setOpen.isPending && setOpen.variables?.name === skill.name + ? setOpen.variables.open + : undefined) ?? skill.open; + return ( + + {index > 0 ? : null} + + + + + + {skill.name} + + {open + ? "Every Bot may use it." + : "Only your Bots use it."} + {skill.description ? ` ${skill.description}` : ""} + + + + + setOpen.mutate({ name: skill.name, open: checked }) + } + /> + + + + ); + })} + + )} + + ) : null} + {failure ? ( +

+ {failure.message} +

+ ) : null} + + ); +} diff --git a/app/src/components/settings/settings-sidebar.tsx b/app/src/components/settings/settings-sidebar.tsx index 769796851..dab7e35ac 100644 --- a/app/src/components/settings/settings-sidebar.tsx +++ b/app/src/components/settings/settings-sidebar.tsx @@ -2,10 +2,12 @@ import { IconArrowLeft, IconBell, IconBrain, + IconBrowser, IconChecks, IconKey, IconLayoutGrid, IconPlug, + IconSchool, IconSettings, } from "@tabler/icons-react"; import { Link, type LinkOptions } from "@tanstack/react-router"; @@ -23,7 +25,7 @@ import { const appLinkOptions = { to: "/" } satisfies LinkOptions; -const ITEMS: { +export const SETTINGS_SIDEBAR_ITEMS: { /** * Whether this entry lights only on its own route. * @@ -69,6 +71,18 @@ const ITEMS: { icon: IconPlug, linkOptions: { to: "/settings/connected-accounts" }, }, + { + /* The browsers signed in to the Recorder extension as you; each can be disconnected here. */ + title: "Browsers", + icon: IconBrowser, + linkOptions: { to: "/settings/browsers" }, + }, + { + /* Who else's Bots use the Skills learned from your recordings: none, all, or the ones you open. */ + title: "Learning", + icon: IconSchool, + linkOptions: { to: "/settings/learning" }, + }, { /* Logins saved from a Bot's private sign-in form. */ title: "Passwords", @@ -110,7 +124,7 @@ export function SettingsSidebar({ */} - {ITEMS.map((option) => ( + {SETTINGS_SIDEBAR_ITEMS.map((option) => ( ( diff --git a/app/src/lib/learning/mutations.ts b/app/src/lib/learning/mutations.ts new file mode 100644 index 000000000..af6057195 --- /dev/null +++ b/app/src/lib/learning/mutations.ts @@ -0,0 +1,40 @@ +import type { LearningSharingMode } from "@openbot/shared/learning"; +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { learningKeys } from "./queries"; + +const FALLBACK = "Your Learning settings could not be saved."; + +/** Who else's Bots use your Skills: only yours, everyone's, or the ones you open. */ +export function setLearningSharingModeMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (mode: LearningSharingMode): Promise => { + await client("/api/settings/learning/mode", { + method: "PUT", + body: { mode }, + fallback: FALLBACK, + }); + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: learningKeys.personal }), + }); +} + +/** Opens one of your Skills to every Bot, or closes it again. */ +export function setLearnedSkillOpenMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + name: string; + open: boolean; + }): Promise => { + await client( + `/api/settings/learning/skills/${encodeURIComponent(variables.name)}`, + { method: "PUT", body: { open: variables.open }, fallback: FALLBACK }, + ); + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: learningKeys.personal }), + }); +} diff --git a/app/src/lib/learning/queries.ts b/app/src/lib/learning/queries.ts index ba9fc27d4..4ff5f2f27 100644 --- a/app/src/lib/learning/queries.ts +++ b/app/src/lib/learning/queries.ts @@ -1,17 +1,35 @@ -import { queryOptions } from "@tanstack/react-query"; -import { client } from "@/lib/client"; import type { LearningAutoAssignState, LearningDeliveryStatus, LearningOverview, LearningSettings, + PersonalLearningSharing, } from "@openbot/shared/learning"; +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; export const learningKeys = { all: ["learning"] as const, settings: ["learning", "settings"] as const, automatic: ["learning", "automatic"] as const, + personal: ["learning", "personal"] as const, }; +/** + * Settings, Learning: who else's Bots use the Skills learned from your own recordings, and the Skills in your own + * Learning space (null when Intelligence did not deliver them). + */ +export function personalLearningQueryOptions() { + return queryOptions({ + queryKey: learningKeys.personal, + retry: false, + queryFn: async (): Promise => + ( + await client("/api/settings/learning", { + fallback: "Your Learning settings could not be loaded.", + }) + ).json(), + }); +} export function learningSettingsQueryOptions() { return queryOptions({ queryKey: learningKeys.settings, diff --git a/app/src/lib/recorder/bridge.ts b/app/src/lib/recorder/bridge.ts new file mode 100644 index 000000000..4e68e1c00 --- /dev/null +++ b/app/src/lib/recorder/bridge.ts @@ -0,0 +1,106 @@ +// The browser's extension messaging API, with no server traffic. The app has no @types/chrome, so the one member +// used is declared here. +type ExtensionMessaging = { + runtime?: { + sendMessage?( + id: string, + message: unknown, + reply: (answer: unknown) => void, + ): void; + lastError?: unknown; + }; +}; + +/** `version` and `installId` come only when this copy is connected to this OpenBot; an unconnected copy answers `{ ok: true }`. */ +export type DetectedRecorder = { + id: string; + version?: string; + installId?: string; +}; + +/** A `throttled` refusal carries `retryAfterMs`: how long until the Recorder takes a connect from this OpenBot again. */ +type ConnectCodeAnswer = + | { ok: true; installId?: string; awaitingConfirmation?: boolean } + | { ok: false; error: string; retryAfterMs?: number }; + +const DEFAULT_DETECT_TIMEOUT_MS = 1500; + +function runtimeOf(): ExtensionMessaging["runtime"] { + return (globalThis as { chrome?: ExtensionMessaging }).chrome?.runtime; +} + +/** The extension's answer, or `undefined` when it did not answer (not installed, lastError, or a throw). */ +function ask(id: string, message: unknown): Promise { + return new Promise((resolve) => { + const runtime = runtimeOf(); + if (!runtime?.sendMessage) return resolve(undefined); + try { + runtime.sendMessage(id, message, (answer) => { + // Reading lastError is what tells Chrome the failure was handled. + resolve(runtime.lastError ? undefined : answer); + }); + } catch { + resolve(undefined); + } + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function detectRecorder( + ids: readonly string[], + timeoutMs: number = DEFAULT_DETECT_TIMEOUT_MS, +): Promise { + if (!runtimeOf()?.sendMessage || ids.length === 0) + return Promise.resolve(null); + return new Promise((resolve) => { + let pending = ids.length; + const timer = setTimeout(() => resolve(null), timeoutMs); + const settle = (found: DetectedRecorder | null) => { + if (found) { + clearTimeout(timer); + resolve(found); + } else if (--pending === 0) { + clearTimeout(timer); + resolve(null); + } + }; + for (const id of ids) { + void ask(id, { type: "openbot.ping" }).then((answer) => { + if (!isRecord(answer) || answer.ok !== true) return settle(null); + const found: DetectedRecorder = { id }; + if (typeof answer.version === "string") found.version = answer.version; + if (typeof answer.installId === "string") + found.installId = answer.installId; + settle(found); + }); + } + }); +} + +export async function sendConnectCode( + id: string, + code: string, +): Promise { + const answer = await ask(id, { type: "openbot.connect", code }); + if (!isRecord(answer)) return { ok: false, error: "unreachable" }; + if (answer.ok !== true) { + const refusal: ConnectCodeAnswer = { + ok: false, + error: typeof answer.error === "string" ? answer.error : "invalid", + }; + if ( + typeof answer.retryAfterMs === "number" && + Number.isFinite(answer.retryAfterMs) && + answer.retryAfterMs >= 0 + ) + refusal.retryAfterMs = answer.retryAfterMs; + return refusal; + } + const result: ConnectCodeAnswer = { ok: true }; + if (typeof answer.installId === "string") result.installId = answer.installId; + if (answer.awaitingConfirmation === true) result.awaitingConfirmation = true; + return result; +} diff --git a/app/src/lib/recorder/mutations.ts b/app/src/lib/recorder/mutations.ts new file mode 100644 index 000000000..2749f9ce3 --- /dev/null +++ b/app/src/lib/recorder/mutations.ts @@ -0,0 +1,31 @@ +import type { ConnectCodeResponse } from "@openbot/shared/recorder-api"; +import { recorderPaths } from "@openbot/shared/recorder-api"; +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { recorderKeys } from "./queries"; + +export function revokeRecorderConnectionMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (connectionId: string): Promise => { + await client(recorderPaths.connection(connectionId), { + method: "DELETE", + fallback: "Could not disconnect this browser", + }); + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: recorderKeys.all }), + }); +} + +/** A step inside the connect flow: a code for the Recorder to exchange, not state anything caches. */ +export async function mintRecorderConnectCode(): Promise { + return ( + await client(recorderPaths.connectCodes, { + method: "POST", + body: { via: "web" }, + fallback: "Could not start connecting this browser", + }) + ).json(); +} diff --git a/app/src/lib/recorder/queries.ts b/app/src/lib/recorder/queries.ts new file mode 100644 index 000000000..a30410228 --- /dev/null +++ b/app/src/lib/recorder/queries.ts @@ -0,0 +1,30 @@ +import type { RecorderConnectionsResponse } from "@openbot/shared/recorder-api"; +import { recorderPaths } from "@openbot/shared/recorder-api"; +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +export const recorderKeys = { + all: ["recorder"] as const, + connections: () => ["recorder", "connections"] as const, +}; + +/** + * The browsers connected for this person, and the extension ids this OpenBot trusts. + * + * `awaiting` polls every 2 s while a "Connect this browser" request waits for the person to confirm + * it in the Recorder's own tab. + */ +export const recorderConnectionsQueryOptions = (options?: { + awaiting?: boolean; +}) => + queryOptions({ + queryKey: recorderKeys.connections(), + // Two top-level keys, so no envelope key: without one, client() returns the Response. + queryFn: async (): Promise => + ( + await client(recorderPaths.connections, { + fallback: "Could not load connected browsers", + }) + ).json(), + refetchInterval: options?.awaiting ? 2000 : false, + }); diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index ca53f4c05..7f631b980 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -38,6 +38,8 @@ import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admi import { Route as AuthedAdminSkillsRouteImport } from './routes/_authed/admin/skills' import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settings/index' import { Route as AuthedSettingsApprovalsRouteImport } from './routes/_authed/settings/approvals' +import { Route as AuthedSettingsBrowsersRouteImport } from './routes/_authed/settings/browsers' +import { Route as AuthedSettingsLearningRouteImport } from './routes/_authed/settings/learning' import { Route as AuthedSettingsMemoryRouteImport } from './routes/_authed/settings/memory' import { Route as AuthedSettingsNotificationsRouteImport } from './routes/_authed/settings/notifications' import { Route as AuthedSettingsPasswordsRouteImport } from './routes/_authed/settings/passwords' @@ -214,6 +216,16 @@ const AuthedSettingsApprovalsRoute = AuthedSettingsApprovalsRouteImport.update({ path: '/approvals', getParentRoute: () => AuthedSettingsRouteRoute, } as any) +const AuthedSettingsBrowsersRoute = AuthedSettingsBrowsersRouteImport.update({ + id: '/browsers', + path: '/browsers', + getParentRoute: () => AuthedSettingsRouteRoute, +} as any) +const AuthedSettingsLearningRoute = AuthedSettingsLearningRouteImport.update({ + id: '/learning', + path: '/learning', + getParentRoute: () => AuthedSettingsRouteRoute, +} as any) const AuthedSettingsMemoryRoute = AuthedSettingsMemoryRouteImport.update({ id: '/memory', path: '/memory', @@ -411,6 +423,8 @@ export interface FileRoutesByFullPath { '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/skills': typeof AuthedAdminSkillsRoute '/settings/approvals': typeof AuthedSettingsApprovalsRoute + '/settings/browsers': typeof AuthedSettingsBrowsersRoute + '/settings/learning': typeof AuthedSettingsLearningRoute '/settings/memory': typeof AuthedSettingsMemoryRoute '/settings/notifications': typeof AuthedSettingsNotificationsRoute '/settings/passwords': typeof AuthedSettingsPasswordsRoute @@ -468,6 +482,8 @@ export interface FileRoutesByTo { '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/skills': typeof AuthedAdminSkillsRoute '/settings/approvals': typeof AuthedSettingsApprovalsRoute + '/settings/browsers': typeof AuthedSettingsBrowsersRoute + '/settings/learning': typeof AuthedSettingsLearningRoute '/settings/memory': typeof AuthedSettingsMemoryRoute '/settings/notifications': typeof AuthedSettingsNotificationsRoute '/settings/passwords': typeof AuthedSettingsPasswordsRoute @@ -529,6 +545,8 @@ export interface FileRoutesById { '/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute '/_authed/admin/skills': typeof AuthedAdminSkillsRoute '/_authed/settings/approvals': typeof AuthedSettingsApprovalsRoute + '/_authed/settings/browsers': typeof AuthedSettingsBrowsersRoute + '/_authed/settings/learning': typeof AuthedSettingsLearningRoute '/_authed/settings/memory': typeof AuthedSettingsMemoryRoute '/_authed/settings/notifications': typeof AuthedSettingsNotificationsRoute '/_authed/settings/passwords': typeof AuthedSettingsPasswordsRoute @@ -591,6 +609,8 @@ export interface FileRouteTypes { | '/admin/playground' | '/admin/skills' | '/settings/approvals' + | '/settings/browsers' + | '/settings/learning' | '/settings/memory' | '/settings/notifications' | '/settings/passwords' @@ -648,6 +668,8 @@ export interface FileRouteTypes { | '/admin/playground' | '/admin/skills' | '/settings/approvals' + | '/settings/browsers' + | '/settings/learning' | '/settings/memory' | '/settings/notifications' | '/settings/passwords' @@ -708,6 +730,8 @@ export interface FileRouteTypes { | '/_authed/admin/playground' | '/_authed/admin/skills' | '/_authed/settings/approvals' + | '/_authed/settings/browsers' + | '/_authed/settings/learning' | '/_authed/settings/memory' | '/_authed/settings/notifications' | '/_authed/settings/passwords' @@ -953,6 +977,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedSettingsApprovalsRouteImport parentRoute: typeof AuthedSettingsRouteRoute } + '/_authed/settings/browsers': { + id: '/_authed/settings/browsers' + path: '/browsers' + fullPath: '/settings/browsers' + preLoaderRoute: typeof AuthedSettingsBrowsersRouteImport + parentRoute: typeof AuthedSettingsRouteRoute + } + '/_authed/settings/learning': { + id: '/_authed/settings/learning' + path: '/learning' + fullPath: '/settings/learning' + preLoaderRoute: typeof AuthedSettingsLearningRouteImport + parentRoute: typeof AuthedSettingsRouteRoute + } '/_authed/settings/memory': { id: '/_authed/settings/memory' path: '/memory' @@ -1215,6 +1253,8 @@ const AuthedAdminRouteRouteWithChildren = interface AuthedSettingsRouteRouteChildren { AuthedSettingsApprovalsRoute: typeof AuthedSettingsApprovalsRoute + AuthedSettingsBrowsersRoute: typeof AuthedSettingsBrowsersRoute + AuthedSettingsLearningRoute: typeof AuthedSettingsLearningRoute AuthedSettingsMemoryRoute: typeof AuthedSettingsMemoryRoute AuthedSettingsNotificationsRoute: typeof AuthedSettingsNotificationsRoute AuthedSettingsPasswordsRoute: typeof AuthedSettingsPasswordsRoute @@ -1227,6 +1267,8 @@ interface AuthedSettingsRouteRouteChildren { const AuthedSettingsRouteRouteChildren: AuthedSettingsRouteRouteChildren = { AuthedSettingsApprovalsRoute: AuthedSettingsApprovalsRoute, + AuthedSettingsBrowsersRoute: AuthedSettingsBrowsersRoute, + AuthedSettingsLearningRoute: AuthedSettingsLearningRoute, AuthedSettingsMemoryRoute: AuthedSettingsMemoryRoute, AuthedSettingsNotificationsRoute: AuthedSettingsNotificationsRoute, AuthedSettingsPasswordsRoute: AuthedSettingsPasswordsRoute, diff --git a/app/src/routes/_authed/settings/browsers.tsx b/app/src/routes/_authed/settings/browsers.tsx new file mode 100644 index 000000000..1bafaf6ff --- /dev/null +++ b/app/src/routes/_authed/settings/browsers.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PageShell } from "@/components/layout/page-shell"; +import { BrowserSections } from "@/components/settings/browsers/browser-sections"; + +export const Route = createFileRoute("/_authed/settings/browsers")({ + component: BrowsersPage, +}); + +/** Connect the OpenBot Recorder in this browser, and see or disconnect every browser connected as you. */ +function BrowsersPage() { + return ( + + + + ); +} diff --git a/app/src/routes/_authed/settings/learning.tsx b/app/src/routes/_authed/settings/learning.tsx new file mode 100644 index 000000000..42336aaf4 --- /dev/null +++ b/app/src/routes/_authed/settings/learning.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PageShell } from "@/components/layout/page-shell"; +import { LearningSharing } from "@/components/settings/learning-sharing"; + +export const Route = createFileRoute("/_authed/settings/learning")({ + component: LearningSettingsPage, +}); + +/** Who else's Bots use the Skills learned from your own recordings. */ +function LearningSettingsPage() { + return ( + + + + ); +} diff --git a/app/src/styles.css b/app/src/styles.css index 653b84df5..810051f11 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -4,6 +4,7 @@ /* Composer chips/dropdown. Reads the shadcn tokens declared below, so it themes itself. */ @import "prompt-area/tailwind.css"; @import "@fontsource-variable/inter"; +@import "./theme.css"; /* * Streamdown draws a Bot's prose, and Tailwind only ships classes it can see used. Its markup lives @@ -12,187 +13,6 @@ */ @source "../node_modules/streamdown/dist/*.js"; -@custom-variant dark (&:is(.dark *)); - -html { - font-size: 15px; -} - -:root { - color-scheme: light; - --background: oklch(0.985 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.96 0 0); - --muted-foreground: oklch(0.5 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --success: oklch(0.6 0.118 184.704); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.55rem; - --sidebar: oklch(0.97 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.5 0 0); - --sidebar-accent: oklch(0.95 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - color-scheme: dark; - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --success: oklch(0.704 0.14 182.503); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - position: relative; - min-width: 20rem; - min-height: 100vh; - background: var(--background); - color: var(--foreground); - font-family: Inter, ui-sans-serif, system-ui, sans-serif; -} - -/* - * THERE IS DELIBERATELY NO `button, input, textarea, select { font: inherit }` HERE. - * - * Form controls do need it — Chrome otherwise draws them in its own ~13.3px system font — and - * Tailwind's Preflight already does exactly that, inside `@layer base`, where a utility can still - * override it. A second copy written here would sit in NO layer, and an unlayered rule outranks - * every layered one no matter how specific: a bare `input` selector beats `.text-sm`. That is not a - * specificity fight anybody wins, because layers are resolved before specificity is even consulted. - * - * One was here, and it silently disabled every `text-*` on every button, input, textarea and select - * in the app. Nothing looked broken; the sizes were simply never applied, and the labels — which are - * not in that selector list — rendered a size smaller than the fields they belonged to. - */ - -@theme inline { - --font-heading: var(--font-sans); - --font-sans: "Inter Variable", sans-serif; - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-success: var(--success); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } - html { - @apply font-sans; - } - - ::-webkit-scrollbar { - width: 5px; - } - - ::-webkit-scrollbar-track { - background: transparent; - } - - ::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 5px; - } - - * { - scrollbar-width: thin; - scrollbar-color: var(--border) transparent; - } -} - /* * A tool line that is still running. * @@ -241,21 +61,6 @@ body { transform: rotate(90deg); } -/* - * Row descriptions wrap on sense rather than on width. - * - * Here rather than on the `ItemDescription` primitive, which is where this started. The reason was - * that the primitive was vendored and the next `shadcn add item` would silently remove anything put - * inside it; the primitives are the repository's own now, so that is no longer true. It stays here - * because it belongs with the other layout rules rather than inside one primitive. - * - * The primitive already emits the attribute this selects, so nothing has to be edited for it to - * apply, and nothing upstream can take it away. - */ -[data-slot="item-description"] { - text-wrap: pretty; -} - /* * A generated interface is never allowed to occupy nothing. * diff --git a/app/src/theme.css b/app/src/theme.css new file mode 100644 index 000000000..37064b0b9 --- /dev/null +++ b/app/src/theme.css @@ -0,0 +1,195 @@ +@custom-variant dark (&:is(.dark *)); + +html { + font-size: 15px; +} + +:root { + color-scheme: light; + --background: oklch(0.985 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.96 0 0); + --muted-foreground: oklch(0.5 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --success: oklch(0.6 0.118 184.704); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.55rem; + --sidebar: oklch(0.97 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.5 0 0); + --sidebar-accent: oklch(0.95 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + color-scheme: dark; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --success: oklch(0.704 0.14 182.503); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + position: relative; + min-width: 20rem; + min-height: 100vh; + background: var(--background); + color: var(--foreground); + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} + +/* + * THERE IS DELIBERATELY NO `button, input, textarea, select { font: inherit }` HERE. + * + * Form controls do need it — Chrome otherwise draws them in its own ~13.3px system font — and + * Tailwind's Preflight already does exactly that, inside `@layer base`, where a utility can still + * override it. A second copy written here would sit in NO layer, and an unlayered rule outranks + * every layered one no matter how specific: a bare `input` selector beats `.text-sm`. That is not a + * specificity fight anybody wins, because layers are resolved before specificity is even consulted. + * + * One was here, and it silently disabled every `text-*` on every button, input, textarea and select + * in the app. Nothing looked broken; the sizes were simply never applied, and the labels — which are + * not in that selector list — rendered a size smaller than the fields they belonged to. + */ + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: "Inter Variable", sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-success: var(--success); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } + + ::-webkit-scrollbar { + width: 5px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 5px; + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } +} + +/* + * Row descriptions wrap on sense rather than on width. + * + * Here rather than on the `ItemDescription` primitive, which is where this started. The reason was + * that the primitive was vendored and the next `shadcn add item` would silently remove anything put + * inside it; the primitives are the repository's own now, so that is no longer true. It stays here + * because it belongs with the other layout rules rather than inside one primitive. + * + * The primitive already emits the attribute this selects, so nothing has to be edited for it to + * apply, and nothing upstream can take it away. + */ +[data-slot="item-description"] { + text-wrap: pretty; +} diff --git a/app/tests/authoring-tool-cards.test.tsx b/app/tests/authoring-tool-cards.test.tsx index 8559bcc4e..1d073ba6e 100644 --- a/app/tests/authoring-tool-cards.test.tsx +++ b/app/tests/authoring-tool-cards.test.tsx @@ -120,21 +120,34 @@ function serving(grant: string) { type Renderer = ComponentType>; type Handler = (args: unknown, context: unknown) => Promise; type Kept = { + core?: ReturnType["copilotkit"]; renderers: Map; handlers: Map; }; -function Capture({ kept, names }: { kept: Kept; names: string[] }) { - const { copilotkit } = useCopilotKit(); +/** + * Hands the test the provider's core, and nothing else. + * + * What the SDK kept is read from the core when the test asks (`keep`), never during this render. + * Registering a tool re-renders nothing that only calls `useCopilotKit`: before runtime 1.79 the + * provider's config effect fired `onHeadersChanged` each time it re-ran, which redrew every caller + * as a side effect, and 1.79 made an unchanged header record a no-op. A read here would hold + * whatever was registered when this last drew, which is nothing. + */ +function Capture({ kept }: { kept: Kept }) { + kept.core = useCopilotKit().copilotkit; + return null; +} + +function keep(kept: Kept, names: string[]) { for (const name of names) { - const entry = copilotkit.renderToolCalls.find( + const entry = kept.core?.renderToolCalls.find( (candidate) => candidate.name === name, ); if (entry) kept.renderers.set(name, entry.render as Renderer); - const tool = copilotkit.getTool({ toolName: name }); + const tool = kept.core?.getTool({ toolName: name }); if (tool?.handler) kept.handlers.set(name, tool.handler as Handler); } - return null; } function Declare() { @@ -161,10 +174,13 @@ test("a proposed coworker named like an existing one says so", async () => { const tree = render( - + , ); - await waitFor(() => expect(kept.renderers.get("save_bot")).toBeDefined()); + await waitFor(() => { + keep(kept, ["save_bot"]); + expect(kept.renderers.get("save_bot")).toBeDefined(); + }); // The roster arrives after the grant turned the tool on. release(); @@ -172,7 +188,7 @@ test("a proposed coworker named like an existing one says so", async () => { tree.rerender( - + ( - + {card} ); const tree = render(drawn()); await waitFor(() => { + keep(kept, names); expect(kept.renderers.get("save_skill")).toBeDefined(); expect(kept.handlers.get("list_skills")).toBeDefined(); }); diff --git a/app/tests/learning-sharing.test.tsx b/app/tests/learning-sharing.test.tsx new file mode 100644 index 000000000..8b0721bb1 --- /dev/null +++ b/app/tests/learning-sharing.test.tsx @@ -0,0 +1,241 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import type { PersonalLearningSharing } from "@openbot/shared/learning"; +import { environmentManager, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { + LearningSharing, + SHARING_CHOICES, +} from "@/components/settings/learning-sharing"; +import { queryClient } from "@/query-client"; + +let wasServer = true; +beforeAll(() => { + GlobalRegistrator.register(); + wasServer = environmentManager.isServer(); + environmentManager.setIsServer(() => false); +}); +afterAll(() => { + environmentManager.setIsServer(() => wasServer); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +let answer: PersonalLearningSharing; +let failRead = false; +let failWrite = false; +const sent: { path: string; method: string; body: unknown }[] = []; + +beforeEach(() => { + queryClient.clear(); + sent.length = 0; + failRead = false; + failWrite = false; + answer = { + mode: "closed", + skills: [ + { name: "refund-order", description: "Refunds an order.", open: false }, + { name: "close-ticket", description: "Closes a ticket.", open: true }, + ], + }; + global.fetch = Object.assign( + async (path: Parameters[0], init?: RequestInit) => { + const url = String(path); + const method = init?.method ?? "GET"; + if (method === "PUT") { + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + sent.push({ path: url, method, body }); + if (failWrite) + return Response.json( + { error: "Your Learning settings could not be saved." }, + { status: 503 }, + ); + if (url === "/api/settings/learning/mode") + answer = { ...answer, mode: body.mode }; + const skill = /^\/api\/settings\/learning\/skills\/(.+)$/.exec(url); + if (skill && answer.skills) + answer = { + ...answer, + skills: answer.skills.map((entry) => + entry.name === decodeURIComponent(skill[1] ?? "") + ? { ...entry, open: body.open } + : entry, + ), + }; + return Response.json({ ok: true }); + } + if (url === "/api/settings/learning") { + if (failRead) return new Response(null, { status: 500 }); + return Response.json(answer); + } + return new Response(null, { status: 404 }); + }, + { preconnect: originalFetch.preconnect }, + ); +}); +afterEach(() => { + cleanup(); + global.fetch = originalFetch; +}); + +function draw() { + return render( + + + , + ); +} + +/** The choice row whose title is `title`: one pressed button of three. */ +const choice = (view: ReturnType, title: string) => + choices(view).find((element) => + element.textContent?.includes(title), + ) as HTMLElement; +const choices = (view: ReturnType) => + view + .getAllByRole("button") + .filter((element) => element.hasAttribute("aria-pressed")); + +test("three choices, each with one plain line saying who sees what", async () => { + const view = draw(); + await view.findByText("Who else's Bots use your Skills"); + expect(choices(view)).toHaveLength(3); + for (const choice of SHARING_CHOICES) { + expect(view.getByText(choice.title)).toBeTruthy(); + expect(view.getByText(choice.description)).toBeTruthy(); + } + expect( + view.getByText("Only the Bots you use draw on your Skills."), + ).toBeTruthy(); + expect( + view.getByText("Every Bot in this OpenBot draws on all of your Skills."), + ).toBeTruthy(); +}); + +test("closed is the default: Only my Bots is checked and no Skill list shows", async () => { + const view = draw(); + await view.findByText("Only my Bots"); + expect(choice(view, "Only my Bots").getAttribute("aria-pressed")).toBe( + "true", + ); + expect(choice(view, "Everyone's Bots").getAttribute("aria-pressed")).toBe( + "false", + ); + expect(view.queryByText("Your Skills")).toBeNull(); + expect(view.queryByRole("switch")).toBeNull(); +}); + +test("choosing Everyone's Bots saves open at once", async () => { + const view = draw(); + await view.findByText("Only my Bots"); + fireEvent.click(choice(view, "Everyone's Bots")); + await waitFor(() => + expect(sent).toContainEqual({ + path: "/api/settings/learning/mode", + method: "PUT", + body: { mode: "open" }, + }), + ); + await waitFor(() => + expect(choice(view, "Everyone's Bots").getAttribute("aria-pressed")).toBe( + "true", + ), + ); +}); + +test("pressing the choice already made saves nothing", async () => { + const view = draw(); + await view.findByText("Only my Bots"); + fireEvent.click(choice(view, "Only my Bots")); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(sent).toEqual([]); +}); + +test("under The Skills I choose, each Skill in your space has a switch that opens it to everyone", async () => { + answer = { ...answer, mode: "choose" }; + const view = draw(); + await view.findByText("Your Skills"); + const refund = view.getByRole("switch", { + name: "Open refund-order to everyone", + }); + const close = view.getByRole("switch", { + name: "Open close-ticket to everyone", + }); + expect(refund.getAttribute("aria-checked")).toBe("false"); + expect(close.getAttribute("aria-checked")).toBe("true"); + expect( + view.getByText(/Only your Bots use it\. Refunds an order\./), + ).toBeTruthy(); + expect( + view.getByText(/Every Bot may use it\. Closes a ticket\./), + ).toBeTruthy(); + fireEvent.click(refund); + await waitFor(() => + expect(sent).toContainEqual({ + path: "/api/settings/learning/skills/refund-order", + method: "PUT", + body: { open: true }, + }), + ); + await waitFor(() => + expect( + view + .getByRole("switch", { name: "Open refund-order to everyone" }) + .getAttribute("aria-checked"), + ).toBe("true"), + ); +}); + +test("a space with no Skills yet says so plainly", async () => { + answer = { mode: "choose", skills: [] }; + const view = draw(); + await view.findByText( + "No Skills have been learned from your recordings yet. Each one you open here reaches every Bot once it is.", + ); + expect(view.queryByRole("switch")).toBeNull(); +}); + +test("Skills Intelligence did not deliver show a plain unavailable line, and the choice still works", async () => { + answer = { mode: "choose", skills: null, unavailable: "unavailable" }; + const view = draw(); + await view.findByText( + "Your Skills could not be read from Intelligence just now. Your choice above still applies to them.", + ); + fireEvent.click(choice(view, "Only my Bots")); + await waitFor(() => + expect(sent).toContainEqual({ + path: "/api/settings/learning/mode", + method: "PUT", + body: { mode: "closed" }, + }), + ); +}); + +test("Automatic Learning off says no Skills are learned", async () => { + answer = { mode: "choose", skills: null, unavailable: "off" }; + const view = draw(); + await view.findByText( + "Automatic Learning is off in this OpenBot, so no Skills are learned from your recordings.", + ); +}); + +test("a read that fails says so, and a save that fails says why", async () => { + failRead = true; + const failed = draw(); + await failed.findByText("Your Learning settings could not be loaded."); + cleanup(); + queryClient.clear(); + failRead = false; + failWrite = true; + const view = draw(); + await view.findByText("Only my Bots"); + fireEvent.click(choice(view, "Everyone's Bots")); + await view.findByText("Your Learning settings could not be saved."); +}); diff --git a/app/tests/recorder-bridge.test.ts b/app/tests/recorder-bridge.test.ts new file mode 100644 index 000000000..3f321a10a --- /dev/null +++ b/app/tests/recorder-bridge.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { detectRecorder, sendConnectCode } from "@/lib/recorder/bridge"; + +type Reply = (answer: unknown) => void; +type Handler = (id: string, message: unknown, reply: Reply) => void; + +const globals = globalThis as { chrome?: unknown }; +let savedChrome: unknown; +let hadChrome = false; + +function installChrome(handler: Handler) { + const runtime: { lastError?: unknown; sendMessage: Handler } = { + sendMessage: (id, message, reply) => handler(id, message, reply), + }; + globals.chrome = { runtime }; + return runtime; +} + +beforeEach(() => { + hadChrome = "chrome" in globals; + savedChrome = globals.chrome; +}); + +afterEach(() => { + if (hadChrome) globals.chrome = savedChrome; + else delete globals.chrome; +}); + +describe("detectRecorder", () => { + test("the first id that answers wins and its installId is returned", async () => { + const asked: unknown[] = []; + installChrome((id, message, reply) => { + asked.push(message); + if (id === "bbb") + setTimeout( + () => + reply({ ok: true, version: "1.2.3", installId: "a".repeat(32) }), + 5, + ); + if (id === "ccc") setTimeout(() => reply({ ok: true, version: "9" }), 40); + }); + const found = await detectRecorder(["aaa", "bbb", "ccc"], 200); + expect(found).toEqual({ + id: "bbb", + version: "1.2.3", + installId: "a".repeat(32), + }); + expect(asked[0]).toEqual({ type: "openbot.ping" }); + }); + + test("an answer of only ok: true is detected with no installId", async () => { + installChrome((_id, _message, reply) => reply({ ok: true })); + const found = await detectRecorder(["aaa"], 200); + expect(found).toEqual({ id: "aaa" }); + expect(found?.installId).toBeUndefined(); + }); + + test("an id that fails with lastError is skipped for one that answers", async () => { + const runtime = installChrome((id, _message, reply) => { + if (id === "aaa") { + runtime.lastError = { message: "Could not establish connection" }; + reply(undefined); + runtime.lastError = undefined; + } else setTimeout(() => reply({ ok: true }), 5); + }); + expect(await detectRecorder(["aaa", "bbb"], 200)).toEqual({ id: "bbb" }); + }); + + test("no answer within the timeout resolves null", async () => { + installChrome(() => {}); + expect(await detectRecorder(["aaa"], 20)).toBeNull(); + }); + + test("every id failing resolves null", async () => { + const runtime = installChrome((_id, _message, reply) => { + runtime.lastError = { message: "no" }; + reply(undefined); + runtime.lastError = undefined; + }); + expect(await detectRecorder(["aaa", "bbb"], 200)).toBeNull(); + }); + + test("no ids resolves null", async () => { + installChrome(() => {}); + expect(await detectRecorder([], 20)).toBeNull(); + }); + + test("chrome absent (Firefox, Safari) resolves null without throwing", async () => { + delete globals.chrome; + expect(await detectRecorder(["aaa"], 20)).toBeNull(); + }); + + test("chrome without runtime.sendMessage resolves null", async () => { + globals.chrome = { runtime: {} }; + expect(await detectRecorder(["aaa"], 20)).toBeNull(); + }); + + test("a sendMessage that throws counts as no answer", async () => { + installChrome(() => { + throw new Error("bad id"); + }); + expect(await detectRecorder(["aaa"], 20)).toBeNull(); + }); +}); + +describe("sendConnectCode", () => { + test("sends openbot.connect to the id and returns the answer's installId", async () => { + let sent: { id: string; message: unknown } | undefined; + installChrome((id, message, reply) => { + sent = { id, message }; + reply({ + ok: true, + version: "1", + installId: "b".repeat(32), + awaitingConfirmation: true, + }); + }); + const answer = await sendConnectCode("aaa", "obc_x"); + expect(sent).toEqual({ + id: "aaa", + message: { type: "openbot.connect", code: "obc_x" }, + }); + expect(answer).toEqual({ + ok: true, + installId: "b".repeat(32), + awaitingConfirmation: true, + }); + }); + + test("a refusal comes back as its error code", async () => { + installChrome((_id, _message, reply) => + reply({ ok: false, error: "pinned-elsewhere" }), + ); + expect(await sendConnectCode("aaa", "obc_x")).toEqual({ + ok: false, + error: "pinned-elsewhere", + }); + }); + + test("a throttled refusal carries how long until the Recorder takes a connect again", async () => { + installChrome((_id, _message, reply) => + reply({ ok: false, error: "throttled", retryAfterMs: 420_000 }), + ); + expect(await sendConnectCode("aaa", "obc_x")).toEqual({ + ok: false, + error: "throttled", + retryAfterMs: 420_000, + }); + }); + + test("lastError, no answer or no chrome is a refusal, not a throw", async () => { + const runtime = installChrome((_id, _message, reply) => { + runtime.lastError = { message: "gone" }; + reply(undefined); + runtime.lastError = undefined; + }); + expect((await sendConnectCode("aaa", "obc_x")).ok).toBe(false); + delete globals.chrome; + expect((await sendConnectCode("aaa", "obc_x")).ok).toBe(false); + }); +}); diff --git a/app/tests/recorder-browsers.test.tsx b/app/tests/recorder-browsers.test.tsx new file mode 100644 index 000000000..41550b886 --- /dev/null +++ b/app/tests/recorder-browsers.test.tsx @@ -0,0 +1,397 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { environmentManager, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; +import { BrowserSections } from "@/components/settings/browsers/browser-sections"; +import { ConnectedBrowsers } from "@/components/settings/browsers/connected-browsers"; +import { ThisBrowser } from "@/components/settings/browsers/this-browser"; +import { queryClient } from "@/query-client"; + +let wasServer = true; +beforeAll(() => { + GlobalRegistrator.register(); + wasServer = environmentManager.isServer(); + // The query library decided "server" when it was imported, before the DOM existed, and so never polls. + environmentManager.setIsServer(() => false); +}); +afterAll(() => { + environmentManager.setIsServer(() => wasServer); + GlobalRegistrator.unregister(); +}); + +const EXTENSION_ID = "a".repeat(32); +const THIS_COPY = "c".repeat(32); +const OTHER_COPY = "d".repeat(32); +const originalFetch = global.fetch; +let connectionsFail = false; +let connectionsFetches = 0; +const sent: { path: string; method: string; body: unknown }[] = []; +let connections: Record[] = []; +/** The Recorder's answers: what a ping and a connect message get, or undefined for no extension. */ +let recorder: + | { + ping: Record; + connect: Record; + } + | undefined; +const messages: { id: string; message: unknown }[] = []; +const globals = globalThis as { chrome?: unknown }; +let hadChrome = false; +let savedChrome: unknown; + +function connection(over: Record = {}) { + return { + id: "conn-1", + label: "Chrome on Mac", + via: "web", + extensionId: EXTENSION_ID, + installId: THIS_COPY, + createdAt: new Date(Date.now() - 3 * 3_600_000).toISOString(), + lastUsedAt: null, + ...over, + }; +} + +beforeEach(() => { + queryClient.clear(); + sent.length = 0; + messages.length = 0; + connections = []; + connectionsFail = false; + connectionsFetches = 0; + recorder = undefined; + hadChrome = "chrome" in globals; + savedChrome = globals.chrome; + globals.chrome = { + runtime: { + sendMessage: ( + id: string, + message: unknown, + reply: (answer: unknown) => void, + ) => { + messages.push({ id, message }); + if (!recorder) { + // Chrome's answer for an id that is not installed. + ( + globals.chrome as { runtime: { lastError?: unknown } } + ).runtime.lastError = { message: "Could not establish connection." }; + reply(undefined); + return; + } + const type = (message as { type: string }).type; + reply(type === "openbot.ping" ? recorder.ping : recorder.connect); + }, + }, + }; + global.fetch = Object.assign( + async (path: Parameters[0], init?: RequestInit) => { + const url = String(path); + const method = init?.method ?? "GET"; + if (method !== "GET") { + sent.push({ + path: url, + method, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + if (url === "/api/recorder/connect-codes") + return Response.json( + { code: "obc_x", expiresAt: new Date().toISOString() }, + { status: 201 }, + ); + if (method === "DELETE") { + const id = url.split("/").pop(); + connections = connections.filter((c) => c.id !== id); + return new Response(null, { status: 204 }); + } + } + if (url === "/api/recorder/connections") { + connectionsFetches += 1; + if (connectionsFail) return new Response(null, { status: 500 }); + } + if (url === "/api/recorder/connections") + return Response.json({ connections, extensionIds: [EXTENSION_ID] }); + return new Response(null, { status: 404 }); + }, + { preconnect: originalFetch.preconnect }, + ); +}); +afterEach(() => { + cleanup(); + global.fetch = originalFetch; + if (hadChrome) globals.chrome = savedChrome; + else delete globals.chrome; +}); + +function draw(screen = ) { + return render( + {screen}, + ); +} + +test("without the Recorder the row says so and offers no button or link", async () => { + const view = draw(); + await view.findByText( + "The OpenBot Recorder is not installed in this browser.", + ); + expect(view.queryByRole("button")).toBeNull(); + expect(view.queryByRole("link")).toBeNull(); +}); + +test("Connect sends the code to the detected Recorder and asks for confirmation there", async () => { + recorder = { + ping: { ok: true }, + connect: { ok: true, installId: THIS_COPY, awaitingConfirmation: true }, + }; + const view = draw(); + fireEvent.click( + await view.findByRole("button", { name: "Connect this browser" }), + ); + await view.findByText("Confirm in the OpenBot Recorder tab that opened."); + expect(sent).toContainEqual({ + path: "/api/recorder/connect-codes", + method: "POST", + body: { via: "web" }, + }); + expect(messages).toContainEqual({ + id: EXTENSION_ID, + message: { type: "openbot.connect", code: "obc_x" }, + }); +}); + +test("a new connection with the install id the connect answer carried switches the row to Connected", async () => { + recorder = { + ping: { ok: true }, + connect: { ok: true, installId: THIS_COPY, awaitingConfirmation: true }, + }; + const view = draw(); + fireEvent.click( + await view.findByRole("button", { name: "Connect this browser" }), + ); + await view.findByText("Confirm in the OpenBot Recorder tab that opened."); + connections = [connection()]; + await view.findByText("Connected", {}, { timeout: 4000 }); + expect(view.queryByRole("button")).toBeNull(); +}); + +test("a connection from another copy (Chrome connected, this is Edge) neither shows Connected nor ends the wait", async () => { + connections = [connection({ installId: OTHER_COPY })]; + recorder = { + ping: { ok: true }, + connect: { ok: true, installId: THIS_COPY, awaitingConfirmation: true }, + }; + const view = draw(); + fireEvent.click( + await view.findByRole("button", { name: "Connect this browser" }), + ); + await view.findByText("Confirm in the OpenBot Recorder tab that opened."); + await new Promise((resolve) => setTimeout(resolve, 2500)); + expect(view.queryByText("Connected")).toBeNull(); + expect( + view.getByText("Confirm in the OpenBot Recorder tab that opened."), + ).toBeTruthy(); +}); + +test("a copy that answers the ping with its install id and a matching connection reads Connected", async () => { + connections = [connection()]; + recorder = { + ping: { ok: true, installId: THIS_COPY }, + connect: { ok: true }, + }; + const view = draw(); + await view.findByText("Connected"); +}); + +test("a pinned-elsewhere refusal shows its sentence", async () => { + recorder = { + ping: { ok: true }, + connect: { ok: false, error: "pinned-elsewhere" }, + }; + const view = draw(); + fireEvent.click( + await view.findByRole("button", { name: "Connect this browser" }), + ); + await view.findByText( + "Your organization set the Recorder to another OpenBot.", + ); +}); + +test("a throttled connect says plainly when to try again, not that another request is under way", async () => { + recorder = { + ping: { ok: true }, + connect: { ok: false, error: "throttled", retryAfterMs: 6.5 * 60_000 }, + }; + const view = draw(); + const button = await view.findByRole("button", { + name: "Connect this browser", + }); + fireEvent.click(button); + await view.findByText( + "Too many connect requests in a short time. Try again in 7 minutes.", + ); + expect(view.queryByText(/finishing another connect request/)).toBeNull(); + + // Under a minute left reads as one minute, never zero. + recorder.connect = { ok: false, error: "throttled", retryAfterMs: 20_000 }; + fireEvent.click(button); + await view.findByText( + "Too many connect requests in a short time. Try again in 1 minute.", + ); +}); + +test("a connect the Recorder is still finishing keeps its own sentence", async () => { + recorder = { ping: { ok: true }, connect: { ok: false, error: "busy" } }; + const view = draw(); + fireEvent.click( + await view.findByRole("button", { name: "Connect this browser" }), + ); + await view.findByText( + "The Recorder is finishing another connect request. Try again in a moment.", + ); +}); + +test("an answer with no sentence of its own still gets one", async () => { + recorder = { ping: { ok: true }, connect: { ok: false, error: "invalid" } }; + const view = draw(); + fireEvent.click( + await view.findByRole("button", { name: "Connect this browser" }), + ); + await view.findByText("The Recorder did not accept the request. Try again."); +}); + +test("Disconnect sends the DELETE and the row disappears", async () => { + connections = [connection()]; + const view = draw(); + await view.findByText("Chrome on Mac"); + const at = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(String(connections[0]?.createdAt))); + expect( + view.getByText(`Connected ${at} from OpenBot, never used`), + ).toBeTruthy(); + fireEvent.click( + view.getByRole("button", { name: "Disconnect Chrome on Mac" }), + ); + await waitFor(() => + expect(sent).toContainEqual({ + path: "/api/recorder/connections/conn-1", + method: "DELETE", + body: undefined, + }), + ); + await view.findByText("No browsers are connected."); + expect(view.queryByText("Chrome on Mac")).toBeNull(); +}); + +test("when the connections cannot be loaded This browser says it cannot tell, and is not empty", async () => { + connectionsFail = true; + const view = draw(); + await view.findByText( + "Could not check whether this browser is connected.", + {}, + { timeout: 4000 }, + ); + expect(view.getByRole("alert")).toBeTruthy(); + expect(view.queryByRole("button")).toBeNull(); +}); + +test("a connect request nobody confirms expires after five minutes and stops polling", async () => { + recorder = { + ping: { ok: true }, + connect: { ok: true, installId: THIS_COPY, awaitingConfirmation: true }, + }; + // Capture the five-minute timers (the expiry, and the query cache's own 5-minute gc) so the test can fire them instead of waiting. + const realSetTimeout = globalThis.setTimeout; + const expiries: (() => void)[] = []; + globalThis.setTimeout = (( + handler: () => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === 5 * 60_000) { + expiries.push(handler); + return 0; + } + return realSetTimeout(handler, delay, ...args); + }) as unknown as typeof setTimeout; + try { + const view = draw(); + fireEvent.click( + await view.findByRole("button", { name: "Connect this browser" }), + ); + await view.findByText("Confirm in the OpenBot Recorder tab that opened."); + expect(expiries.length).toBeGreaterThan(0); + const polling = connectionsFetches; + await new Promise((resolve) => realSetTimeout(resolve, 2500)); + expect(connectionsFetches).toBeGreaterThan(polling); + act(() => { + for (const fire of expiries) fire(); + }); + await view.findByText("The request expired. Try again."); + const stopped = connectionsFetches; + await new Promise((resolve) => realSetTimeout(resolve, 2500)); + expect(connectionsFetches).toBe(stopped); + } finally { + globalThis.setTimeout = realSetTimeout; + } +}, 15_000); + +test("the copy in this browser is marked among connected browsers that share a name, and each row says when it connected", async () => { + // End-to-end run 6: three rows all read "Google Chrome on macOS" (three Chrome profiles), and none said which one was + // the browser the page was open in. + const hour = 3_600_000; + connections = [ + connection({ + id: "conn-1", + label: "Google Chrome on macOS", + installId: OTHER_COPY, + createdAt: new Date(Date.now() - 30 * hour).toISOString(), + lastUsedAt: new Date(Date.now() - 5 * hour).toISOString(), + }), + connection({ + id: "conn-2", + label: "Google Chrome on macOS", + installId: "e".repeat(32), + createdAt: new Date(Date.now() - 2 * hour - 60_000).toISOString(), + lastUsedAt: new Date(Date.now() - 2 * hour).toISOString(), + }), + connection({ + id: "conn-3", + label: "Google Chrome on macOS", + installId: THIS_COPY, + createdAt: new Date(Date.now() - 2 * hour).toISOString(), + lastUsedAt: new Date(Date.now() - 45 * 60_000).toISOString(), + }), + ]; + // A connected copy answers the ping with its install id. + recorder = { + ping: { ok: true, installId: THIS_COPY }, + connect: { ok: true }, + }; + const view = draw(); + await view.findByText("Google Chrome on macOS · This browser"); + expect(view.getAllByText("Google Chrome on macOS")).toHaveLength(2); + expect( + view.getByRole("button", { + name: "Disconnect Google Chrome on macOS · This browser", + }), + ).toBeTruthy(); + // Two copies connected two hours ago, a minute apart, read differently. + const descriptions = view + .getAllByText(/^Connected .* from OpenBot, last used /) + .map((element) => element.textContent); + expect(new Set(descriptions).size).toBe(3); +}); diff --git a/app/tests/settings-sidebar.test.ts b/app/tests/settings-sidebar.test.ts new file mode 100644 index 000000000..1b8a61112 --- /dev/null +++ b/app/tests/settings-sidebar.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { SETTINGS_SIDEBAR_ITEMS } from "../src/components/settings/settings-sidebar"; + +describe("settings sidebar", () => { + test("Browsers links to /settings/browsers, right after Connected accounts", () => { + const titles = SETTINGS_SIDEBAR_ITEMS.map((item) => item.title); + const browsers = SETTINGS_SIDEBAR_ITEMS[titles.indexOf("Browsers")]; + + expect(browsers?.linkOptions).toEqual({ to: "/settings/browsers" }); + expect(titles.indexOf("Browsers")).toBe( + titles.indexOf("Connected accounts") + 1, + ); + }); + + test("Learning links to /settings/learning, right after Browsers", () => { + const titles = SETTINGS_SIDEBAR_ITEMS.map((item) => item.title); + const learning = SETTINGS_SIDEBAR_ITEMS[titles.indexOf("Learning")]; + + expect(learning?.linkOptions).toEqual({ to: "/settings/learning" }); + expect(titles.indexOf("Learning")).toBe(titles.indexOf("Browsers") + 1); + }); +}); diff --git a/app/tests/sign-in-card.test.tsx b/app/tests/sign-in-card.test.tsx index b3b26a639..7f2d0b3d2 100644 --- a/app/tests/sign-in-card.test.tsx +++ b/app/tests/sign-in-card.test.tsx @@ -73,20 +73,33 @@ function serving(open: (typeof signInRequest)[]) { type Renderer = ComponentType>; type Kept = { + core?: ReturnType["copilotkit"]; renderer?: Renderer; handler?: (args: unknown, context: unknown) => Promise; }; +/** + * Hands the test the provider's core, and nothing else. + * + * What the SDK kept is read from the core when the test asks (`keep`), never during this render. + * Registering a tool re-renders nothing that only calls `useCopilotKit`: before runtime 1.79 the + * provider's config effect fired `onHeadersChanged` each time it re-ran, which redrew every caller + * as a side effect, and 1.79 made an unchanged header record a no-op. A read here would hold + * whatever was registered when this last drew, which is nothing. + */ function Capture({ kept }: { kept: Kept }) { - const { copilotkit } = useCopilotKit(); - const entry = copilotkit.renderToolCalls.find( + kept.core = useCopilotKit().copilotkit; + return null; +} + +function keep(kept: Kept) { + const entry = kept.core?.renderToolCalls.find( (candidate) => candidate.name === "computer_request_sign_in", ); kept.renderer = entry?.render as Renderer | undefined; - kept.handler = copilotkit.getTool({ + kept.handler = kept.core?.getTool({ toolName: "computer_request_sign_in", })?.handler as Kept["handler"]; - return null; } function Providers({ @@ -115,7 +128,10 @@ test("the waiting card offers the private sign-in once the request exists", asyn , ); - await waitFor(() => expect(kept.handler).toBeDefined()); + await waitFor(() => { + keep(kept); + expect(kept.handler).toBeDefined(); + }); // The chat draws the call as soon as it streams in, before the handler has opened the request. const Kept = kept.renderer as Renderer; diff --git a/app/tests/theme-preference.test.ts b/app/tests/theme-preference.test.ts index ead9b15b7..6a2681a97 100644 --- a/app/tests/theme-preference.test.ts +++ b/app/tests/theme-preference.test.ts @@ -57,7 +57,7 @@ describe("pre-paint theme boot", () => { describe("color scheme", () => { const styles = readFileSync( - new URL("../src/styles.css", import.meta.url), + new URL("../src/theme.css", import.meta.url), "utf8", ); @@ -65,4 +65,16 @@ describe("color scheme", () => { expect(styles).toMatch(/:root\s*\{[\s\S]*?color-scheme:\s*light/); expect(styles).toMatch(/\.dark\s*\{[\s\S]*?color-scheme:\s*dark/); }); + + // The Recorder's pages import theme.css too, so the chat's own rules live in styles.css. + test("the shared theme carries no chat-only rule", () => { + expect(styles).not.toContain("tool-line"); + expect(styles).not.toContain("websandbox"); + const app = readFileSync( + new URL("../src/styles.css", import.meta.url), + "utf8", + ); + expect(app).toContain(".tool-line-running"); + expect(app).toContain("iframe.websandbox__frame"); + }); }); diff --git a/biome.json b/biome.json index 8fedba4cb..df77ec353 100644 --- a/biome.json +++ b/biome.json @@ -75,6 +75,37 @@ } } }, + { + "includes": ["extension/src/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "../../server/**", + "../../../server/**", + "../../../../server/**", + "drizzle-orm", + "drizzle-orm/**", + "hono", + "hono/**", + "@/routes/**", + "@/lib/client", + "@/query-client" + ], + "message": "The extension runs in the browser. It talks to OpenBot over HTTP only, and takes only OpenBot's UI primitives and layout parts from the app." + } + ] + } + } + } + } + } + }, { "includes": ["toolkit/src/copilotkit/**"], "linter": { diff --git a/bun.lock b/bun.lock index c80a18591..3b5706474 100644 --- a/bun.lock +++ b/bun.lock @@ -20,11 +20,11 @@ "name": "app", "version": "0.0.0", "dependencies": { - "@ag-ui/core": "1.0.1", + "@ag-ui/core": "1.0.2", "@base-ui/react": "^1.6.0", "@better-auth/sso": "^1.7.1", - "@copilotkit/a2ui-renderer": "1.77.0", - "@copilotkit/react-core": "1.77.0", + "@copilotkit/a2ui-renderer": "1.79.1", + "@copilotkit/react-core": "1.79.1", "@fontsource-variable/inter": "^5.3.0", "@openbot/shared": "workspace:*", "@shadcn/react": "^0.3.0", @@ -64,12 +64,52 @@ "vite": "^7.1.12", }, }, + "extension": { + "name": "@openbot/recorder-extension", + "version": "0.0.0", + "devDependencies": { + "@ag-ui/client": "1.0.2", + "@ag-ui/core": "1.0.2", + "@ag-ui/proto": "1.0.2", + "@base-ui/react": "^1.6.0", + "@copilotkit/learning": "0.0.1", + "@fontsource-variable/inter": "^5.3.0", + "@happy-dom/global-registrator": "^20.11.2", + "@openbot/shared": "workspace:*", + "@tabler/icons-react": "^3.36.1", + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.4", + "@types/chrome": "0.3.4", + "@types/phoenix": "1.6.7", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "eventsource-parser": "3.1.1", + "fake-indexeddb": "6.2.5", + "happy-dom": "^20.11.2", + "phoenix": "1.8.15", + "prompt-area": "^0.6.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "rxjs": "7.8.1", + "shadcn": "^4.17.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "vite": "^7.1.12", + "zod": "^4.4.3", + }, + }, "server": { "name": "server", "version": "0.0.0", "dependencies": { - "@ag-ui/client": "1.0.1", - "@ag-ui/core": "1.0.1", + "@ag-ui/client": "1.0.2", + "@ag-ui/core": "1.0.2", "@ag-ui/mastra": "1.1.6", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/electron": "1.7.1", @@ -77,7 +117,7 @@ "@better-auth/scim": "1.7.1", "@better-auth/sso": "^1.7.1", "@composio/core": "^0.18.1", - "@copilotkit/runtime": "1.77.0", + "@copilotkit/runtime": "1.79.1", "@mastra/client-js": "^1.43.0", "@mastra/core": "^1.64.0", "@modelcontextprotocol/sdk": "^1.30.0", @@ -108,15 +148,20 @@ }, "shared": { "name": "@openbot/shared", + "dependencies": { + "zod": "^4.4.3", + }, "devDependencies": { + "@happy-dom/global-registrator": "^20.11.2", + "happy-dom": "^20.11.2", "typescript": "^5.9.3", }, }, "toolkit": { "name": "@openbot/toolkit", "dependencies": { - "@ag-ui/client": "1.0.1", - "@copilotkit/runtime": "1.77.0", + "@ag-ui/client": "1.0.2", + "@copilotkit/runtime": "1.79.1", "@openbot/shared": "workspace:*", "rxjs": "7.8.1", "zod": "^4.4.3", @@ -133,6 +178,9 @@ }, }, }, + "patchedDependencies": { + "@copilotkit/learning@0.0.1": "patches/@copilotkit%2Flearning@0.0.1.patch", + }, "overrides": { "lodash-es": "4.18.0", }, @@ -149,11 +197,11 @@ "@ag-ui/a2ui-toolkit": ["@ag-ui/a2ui-toolkit@0.0.4", "", {}, "sha512-9VPmgpCAsFVICk7z23kh+Kp/BwYMxw0D0KGjOiKXhm1MAH+Wid2iC3yKsXso+q7UZZiyhWgeDUSgaAkltyLmGg=="], - "@ag-ui/client": ["@ag-ui/client@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/encoder": "1.0.1", "@ag-ui/proto": "1.0.1", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.76" } }, "sha512-AxJ1+fnsRrhWBg+dXItoyXNqUhI/tOXk0/TxXrsuvdjtyTx216Hu+3q5DgQnTDbluP95fCW4buoCKQsKpHmmCw=="], + "@ag-ui/client": ["@ag-ui/client@1.0.2", "", { "dependencies": { "@ag-ui/core": "1.0.2", "@ag-ui/encoder": "1.0.2", "@ag-ui/proto": "1.0.2", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.76" } }, "sha512-BPxcQBPxJZi7XiQI8eBpiE6KBcySjD6hV7XWQhezYbLeK9WJkcUZ6RhX5dngWw+VgfapxPdJdEDTV/JxF21Duw=="], - "@ag-ui/core": ["@ag-ui/core@1.0.1", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Q6pxgI7gcey/0pHfM37S7GNrsYqTrrjjFLpi1dckHNGX2l9VTL0CjnyNc44lwPfHIzFmmEvQfbrAeEPINGGg/Q=="], + "@ag-ui/core": ["@ag-ui/core@1.0.2", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-lb0YfvHY4eTHcXthTWzXjsElLqycGWDhaXGTejwMkUV5TtJVZircRS12/tMpqzd32sL+WZ2PWQ4m0+ThKrGF8A=="], - "@ag-ui/encoder": ["@ag-ui/encoder@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/proto": "1.0.1" } }, "sha512-or0xYLdzGOuJg5avAtUxS1zNiMqO7MxUso+7z90Wl4jpl94QzfmfybXWopvPipVm/dh8IqkB/Zn+LHy4MI2e+g=="], + "@ag-ui/encoder": ["@ag-ui/encoder@1.0.2", "", { "dependencies": { "@ag-ui/core": "1.0.2", "@ag-ui/proto": "1.0.2" } }, "sha512-SE8pQ7dpXwtqpD1WzW2Of9h4uZRDPVGZqT7kdrQ17w9+eOxAolqqRybVLBqJTqCZiUNgX0hFUNlAJSOV0z0UDg=="], "@ag-ui/langgraph": ["@ag-ui/langgraph@0.0.44", "", { "dependencies": { "@ag-ui/a2ui-toolkit": "0.0.4", "@langchain/core": "^1.1.40", "@langchain/langgraph-sdk": "^1.8.8", "langchain": ">=1.2.0", "partial-json": "^0.1.7", "rxjs": "7.8.1" }, "peerDependencies": { "@ag-ui/client": ">=0.0.42", "@ag-ui/core": ">=0.0.42" } }, "sha512-6FthYxO7ZJa1CuvU0/ajP4FmPW1TojJi4y19wUER3iSBibwPbDRHD9ZzGwTu82ZeM+OTeAmzGwlLfYf3zJMPgQ=="], @@ -163,7 +211,7 @@ "@ag-ui/mcp-middleware": ["@ag-ui/mcp-middleware@0.0.2", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" }, "peerDependencies": { "@ag-ui/client": ">=0.0.40", "rxjs": "7.8.1" } }, "sha512-+CwY9SUjXTvk1h77/nqpXEPj79i/Gt0G7ZEcZLlYsJHJkYYC8IG9yN/tiR++HS1Qb1R46btomrgoBfpBvpGwDw=="], - "@ag-ui/proto": ["@ag-ui/proto@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1", "zod": "^3.25.76" } }, "sha512-w7R2gLeXv04Mss0B29thkF+nLitSx6aLeqhUpvmeJZNjBPbwZ6zkXdK3/KEqavCMtHvot7AGj6RaHG1DvbJwww=="], + "@ag-ui/proto": ["@ag-ui/proto@1.0.2", "", { "dependencies": { "@ag-ui/core": "1.0.2", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1", "zod": "^3.25.76" } }, "sha512-uIp6TkQdos03Bkjl01Z46cK/D7QAKNO26RYIIX1WfxNy4penVatU0b/TFTjVrOtwSnp5mtl8Rpzkiiu9cZHwlg=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.111", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-atgBW8jZPr/KuaKX5FvDIHuXBI8VCol6kVeoD4P0657+VXR73QsLogXQVN/Zt5FHtq9WzpdIZseCJiXPqkgwwA=="], @@ -331,7 +379,7 @@ "@composio/json-schema-to-zod": ["@composio/json-schema-to-zod@0.3.2", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "dequal": "^2.0.3" }, "peerDependencies": { "zod": ">=3.25.76 <5" } }, "sha512-TUUzu4uQH6esokVd6LD0SK4oIz8WaYRtU0cnuj0rmxbiHS0VbhN1qdvj2gIvrIWDdNyvQUIalO63YT/lNx46Gw=="], - "@copilotkit/a2ui-renderer": ["@copilotkit/a2ui-renderer@1.77.0", "", { "dependencies": { "@a2ui/web_core": "0.10.4", "clsx": "^2.1.1", "zod": "^3.25.75", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" }, "optionalPeers": ["react", "react-dom"] }, "sha512-FfSrG6DfBi94HzRfKLLaIq+dZPsoR+Buqa1czllWw445QY0ldLgyIqQjMQGyWZYPYUYxsxmgrDZTxF1cGDIqHg=="], + "@copilotkit/a2ui-renderer": ["@copilotkit/a2ui-renderer@1.79.1", "", { "dependencies": { "@a2ui/web_core": "0.10.4", "clsx": "^2.1.1", "zod": "^3.25.75", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" }, "optionalPeers": ["react", "react-dom"] }, "sha512-s7vrPjcZ30M7w566Y8Lek3AMO1Lx7lsZwp3vz7/gtCjbhT9QDDViOQ5Yz5svvxtW0mRc9Lo/es1rduoB0Gnlug=="], "@copilotkit/aimock": ["@copilotkit/aimock@1.43.0", "", { "peerDependencies": { "jest": ">=29", "vitest": ">=3" }, "optionalPeers": ["jest", "vitest"], "bin": { "aimock": "dist/aimock-cli.js", "llmock": "dist/cli.js" } }, "sha512-V8D8cjSMtAR4rHaD2ulp3DR/ZguXQjDNOqwKDNvjb/OJL/KR1I0SDf5xpRCd+TYKjhmf9fM8Xpg3QBKPmV9Kmw=="], @@ -345,25 +393,25 @@ "@copilotkit/channels-ui": ["@copilotkit/channels-ui@0.11.0", "", { "dependencies": { "@copilotkit/shared": "^1.73.1" } }, "sha512-lhPkazCh0mLHeYi5o1y9RdPY4VOuXr4Q9V0htAvy374t72SdI+5S38yf8Ot8EQw1uNx2q8q1Ta8q+P5I9WJ4IA=="], - "@copilotkit/core": ["@copilotkit/core@1.77.0", "", { "dependencies": { "@ag-ui/client": "1.0.1", "@copilotkit/learning": "0.0.1", "@copilotkit/shared": "1.77.0", "@tanstack/pacer": "^0.20.1", "phoenix": "^1.8.4", "rxjs": "7.8.1", "zod-to-json-schema": "^3.24.6" } }, "sha512-p+GHbrVPgPLNgGVMAj5IfDIkzjzX0YemASXDbX8bWj+3+XFQuiqCpxu0kq6Zh0U/Tb7Be+0Kps130aw48ks2Ig=="], + "@copilotkit/core": ["@copilotkit/core@1.79.1", "", { "dependencies": { "@ag-ui/client": "1.0.2", "@copilotkit/learning": "^0.0.1", "@copilotkit/shared": "1.79.1", "@tanstack/pacer": "^0.20.1", "phoenix": "^1.8.4", "rxjs": "7.8.1", "zod-to-json-schema": "^3.24.6" } }, "sha512-H4V9VDaCbOnsRW+xmEaLl3o0GuN5/TzSCJk82WrtsWS2mFFUPcNW6EIcqNLGQqtGBvcxzX1sasBhiRvV2Imasw=="], "@copilotkit/learning": ["@copilotkit/learning@0.0.1", "", {}, "sha512-0uh9hTZA4y6f4hMVBDsfr5kupvaHmuKmwMIrTieF/9rKrnB4/bazSl9Nyv5OIYFMpAx03u8KnSEeKe0aHGCy6w=="], "@copilotkit/license-verifier": ["@copilotkit/license-verifier@0.5.0", "", {}, "sha512-vrwKtIpYwF0FT9ZoYASH8owa2cGV0dhDvJGaCRaRMStwDxpc6DRdydKkhx8cWZXyBRxEYcq/Vygv4JvevhQQdQ=="], - "@copilotkit/mcp-apps-renderer": ["@copilotkit/mcp-apps-renderer@1.77.0", "", { "dependencies": { "@copilotkit/shared": "1.77.0", "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "^1.29.0" }, "peerDependencies": { "@ag-ui/client": ">=0.0.59", "zod": ">=3.25" } }, "sha512-eq7zgp4ZyvaRiU/vzbDCGcDF/IUtSgSEwAVahNti5xrwZ6gxHq8n/PNUk1er03FokdgLTQTflwxvWzUmavukKQ=="], + "@copilotkit/mcp-apps-renderer": ["@copilotkit/mcp-apps-renderer@1.79.1", "", { "dependencies": { "@copilotkit/shared": "1.79.1", "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "^1.29.0" }, "peerDependencies": { "@ag-ui/client": ">=0.0.59", "zod": ">=3.25" } }, "sha512-deEGFQNcBkZD+/I9eVceQKTr7RMNb1BckNxrsjXbCA8T9HuID+P3DXVb5enxuDiuu8DHUZFRQ7um2SzpreSlJA=="], - "@copilotkit/react-core": ["@copilotkit/react-core@1.77.0", "", { "dependencies": { "@ag-ui/client": "1.0.1", "@ag-ui/core": "1.0.1", "@copilotkit/a2ui-renderer": "1.77.0", "@copilotkit/core": "1.77.0", "@copilotkit/mcp-apps-renderer": "1.77.0", "@copilotkit/runtime-client-gql": "1.77.0", "@copilotkit/shared": "1.77.0", "@copilotkit/web-components": "1.77.0", "@copilotkit/web-inspector": "1.77.0", "@jetbrains/websandbox": "^1.1.3", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", "@scarf/scarf": "^1.3.0", "@tanstack/react-virtual": "^3.14.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "katex": "^0.16.22", "lit": "^3.3.2", "lucide-react": "^0.525.0", "react-markdown": "^8.0.7", "rxjs": "7.8.1", "streamdown": "^1.3.0", "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.3.5", "untruncate-json": "^0.0.1", "use-stick-to-bottom": "^1.1.1", "zod-to-json-schema": "^3.24.5" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc", "zod": ">=3.25" } }, "sha512-FEaTnVQO2ogsm1dfLsYxYQqjPI90ZxRsP3IRdnMty5AmKWneJsLq+/SrS2v2VS4tScVFEXq5+EbvPgkibDo8wQ=="], + "@copilotkit/react-core": ["@copilotkit/react-core@1.79.1", "", { "dependencies": { "@ag-ui/client": "1.0.2", "@ag-ui/core": "1.0.2", "@copilotkit/a2ui-renderer": "1.79.1", "@copilotkit/core": "1.79.1", "@copilotkit/mcp-apps-renderer": "1.79.1", "@copilotkit/runtime-client-gql": "1.79.1", "@copilotkit/shared": "1.79.1", "@copilotkit/web-components": "1.79.1", "@copilotkit/web-inspector": "1.79.1", "@jetbrains/websandbox": "^1.1.3", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", "@scarf/scarf": "^1.3.0", "@tanstack/react-virtual": "^3.14.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "katex": "^0.16.22", "lit": "^3.3.2", "lucide-react": "^0.525.0", "react-markdown": "^8.0.7", "rxjs": "7.8.1", "streamdown": "^1.3.0", "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.3.5", "untruncate-json": "^0.0.1", "use-stick-to-bottom": "^1.1.1", "zod-to-json-schema": "^3.24.5" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc", "zod": ">=3.25" } }, "sha512-asxn37aDatu4zGdzzaQ6UvOqOeNzDMWbYILYe04hx/5Nz8HwDWPXNLlEA8Ll7yNmIRXr5cUVRKiW5RHo1kQQQA=="], - "@copilotkit/runtime": ["@copilotkit/runtime@1.77.0", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "1.0.1", "@ag-ui/core": "1.0.1", "@ag-ui/encoder": "1.0.1", "@ag-ui/langgraph": "0.0.44", "@ag-ui/mcp-apps-middleware": "^0.1.1", "@ag-ui/mcp-middleware": "0.0.2", "@ai-sdk/anthropic": "^3.0.49", "@ai-sdk/google": "^3.0.33", "@ai-sdk/google-vertex": "^3.0.97", "@ai-sdk/mcp": "^1.0.21", "@ai-sdk/openai": "^3.0.36", "@cfworker/json-schema": "^4.1.1", "@copilotkit/channels-core": "^0.11.0", "@copilotkit/channels-intelligence": "0.11.0", "@copilotkit/license-verifier": "~0.5.0", "@copilotkit/shared": "1.77.0", "@graphql-yoga/plugin-defer-stream": "^3.3.1", "@hono/node-server": "^1.13.5", "@modelcontextprotocol/sdk": "^1.18.2", "@remix-run/node-fetch-server": "^0.13.0", "@scarf/scarf": "^1.3.0", "@segment/analytics-node": "^2.1.2", "@types/cors": "^2.8.17", "ai": "^6.0.104", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cors": "^2.8.5", "graphql": "^16.8.1", "graphql-scalars": "^1.23.0", "graphql-yoga": "^5.3.1", "hono": "^4.11.4", "openai": "^4.85.1 || >=5.0.0", "partial-json": "^0.1.7", "phoenix": "^1.8.4", "pino": "^10.1.1", "pino-pretty": "^11.2.1", "reflect-metadata": "^0.2.2", "rxjs": "7.8.1", "type-graphql": "2.0.0-rc.1", "uuid": "^11.1.0", "ws": "^8.18.0", "yauzl": "2.10.0", "zod": "^3.25.18" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.57.0", "@langchain/aws": ">=0.1.9", "@langchain/community": ">=0.3.58", "@langchain/core": ">=0.3.66", "@langchain/google-gauth": ">=0.1.0", "@langchain/langgraph-sdk": ">=0.1.2", "@langchain/openai": ">=0.4.2", "express": "^4.18.0 || ^5.0.0", "groq-sdk": ">=0.3.0 <1.0.0", "langchain": ">=0.3.3" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/aws", "@langchain/community", "@langchain/google-gauth", "@langchain/langgraph-sdk", "@langchain/openai", "express", "groq-sdk", "langchain"] }, "sha512-dhzVC+rRcjRWHaMwWlspIlfNX8gNBaDBLsevWkOuDYL880WGa1pSaOB8bC+vx8c1tVceY8iUOrlRXOGBDkIWNA=="], + "@copilotkit/runtime": ["@copilotkit/runtime@1.79.1", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "1.0.2", "@ag-ui/core": "1.0.2", "@ag-ui/encoder": "1.0.2", "@ag-ui/langgraph": "0.0.44", "@ag-ui/mcp-apps-middleware": "^0.1.1", "@ag-ui/mcp-middleware": "0.0.2", "@ai-sdk/anthropic": "^3.0.49", "@ai-sdk/google": "^3.0.33", "@ai-sdk/google-vertex": "^3.0.97", "@ai-sdk/mcp": "^1.0.21", "@ai-sdk/openai": "^3.0.36", "@cfworker/json-schema": "^4.1.1", "@copilotkit/channels-core": "^0.11.0", "@copilotkit/channels-intelligence": "^0.11.0", "@copilotkit/license-verifier": "~0.5.0", "@copilotkit/shared": "1.79.1", "@graphql-yoga/plugin-defer-stream": "^3.3.1", "@hono/node-server": "^1.13.5", "@modelcontextprotocol/sdk": "^1.18.2", "@remix-run/node-fetch-server": "^0.13.0", "@scarf/scarf": "^1.3.0", "@segment/analytics-node": "^2.1.2", "@types/cors": "^2.8.17", "ai": "^6.0.104", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cors": "^2.8.5", "graphql": "^16.8.1", "graphql-scalars": "^1.23.0", "graphql-yoga": "^5.3.1", "hono": "^4.11.4", "openai": "^4.85.1 || >=5.0.0", "partial-json": "^0.1.7", "phoenix": "^1.8.4", "pino": "^10.1.1", "pino-pretty": "^11.2.1", "reflect-metadata": "^0.2.2", "rxjs": "7.8.1", "type-graphql": "2.0.0-rc.1", "uuid": "^11.1.0", "ws": "^8.18.0", "yauzl": "2.10.0", "zod": "^3.25.18" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.57.0", "@langchain/aws": ">=0.1.9", "@langchain/community": ">=0.3.58", "@langchain/core": ">=0.3.66", "@langchain/google-gauth": ">=0.1.0", "@langchain/langgraph-sdk": ">=0.1.2", "@langchain/openai": ">=0.4.2", "express": "^4.18.0 || ^5.0.0", "groq-sdk": ">=0.3.0 <1.0.0", "langchain": ">=0.3.3" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/aws", "@langchain/community", "@langchain/google-gauth", "@langchain/langgraph-sdk", "@langchain/openai", "express", "groq-sdk", "langchain"] }, "sha512-9CS+/o51DzPAxsmPYd32DPNVDTFg3wOfC4dbIo1GI7umj3mCENjnkUVXk4KprwD8J+BD4LcMf5HvP+ZDbEecSA=="], - "@copilotkit/runtime-client-gql": ["@copilotkit/runtime-client-gql@1.77.0", "", { "dependencies": { "@copilotkit/shared": "1.77.0", "@urql/core": "^5.0.3", "untruncate-json": "^0.0.1", "urql": "^4.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-eyuUAfzPumOt67da7rvjiqibTkqXm3Io8jqyIkV23ZwPTG4NzZAWbNY5U3w7S0QIJjVo9aFafXQ074CwTzDc2g=="], + "@copilotkit/runtime-client-gql": ["@copilotkit/runtime-client-gql@1.79.1", "", { "dependencies": { "@copilotkit/shared": "1.79.1", "@urql/core": "^5.0.3", "untruncate-json": "^0.0.1", "urql": "^4.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-rKHnCMe+/3TlSzVycKt36ZCpo1lO/eHZg84L0lDHE0WiEoAYN6f8sWaqDMD+WpKbzuZ6pUJ2vUfWQiFtKVr4cw=="], - "@copilotkit/shared": ["@copilotkit/shared@1.77.0", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "1.0.1", "@copilotkit/license-verifier": "~0.5.0", "@segment/analytics-node": "^2.1.2", "@standard-schema/spec": "^1.0.0", "chalk": "4.1.2", "clarinet": "^0.12.4", "graphql": "^16.8.1", "partial-json": "^0.1.7", "rxjs": "7.8.1", "uuid": "^11.1.0", "zod": "^3.23.3", "zod-to-json-schema": "^3.23.5" }, "peerDependencies": { "@ag-ui/core": ">=0.0.48" } }, "sha512-rnE4D1IKoKh3jXDe1KsE7Ayzm3LA7p1nrLFHWssRmuo65E38MXAIqwV7Ap7BydJaV8VtozyM/HvbLmDBVDU39A=="], + "@copilotkit/shared": ["@copilotkit/shared@1.79.1", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "1.0.2", "@copilotkit/license-verifier": "~0.5.0", "@segment/analytics-node": "^2.1.2", "@standard-schema/spec": "^1.0.0", "chalk": "4.1.2", "clarinet": "^0.12.4", "graphql": "^16.8.1", "partial-json": "^0.1.7", "rxjs": "7.8.1", "uuid": "^11.1.0", "zod": "^3.23.3", "zod-to-json-schema": "^3.23.5" }, "peerDependencies": { "@ag-ui/core": ">=0.0.48" } }, "sha512-raIecBpsKpMJu8dfoEGNtyYIJ6Rwhb93BSLdYhQCW/lfBzrU+4FI/tPbkDf9svvQhltbqL6c5JJoXBOVKNwlLQ=="], - "@copilotkit/web-components": ["@copilotkit/web-components@1.77.0", "", { "peerDependencies": { "lit": "^3.3.2" } }, "sha512-o4VW8NX87LGbjygOJyTJiCrsA+YTPWu7dAfo/ssD8eP/tgk6uz35arjPJAnh07fv9lLbeMEZ+wG9xRVuf+jctQ=="], + "@copilotkit/web-components": ["@copilotkit/web-components@1.79.1", "", { "peerDependencies": { "lit": "^3.3.2" } }, "sha512-6fE3n9FYamezF2eRY/q5WuL/b4QN77AWK6sh2PSS2YOPN7s8BxozMH80B9uIKh7gapHWjdA7O1p30urd2QFtUA=="], - "@copilotkit/web-inspector": ["@copilotkit/web-inspector@1.77.0", "", { "dependencies": { "@ag-ui/client": "1.0.1", "@copilotkit/core": "1.77.0", "@copilotkit/shared": "1.77.0", "lit": "^3.2.0", "lucide": "^0.525.0", "marked": "^12.0.2", "semver": "^7.8.5" } }, "sha512-JMR8IrVdjQNNb41mtVrzIkBXVjfMd3uLCGz9spY/xvU/NWRd9ChdeUjoM32tiMHWfywJsFjxaXa5QjXgfxEcCw=="], + "@copilotkit/web-inspector": ["@copilotkit/web-inspector@1.79.1", "", { "dependencies": { "@ag-ui/client": "1.0.2", "@copilotkit/core": "1.79.1", "@copilotkit/shared": "1.79.1", "lit": "^3.2.0", "lucide": "^0.525.0", "marked": "^12.0.2", "semver": "^7.8.5" } }, "sha512-MTyGWBE0dSWsSVmRlhziYDMKF2oF7ndtO2+Ern/2ZW/dhjjkAKNa2J/dNMghDyZWUCpDkvNTMHi6XMc9DZB0KQ=="], "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], @@ -533,6 +581,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@openbot/recorder-extension": ["@openbot/recorder-extension@workspace:extension"], + "@openbot/shared": ["@openbot/shared@workspace:shared"], "@openbot/toolkit": ["@openbot/toolkit@workspace:toolkit"], @@ -823,6 +873,8 @@ "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/chrome": ["@types/chrome@0.3.4", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-dcySM5R3WAUVYFjylu6tvI4D03hPKjgAqk+tKZ/tm/vxL4mLgbnSsOEQ3J7V99qP/U3Oo9IORstJLmX2hPaDlA=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], @@ -899,8 +951,14 @@ "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.3", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw=="], + "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], + + "@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="], + "@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], @@ -917,6 +975,8 @@ "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], @@ -993,7 +1053,7 @@ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], @@ -1075,7 +1135,7 @@ "cel-js": ["cel-js@0.8.2", "", { "dependencies": { "chevrotain": "11.0.3", "ramda": "0.30.1" } }, "sha512-fPk7V5wcjp6GQ2CqkQKbfaIGQ5rQIm8xOueVqbp9MPfviqZEBOReBF82iZa0oHUm449UTsj1LAzUIAsI356ong=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -1357,6 +1417,8 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1939,7 +2001,7 @@ "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - "phoenix": ["phoenix@1.8.12", "", {}, "sha512-svUniHb83aGh1XpYWHe4fkbfrGd6xa26TWO/3clkuNplXwgjxh7RI7GOWxf28VAgDWwQ2/yN8PxIa5dqjXOcAA=="], + "phoenix": ["phoenix@1.8.15", "", {}, "sha512-AU2t9smrLcSy7DF1ic3VKDVNmjnhcsOgdeXBiVrR29BgFLmO4EN+tyGx/lRY3naxl7eJtyLrnlBOfJu8gYvXdQ=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -2441,20 +2503,42 @@ "@copilotkit/channels-core/@ag-ui/core": ["@ag-ui/core@0.0.59", "", { "dependencies": { "zod": "^3.22.4" } }, "sha512-hDgy4ipTqXieT8YG8Mr917Y+FD/f11VK1GefZ5CwTDCuNqS/oTwjJ5l/DZkicThgS8hQW/Y7wPylPBMBJ8BkUg=="], + "@copilotkit/channels-core/@copilotkit/core": ["@copilotkit/core@1.77.0", "", { "dependencies": { "@ag-ui/client": "1.0.1", "@copilotkit/learning": "0.0.1", "@copilotkit/shared": "1.77.0", "@tanstack/pacer": "^0.20.1", "phoenix": "^1.8.4", "rxjs": "7.8.1", "zod-to-json-schema": "^3.24.6" } }, "sha512-p+GHbrVPgPLNgGVMAj5IfDIkzjzX0YemASXDbX8bWj+3+XFQuiqCpxu0kq6Zh0U/Tb7Be+0Kps130aw48ks2Ig=="], + + "@copilotkit/channels-core/@copilotkit/shared": ["@copilotkit/shared@1.77.0", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "1.0.1", "@copilotkit/license-verifier": "~0.5.0", "@segment/analytics-node": "^2.1.2", "@standard-schema/spec": "^1.0.0", "chalk": "4.1.2", "clarinet": "^0.12.4", "graphql": "^16.8.1", "partial-json": "^0.1.7", "rxjs": "7.8.1", "uuid": "^11.1.0", "zod": "^3.23.3", "zod-to-json-schema": "^3.23.5" }, "peerDependencies": { "@ag-ui/core": ">=0.0.48" } }, "sha512-rnE4D1IKoKh3jXDe1KsE7Ayzm3LA7p1nrLFHWssRmuo65E38MXAIqwV7Ap7BydJaV8VtozyM/HvbLmDBVDU39A=="], + "@copilotkit/channels-intelligence/@ag-ui/client": ["@ag-ui/client@0.0.59", "", { "dependencies": { "@ag-ui/core": "0.0.59", "@ag-ui/encoder": "0.0.59", "@ag-ui/proto": "0.0.59", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.22.4" } }, "sha512-d7++r8sBAq6z9G/D/WKrMznQ1Mdvew14pCqOVATReFIvl06mCi1BPqTwmos3zCDGAIiSgcvxc/8m472rYQgFFQ=="], + "@copilotkit/channels-intelligence/phoenix": ["phoenix@1.8.12", "", {}, "sha512-svUniHb83aGh1XpYWHe4fkbfrGd6xa26TWO/3clkuNplXwgjxh7RI7GOWxf28VAgDWwQ2/yN8PxIa5dqjXOcAA=="], + "@copilotkit/channels-slack/@ag-ui/client": ["@ag-ui/client@0.0.59", "", { "dependencies": { "@ag-ui/core": "0.0.59", "@ag-ui/encoder": "0.0.59", "@ag-ui/proto": "0.0.59", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.22.4" } }, "sha512-d7++r8sBAq6z9G/D/WKrMznQ1Mdvew14pCqOVATReFIvl06mCi1BPqTwmos3zCDGAIiSgcvxc/8m472rYQgFFQ=="], "@copilotkit/channels-slack/@ag-ui/core": ["@ag-ui/core@0.0.59", "", { "dependencies": { "zod": "^3.22.4" } }, "sha512-hDgy4ipTqXieT8YG8Mr917Y+FD/f11VK1GefZ5CwTDCuNqS/oTwjJ5l/DZkicThgS8hQW/Y7wPylPBMBJ8BkUg=="], + "@copilotkit/channels-slack/@copilotkit/core": ["@copilotkit/core@1.77.0", "", { "dependencies": { "@ag-ui/client": "1.0.1", "@copilotkit/learning": "0.0.1", "@copilotkit/shared": "1.77.0", "@tanstack/pacer": "^0.20.1", "phoenix": "^1.8.4", "rxjs": "7.8.1", "zod-to-json-schema": "^3.24.6" } }, "sha512-p+GHbrVPgPLNgGVMAj5IfDIkzjzX0YemASXDbX8bWj+3+XFQuiqCpxu0kq6Zh0U/Tb7Be+0Kps130aw48ks2Ig=="], + + "@copilotkit/channels-slack/@copilotkit/shared": ["@copilotkit/shared@1.77.0", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "1.0.1", "@copilotkit/license-verifier": "~0.5.0", "@segment/analytics-node": "^2.1.2", "@standard-schema/spec": "^1.0.0", "chalk": "4.1.2", "clarinet": "^0.12.4", "graphql": "^16.8.1", "partial-json": "^0.1.7", "rxjs": "7.8.1", "uuid": "^11.1.0", "zod": "^3.23.3", "zod-to-json-schema": "^3.23.5" }, "peerDependencies": { "@ag-ui/core": ">=0.0.48" } }, "sha512-rnE4D1IKoKh3jXDe1KsE7Ayzm3LA7p1nrLFHWssRmuo65E38MXAIqwV7Ap7BydJaV8VtozyM/HvbLmDBVDU39A=="], + "@copilotkit/channels-teams/@ag-ui/client": ["@ag-ui/client@0.0.59", "", { "dependencies": { "@ag-ui/core": "0.0.59", "@ag-ui/encoder": "0.0.59", "@ag-ui/proto": "0.0.59", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.22.4" } }, "sha512-d7++r8sBAq6z9G/D/WKrMznQ1Mdvew14pCqOVATReFIvl06mCi1BPqTwmos3zCDGAIiSgcvxc/8m472rYQgFFQ=="], "@copilotkit/channels-teams/@ag-ui/core": ["@ag-ui/core@0.0.59", "", { "dependencies": { "zod": "^3.22.4" } }, "sha512-hDgy4ipTqXieT8YG8Mr917Y+FD/f11VK1GefZ5CwTDCuNqS/oTwjJ5l/DZkicThgS8hQW/Y7wPylPBMBJ8BkUg=="], + "@copilotkit/channels-teams/@copilotkit/core": ["@copilotkit/core@1.77.0", "", { "dependencies": { "@ag-ui/client": "1.0.1", "@copilotkit/learning": "0.0.1", "@copilotkit/shared": "1.77.0", "@tanstack/pacer": "^0.20.1", "phoenix": "^1.8.4", "rxjs": "7.8.1", "zod-to-json-schema": "^3.24.6" } }, "sha512-p+GHbrVPgPLNgGVMAj5IfDIkzjzX0YemASXDbX8bWj+3+XFQuiqCpxu0kq6Zh0U/Tb7Be+0Kps130aw48ks2Ig=="], + + "@copilotkit/channels-teams/@copilotkit/shared": ["@copilotkit/shared@1.77.0", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "1.0.1", "@copilotkit/license-verifier": "~0.5.0", "@segment/analytics-node": "^2.1.2", "@standard-schema/spec": "^1.0.0", "chalk": "4.1.2", "clarinet": "^0.12.4", "graphql": "^16.8.1", "partial-json": "^0.1.7", "rxjs": "7.8.1", "uuid": "^11.1.0", "zod": "^3.23.3", "zod-to-json-schema": "^3.23.5" }, "peerDependencies": { "@ag-ui/core": ">=0.0.48" } }, "sha512-rnE4D1IKoKh3jXDe1KsE7Ayzm3LA7p1nrLFHWssRmuo65E38MXAIqwV7Ap7BydJaV8VtozyM/HvbLmDBVDU39A=="], + + "@copilotkit/channels-ui/@copilotkit/shared": ["@copilotkit/shared@1.77.0", "", { "dependencies": { "@ag-ui/a2ui-middleware": "0.0.10", "@ag-ui/client": "1.0.1", "@copilotkit/license-verifier": "~0.5.0", "@segment/analytics-node": "^2.1.2", "@standard-schema/spec": "^1.0.0", "chalk": "4.1.2", "clarinet": "^0.12.4", "graphql": "^16.8.1", "partial-json": "^0.1.7", "rxjs": "7.8.1", "uuid": "^11.1.0", "zod": "^3.23.3", "zod-to-json-schema": "^3.23.5" }, "peerDependencies": { "@ag-ui/core": ">=0.0.48" } }, "sha512-rnE4D1IKoKh3jXDe1KsE7Ayzm3LA7p1nrLFHWssRmuo65E38MXAIqwV7Ap7BydJaV8VtozyM/HvbLmDBVDU39A=="], + + "@copilotkit/core/phoenix": ["phoenix@1.8.12", "", {}, "sha512-svUniHb83aGh1XpYWHe4fkbfrGd6xa26TWO/3clkuNplXwgjxh7RI7GOWxf28VAgDWwQ2/yN8PxIa5dqjXOcAA=="], + "@copilotkit/react-core/streamdown": ["streamdown@1.6.11", "", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y38fwRx5kCKTluwM+Gf27jbbi9q6Qy+WC9YrC1YbCpMkktT3PsRBJHMWiqYeF8y/JzLpB1IzDoeaB6qkQEDnAA=="], + "@copilotkit/runtime/phoenix": ["phoenix@1.8.12", "", {}, "sha512-svUniHb83aGh1XpYWHe4fkbfrGd6xa26TWO/3clkuNplXwgjxh7RI7GOWxf28VAgDWwQ2/yN8PxIa5dqjXOcAA=="], + "@copilotkit/runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/shared/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@copilotkit/shared/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@copilotkit/web-inspector/marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="], @@ -2611,8 +2695,6 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], "mdast-util-definitions/@types/mdast": ["@types/mdast@3.0.15", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ=="], @@ -2651,14 +2733,10 @@ "onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], @@ -2751,6 +2829,16 @@ "@copilotkit/channels-core/@ag-ui/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/channels-core/@copilotkit/core/@ag-ui/client": ["@ag-ui/client@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/encoder": "1.0.1", "@ag-ui/proto": "1.0.1", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.76" } }, "sha512-AxJ1+fnsRrhWBg+dXItoyXNqUhI/tOXk0/TxXrsuvdjtyTx216Hu+3q5DgQnTDbluP95fCW4buoCKQsKpHmmCw=="], + + "@copilotkit/channels-core/@copilotkit/core/phoenix": ["phoenix@1.8.12", "", {}, "sha512-svUniHb83aGh1XpYWHe4fkbfrGd6xa26TWO/3clkuNplXwgjxh7RI7GOWxf28VAgDWwQ2/yN8PxIa5dqjXOcAA=="], + + "@copilotkit/channels-core/@copilotkit/shared/@ag-ui/client": ["@ag-ui/client@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/encoder": "1.0.1", "@ag-ui/proto": "1.0.1", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.76" } }, "sha512-AxJ1+fnsRrhWBg+dXItoyXNqUhI/tOXk0/TxXrsuvdjtyTx216Hu+3q5DgQnTDbluP95fCW4buoCKQsKpHmmCw=="], + + "@copilotkit/channels-core/@copilotkit/shared/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@copilotkit/channels-core/@copilotkit/shared/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/channels-intelligence/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@0.0.59", "", { "dependencies": { "zod": "^3.22.4" } }, "sha512-hDgy4ipTqXieT8YG8Mr917Y+FD/f11VK1GefZ5CwTDCuNqS/oTwjJ5l/DZkicThgS8hQW/Y7wPylPBMBJ8BkUg=="], "@copilotkit/channels-intelligence/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@0.0.59", "", { "dependencies": { "@ag-ui/core": "0.0.59", "@ag-ui/proto": "0.0.59" } }, "sha512-wQCzBsStyZMm8nzhTdTx1G0B1C8yv5rbzZLnz1ka/l5LcJEsK3KTounU8CR9l+7QtcpOUUDJKd0xAbyI6gNcSw=="], @@ -2767,6 +2855,16 @@ "@copilotkit/channels-slack/@ag-ui/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/channels-slack/@copilotkit/core/@ag-ui/client": ["@ag-ui/client@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/encoder": "1.0.1", "@ag-ui/proto": "1.0.1", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.76" } }, "sha512-AxJ1+fnsRrhWBg+dXItoyXNqUhI/tOXk0/TxXrsuvdjtyTx216Hu+3q5DgQnTDbluP95fCW4buoCKQsKpHmmCw=="], + + "@copilotkit/channels-slack/@copilotkit/core/phoenix": ["phoenix@1.8.12", "", {}, "sha512-svUniHb83aGh1XpYWHe4fkbfrGd6xa26TWO/3clkuNplXwgjxh7RI7GOWxf28VAgDWwQ2/yN8PxIa5dqjXOcAA=="], + + "@copilotkit/channels-slack/@copilotkit/shared/@ag-ui/client": ["@ag-ui/client@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/encoder": "1.0.1", "@ag-ui/proto": "1.0.1", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.76" } }, "sha512-AxJ1+fnsRrhWBg+dXItoyXNqUhI/tOXk0/TxXrsuvdjtyTx216Hu+3q5DgQnTDbluP95fCW4buoCKQsKpHmmCw=="], + + "@copilotkit/channels-slack/@copilotkit/shared/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@copilotkit/channels-slack/@copilotkit/shared/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/channels-teams/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@0.0.59", "", { "dependencies": { "@ag-ui/core": "0.0.59", "@ag-ui/proto": "0.0.59" } }, "sha512-wQCzBsStyZMm8nzhTdTx1G0B1C8yv5rbzZLnz1ka/l5LcJEsK3KTounU8CR9l+7QtcpOUUDJKd0xAbyI6gNcSw=="], "@copilotkit/channels-teams/@ag-ui/client/@ag-ui/proto": ["@ag-ui/proto@0.0.59", "", { "dependencies": { "@ag-ui/core": "0.0.59", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1" } }, "sha512-X+uvDaegLEHw5kJu8tv2eSqHH8ouat+JCfFokGV1uuMquY8qCEQSlGsM7xzexwX9fujuxgHOWumg5kJDqa0+RA=="], @@ -2775,12 +2873,30 @@ "@copilotkit/channels-teams/@ag-ui/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/channels-teams/@copilotkit/core/@ag-ui/client": ["@ag-ui/client@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/encoder": "1.0.1", "@ag-ui/proto": "1.0.1", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.76" } }, "sha512-AxJ1+fnsRrhWBg+dXItoyXNqUhI/tOXk0/TxXrsuvdjtyTx216Hu+3q5DgQnTDbluP95fCW4buoCKQsKpHmmCw=="], + + "@copilotkit/channels-teams/@copilotkit/core/phoenix": ["phoenix@1.8.12", "", {}, "sha512-svUniHb83aGh1XpYWHe4fkbfrGd6xa26TWO/3clkuNplXwgjxh7RI7GOWxf28VAgDWwQ2/yN8PxIa5dqjXOcAA=="], + + "@copilotkit/channels-teams/@copilotkit/shared/@ag-ui/client": ["@ag-ui/client@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/encoder": "1.0.1", "@ag-ui/proto": "1.0.1", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.76" } }, "sha512-AxJ1+fnsRrhWBg+dXItoyXNqUhI/tOXk0/TxXrsuvdjtyTx216Hu+3q5DgQnTDbluP95fCW4buoCKQsKpHmmCw=="], + + "@copilotkit/channels-teams/@copilotkit/shared/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@copilotkit/channels-teams/@copilotkit/shared/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@copilotkit/channels-ui/@copilotkit/shared/@ag-ui/client": ["@ag-ui/client@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/encoder": "1.0.1", "@ag-ui/proto": "1.0.1", "@types/uuid": "^10.0.0", "compare-versions": "^6.1.1", "fast-json-patch": "^3.1.1", "rxjs": "7.8.1", "untruncate-json": "^0.0.1", "uuid": "^11.1.0", "zod": "^3.25.76" } }, "sha512-AxJ1+fnsRrhWBg+dXItoyXNqUhI/tOXk0/TxXrsuvdjtyTx216Hu+3q5DgQnTDbluP95fCW4buoCKQsKpHmmCw=="], + + "@copilotkit/channels-ui/@copilotkit/shared/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@copilotkit/channels-ui/@copilotkit/shared/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@copilotkit/react-core/streamdown/lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], "@copilotkit/react-core/streamdown/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "@copilotkit/react-core/streamdown/remend": ["remend@1.0.1", "", {}, "sha512-152puVH0qMoRJQFnaMG+rVDdf01Jq/CaED+MBuXExurJgdbkLp0c3TIe4R12o28Klx8uyGsjvFNG05aFG69G9w=="], + "@copilotkit/shared/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -2977,6 +3093,62 @@ "style-to-js/style-to-object/inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "@copilotkit/channels-core/@copilotkit/core/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@1.0.1", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Q6pxgI7gcey/0pHfM37S7GNrsYqTrrjjFLpi1dckHNGX2l9VTL0CjnyNc44lwPfHIzFmmEvQfbrAeEPINGGg/Q=="], + + "@copilotkit/channels-core/@copilotkit/core/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/proto": "1.0.1" } }, "sha512-or0xYLdzGOuJg5avAtUxS1zNiMqO7MxUso+7z90Wl4jpl94QzfmfybXWopvPipVm/dh8IqkB/Zn+LHy4MI2e+g=="], + + "@copilotkit/channels-core/@copilotkit/core/@ag-ui/client/@ag-ui/proto": ["@ag-ui/proto@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1", "zod": "^3.25.76" } }, "sha512-w7R2gLeXv04Mss0B29thkF+nLitSx6aLeqhUpvmeJZNjBPbwZ6zkXdK3/KEqavCMtHvot7AGj6RaHG1DvbJwww=="], + + "@copilotkit/channels-core/@copilotkit/core/@ag-ui/client/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@copilotkit/channels-core/@copilotkit/shared/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@1.0.1", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Q6pxgI7gcey/0pHfM37S7GNrsYqTrrjjFLpi1dckHNGX2l9VTL0CjnyNc44lwPfHIzFmmEvQfbrAeEPINGGg/Q=="], + + "@copilotkit/channels-core/@copilotkit/shared/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/proto": "1.0.1" } }, "sha512-or0xYLdzGOuJg5avAtUxS1zNiMqO7MxUso+7z90Wl4jpl94QzfmfybXWopvPipVm/dh8IqkB/Zn+LHy4MI2e+g=="], + + "@copilotkit/channels-core/@copilotkit/shared/@ag-ui/client/@ag-ui/proto": ["@ag-ui/proto@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1", "zod": "^3.25.76" } }, "sha512-w7R2gLeXv04Mss0B29thkF+nLitSx6aLeqhUpvmeJZNjBPbwZ6zkXdK3/KEqavCMtHvot7AGj6RaHG1DvbJwww=="], + + "@copilotkit/channels-core/@copilotkit/shared/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@copilotkit/channels-slack/@copilotkit/core/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@1.0.1", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Q6pxgI7gcey/0pHfM37S7GNrsYqTrrjjFLpi1dckHNGX2l9VTL0CjnyNc44lwPfHIzFmmEvQfbrAeEPINGGg/Q=="], + + "@copilotkit/channels-slack/@copilotkit/core/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/proto": "1.0.1" } }, "sha512-or0xYLdzGOuJg5avAtUxS1zNiMqO7MxUso+7z90Wl4jpl94QzfmfybXWopvPipVm/dh8IqkB/Zn+LHy4MI2e+g=="], + + "@copilotkit/channels-slack/@copilotkit/core/@ag-ui/client/@ag-ui/proto": ["@ag-ui/proto@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1", "zod": "^3.25.76" } }, "sha512-w7R2gLeXv04Mss0B29thkF+nLitSx6aLeqhUpvmeJZNjBPbwZ6zkXdK3/KEqavCMtHvot7AGj6RaHG1DvbJwww=="], + + "@copilotkit/channels-slack/@copilotkit/core/@ag-ui/client/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@copilotkit/channels-slack/@copilotkit/shared/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@1.0.1", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Q6pxgI7gcey/0pHfM37S7GNrsYqTrrjjFLpi1dckHNGX2l9VTL0CjnyNc44lwPfHIzFmmEvQfbrAeEPINGGg/Q=="], + + "@copilotkit/channels-slack/@copilotkit/shared/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/proto": "1.0.1" } }, "sha512-or0xYLdzGOuJg5avAtUxS1zNiMqO7MxUso+7z90Wl4jpl94QzfmfybXWopvPipVm/dh8IqkB/Zn+LHy4MI2e+g=="], + + "@copilotkit/channels-slack/@copilotkit/shared/@ag-ui/client/@ag-ui/proto": ["@ag-ui/proto@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1", "zod": "^3.25.76" } }, "sha512-w7R2gLeXv04Mss0B29thkF+nLitSx6aLeqhUpvmeJZNjBPbwZ6zkXdK3/KEqavCMtHvot7AGj6RaHG1DvbJwww=="], + + "@copilotkit/channels-slack/@copilotkit/shared/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@copilotkit/channels-teams/@copilotkit/core/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@1.0.1", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Q6pxgI7gcey/0pHfM37S7GNrsYqTrrjjFLpi1dckHNGX2l9VTL0CjnyNc44lwPfHIzFmmEvQfbrAeEPINGGg/Q=="], + + "@copilotkit/channels-teams/@copilotkit/core/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/proto": "1.0.1" } }, "sha512-or0xYLdzGOuJg5avAtUxS1zNiMqO7MxUso+7z90Wl4jpl94QzfmfybXWopvPipVm/dh8IqkB/Zn+LHy4MI2e+g=="], + + "@copilotkit/channels-teams/@copilotkit/core/@ag-ui/client/@ag-ui/proto": ["@ag-ui/proto@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1", "zod": "^3.25.76" } }, "sha512-w7R2gLeXv04Mss0B29thkF+nLitSx6aLeqhUpvmeJZNjBPbwZ6zkXdK3/KEqavCMtHvot7AGj6RaHG1DvbJwww=="], + + "@copilotkit/channels-teams/@copilotkit/core/@ag-ui/client/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@copilotkit/channels-teams/@copilotkit/shared/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@1.0.1", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Q6pxgI7gcey/0pHfM37S7GNrsYqTrrjjFLpi1dckHNGX2l9VTL0CjnyNc44lwPfHIzFmmEvQfbrAeEPINGGg/Q=="], + + "@copilotkit/channels-teams/@copilotkit/shared/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/proto": "1.0.1" } }, "sha512-or0xYLdzGOuJg5avAtUxS1zNiMqO7MxUso+7z90Wl4jpl94QzfmfybXWopvPipVm/dh8IqkB/Zn+LHy4MI2e+g=="], + + "@copilotkit/channels-teams/@copilotkit/shared/@ag-ui/client/@ag-ui/proto": ["@ag-ui/proto@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1", "zod": "^3.25.76" } }, "sha512-w7R2gLeXv04Mss0B29thkF+nLitSx6aLeqhUpvmeJZNjBPbwZ6zkXdK3/KEqavCMtHvot7AGj6RaHG1DvbJwww=="], + + "@copilotkit/channels-teams/@copilotkit/shared/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@copilotkit/channels-ui/@copilotkit/shared/@ag-ui/client/@ag-ui/core": ["@ag-ui/core@1.0.1", "", { "peerDependencies": { "zod": "^3.25.18 || ^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Q6pxgI7gcey/0pHfM37S7GNrsYqTrrjjFLpi1dckHNGX2l9VTL0CjnyNc44lwPfHIzFmmEvQfbrAeEPINGGg/Q=="], + + "@copilotkit/channels-ui/@copilotkit/shared/@ag-ui/client/@ag-ui/encoder": ["@ag-ui/encoder@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@ag-ui/proto": "1.0.1" } }, "sha512-or0xYLdzGOuJg5avAtUxS1zNiMqO7MxUso+7z90Wl4jpl94QzfmfybXWopvPipVm/dh8IqkB/Zn+LHy4MI2e+g=="], + + "@copilotkit/channels-ui/@copilotkit/shared/@ag-ui/client/@ag-ui/proto": ["@ag-ui/proto@1.0.1", "", { "dependencies": { "@ag-ui/core": "1.0.1", "@bufbuild/protobuf": "^2.2.5", "@protobuf-ts/protoc": "^2.11.1", "zod": "^3.25.76" } }, "sha512-w7R2gLeXv04Mss0B29thkF+nLitSx6aLeqhUpvmeJZNjBPbwZ6zkXdK3/KEqavCMtHvot7AGj6RaHG1DvbJwww=="], + + "@copilotkit/channels-ui/@copilotkit/shared/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "react-markdown/remark-parse/mdast-util-from-markdown/mdast-util-to-string": ["mdast-util-to-string@3.2.0", "", { "dependencies": { "@types/mdast": "^3.0.0" } }, "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg=="], "react-markdown/remark-parse/mdast-util-from-markdown/micromark": ["micromark@3.2.0", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "micromark-core-commonmark": "^1.0.1", "micromark-factory-space": "^1.0.0", "micromark-util-character": "^1.0.0", "micromark-util-chunked": "^1.0.0", "micromark-util-combine-extensions": "^1.0.0", "micromark-util-decode-numeric-character-reference": "^1.0.0", "micromark-util-encode": "^1.0.0", "micromark-util-normalize-identifier": "^1.0.0", "micromark-util-resolve-all": "^1.0.0", "micromark-util-sanitize-uri": "^1.0.0", "micromark-util-subtokenize": "^1.0.0", "micromark-util-symbol": "^1.0.0", "micromark-util-types": "^1.0.1", "uvu": "^0.5.0" } }, "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA=="], diff --git a/desktop/src-tauri/src/deployment.rs b/desktop/src-tauri/src/deployment.rs index 563d8474e..3c7931caa 100644 --- a/desktop/src-tauri/src/deployment.rs +++ b/desktop/src-tauri/src/deployment.rs @@ -195,8 +195,11 @@ fn layout() -> Vec { pub const REQUIRED: [&str; 4] = ["docker-compose.yml", "server", "app", "worker"]; /// The rest of what a deployment needs, which is not what it is checked for. -pub const ALSO_COPIED: [&str; 9] = [ +pub const ALSO_COPIED: [&str; 10] = [ "shared", + // The dependency patches `package.json` names in `patchedDependencies`. Without them `bun install + // --frozen-lockfile` stops with `Couldn't find patch file`. + "patches", // Not a workspace, but the server imports its egress rules from `agent-computer/src` and // `scripts/start-local-chrome-computer.ts` its browser runtime. Without it the server exits at // start with `Cannot find module '../../../agent-computer/src/egress'`. @@ -214,6 +217,14 @@ pub const ALSO_COPIED: [&str; 9] = [ "bunfig.toml", ]; +/// Single files copied from a directory that is otherwise left behind. +pub const ALSO_COPIED_FILES: [&str; 1] = [ + // The Recorder extension's workspace manifest, and nothing else of it. Desktop does not build + // or run the extension, but `bun install --frozen-lockfile` stops with `Workspace not found + // "extension"` without its manifest. Its source, tests and assets stay out of a deployment. + "extension/package.json", +]; + /// Fetch the tagged tarball and lay the deployment out under `root`. /// /// The stamp is written last. Anything that fails before that leaves a directory without one, which @@ -265,14 +276,13 @@ fn destination_in(root: &Path, path: &Path) -> Result, String> { } // Only what a deployment needs. The rest of the tree is a place to develop, not to run. - let wanted = relative + let names: Vec = relative .components() - .next() - .map(|first| { - let name = first.as_os_str().to_string_lossy().into_owned(); - REQUIRED.contains(&name.as_str()) || ALSO_COPIED.contains(&name.as_str()) - }) - .unwrap_or(false); + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + let wanted = names.first().is_some_and(|first| { + REQUIRED.contains(&first.as_str()) || ALSO_COPIED.contains(&first.as_str()) + }) || ALSO_COPIED_FILES.contains(&names.join("/").as_str()); if !wanted { return Ok(None); } @@ -666,6 +676,43 @@ mod tests { ); } + #[test] + fn only_the_recorder_extensions_manifest_is_copied() { + let dir = scratch("extension"); + + let archive = tarball(&[ + ("OpenBot-0.0.8/extension/package.json", b"{}"), + ( + "OpenBot-0.0.8/extension/src/background/index.ts", + b"export {}", + ), + ( + "OpenBot-0.0.8/extension/tests/recording.test.ts", + b"export {}", + ), + ("OpenBot-0.0.8/extension/public/manifest.json", b"{}"), + ("OpenBot-0.0.8/extension/package.json.bak", b"{}"), + ]); + unpack(&dir, &archive).expect("the download is laid out"); + + let manifest = std::fs::read_to_string(dir.join("extension/package.json")).ok(); + let mut copied: Vec = Vec::new(); + let mut pending = vec![dir.join("extension")]; + while let Some(next) = pending.pop() { + for entry in std::fs::read_dir(&next).into_iter().flatten().flatten() { + if entry.path().is_dir() { + pending.push(entry.path()); + } else { + copied.push(entry.file_name().to_string_lossy().into_owned()); + } + } + } + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(manifest.as_deref(), Some("{}")); + assert_eq!(copied, ["package.json"]); + } + #[test] fn a_recorded_version_is_not_fetched_again() { let dir = std::env::temp_dir().join(format!("openbot-dep-same-{}", std::process::id())); @@ -744,4 +791,19 @@ mod tests { ); assert!(!REQUIRED.contains(&"docs")); } + + #[test] + fn every_root_workspace_is_copied() { + let manifest: serde_json::Value = + serde_json::from_str(include_str!("../../../package.json")).unwrap(); + for workspace in manifest["workspaces"].as_array().unwrap() { + let workspace = workspace.as_str().unwrap(); + assert!( + REQUIRED.contains(&workspace) + || ALSO_COPIED.contains(&workspace) + || ALSO_COPIED_FILES.contains(&format!("{workspace}/package.json").as_str()), + "{workspace} is a root workspace that a frozen install needs, but is not copied" + ); + } + } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1a0a49877..632f66234 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -17,6 +17,8 @@ pub mod provider; pub mod provider_oauth; pub mod pull_metrics; pub mod quiet; +pub mod recorder_host; +pub mod recorder_install; pub mod saved_intent; pub mod self_hosted_intelligence; pub mod stack; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3877d8b90..6fe7528e1 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -13,8 +13,8 @@ mod test_support; use openbot_desktop_lib::{ acquire, deployment, deployment_release, engine, env as openbot_env, harness, host_access, - install, preparation, problem::Problem, provider, pull_metrics, quiet, stack, supervise, - telemetry, tray, windows as win, + install, preparation, problem::Problem, provider, pull_metrics, quiet, recorder_host, + recorder_install, stack, supervise, telemetry, tray, windows as win, }; const QUIT_CLEANUP_NOTICE_FILE: &str = ".openbot-quit-cleanup-notice"; @@ -2648,6 +2648,124 @@ fn owned_app_url(root: &Path, ports: &openbot_env::Ports) -> Option { stack::app_url(ports.app) } +/// An address the Recorder's native host answers in place of a Desktop deployment's own, so a test +/// can drive the whole handoff against an OpenBot it started itself. Debug builds only: a release +/// binary never reads the variable. +#[cfg(debug_assertions)] +fn recorder_host_test_app_url() -> Option { + std::env::var("OPENBOT_RECORDER_HOST_TEST_APP_URL") + .ok() + .filter(|url| !url.is_empty()) +} + +#[cfg(not(debug_assertions))] +fn recorder_host_test_app_url() -> Option { + None +} + +/// The organization's sign-in address when this deployment runs in organization mode. +fn organization_authority(root: &Path) -> Option { + openbot_env::already_set(&root.join(".env"), &["OPENBOT_ORGANIZATION_AUTH_URL"]) + .remove("OPENBOT_ORGANIZATION_AUTH_URL") + .filter(|authority| !authority.is_empty()) +} + +/// A sign-in the organization or the local OpenBot refused is one to make again; anything else may pass. +fn organization_refusal(problem: &Problem) -> recorder_host::Refusal { + if problem.connection == Some(openbot_desktop_lib::problem::Connection::Organization) { + recorder_host::Refusal::NotSignedIn + } else { + recorder_host::Refusal::OrganizationUnavailable + } +} + +/// The ping's one question to the app: does `/api/capabilities` answer through the app's port, on either loopback. +fn recorder_ping_app_answers(port: u16) -> bool { + stack::answering_within( + port, + "/api/capabilities", + recorder_host::PING_PROBE_PATIENCE, + ) + .is_some() +} + +/// What the Recorder's native host knows about this computer's OpenBot. +/// +/// The browser starts the host as a fresh process with no app around it, so it finds the deployment +/// the way the app's setup does: the root last launched, read from Tauri's config directory. +fn recorder_host_environment() -> recorder_host::HostEnvironment { + let home = recorder_home().unwrap_or_else(|| PathBuf::from(".")); + let config = recorder_host::app_config_dir( + recorder_os(), + &home, + std::env::var_os("XDG_CONFIG_HOME") + .as_deref() + .map(Path::new), + std::env::var_os("APPDATA").as_deref().map(Path::new), + ); + let root = preparation::selected_root(&config); + // For a ping: whether Desktop holds the organization sign-in at all, read from this computer alone. + let signed_in_root = root.clone(); + let signed_in: recorder_host::Check> = Box::new(move || { + let Some(root) = signed_in_root.as_deref() else { + return Ok(()); + }; + let Some(authority) = organization_authority(root) else { + return Ok(()); + }; + match openbot_desktop_lib::organization_auth::signed_in_locally(root, &authority) { + Ok(true) => Ok(()), + Ok(false) => Err(recorder_host::Refusal::NotSignedIn), + Err(problem) => Err(organization_refusal(&problem)), + } + }); + // For a handoff: in organization mode the local OpenBot admits only its organization session cookie, handed + // over from Desktop's saved sign-in exactly as opening the window does. Never logged. + let organization_root = root.clone(); + let organization_cookie: recorder_host::OrganizationCookie = Box::new(move |app_url| { + let Some(root) = organization_root.as_deref() else { + return Ok(None); + }; + let Some(authority) = organization_authority(root) else { + return Ok(None); + }; + openbot_desktop_lib::organization_auth::local_session_cookie(root, &authority, app_url) + .map(Some) + .map_err(|problem| organization_refusal(&problem)) + }); + if let Some(url) = recorder_host_test_app_url() { + let port = reqwest::Url::parse(&url) + .ok() + .and_then(|url| url.port_or_known_default()); + return recorder_host::HostEnvironment { + installed: Box::new(|| true), + app_answers: Box::new(move || port.is_some_and(recorder_ping_app_answers)), + signed_in, + app_url: Box::new(move || Some(url.clone())), + organization_cookie, + }; + } + let installed = root.is_some(); + let ping_root = root.clone(); + recorder_host::HostEnvironment { + installed: Box::new(move || installed), + // No ownership check: a ping proves nothing and sends nothing secret, and the handoff checks before it does. + app_answers: Box::new(move || { + ping_root + .as_deref() + .and_then(|root| openbot_env::Ports::read(root).ok()) + .is_some_and(|ports| recorder_ping_app_answers(ports.app)) + }), + signed_in, + app_url: Box::new(move || { + let root = root.as_deref()?; + let ports = openbot_env::Ports::read(root).ok()?; + owned_app_url(root, &ports) + }), + organization_cookie, + } +} + /// What stopped the stack, if anything did, and forget it once it has been read. /// /// Cleared on reading so a failure from an hour ago does not greet somebody who has since fixed it. @@ -3741,7 +3859,168 @@ fn quit_menu_accelerator() -> Option<&'static str> { Some(QUIT_MENU_ACCELERATOR) } +/// The Recorder host install's real side effects on this computer. +struct RecorderInstallWriter; + +impl recorder_install::InstallWriter for RecorderInstallWriter { + fn write_file(&mut self, path: &Path, contents: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, contents) + } + + fn read_file(&self, path: &Path) -> Option { + std::fs::read_to_string(path).ok() + } + + // Output is captured, never inherited: `reg.exe` prints a success line, and the install command's + // own output is only the manifest paths. + fn run(&mut self, argv: &[String]) -> std::io::Result<()> { + // The registry is the developer's own, whatever folder a test points the install at. + if cfg!(debug_assertions) { + return Err(std::io::Error::other( + "a debug build never writes the Windows registry", + )); + } + let (program, arguments) = argv + .split_first() + .ok_or_else(|| std::io::Error::other("an empty command"))?; + let output = quiet::command(program) + .args(arguments) + .stdin(std::process::Stdio::null()) + .output()?; + if output.status.success() { + return Ok(()); + } + Err(std::io::Error::other(format!( + "{program} exited with {}: {}", + output.status, + quiet::said(&output.stderr) + ))) + } + + fn exists(&self, path: &Path) -> bool { + path.exists() + } +} + +fn recorder_os() -> recorder_install::Os { + if cfg!(windows) { + recorder_install::Os::Windows + } else if cfg!(target_os = "macos") { + recorder_install::Os::Mac + } else { + recorder_install::Os::Linux + } +} + +/// The person's home folder, read as `stack.rs` reads it. +fn recorder_home() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .filter(|home| !home.is_empty()) + .map(PathBuf::from) +} + +/// The variable a debug build's Recorder host install reads its home folder from. +const RECORDER_HOST_TEST_HOME: &str = "OPENBOT_RECORDER_HOST_TEST_HOME"; + +fn non_empty_var(name: &str) -> Option { + std::env::var_os(name).filter(|value| !value.is_empty()) +} + +/// Lets each browser's Recorder find this app: one host manifest per browser present for this user. +/// +/// A release build writes into this person's own browser folders. A debug build is a developer's binary, rebuilt on +/// every change, and must never be registered as the host in the developer's own browsers, so it writes only under +/// the folder `OPENBOT_RECORDER_HOST_TEST_HOME` names (a test's), takes every other folder from there too, never +/// writes the Windows registry, and without the variable writes nothing. +fn install_recorder_host_for_this_user() -> Result, Problem> { + let current_exe = std::env::current_exe().map_err(|error| { + Problem::with( + "OpenBot Desktop could not set up the Recorder.", + error.to_string(), + ) + })?; + let stable = recorder_install::stable_host_path( + recorder_os(), + ¤t_exe, + std::env::var_os("APPIMAGE").as_deref().map(Path::new), + )?; + if cfg!(debug_assertions) { + let home = non_empty_var(RECORDER_HOST_TEST_HOME) + .map(PathBuf::from) + .ok_or_else(|| { + Problem::plain(format!( + "A debug build sets up the Recorder only under {RECORDER_HOST_TEST_HOME}, never in your own browsers." + )) + })?; + return recorder_install::install( + &stable, + recorder_install::UserDirs::at(&home), + recorder_os(), + &mut RecorderInstallWriter, + ); + } + let home = recorder_home() + .ok_or_else(|| Problem::plain("OpenBot Desktop could not find your home folder."))?; + let xdg_config_home = non_empty_var("XDG_CONFIG_HOME").map(PathBuf::from); + let chrome_config_home = non_empty_var("CHROME_CONFIG_HOME").map(PathBuf::from); + let local_app_data = non_empty_var("LOCALAPPDATA").map(PathBuf::from); + recorder_install::install( + &stable, + recorder_install::UserDirs { + home: &home, + xdg_config_home: xdg_config_home.as_deref(), + chrome_config_home: chrome_config_home.as_deref(), + local_app_data: local_app_data.as_deref(), + }, + recorder_os(), + &mut RecorderInstallWriter, + ) +} + +/// The install at launch, on its own thread so setup never waits on it, and again every minute where a browser +/// installed since needs its own manifest. Never in a debug build (see `install_recorder_host_for_this_user`). +#[cfg(not(debug_assertions))] +fn keep_recorder_host_installed() -> Option> { + Some(recorder_install::keep_recorder_host_installed( + install_recorder_host_for_this_user, + recorder_install::recheck_interval(recorder_os()), + )) +} + +#[cfg(debug_assertions)] +fn keep_recorder_host_installed() -> Option> { + None +} + fn main() { + let args: Vec = std::env::args_os().collect(); + // Chrome, Edge or Chromium starting the Recorder's native messaging host: answer it and exit, before + // anything of the app (the single-instance plugin would forward this launch to a running app). + if let Some(origin) = recorder_host::caller_origin(&args) { + std::process::exit(recorder_host::run(&origin, recorder_host_environment())); + } + // The same install the app's setup runs, as a command, so it can be tested and run by hand. + if args.get(1).is_some_and(|a| a == "--install-recorder-host") { + std::process::exit(match install_recorder_host_for_this_user() { + Ok(paths) => { + for p in paths { + println!("{}", p.display()); + } + 0 + } + Err(problem) => { + eprintln!("{}", problem.said); + if let Some(detail) = problem.detail { + eprintln!("{detail}"); + } + 1 + } + }); + } tauri::Builder::default() // A second launch is somebody looking for the window they already have, not a request for a // second stack. Without this both copies bind the same ports and the loser reports a @@ -3829,6 +4108,9 @@ fn main() { remember_setup_url(app.handle())?; + // On its own thread: setup never waits on it, and a failure is logged. + keep_recorder_host_installed(); + // The status menu lets somebody open the window, stop the stack, or quit the app. use tauri::menu::{Menu, MenuItem}; use tauri::tray::TrayIconBuilder; @@ -3969,6 +4251,13 @@ mod tests { include!("stop_ipc_tests.rs"); + // The release half (it starts the install thread) is the app's own launch; a test runs a debug build. + #[cfg(debug_assertions)] + #[test] + fn a_debug_build_never_starts_the_recorder_host_install_at_launch() { + assert!(keep_recorder_host_installed().is_none()); + } + #[test] fn learning_default_is_written_only_for_the_provisioned_connection() { use std::collections::BTreeMap; @@ -8183,6 +8472,7 @@ fn main() { std::fs::create_dir_all(root.join("worker")).unwrap(); std::fs::create_dir_all(root.join("shared")).unwrap(); std::fs::create_dir_all(root.join("toolkit")).unwrap(); + std::fs::create_dir_all(root.join("extension")).unwrap(); std::fs::write(root.join("docker-compose.yml"), "services: {}\n").unwrap(); std::fs::write( root.join("app/package.json"), @@ -8230,7 +8520,7 @@ fn main() { ) .unwrap(); deployment::record(root, DEPLOYMENT_VERSION).unwrap(); - for package in ["", "server", "worker", "shared", "toolkit"] { + for package in ["", "server", "worker", "shared", "toolkit", "extension"] { std::fs::write(root.join(package).join("package.json"), "{}").unwrap(); } std::fs::write(root.join("bun.lock"), "synthetic-lock").unwrap(); diff --git a/desktop/src-tauri/src/organization_auth.rs b/desktop/src-tauri/src/organization_auth.rs index e6eff80c6..76535f457 100644 --- a/desktop/src-tauri/src/organization_auth.rs +++ b/desktop/src-tauri/src/organization_auth.rs @@ -131,6 +131,21 @@ fn saved(root: &Path, expected: &str) -> Result, Problem> { Ok(Some(session)) } +/// Whether Desktop holds a sign-in for this organization, read from this computer alone. The Recorder's native host +/// asks on every ping, which must stay cheap and never reach the network; whether the organization still accepts the +/// sign-in is the handoff's question. On Windows the saved sign-in is sealed with DPAPI and opening it starts +/// PowerShell, so there only its presence is read. +pub fn signed_in_locally(root: &Path, authority_url: &str) -> Result { + let expected = authority(authority_url)?; + if !crate::vault::stored(root, SAVED_COOKIE) { + return Ok(false); + } + if cfg!(target_os = "windows") { + return Ok(true); + } + Ok(saved(root, &expected)?.is_some()) +} + pub fn status(root: &Path, authority_url: &str) -> Result, Problem> { let expected = authority(authority_url)?; saved(root, &expected)? @@ -339,12 +354,13 @@ pub fn finish(root: &Path) -> Result { Ok(user) } -/// A one-use navigation sets the local HttpOnly cookie; credentials never enter renderer IPC. -pub fn session_destination( +/// Hands the saved organization session to the local OpenBot and returns the one-use +/// ticket address that turns it into the local HttpOnly cookie. +fn handoff_destination( root: &Path, authority_url: &str, app_url: &str, -) -> Result { +) -> Result<(Client, Url), Problem> { let authority = authority(authority_url)?; let session = saved(root, &authority)? .ok_or_else(|| auth_problem("Sign in to your organization to open OpenBot."))?; @@ -394,7 +410,49 @@ pub fn session_destination( destination .query_pairs_mut() .append_pair("ticket", &ticket.ticket); - Ok(destination.to_string()) + Ok((client, destination)) +} + +/// A one-use navigation sets the local HttpOnly cookie; credentials never enter renderer IPC. +pub fn session_destination( + root: &Path, + authority_url: &str, + app_url: &str, +) -> Result { + handoff_destination(root, authority_url, app_url) + .map(|(_, destination)| destination.to_string()) +} + +const LOCAL_SESSION_COOKIE: &str = "openbot.organization-session="; + +/// The local OpenBot's organization session cookie pair for the saved sign-in, for the +/// Recorder's native host. Never logged. +pub fn local_session_cookie( + root: &Path, + authority_url: &str, + app_url: &str, +) -> Result { + let (client, destination) = handoff_destination(root, authority_url, app_url)?; + let response = client.get(destination).send().map_err(|_| { + problem("OpenBot could not read your organization sign-in from this installation.") + })?; + if response.status() == 401 || response.status() == 403 { + return Err(auth_problem("Sign in to your organization again.")); + } + if !response.status().is_success() && !response.status().is_redirection() { + return Err(problem( + "OpenBot could not accept your organization sign-in. Try again.", + )); + } + response + .headers() + .get_all(reqwest::header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .filter_map(|value| value.split(';').next()) + .find(|value| value.starts_with(LOCAL_SESSION_COOKIE)) + .map(str::to_string) + .ok_or_else(|| problem("OpenBot did not return an organization session.")) } #[cfg(test)] @@ -562,4 +620,140 @@ mod tests { ); } } + + /// A fake local OpenBot: answers the handoff POST, then the ticket GET. + fn fake_app( + post: &'static str, + get: &'static str, + ) -> (String, std::thread::JoinHandle>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let worker = std::thread::spawn(move || { + let mut seen = Vec::new(); + for reply in [post, get] { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request = String::new(); + let mut length = 0; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap() == 0 { + break; + } + if line == "\r\n" { + break; + } + if let Some(value) = line.to_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + request.push_str(&line); + } + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + seen.push(request); + stream.write_all(reply.as_bytes()).unwrap(); + if reply.starts_with("HTTP/1.1 401") { + break; + } + } + seen + }); + (url, worker) + } + + fn authority_with_session(name: &str) -> (PathBuf, String, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let authority = format!("http://{}", listener.local_addr().unwrap()); + let worker = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + } + let body = r#"{"user":{"id":"u1","email":"a@b.c","name":null,"role":"user"}}"#; + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + }); + let root = std::env::temp_dir().join(format!( + "openbot-local-cookie-{name}-{}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + crate::vault::remember( + &root, + SAVED_COOKIE, + &serde_json::to_string(&SavedSession { + authority: authority.clone(), + cookie: "better-auth.session_token=saved".into(), + }) + .unwrap(), + ) + .unwrap(); + (root, authority, worker) + } + + #[test] + fn signed_in_locally_reads_the_saved_sign_in_and_creates_nothing() { + // Nothing listens at either authority: the check never dials one. + let authority = "http://127.0.0.1:9"; + let root = std::env::temp_dir().join(format!( + "openbot-signed-in-locally-{}-{}", + std::process::id(), + random() + )); + std::fs::create_dir_all(&root).unwrap(); + assert!(!signed_in_locally(&root, authority).unwrap()); + assert!(!root.join(".secrets").exists()); + crate::vault::remember( + &root, + SAVED_COOKIE, + &serde_json::to_string(&SavedSession { + authority: authority.into(), + cookie: "better-auth.session_token=saved".into(), + }) + .unwrap(), + ) + .unwrap(); + assert!(signed_in_locally(&root, authority).unwrap()); + // Where the saved sign-in can be read cheaply, one for another organization is not this one's. + if !cfg!(target_os = "windows") { + assert!(!signed_in_locally(&root, "http://127.0.0.1:8").unwrap()); + } + assert!(signed_in_locally(&root, "not a url").is_err()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn local_session_cookie_returns_the_set_cookie_pair() { + let (root, authority, authority_worker) = authority_with_session("ok"); + let (app, app_worker) = fake_app( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 14\r\nConnection: close\r\n\r\n{\"ticket\":\"t\"}", + "HTTP/1.1 302 Found\r\nLocation: /\r\nSet-Cookie: openbot.organization-session=v; Path=/; HttpOnly\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + let cookie = local_session_cookie(&root, &authority, &app).unwrap(); + assert_eq!(cookie, "openbot.organization-session=v"); + let seen = app_worker.join().unwrap(); + assert!(seen[0].starts_with("POST /api/auth/organization/session HTTP/1.1")); + assert!(seen[0].to_lowercase().contains(&format!("origin: {app}"))); + assert!(seen[1].starts_with("GET /api/auth/organization/session?ticket=t HTTP/1.1")); + authority_worker.join().unwrap(); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn local_session_cookie_maps_a_rejected_handoff_to_the_sign_in_problem() { + let (root, authority, authority_worker) = authority_with_session("401"); + let (app, app_worker) = fake_app( + "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + "", + ); + let error = local_session_cookie(&root, &authority, &app).unwrap_err(); + assert_eq!(error.connection, Some(Connection::Organization)); + app_worker.join().unwrap(); + authority_worker.join().unwrap(); + let _ = std::fs::remove_dir_all(root); + } } diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index 0747dae96..67067cf7a 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -11,7 +11,15 @@ pub const REQUIRED: &str = "Finish installing OpenBot's local software before si pub const FILE: &str = ".openbot-prepared.json"; const DEPENDENCIES: &str = ".openbot-dependencies.json"; const LAUNCH: &str = ".openbot-launched.json"; -const PACKAGES: [&str; 6] = ["", "server", "app", "worker", "shared", "toolkit"]; +const PACKAGES: [&str; 7] = [ + "", + "server", + "app", + "worker", + "shared", + "toolkit", + "extension", +]; #[derive(Serialize, Deserialize)] struct Dependencies { @@ -552,4 +560,17 @@ fn main() { "repair must not forget the last successful folder" ); } + + #[test] + fn every_root_workspace_is_fingerprinted() { + let manifest: serde_json::Value = + serde_json::from_str(include_str!("../../../package.json")).unwrap(); + for workspace in manifest["workspaces"].as_array().unwrap() { + let workspace = workspace.as_str().unwrap(); + assert!( + PACKAGES.contains(&workspace), + "{workspace} is a root workspace whose dependencies are not fingerprinted" + ); + } + } } diff --git a/desktop/src-tauri/src/recorder_host.rs b/desktop/src-tauri/src/recorder_host.rs new file mode 100644 index 000000000..dcaf200ff --- /dev/null +++ b/desktop/src-tauri/src/recorder_host.rs @@ -0,0 +1,1163 @@ +//! The native messaging host the Recorder extension talks to. +//! +//! Chrome, Edge and Chromium start OpenBot Desktop's own binary as the host. Its first argument is +//! the caller's origin, and on Windows a `--parent-window=` argument follows. Each message, both +//! ways, is UTF-8 JSON preceded by its length as a 32-bit integer in native byte order; a message +//! from the host may be at most 1 MB. The browser closes the host's stdin when it is done with it. +//! +//! +//! The host answers two messages, each within its own deadline ([`DEADLINES`]), which sits below the time the extension +//! waits for the answer, so the extension always hears why instead of giving up. +//! +//! `ping` says, cheaply, whether a connect would work now: an OpenBot is installed, its app answers on its port, and in +//! organization mode Desktop holds a sign-in for the organization. It reads two files and asks the app one question. It +//! does not prove which process answers, sends nothing secret and mints nothing. +//! +//! `handoff` makes sure the deployment's own processes hold its ports, hands the organization sign-in over in +//! organization mode, asks that OpenBot for a one-time connect code and returns it with the OpenBot's address, so the +//! Recorder can connect without the person typing either. +//! +//! Every reply carries `ok`. `ok: false` comes with `error`, one of the stable codes of [`Refusal`]. Neither a code nor +//! any cookie is ever written to a log. +use crate::recorder_install::{extension_origin, linux_config_dir, Os}; +use reqwest::blocking::Client; +use serde_json::{json, Value}; +use std::ffi::OsString; +use std::io::{self, ErrorKind, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::Arc; +use std::time::Duration; + +/// Tauri's bundle identifier for OpenBot Desktop (`tauri.conf.json` `identifier`). +const APP_IDENTIFIER: &str = "ai.copilotkit.openbot.desktop"; + +/// The largest message either way. Chrome refuses a host message over 1 MB, and the extension sends +/// only a few bytes, so a longer length prefix is a broken or hostile pipe. +const MESSAGE_LIMIT: usize = 1024 * 1024; + +/// Why the host could not do what it was asked: the `error` of a reply with `ok: false`. The extension shows each one, +/// so a code never changes its meaning. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// `origin-not-allowed`: the caller is not the Recorder's own extension. + OriginNotAllowed, + /// `not-installed`: OpenBot Desktop has not set up an OpenBot on this computer. + NotInstalled, + /// `not-running`: this computer's OpenBot is not running, its app does not answer, or a process that is not this + /// OpenBot's holds its port. + NotRunning, + /// `not-signed-in`: organization mode, and Desktop holds no sign-in for the organization, or the organization or + /// the local OpenBot refused the one it holds. Signing in again in OpenBot Desktop fixes it. + NotSignedIn, + /// `organization-unavailable`: organization mode, and the sign-in Desktop holds could not be handed over now: the + /// organization's OpenBot did not answer, or answered with an error, or the saved sign-in could not be read. Trying + /// again later may work. + OrganizationUnavailable, + /// `handoff-failed`: the local OpenBot answered the request for a connect code without one. + HandoffFailed, + /// `timed-out`: the host did not finish within its deadline. + TimedOut, + /// `host-failed`: the host stopped before it could answer. A bug in OpenBot Desktop. + HostFailed, + /// `unknown-message`: a message the host does not know. + UnknownMessage, +} + +impl Refusal { + pub fn code(self) -> &'static str { + match self { + Refusal::OriginNotAllowed => "origin-not-allowed", + Refusal::NotInstalled => "not-installed", + Refusal::NotRunning => "not-running", + Refusal::NotSignedIn => "not-signed-in", + Refusal::OrganizationUnavailable => "organization-unavailable", + Refusal::HandoffFailed => "handoff-failed", + Refusal::TimedOut => "timed-out", + Refusal::HostFailed => "host-failed", + Refusal::UnknownMessage => "unknown-message", + } + } +} + +/// One question main.rs answers about this computer's OpenBot. Asked from the host's worker thread. +pub type Check = Box T + Send + Sync>; + +/// The local organization session cookie for the app at the given address. Ok(None) in single-user mode. +pub type OrganizationCookie = Box Result, Refusal> + Send + Sync>; + +/// What main.rs knows about this computer's OpenBot. +pub struct HostEnvironment { + /// A selected root exists. + pub installed: Check, + /// For a ping: the app answers on its port. Quick, and not proof of which process answered. + pub app_answers: Check, + /// For a ping: Ok in single-user mode or when Desktop holds an organization sign-in, read without the network. + pub signed_in: Check>, + /// For a handoff: the owned app's address, None when it is not running or another process holds its port. + pub app_url: Check>, + /// For a handoff: the organization sign-in handed to the local OpenBot, as its session cookie. + pub organization_cookie: OrganizationCookie, +} + +/// How long the host gives each message before it answers `timed-out`. +#[derive(Clone, Copy, Debug)] +pub struct Deadlines { + pub ping: Duration, + pub handoff: Duration, +} + +/// The extension waits 3 s for a ping and 8 s for a handoff, counted from when it asks Chrome, so the time Chrome takes +/// to start this binary comes out of the same budget. Timed on an Apple M5 Max, from starting a release build to its +/// reply: a ping about 3 ms (the earlier ping, which ran the handoff's ownership checks, about 175 ms), and a handoff +/// about 175 ms in either mode, nearly all of it the two `lsof` ownership checks of about 80 ms each. The first start +/// of a freshly built binary took about 0.5 s. On Windows those ownership checks run PowerShell and netstat, twice (not +/// timed here), and only the handoff runs them. Each deadline leaves a second of the extension's wait for a slow start. +pub const DEADLINES: Deadlines = Deadlines { + ping: Duration::from_millis(2_000), + handoff: Duration::from_millis(7_000), +}; + +/// How long a ping waits for the app on each loopback. A running app answers in milliseconds, and a closed port is +/// refused at once; this bounds a port held by something that accepts and never answers, twice within the deadline. +pub const PING_PROBE_PATIENCE: Duration = Duration::from_millis(750); + +/// Only the Recorder's own fixed id may ask, in debug and release builds alike. +fn origin_allowed(origin: &str) -> bool { + origin == extension_origin() +} + +/// An extension origin as Chrome passes it: `chrome-extension://` and an id of 32 letters a to p. +fn is_extension_origin(value: &str) -> bool { + value + .strip_prefix("chrome-extension://") + .and_then(|rest| rest.strip_suffix('/')) + .is_some_and(|id| id.len() == 32 && id.bytes().all(|byte| (b'a'..=b'p').contains(&byte))) +} + +/// Chrome starts a host with the caller's origin as the first argument ("chrome-extension:///"), +/// and on Windows also "--parent-window=". +pub fn caller_origin(args: &[OsString]) -> Option { + let first = args.get(1)?.to_str()?; + is_extension_origin(first).then(|| first.to_string()) +} + +/// Reads one native-endian u32 length-prefixed JSON message; refuses messages over 1 MiB. +/// Ok(None) when the input closed cleanly before a new message began. +pub fn read_message(input: &mut impl Read) -> io::Result> { + let mut prefix = [0u8; 4]; + let mut filled = 0; + while filled < prefix.len() { + match input.read(&mut prefix[filled..]) { + Ok(0) if filled == 0 => return Ok(None), + Ok(0) => { + return Err(io::Error::new( + ErrorKind::UnexpectedEof, + "the input closed inside a length prefix", + )) + } + Ok(read) => filled += read, + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + let length = u32::from_ne_bytes(prefix) as usize; + if length > MESSAGE_LIMIT { + return Err(io::Error::new( + ErrorKind::InvalidData, + format!("a {length} byte message is over the 1 MiB limit"), + )); + } + let mut body = vec![0; length]; + input.read_exact(&mut body)?; + serde_json::from_slice(&body) + .map(Some) + .map_err(|error| io::Error::new(ErrorKind::InvalidData, error)) +} + +/// Writes one native-endian u32 length-prefixed JSON message; refuses, writing nothing, one over 1 MiB. +pub fn write_message(output: &mut impl Write, value: &Value) -> io::Result<()> { + let body = serde_json::to_vec(value)?; + if body.len() > MESSAGE_LIMIT { + return Err(io::Error::new( + ErrorKind::InvalidInput, + format!("a {} byte message is over the 1 MiB limit", body.len()), + )); + } + output.write_all(&(body.len() as u32).to_ne_bytes())?; + output.write_all(&body)?; + output.flush() +} + +/// Tauri 2's app_config_dir for ai.copilotkit.openbot.desktop, derived without an AppHandle: +/// macOS ~/Library/Application Support/, Linux $XDG_CONFIG_HOME/ or ~/.config/, Windows %APPDATA%\. +/// +/// Tauri joins its identifier to the `dirs` crate's `config_dir`, which on Linux takes +/// `$XDG_CONFIG_HOME` only when it is an absolute path. +pub fn app_config_dir( + os: Os, + home: &Path, + xdg_config_home: Option<&Path>, + app_data: Option<&Path>, +) -> PathBuf { + let config = match os { + Os::Mac => home.join("Library").join("Application Support"), + Os::Linux => linux_config_dir(home, xdg_config_home), + Os::Windows => app_data + .map(Path::to_path_buf) + .unwrap_or_else(|| home.join("AppData").join("Roaming")), + }; + config.join(APP_IDENTIFIER) +} + +fn refused(refusal: Refusal) -> Value { + json!({ "ok": false, "error": refusal.code() }) +} + +fn ping_reply(result: Result<(), Refusal>) -> Value { + match result { + Ok(()) => json!({ "ok": true, "available": true }), + Err(refusal) => json!({ "ok": false, "available": false, "error": refusal.code() }), + } +} + +fn ping(origin_allowed: bool, env: &HostEnvironment) -> Result<(), Refusal> { + if !origin_allowed { + return Err(Refusal::OriginNotAllowed); + } + if !(env.installed)() { + return Err(Refusal::NotInstalled); + } + if !(env.app_answers)() { + return Err(Refusal::NotRunning); + } + (env.signed_in)() +} + +fn handoff( + origin_allowed: bool, + env: &HostEnvironment, + client: &Client, +) -> Result<(String, String), Refusal> { + if !origin_allowed { + return Err(Refusal::OriginNotAllowed); + } + if !(env.installed)() { + return Err(Refusal::NotInstalled); + } + let app_url = (env.app_url)().ok_or(Refusal::NotRunning)?; + let cookie = (env.organization_cookie)(&app_url)?; + let mut request = client + .post(format!( + "{}/api/recorder/connect-codes", + app_url.trim_end_matches('/') + )) + .json(&json!({ "via": "desktop" })); + if let Some(cookie) = cookie { + request = request.header("cookie", cookie); + } + let response = request.send().map_err(|error| { + eprintln!("[recorder-host] the local OpenBot did not answer the handoff"); + if error.is_timeout() { + Refusal::TimedOut + } else { + Refusal::NotRunning + } + })?; + let status = response.status(); + if status != reqwest::StatusCode::CREATED { + eprintln!("[recorder-host] the local OpenBot answered the handoff with {status}"); + return Err( + if status == reqwest::StatusCode::UNAUTHORIZED + || status == reqwest::StatusCode::FORBIDDEN + { + Refusal::NotSignedIn + } else { + Refusal::HandoffFailed + }, + ); + } + match response + .json::() + .ok() + .and_then(|minted| minted.get("code")?.as_str().map(str::to_string)) + { + Some(code) if !code.is_empty() => Ok((app_url, code)), + _ => { + eprintln!("[recorder-host] the local OpenBot answered the handoff without a code"); + Err(Refusal::HandoffFailed) + } + } +} + +/// Answers one message, however long that takes. "ping" never mints anything. +fn handle(message: &Value, origin_allowed: bool, env: &HostEnvironment, client: &Client) -> Value { + match message.get("type").and_then(Value::as_str) { + Some("ping") => ping_reply(ping(origin_allowed, env)), + Some("handoff") => match handoff(origin_allowed, env, client) { + Ok((app_url, code)) => json!({ "ok": true, "openbotUrl": app_url, "code": code }), + Err(refusal) => refused(refusal), + }, + _ => refused(Refusal::UnknownMessage), + } +} + +/// Answers one message within its deadline. The work runs on its own thread; one still running at the deadline is left +/// to finish unheard, and the process exits under it once the input closes. +fn answer( + message: Value, + origin_allowed: bool, + env: &Arc, + client: &Client, + deadlines: Deadlines, +) -> Value { + let (kind, deadline) = match message.get("type").and_then(Value::as_str) { + Some("ping") => ("ping", deadlines.ping), + Some("handoff") => ("handoff", deadlines.handoff), + _ => return refused(Refusal::UnknownMessage), + }; + let failed = |refusal| { + if kind == "ping" { + ping_reply(Err(refusal)) + } else { + refused(refusal) + } + }; + let (sender, receiver) = mpsc::channel(); + let (env, client) = (Arc::clone(env), client.clone()); + let worker = std::thread::Builder::new().spawn(move || { + let _ = sender.send(handle(&message, origin_allowed, &env, &client)); + }); + if let Err(error) = worker { + eprintln!("[recorder-host] could not start answering a {kind}: {error}"); + return failed(Refusal::HostFailed); + } + match receiver.recv_timeout(deadline) { + Ok(reply) => reply, + Err(RecvTimeoutError::Timeout) => { + eprintln!("[recorder-host] a {kind} was not answered within {deadline:?}"); + failed(Refusal::TimedOut) + } + Err(RecvTimeoutError::Disconnected) => { + eprintln!("[recorder-host] a {kind} stopped before it was answered"); + failed(Refusal::HostFailed) + } + } +} + +/// The handoff's request for a connect code. The local OpenBot is on this computer; the deadline answers first if it +/// is slow, and this only stops the abandoned request. +fn client() -> reqwest::Result { + Client::builder() + .timeout(DEADLINES.handoff) + .redirect(reqwest::redirect::Policy::none()) + .build() +} + +/// Answers each message until the input closes (0), or until a message cannot be read or answered (1). +fn serve( + input: &mut impl Read, + output: &mut impl Write, + origin_allowed: bool, + env: &Arc, + client: &Client, + deadlines: Deadlines, +) -> i32 { + loop { + let message = match read_message(input) { + Ok(Some(message)) => message, + Ok(None) => return 0, + Err(error) => { + eprintln!("[recorder-host] could not read a message: {error}"); + return 1; + } + }; + let reply = answer(message, origin_allowed, env, client, deadlines); + if let Err(error) = write_message(output, &reply) { + eprintln!("[recorder-host] could not answer a message: {error}"); + return 1; + } + } +} + +/// Runs the host until stdin closes. Returns the process exit code. `origin_allowed` is true only for +/// "chrome-extension:///" (the id from shared/recorder-extension.json), in every build. +pub fn run(origin: &str, env: HostEnvironment) -> i32 { + let client = match client() { + Ok(client) => client, + Err(error) => { + eprintln!("[recorder-host] could not prepare its HTTP client: {error}"); + return 1; + } + }; + serve( + &mut io::stdin().lock(), + &mut io::stdout().lock(), + origin_allowed(origin), + &Arc::new(env), + &client, + DEADLINES, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + use std::ffi::OsString; + use std::io::{BufRead, BufReader, Cursor, ErrorKind, Read, Write}; + use std::net::TcpListener; + use std::path::Path; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + use std::time::Instant; + + const ID: &str = "icpcnhgjghecmklnjbccdgdicklblcgd"; + const DESKTOP: &str = "ai.copilotkit.openbot.desktop"; + + fn args(list: &[&str]) -> Vec { + list.iter().map(OsString::from).collect() + } + + fn framed(value: &Value) -> Vec { + let mut bytes = Vec::new(); + write_message(&mut bytes, value).unwrap(); + bytes + } + + fn fixed_origin() -> String { + format!("chrome-extension://{ID}/") + } + + #[test] + fn only_the_shared_files_origin_is_allowed() { + assert_eq!(extension_origin(), fixed_origin()); + assert!(origin_allowed(&fixed_origin())); + assert!(!origin_allowed(&format!( + "chrome-extension://{}/", + "p".repeat(32) + ))); + assert!(!origin_allowed(&format!("chrome-extension://{ID}"))); + assert!(!origin_allowed("")); + } + + #[test] + fn a_message_round_trips_with_a_native_endian_length_prefix() { + let ping = json!({ "type": "ping" }); + let bytes = framed(&ping); + let body = ping.to_string(); + assert_eq!(&bytes[..4], &(body.len() as u32).to_ne_bytes()); + assert_eq!(&bytes[4..], body.as_bytes()); + let handoff = json!({ "type": "handoff" }); + let mut input = Cursor::new([bytes, framed(&handoff)].concat()); + assert_eq!(read_message(&mut input).unwrap(), Some(ping)); + assert_eq!(read_message(&mut input).unwrap(), Some(handoff)); + assert_eq!(read_message(&mut input).unwrap(), None); + } + + #[test] + fn a_two_mebibyte_length_prefix_is_refused_without_reading_the_body() { + let mut input = Cursor::new((2 * 1024 * 1024u32).to_ne_bytes().to_vec()); + let error = read_message(&mut input).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::InvalidData); + assert_eq!(input.position(), 4); + } + + #[test] + fn one_mebibyte_is_the_largest_message_either_way() { + let largest = Value::String("a".repeat(1024 * 1024 - 2)); + let bytes = framed(&largest); + assert_eq!(bytes.len(), 4 + 1024 * 1024); + assert_eq!( + read_message(&mut Cursor::new(bytes)).unwrap(), + Some(largest) + ); + let over = (1024 * 1024 + 1u32).to_ne_bytes().to_vec(); + assert_eq!( + read_message(&mut Cursor::new(over)).unwrap_err().kind(), + ErrorKind::InvalidData + ); + let mut sink = Vec::new(); + let too_big = Value::String("a".repeat(1024 * 1024 - 1)); + assert_eq!( + write_message(&mut sink, &too_big).unwrap_err().kind(), + ErrorKind::InvalidInput + ); + assert!(sink.is_empty()); + } + + #[test] + fn a_cut_short_or_malformed_message_is_an_error() { + assert_eq!( + read_message(&mut Cursor::new(vec![3u8, 0])) + .unwrap_err() + .kind(), + ErrorKind::UnexpectedEof + ); + let mut short_body = 10u32.to_ne_bytes().to_vec(); + short_body.extend_from_slice(b"{}"); + assert_eq!( + read_message(&mut Cursor::new(short_body)) + .unwrap_err() + .kind(), + ErrorKind::UnexpectedEof + ); + let mut not_json = 3u32.to_ne_bytes().to_vec(); + not_json.extend_from_slice(b"{{{"); + assert_eq!( + read_message(&mut Cursor::new(not_json)).unwrap_err().kind(), + ErrorKind::InvalidData + ); + } + + #[test] + fn the_caller_origin_is_the_first_argument_with_or_without_the_parent_window() { + let origin = fixed_origin(); + assert_eq!( + caller_origin(&args(&["openbot-desktop", &origin])), + Some(origin.clone()) + ); + assert_eq!( + caller_origin(&args(&[ + "openbot-desktop.exe", + &origin, + "--parent-window=0" + ])), + Some(origin.clone()) + ); + let other = format!("chrome-extension://{}/", "p".repeat(32)); + assert_eq!( + caller_origin(&args(&["openbot-desktop", &other])), + Some(other) + ); + } + + #[test] + fn anything_else_first_is_not_a_native_host_launch() { + let origin = fixed_origin(); + let refused = [ + vec!["openbot-desktop".to_string()], + vec!["openbot-desktop".into(), "--install-recorder-host".into()], + vec![ + "openbot-desktop".into(), + "--parent-window=0".into(), + origin.clone(), + ], + vec!["openbot-desktop".into(), format!("chrome-extension://{ID}")], + vec![ + "openbot-desktop".into(), + format!("chrome-extension://{}/", "a".repeat(31)), + ], + vec![ + "openbot-desktop".into(), + format!("chrome-extension://{}/", "a".repeat(33)), + ], + vec![ + "openbot-desktop".into(), + format!("chrome-extension://{}/", "q".repeat(32)), + ], + vec![ + "openbot-desktop".into(), + format!("chrome-extension://{}/", ID.to_uppercase()), + ], + vec![ + "openbot-desktop".into(), + format!("chrome-extension://{ID}/path"), + ], + vec!["openbot-desktop".into(), format!("moz-extension://{ID}/")], + vec!["openbot-desktop".into(), "https://openbot.example/".into()], + ]; + for list in refused { + let list: Vec<&str> = list.iter().map(String::as_str).collect(); + assert_eq!(caller_origin(&args(&list)), None, "{list:?}"); + } + } + + #[test] + fn app_config_dir_matches_tauri_on_each_os() { + let home = Path::new("/home/a"); + let mac = home + .join("Library") + .join("Application Support") + .join(DESKTOP); + assert_eq!(app_config_dir(Os::Mac, home, None, None), mac); + let roaming = Path::new("C:\\Users\\a\\AppData\\Roaming"); + assert_eq!( + app_config_dir(Os::Mac, home, Some(Path::new("/xdg")), Some(roaming)), + mac + ); + let dot_config = home.join(".config").join(DESKTOP); + assert_eq!(app_config_dir(Os::Linux, home, None, None), dot_config); + assert_eq!( + app_config_dir(Os::Linux, home, Some(Path::new("/xdg")), None), + Path::new("/xdg").join(DESKTOP) + ); + for ignored in ["", "relative/config"] { + assert_eq!( + app_config_dir(Os::Linux, home, Some(Path::new(ignored)), None), + dot_config, + "{ignored:?}" + ); + } + assert_eq!( + app_config_dir(Os::Windows, home, Some(Path::new("/xdg")), Some(roaming)), + roaming.join(DESKTOP) + ); + assert_eq!( + app_config_dir(Os::Windows, home, None, None), + home.join("AppData").join("Roaming").join(DESKTOP) + ); + } + + #[test] + fn the_app_identifier_is_the_one_tauri_uses() { + let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + assert_eq!(config["identifier"], DESKTOP); + } + + /// What the host asked of the environment: each deep check it ran, and the addresses it asked a cookie for. + #[derive(Default)] + struct Asked { + app_answers: AtomicUsize, + signed_in: AtomicUsize, + app_url: AtomicUsize, + cookies: Mutex>, + } + + impl Asked { + fn cookies(&self) -> Vec { + self.cookies.lock().unwrap().clone() + } + } + + /// A computer's OpenBot as a test describes it. + struct Computer { + installed: bool, + app_url: Option, + signed_in: Result<(), Refusal>, + cookie: Result, Refusal>, + } + + /// Installed, running at `app_url`, single-user mode. + fn running(app_url: &str) -> Computer { + Computer { + installed: true, + app_url: Some(app_url.to_string()), + signed_in: Ok(()), + cookie: Ok(None), + } + } + + fn environment(computer: Computer) -> (Arc, Arc) { + let asked = Arc::new(Asked::default()); + let (a, b, c, d) = (asked.clone(), asked.clone(), asked.clone(), asked.clone()); + let Computer { + installed, + app_url, + signed_in, + cookie, + } = computer; + let answers = app_url.is_some(); + let environment = HostEnvironment { + installed: Box::new(move || installed), + app_answers: Box::new(move || { + a.app_answers.fetch_add(1, Ordering::SeqCst); + answers + }), + signed_in: Box::new(move || { + b.signed_in.fetch_add(1, Ordering::SeqCst); + signed_in + }), + app_url: Box::new(move || { + c.app_url.fetch_add(1, Ordering::SeqCst); + app_url.clone() + }), + organization_cookie: Box::new(move |url| { + d.cookies.lock().unwrap().push(url.to_string()); + cookie.clone() + }), + }; + (Arc::new(environment), asked) + } + + fn fake_app() -> (String, TcpListener) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + ( + format!("http://{}", listener.local_addr().unwrap()), + listener, + ) + } + + /// The fake app was never dialled: nothing waits in its accept queue. + fn assert_untouched(listener: &TcpListener) { + listener.set_nonblocking(true).unwrap(); + match listener.accept() { + Err(error) => assert_eq!(error.kind(), ErrorKind::WouldBlock), + Ok((_, peer)) => panic!("the fake app was dialled from {peer}"), + } + } + + /// Answers one request with `reply` and hands back its head and body. + fn answer_once( + listener: TcpListener, + reply: String, + ) -> std::thread::JoinHandle<(String, String)> { + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut head = String::new(); + let mut length = 0; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap() == 0 || line == "\r\n" { + break; + } + if let Some(value) = line.to_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + head.push_str(&line); + } + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + stream.write_all(reply.as_bytes()).unwrap(); + (head, String::from_utf8(body).unwrap()) + }) + } + + fn reply(status: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + + const MINTED: &str = r#"{"code":"obc_fixture","expiresAt":"2026-10-18T12:00:00.000Z"}"#; + + fn ask(message: Value, origin_allowed: bool, environment: &HostEnvironment) -> Value { + handle(&message, origin_allowed, environment, &client().unwrap()) + } + + fn not(code: &str) -> Value { + json!({ "ok": false, "error": code }) + } + + fn unavailable(code: &str) -> Value { + json!({ "ok": false, "available": false, "error": code }) + } + + #[test] + fn every_code_is_stable() { + let codes: Vec<&str> = [ + Refusal::OriginNotAllowed, + Refusal::NotInstalled, + Refusal::NotRunning, + Refusal::NotSignedIn, + Refusal::OrganizationUnavailable, + Refusal::HandoffFailed, + Refusal::TimedOut, + Refusal::HostFailed, + Refusal::UnknownMessage, + ] + .into_iter() + .map(Refusal::code) + .collect(); + assert_eq!( + codes, + [ + "origin-not-allowed", + "not-installed", + "not-running", + "not-signed-in", + "organization-unavailable", + "handoff-failed", + "timed-out", + "host-failed", + "unknown-message", + ] + ); + } + + #[test] + fn ping_answers_available_without_the_deep_checks_and_mints_nothing() { + let (url, listener) = fake_app(); + let (environment, asked) = environment(running(&url)); + assert_eq!( + ask(json!({ "type": "ping" }), true, &environment), + json!({ "ok": true, "available": true }) + ); + assert_untouched(&listener); + assert_eq!(asked.app_answers.load(Ordering::SeqCst), 1); + assert_eq!(asked.signed_in.load(Ordering::SeqCst), 1); + // Neither the ownership check behind `app_url` nor the organization handover runs for a ping. + assert_eq!(asked.app_url.load(Ordering::SeqCst), 0); + assert!(asked.cookies().is_empty()); + } + + #[test] + fn ping_says_why_a_connect_would_not_work() { + let (url, listener) = fake_app(); + let cases = [ + (false, running(&url), "origin-not-allowed"), + ( + true, + Computer { + installed: false, + ..running(&url) + }, + "not-installed", + ), + ( + true, + Computer { + app_url: None, + ..running(&url) + }, + "not-running", + ), + ( + true, + Computer { + signed_in: Err(Refusal::NotSignedIn), + ..running(&url) + }, + "not-signed-in", + ), + ( + true, + Computer { + signed_in: Err(Refusal::OrganizationUnavailable), + ..running(&url) + }, + "organization-unavailable", + ), + ]; + for (origin_allowed, computer, code) in cases { + let (environment, asked) = environment(computer); + assert_eq!( + ask(json!({ "type": "ping" }), origin_allowed, &environment), + unavailable(code) + ); + assert_eq!(asked.app_url.load(Ordering::SeqCst), 0, "{code}"); + assert!(asked.cookies().is_empty(), "{code}"); + } + assert_untouched(&listener); + } + + #[test] + fn handoff_in_single_user_mode_returns_the_app_url_and_a_fresh_code() { + let (url, listener) = fake_app(); + let app = answer_once(listener, reply("201 Created", MINTED)); + let (environment, asked) = environment(running(&url)); + assert_eq!( + ask(json!({ "type": "handoff" }), true, &environment), + json!({ "ok": true, "openbotUrl": url, "code": "obc_fixture" }) + ); + let (head, body) = app.join().unwrap(); + assert!(head.starts_with("POST /api/recorder/connect-codes HTTP/1.1\r\n")); + let head = head.to_lowercase(); + assert!(head.contains("content-type: application/json\r\n")); + assert!(!head.contains("cookie:")); + assert_eq!( + serde_json::from_str::(&body).unwrap(), + json!({ "via": "desktop" }) + ); + assert_eq!(asked.cookies(), vec![url]); + assert_eq!(asked.app_url.load(Ordering::SeqCst), 1); + } + + #[test] + fn handoff_in_organization_mode_sends_the_local_session_cookie() { + let (url, listener) = fake_app(); + let app = answer_once(listener, reply("201 Created", MINTED)); + let (environment, asked) = environment(Computer { + cookie: Ok(Some("openbot.organization-session=v".into())), + ..running(&url) + }); + assert_eq!( + ask(json!({ "type": "handoff" }), true, &environment), + json!({ "ok": true, "openbotUrl": url, "code": "obc_fixture" }) + ); + let (head, _) = app.join().unwrap(); + assert!(head + .to_lowercase() + .contains("cookie: openbot.organization-session=v\r\n")); + assert_eq!(asked.cookies(), vec![url]); + } + + #[test] + fn handoff_is_not_running_when_nothing_listens() { + let (url, listener) = fake_app(); + drop(listener); + let (environment, _) = environment(running(&url)); + assert_eq!( + ask(json!({ "type": "handoff" }), true, &environment), + not("not-running") + ); + } + + #[test] + fn handoff_says_what_the_local_openbot_answered_in_place_of_a_code() { + for (answer, code) in [ + ( + reply("401 Unauthorized", r#"{"error":"Sign in."}"#), + "not-signed-in", + ), + (reply("403 Forbidden", "{}"), "not-signed-in"), + (reply("500 Internal Server Error", "{}"), "handoff-failed"), + (reply("429 Too Many Requests", "{}"), "handoff-failed"), + (reply("200 OK", MINTED), "handoff-failed"), + (reply("201 Created", "{}"), "handoff-failed"), + (reply("201 Created", r#"{"code":""}"#), "handoff-failed"), + (reply("201 Created", "not json"), "handoff-failed"), + ] { + let (url, listener) = fake_app(); + let app = answer_once(listener, answer.clone()); + let (environment, _) = environment(running(&url)); + assert_eq!( + ask(json!({ "type": "handoff" }), true, &environment), + not(code), + "{answer}" + ); + app.join().unwrap(); + } + } + + #[test] + fn a_handoff_request_that_runs_out_of_time_is_timed_out() { + let (url, listener) = fake_app(); + // Accepts, and never answers. + let held = std::thread::spawn(move || listener.accept().unwrap()); + let (environment, _) = environment(running(&url)); + let impatient = Client::builder() + .timeout(Duration::from_millis(200)) + .build() + .unwrap(); + assert_eq!( + handle( + &json!({ "type": "handoff" }), + true, + &environment, + &impatient + ), + not("timed-out") + ); + drop(held.join().unwrap()); + } + + #[test] + fn handoff_without_a_running_app_is_not_running() { + let (environment, asked) = environment(Computer { + app_url: None, + ..running("http://127.0.0.1:9") + }); + assert_eq!( + ask(json!({ "type": "handoff" }), true, &environment), + not("not-running") + ); + assert!(asked.cookies().is_empty()); + } + + #[test] + fn handoff_without_an_install_is_not_installed() { + let (url, listener) = fake_app(); + let (environment, asked) = environment(Computer { + installed: false, + ..running(&url) + }); + assert_eq!( + ask(json!({ "type": "handoff" }), true, &environment), + not("not-installed") + ); + assert_untouched(&listener); + assert!(asked.cookies().is_empty()); + } + + #[test] + fn handoff_from_a_foreign_origin_is_refused_before_anything_else() { + let (url, listener) = fake_app(); + let (environment, asked) = environment(running(&url)); + assert_eq!( + ask(json!({ "type": "handoff" }), false, &environment), + not("origin-not-allowed") + ); + assert_untouched(&listener); + assert!(asked.cookies().is_empty()); + assert_eq!(asked.app_url.load(Ordering::SeqCst), 0); + } + + #[test] + fn handoff_passes_on_why_the_organization_sign_in_could_not_be_handed_over() { + for (refusal, code) in [ + (Refusal::NotSignedIn, "not-signed-in"), + (Refusal::OrganizationUnavailable, "organization-unavailable"), + ] { + let (url, listener) = fake_app(); + let (environment, asked) = environment(Computer { + cookie: Err(refusal), + ..running(&url) + }); + assert_eq!( + ask(json!({ "type": "handoff" }), true, &environment), + not(code) + ); + assert_untouched(&listener); + assert_eq!(asked.cookies(), vec![url]); + } + } + + #[test] + fn an_unknown_message_is_answered_without_touching_anything() { + let (url, listener) = fake_app(); + let (environment, asked) = environment(running(&url)); + for message in [json!({ "type": "connect" }), json!("ping"), json!(null)] { + assert_eq!(ask(message, true, &environment), not("unknown-message")); + } + assert_untouched(&listener); + assert!(asked.cookies().is_empty()); + assert_eq!(asked.app_answers.load(Ordering::SeqCst), 0); + } + + fn serve_all( + messages: &[Value], + environment: &Arc, + deadlines: Deadlines, + ) -> (i32, Vec) { + let input: Vec = messages.iter().flat_map(framed).collect(); + let mut output = Vec::new(); + let status = serve( + &mut Cursor::new(input), + &mut output, + true, + environment, + &client().unwrap(), + deadlines, + ); + let mut replies = Vec::new(); + let mut output = Cursor::new(output); + while let Some(reply) = read_message(&mut output).unwrap() { + replies.push(reply); + } + (status, replies) + } + + #[test] + fn the_host_answers_each_message_until_its_input_closes() { + let (environment, _) = environment(Computer { + installed: false, + ..running("http://127.0.0.1:9") + }); + let (status, replies) = serve_all( + &[ + json!({ "type": "ping" }), + json!({ "type": "handoff" }), + json!({ "type": "connect" }), + ], + &environment, + DEADLINES, + ); + assert_eq!(status, 0); + assert_eq!( + replies, + [ + unavailable("not-installed"), + not("not-installed"), + not("unknown-message") + ] + ); + } + + #[test] + fn a_check_still_running_at_its_deadline_is_answered_timed_out_and_the_next_message_is_heard() { + let slow = HostEnvironment { + installed: Box::new(|| true), + app_answers: Box::new(|| { + std::thread::sleep(Duration::from_secs(3)); + true + }), + signed_in: Box::new(|| Ok(())), + app_url: Box::new(|| { + std::thread::sleep(Duration::from_secs(3)); + None + }), + organization_cookie: Box::new(|_| Ok(None)), + }; + let deadlines = Deadlines { + ping: Duration::from_millis(100), + handoff: Duration::from_millis(150), + }; + let started = Instant::now(); + let (status, replies) = serve_all( + &[ + json!({ "type": "ping" }), + json!({ "type": "handoff" }), + json!({ "type": "connect" }), + ], + &Arc::new(slow), + deadlines, + ); + assert!( + started.elapsed() < Duration::from_secs(2), + "{:?}", + started.elapsed() + ); + assert_eq!(status, 0); + assert_eq!( + replies, + [ + unavailable("timed-out"), + not("timed-out"), + not("unknown-message") + ] + ); + } + + #[test] + fn a_check_that_fails_outright_is_answered_host_failed() { + let broken = HostEnvironment { + installed: Box::new(|| panic!("a broken check")), + app_answers: Box::new(|| true), + signed_in: Box::new(|| Ok(())), + app_url: Box::new(|| None), + organization_cookie: Box::new(|_| Ok(None)), + }; + let (status, replies) = serve_all( + &[json!({ "type": "ping" }), json!({ "type": "handoff" })], + &Arc::new(broken), + DEADLINES, + ); + assert_eq!(status, 0); + assert_eq!(replies, [unavailable("host-failed"), not("host-failed")]); + } + + #[test] + fn each_deadline_leaves_a_second_of_the_extensions_wait() { + // extension/src/background/native-host.ts: PING_TIMEOUT_MS and HANDOFF_TIMEOUT_MS. + let (extension_ping, extension_handoff) = (Duration::from_secs(3), Duration::from_secs(8)); + assert!(DEADLINES.ping + Duration::from_secs(1) <= extension_ping); + assert!(DEADLINES.handoff + Duration::from_secs(1) <= extension_handoff); + // Two probes, one per loopback, fit in a ping. + assert!(PING_PROBE_PATIENCE * 2 < DEADLINES.ping); + } + + #[test] + fn the_host_stops_with_a_failure_on_a_message_it_cannot_read() { + let (environment, _) = environment(Computer { + installed: false, + ..running("http://127.0.0.1:9") + }); + let input = (2 * 1024 * 1024u32).to_ne_bytes().to_vec(); + let mut output = Vec::new(); + assert_eq!( + serve( + &mut Cursor::new(input), + &mut output, + true, + &environment, + &client().unwrap(), + DEADLINES + ), + 1 + ); + assert!(output.is_empty()); + } +} diff --git a/desktop/src-tauri/src/recorder_install.rs b/desktop/src-tauri/src/recorder_install.rs new file mode 100644 index 000000000..ed6e9aeba --- /dev/null +++ b/desktop/src-tauri/src/recorder_install.rs @@ -0,0 +1,909 @@ +//! Writes the native messaging host manifests that let the Recorder extension find OpenBot Desktop. +//! +//! Where each manifest goes, checked against the official pages: +//! - Chrome and Chromium, macOS, Linux and the Windows registry key: +//! +//! (`~/Library/Application Support/Google/Chrome/NativeMessagingHosts/`, `~/.config/google-chrome/NativeMessagingHosts/`, +//! `HKCU\SOFTWARE\Google\Chrome\NativeMessagingHosts\` with the manifest path as its default value; +//! Chromium uses `Chromium` in place of `Google/Chrome` and `google-chrome`). +//! - Edge, macOS (`~/Library/Application Support/Microsoft Edge/NativeMessagingHosts/`), Linux +//! (`~/.config/microsoft-edge/NativeMessagingHosts`) and `HKCU\SOFTWARE\Microsoft\Edge\NativeMessagingHosts\`; the same page +//! lists `HKCU\SOFTWARE\Chromium\NativeMessagingHosts` as a registry location, and shows `allowed_origins` as +//! `chrome-extension:///`: +//! +//! - On macOS and Linux a browser reads its per-user manifests from `NativeMessagingHosts` inside its user data +//! directory, and on Linux that directory is under `$XDG_CONFIG_HOME` when it is set (and under `$CHROME_CONFIG_HOME` +//! for Chrome and Chromium when that is set), not always under `~/.config`: +//! +//! +//! A browser installed after Desktop started is found too. On Windows every browser's key is written whether or not the +//! browser is there yet: the manifest lives in OpenBot's own folder, and a key no browser reads changes nothing. On macOS +//! and Linux a manifest goes inside the browser's own folder, which is not made for a browser that is not there, so the +//! app checks again every minute and writes only what is missing or different. +//! +//! `allowed_origins` never holds a wildcard, and holds only the Recorder's one extension id, in debug and release builds alike. +use crate::problem::Problem; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Duration; + +/// The native host's name and the Recorder's one extension id, read once from shared/recorder-extension.json, the file the +/// extension and the server read too. +struct RecorderExtension { + native_host: String, + extension_id: String, +} + +fn recorder_extension() -> &'static RecorderExtension { + static PARSED: OnceLock = OnceLock::new(); + PARSED.get_or_init(|| { + let shared: Value = + serde_json::from_str(include_str!("../../../shared/recorder-extension.json")) + .expect("shared/recorder-extension.json is not JSON"); + let field = |name: &str| { + shared + .get(name) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| panic!("shared/recorder-extension.json has no {name}")) + }; + RecorderExtension { + native_host: field("nativeHost"), + extension_id: field("extensionId"), + } + }) +} + +/// The native host's name, the one the extension asks Chrome for. +pub fn host_name() -> &'static str { + &recorder_extension().native_host +} + +/// `chrome-extension:///`: the only origin a manifest allows and the host answers. +pub fn extension_origin() -> String { + format!("chrome-extension://{}/", recorder_extension().extension_id) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Browser { + Chrome, + Edge, + Chromium, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Os { + Mac, + Linux, + Windows, +} + +/// This person's folders, as the environment names them. +#[derive(Clone, Copy, Debug)] +pub struct UserDirs<'a> { + pub home: &'a Path, + /// `$XDG_CONFIG_HOME`. Only Linux reads it. + pub xdg_config_home: Option<&'a Path>, + /// `$CHROME_CONFIG_HOME`. Only Chrome and Chromium on Linux read it, ahead of `$XDG_CONFIG_HOME`. + pub chrome_config_home: Option<&'a Path>, + /// `%LOCALAPPDATA%`. Only Windows reads it, and falls back to `\AppData\Local`. + pub local_app_data: Option<&'a Path>, +} + +impl<'a> UserDirs<'a> { + /// Only `home`: every other folder at its default. + pub fn at(home: &'a Path) -> Self { + Self { + home, + xdg_config_home: None, + chrome_config_home: None, + local_app_data: None, + } + } +} + +/// A Linux configuration folder named by an environment variable. A browser takes a relative value relative to whatever +/// folder it was started in, which Desktop cannot know, so only an absolute one is followed. Checked as text, so the +/// answer is the same on every OS the tests run on. +fn absolute(path: Option<&Path>) -> Option { + path.filter(|path| path.to_string_lossy().starts_with('/')) + .map(Path::to_path_buf) +} + +/// Linux's per-user configuration folder: an absolute `$XDG_CONFIG_HOME`, or `~/.config`. Tauri's own config folder +/// follows the same rule (see `recorder_host::app_config_dir`). +pub fn linux_config_dir(home: &Path, xdg_config_home: Option<&Path>) -> PathBuf { + absolute(xdg_config_home).unwrap_or_else(|| home.join(".config")) +} + +/// The side effects of an install, so tests can watch them without touching the computer. +pub trait InstallWriter { + /// Writes the file, creating its parent directories first. + fn write_file(&mut self, path: &Path, contents: &str) -> std::io::Result<()>; + /// The file's contents, None when it cannot be read. + fn read_file(&self, path: &Path) -> Option; + fn run(&mut self, argv: &[String]) -> std::io::Result<()>; + fn exists(&self, path: &Path) -> bool; +} + +const BROWSERS: [Browser; 3] = [Browser::Chrome, Browser::Edge, Browser::Chromium]; + +fn manifest_file_name() -> String { + format!("{}.json", host_name()) +} + +/// The host manifest Chrome, Edge and Chromium read. +pub fn host_manifest(path_to_exe: &Path) -> Value { + json!({ + "name": host_name(), + "description": "OpenBot Desktop: lets the Recorder find this computer's OpenBot", + "path": path_to_exe.to_string_lossy(), + "type": "stdio", + "allowed_origins": [extension_origin()], + }) +} + +fn windows_local_app_data(dirs: UserDirs) -> PathBuf { + dirs.local_app_data + .map(Path::to_path_buf) + .unwrap_or_else(|| dirs.home.join("AppData").join("Local")) +} + +/// The per-user data directory each browser keeps on each OS. +fn data_directory(os: Os, browser: Browser, dirs: UserDirs) -> PathBuf { + match os { + Os::Mac => dirs + .home + .join("Library") + .join("Application Support") + .join(match browser { + Browser::Chrome => "Google/Chrome", + Browser::Edge => "Microsoft Edge", + Browser::Chromium => "Chromium", + }), + Os::Linux => { + let config = match browser { + Browser::Chrome | Browser::Chromium => absolute(dirs.chrome_config_home), + Browser::Edge => None, + } + .unwrap_or_else(|| linux_config_dir(dirs.home, dirs.xdg_config_home)); + config.join(match browser { + Browser::Chrome => "google-chrome", + Browser::Edge => "microsoft-edge", + Browser::Chromium => "chromium", + }) + } + Os::Windows => { + let base = windows_local_app_data(dirs); + match browser { + Browser::Chrome => base.join("Google").join("Chrome"), + Browser::Edge => base.join("Microsoft").join("Edge"), + Browser::Chromium => base.join("Chromium"), + } + .join("User Data") + } + } +} + +/// Where the host manifest for one browser goes. Windows keeps one file for every browser and points each +/// browser's registry key at it. +pub fn host_manifest_location(os: Os, browser: Browser, dirs: UserDirs) -> PathBuf { + match os { + Os::Windows => windows_local_app_data(dirs) + .join("OpenBot") + .join("recorder") + .join(manifest_file_name()), + Os::Mac | Os::Linux => data_directory(os, browser, dirs) + .join("NativeMessagingHosts") + .join(manifest_file_name()), + } +} + +/// `reg.exe` arguments that point the browser's host key at the manifest file. +pub fn registry_commands(browser: Browser, manifest_path: &Path) -> Vec> { + let vendor = match browser { + Browser::Chrome => "Google\\Chrome", + Browser::Edge => "Microsoft\\Edge", + Browser::Chromium => "Chromium", + }; + vec![vec![ + "reg.exe".to_string(), + "add".to_string(), + format!( + "HKCU\\Software\\{vendor}\\NativeMessagingHosts\\{}", + host_name() + ), + "/ve".to_string(), + "/t".to_string(), + "REG_SZ".to_string(), + "/d".to_string(), + manifest_path.to_string_lossy().into_owned(), + "/f".to_string(), + ]] +} + +/// The browser's per-user data directory exists. +pub fn browser_present( + os: Os, + browser: Browser, + dirs: UserDirs, + writer: &dyn InstallWriter, +) -> bool { + writer.exists(&data_directory(os, browser, dirs)) +} + +/// The path a host manifest may name: one that still exists after this process exits. +pub fn stable_host_path( + os: Os, + current_exe: &Path, + appimage: Option<&Path>, +) -> Result { + let exe = current_exe.to_string_lossy(); + match os { + Os::Linux => { + if let Some(appimage) = appimage { + Ok(appimage.to_path_buf()) + } else if exe.starts_with("/tmp/.mount_") { + Err(Problem::plain( + "Run OpenBot Desktop from its AppImage file to let the Recorder find it.", + )) + } else { + Ok(current_exe.to_path_buf()) + } + } + Os::Mac if exe.contains("/AppTranslocation/") => Err(Problem::plain( + "Move OpenBot Desktop to Applications to let the Recorder find it.", + )), + Os::Mac | Os::Windows => Ok(current_exe.to_path_buf()), + } +} + +/// Writes the manifest unless the file already says exactly that. +fn write_manifest( + writer: &mut dyn InstallWriter, + path: &Path, + stable_exe: &Path, +) -> Result<(), Problem> { + let contents = host_manifest(stable_exe).to_string(); + if writer.read_file(path).as_deref() == Some(contents.as_str()) { + return Ok(()); + } + writer.write_file(path, &contents).map_err(|error| { + Problem::with( + "OpenBot Desktop could not set up the Recorder.", + format!("{}: {error}", path.display()), + ) + }) +} + +/// Lets each browser find the host, and returns the manifests in place. On Windows: one manifest, and every browser's +/// registry key pointed at it. On macOS and Linux: one manifest in each present browser's own folder. +pub fn install( + stable_exe: &Path, + dirs: UserDirs, + os: Os, + writer: &mut dyn InstallWriter, +) -> Result, Problem> { + if os == Os::Windows { + let manifest = host_manifest_location(os, Browser::Chrome, dirs); + write_manifest(writer, &manifest, stable_exe)?; + for browser in BROWSERS { + for argv in registry_commands(browser, &manifest) { + writer.run(&argv).map_err(|error| { + Problem::with( + "OpenBot Desktop could not set up the Recorder.", + format!("{}: {error}", argv.join(" ")), + ) + })?; + } + } + return Ok(vec![manifest]); + } + let mut written = Vec::new(); + for browser in BROWSERS { + if !browser_present(os, browser, dirs, writer) { + continue; + } + let manifest = host_manifest_location(os, browser, dirs); + write_manifest(writer, &manifest, stable_exe)?; + written.push(manifest); + } + Ok(written) +} + +/// How often the app checks again for a browser installed since it started. None on Windows, where the first install +/// already registers every browser. +pub fn recheck_interval(os: Os) -> Option { + (os != Os::Windows).then_some(Duration::from_secs(60)) +} + +/// Runs `job` on its own thread and returns at once. With `every`, runs it again after each wait for as long as the app +/// runs. A failure is logged, once until it changes, and never stops the checks. +pub fn keep_recorder_host_installed( + job: F, + every: Option, +) -> std::thread::JoinHandle<()> +where + F: Fn() -> Result, Problem> + Send + 'static, +{ + std::thread::spawn(move || run_install_rounds(&job, every, None)) +} + +/// One round, then one more after each `every`, up to `rounds` in all (None: no limit). +fn run_install_rounds(job: &F, every: Option, rounds: Option) +where + F: Fn() -> Result, Problem>, +{ + let mut last_failure = None; + let mut done = 0; + loop { + match job() { + Ok(_) => last_failure = None, + Err(problem) => { + let said = format!("{} {}", problem.said, problem.detail.unwrap_or_default()); + if last_failure.as_ref() != Some(&said) { + eprintln!("[recorder] host install failed: {said}"); + last_failure = Some(said); + } + } + } + done += 1; + let Some(every) = every else { return }; + if rounds.is_some_and(|rounds| done >= rounds) { + return; + } + std::thread::sleep(every); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{HashMap, HashSet}; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const ID: &str = "icpcnhgjghecmklnjbccdgdicklblcgd"; + + #[derive(Default)] + struct Recording { + present: HashSet, + on_disk: HashMap, + files: Vec<(PathBuf, String)>, + commands: Vec>, + } + impl InstallWriter for Recording { + fn write_file(&mut self, path: &Path, contents: &str) -> std::io::Result<()> { + self.files.push((path.to_path_buf(), contents.to_string())); + self.on_disk + .insert(path.to_path_buf(), contents.to_string()); + Ok(()) + } + fn read_file(&self, path: &Path) -> Option { + self.on_disk.get(path).cloned() + } + fn run(&mut self, argv: &[String]) -> std::io::Result<()> { + self.commands.push(argv.to_vec()); + Ok(()) + } + fn exists(&self, path: &Path) -> bool { + self.present.contains(path) + } + } + + fn home() -> PathBuf { + PathBuf::from("/home/a") + } + + #[test] + fn the_host_name_and_origin_come_from_the_shared_file() { + let shared: Value = + serde_json::from_str(include_str!("../../../shared/recorder-extension.json")).unwrap(); + assert_eq!(host_name(), shared["nativeHost"]); + assert_eq!(host_name(), "ai.copilotkit.openbot.recorder"); + assert_eq!( + extension_origin(), + format!( + "chrome-extension://{}/", + shared["extensionId"].as_str().unwrap() + ) + ); + assert_eq!(extension_origin(), format!("chrome-extension://{ID}/")); + } + + #[test] + fn manifest_names_the_host_the_path_and_only_the_fixed_origin() { + let manifest = host_manifest(Path::new( + "/Applications/OpenBot.app/Contents/MacOS/openbot", + )); + assert_eq!( + manifest, + serde_json::json!({ + "name": "ai.copilotkit.openbot.recorder", + "description": "OpenBot Desktop: lets the Recorder find this computer's OpenBot", + "path": "/Applications/OpenBot.app/Contents/MacOS/openbot", + "type": "stdio", + "allowed_origins": [format!("chrome-extension://{ID}/")], + }) + ); + let text = manifest.to_string(); + assert!(!text.contains('*')); + } + + #[test] + fn locations_per_os_and_browser() { + let h = home(); + let local = PathBuf::from("/c/Local"); + let expect = |os, browser, dirs: UserDirs, want: &str| { + assert_eq!( + host_manifest_location(os, browser, dirs), + PathBuf::from(want), + "{os:?} {browser:?}" + ); + }; + let file = "ai.copilotkit.openbot.recorder.json"; + let mac = |dir: &str| { + format!("/home/a/Library/Application Support/{dir}/NativeMessagingHosts/{file}") + }; + let plain = UserDirs::at(&h); + expect(Os::Mac, Browser::Chrome, plain, &mac("Google/Chrome")); + expect(Os::Mac, Browser::Edge, plain, &mac("Microsoft Edge")); + expect(Os::Mac, Browser::Chromium, plain, &mac("Chromium")); + let linux = |dir: &str| format!("/home/a/.config/{dir}/NativeMessagingHosts/{file}"); + expect(Os::Linux, Browser::Chrome, plain, &linux("google-chrome")); + expect(Os::Linux, Browser::Edge, plain, &linux("microsoft-edge")); + expect(Os::Linux, Browser::Chromium, plain, &linux("chromium")); + let windows = PathBuf::from("/c/Local") + .join("OpenBot") + .join("recorder") + .join(file); + let with_local = UserDirs { + local_app_data: Some(&local), + ..plain + }; + for browser in [Browser::Chrome, Browser::Edge, Browser::Chromium] { + assert_eq!( + host_manifest_location(Os::Windows, browser, with_local), + windows + ); + } + } + + #[test] + fn linux_follows_an_absolute_xdg_config_home_and_chrome_config_home() { + let h = home(); + let xdg = PathBuf::from("/xdg"); + let chrome = PathBuf::from("/chrome-config"); + let file = "NativeMessagingHosts/ai.copilotkit.openbot.recorder.json"; + let with_xdg = UserDirs { + xdg_config_home: Some(&xdg), + ..UserDirs::at(&h) + }; + for (browser, dir) in [ + (Browser::Chrome, "google-chrome"), + (Browser::Edge, "microsoft-edge"), + (Browser::Chromium, "chromium"), + ] { + assert_eq!( + host_manifest_location(Os::Linux, browser, with_xdg), + PathBuf::from(format!("/xdg/{dir}/{file}")), + "{browser:?}" + ); + } + // Chrome and Chromium read $CHROME_CONFIG_HOME ahead of $XDG_CONFIG_HOME; Edge does not. + let with_both = UserDirs { + chrome_config_home: Some(&chrome), + ..with_xdg + }; + assert_eq!( + host_manifest_location(Os::Linux, Browser::Chrome, with_both), + PathBuf::from(format!("/chrome-config/google-chrome/{file}")) + ); + assert_eq!( + host_manifest_location(Os::Linux, Browser::Chromium, with_both), + PathBuf::from(format!("/chrome-config/chromium/{file}")) + ); + assert_eq!( + host_manifest_location(Os::Linux, Browser::Edge, with_both), + PathBuf::from(format!("/xdg/microsoft-edge/{file}")) + ); + // An empty or relative value cannot be resolved the way the browser would, so ~/.config stands. + for ignored in ["", "relative/config"] { + let ignored = PathBuf::from(ignored); + let dirs = UserDirs { + xdg_config_home: Some(&ignored), + chrome_config_home: Some(&ignored), + ..UserDirs::at(&h) + }; + assert_eq!( + host_manifest_location(Os::Linux, Browser::Chrome, dirs), + PathBuf::from(format!("/home/a/.config/google-chrome/{file}")), + "{ignored:?}" + ); + } + // Neither variable moves anything on macOS. + assert_eq!( + host_manifest_location(Os::Mac, Browser::Chrome, with_both), + host_manifest_location(Os::Mac, Browser::Chrome, UserDirs::at(&h)) + ); + } + + #[test] + fn install_on_linux_writes_into_the_browser_folder_under_xdg_config_home() { + let h = home(); + let xdg = PathBuf::from("/xdg"); + let dirs = UserDirs { + xdg_config_home: Some(&xdg), + ..UserDirs::at(&h) + }; + let mut writer = Recording::default(); + writer.present.insert(xdg.join("google-chrome")); + writer.present.insert(h.join(".config").join("chromium")); + let written = install( + Path::new("/usr/bin/openbot-desktop"), + dirs, + Os::Linux, + &mut writer, + ) + .unwrap(); + assert_eq!( + written, + vec![xdg + .join("google-chrome") + .join("NativeMessagingHosts") + .join("ai.copilotkit.openbot.recorder.json")] + ); + } + + #[test] + fn windows_location_falls_back_to_the_profile_when_local_app_data_is_unset() { + let h = home(); + let got = host_manifest_location(Os::Windows, Browser::Chrome, UserDirs::at(&h)); + assert_eq!( + got, + home() + .join("AppData") + .join("Local") + .join("OpenBot") + .join("recorder") + .join("ai.copilotkit.openbot.recorder.json") + ); + } + + #[test] + fn registry_commands_set_the_default_value_under_each_browsers_key() { + let path = Path::new( + "C:\\Users\\a\\AppData\\Local\\OpenBot\\recorder\\ai.copilotkit.openbot.recorder.json", + ); + let argv = |key: &str| { + vec![vec![ + "reg.exe".to_string(), + "add".to_string(), + format!("{key}\\NativeMessagingHosts\\ai.copilotkit.openbot.recorder"), + "/ve".to_string(), + "/t".to_string(), + "REG_SZ".to_string(), + "/d".to_string(), + path.to_string_lossy().into_owned(), + "/f".to_string(), + ]] + }; + assert_eq!( + registry_commands(Browser::Chrome, path), + argv("HKCU\\Software\\Google\\Chrome") + ); + assert_eq!( + registry_commands(Browser::Edge, path), + argv("HKCU\\Software\\Microsoft\\Edge") + ); + assert_eq!( + registry_commands(Browser::Chromium, path), + argv("HKCU\\Software\\Chromium") + ); + } + + #[test] + fn browser_present_looks_for_each_browsers_user_data_directory() { + let h = home(); + let local = PathBuf::from("/c/Local"); + let dirs = UserDirs { + local_app_data: Some(&local), + ..UserDirs::at(&h) + }; + let cases: Vec<(Os, Browser, PathBuf)> = vec![ + ( + Os::Mac, + Browser::Chrome, + h.join("Library/Application Support/Google/Chrome"), + ), + ( + Os::Mac, + Browser::Edge, + h.join("Library/Application Support/Microsoft Edge"), + ), + ( + Os::Mac, + Browser::Chromium, + h.join("Library/Application Support/Chromium"), + ), + (Os::Linux, Browser::Chrome, h.join(".config/google-chrome")), + (Os::Linux, Browser::Edge, h.join(".config/microsoft-edge")), + (Os::Linux, Browser::Chromium, h.join(".config/chromium")), + ( + Os::Windows, + Browser::Chrome, + local.join("Google").join("Chrome").join("User Data"), + ), + ( + Os::Windows, + Browser::Edge, + local.join("Microsoft").join("Edge").join("User Data"), + ), + ( + Os::Windows, + Browser::Chromium, + local.join("Chromium").join("User Data"), + ), + ]; + for (os, browser, dir) in &cases { + let mut writer = Recording::default(); + assert!(!browser_present(*os, *browser, dirs, &writer)); + writer.present.insert(dir.clone()); + assert!( + browser_present(*os, *browser, dirs, &writer), + "{os:?} {browser:?}" + ); + for (other_os, other, _) in &cases { + if (other_os, other) != (os, browser) && other_os == os { + assert!(!browser_present(*other_os, *other, dirs, &writer)); + } + } + } + } + + #[test] + fn an_appimage_run_names_the_appimage_itself() { + let got = stable_host_path( + Os::Linux, + Path::new("/tmp/.mount_abc/usr/bin/openbot-desktop"), + Some(Path::new("/home/a/OpenBot.AppImage")), + ); + assert_eq!(got.unwrap(), PathBuf::from("/home/a/OpenBot.AppImage")); + } + + #[test] + fn a_mounted_binary_without_an_appimage_is_refused() { + let got = stable_host_path( + Os::Linux, + Path::new("/tmp/.mount_abc/usr/bin/openbot-desktop"), + None, + ); + assert!(got.is_err()); + } + + #[test] + fn a_translocated_mac_app_asks_to_be_moved() { + let got = stable_host_path( + Os::Mac, + Path::new("/private/var/folders/x/T/AppTranslocation/1/d/OpenBot.app/Contents/MacOS/openbot-desktop"), + None, + ); + assert_eq!( + got.unwrap_err().said, + "Move OpenBot Desktop to Applications to let the Recorder find it." + ); + } + + #[test] + fn installed_paths_are_returned_unchanged() { + let mac = Path::new("/Applications/OpenBot.app/Contents/MacOS/openbot-desktop"); + assert_eq!(stable_host_path(Os::Mac, mac, None).unwrap(), mac); + let windows = Path::new("C:\\Program Files\\OpenBot\\openbot-desktop.exe"); + assert_eq!( + stable_host_path(Os::Windows, windows, None).unwrap(), + windows + ); + let deb = Path::new("/usr/bin/openbot-desktop"); + assert_eq!(stable_host_path(Os::Linux, deb, None).unwrap(), deb); + } + + #[test] + fn install_writes_a_manifest_only_for_present_browsers() { + let h = home(); + let mut writer = Recording::default(); + writer + .present + .insert(h.join("Library/Application Support/Google/Chrome")); + writer + .present + .insert(h.join("Library/Application Support/Microsoft Edge")); + let exe = Path::new("/Applications/OpenBot.app/Contents/MacOS/openbot-desktop"); + let written = install(exe, UserDirs::at(&h), Os::Mac, &mut writer).unwrap(); + assert_eq!(written.len(), 2); + assert_eq!(writer.files.len(), 2); + assert!(writer + .files + .iter() + .all(|(p, _)| !p.to_string_lossy().contains("Chromium"))); + assert!(writer.commands.is_empty()); + for (path, contents) in &writer.files { + assert!(written.contains(path)); + let json: serde_json::Value = serde_json::from_str(contents).unwrap(); + assert_eq!(json["path"], exe.to_string_lossy().as_ref()); + assert_eq!( + json["allowed_origins"], + serde_json::json!([format!("chrome-extension://{ID}/")]) + ); + } + } + + #[test] + fn install_writes_nothing_when_no_browser_is_installed() { + let h = home(); + let mut writer = Recording::default(); + let written = install( + Path::new("/usr/bin/openbot-desktop"), + UserDirs::at(&h), + Os::Linux, + &mut writer, + ) + .unwrap(); + assert!(written.is_empty()); + assert!(writer.files.is_empty()); + assert!(writer.commands.is_empty()); + } + + #[test] + fn a_browser_installed_after_the_first_round_gets_its_manifest_on_the_next() { + let h = home(); + let exe = Path::new("/Applications/OpenBot.app/Contents/MacOS/openbot-desktop"); + let mut writer = Recording::default(); + writer + .present + .insert(h.join("Library/Application Support/Google/Chrome")); + install(exe, UserDirs::at(&h), Os::Mac, &mut writer).unwrap(); + assert_eq!(writer.files.len(), 1); + // The same round again rewrites nothing. + install(exe, UserDirs::at(&h), Os::Mac, &mut writer).unwrap(); + assert_eq!(writer.files.len(), 1); + writer + .present + .insert(h.join("Library/Application Support/Microsoft Edge")); + let written = install(exe, UserDirs::at(&h), Os::Mac, &mut writer).unwrap(); + assert_eq!(written.len(), 2); + assert_eq!(writer.files.len(), 2); + assert!(writer.files[1] + .0 + .to_string_lossy() + .contains("Microsoft Edge")); + // A manifest that names another binary is put right. + let moved = Path::new("/Users/a/Applications/OpenBot.app/Contents/MacOS/openbot-desktop"); + install(moved, UserDirs::at(&h), Os::Mac, &mut writer).unwrap(); + assert_eq!(writer.files.len(), 4); + } + + #[test] + fn install_on_windows_writes_one_manifest_and_registers_every_browser_present_or_not() { + let h = home(); + let local = PathBuf::from("/c/Local"); + let dirs = UserDirs { + local_app_data: Some(&local), + ..UserDirs::at(&h) + }; + // No browser is there yet: each key is written anyway, so one installed later finds the host. + let mut writer = Recording::default(); + let exe = Path::new("C:\\Program Files\\OpenBot\\openbot-desktop.exe"); + let written = install(exe, dirs, Os::Windows, &mut writer).unwrap(); + let manifest = local + .join("OpenBot") + .join("recorder") + .join("ai.copilotkit.openbot.recorder.json"); + assert_eq!(written, vec![manifest.clone()]); + assert_eq!(writer.files.len(), 1); + assert_eq!(writer.files[0].0, manifest); + assert_eq!(writer.commands.len(), 3); + assert!(writer.commands[0][2].contains("HKCU\\Software\\Google\\Chrome")); + assert!(writer.commands[1][2].contains("HKCU\\Software\\Microsoft\\Edge")); + assert!(writer.commands[2][2].contains("HKCU\\Software\\Chromium")); + assert!(writer + .commands + .iter() + .all(|c| c[7] == manifest.to_string_lossy().as_ref())); + } + + #[test] + fn uninstalling_on_windows_removes_every_key_and_the_manifest_the_install_writes() { + let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + assert_eq!( + config["bundle"]["windows"]["nsis"]["installerHooks"], + "./windows/hooks.nsh" + ); + let hooks = include_str!("../windows/hooks.nsh"); + let uninstall = hooks + .split("!macro NSIS_HOOK_POSTUNINSTALL") + .nth(1) + .and_then(|rest| rest.split("!macroend").next()) + .expect("hooks.nsh has no NSIS_HOOK_POSTUNINSTALL"); + // An update runs the old uninstaller first; the registrations stay for the new version. + assert!(uninstall.contains("${If} $UpdateMode <> 1")); + for browser in BROWSERS { + let key = registry_commands(browser, Path::new("manifest.json"))[0][2].clone(); + let key = key.strip_prefix("HKCU\\").unwrap(); + assert!( + uninstall.contains(&format!("DeleteRegKey HKCU \"{key}\"")), + "{key}" + ); + } + let local = PathBuf::from("/local"); + let manifest = host_manifest_location( + Os::Windows, + Browser::Chrome, + UserDirs { + local_app_data: Some(&local), + ..UserDirs::at(Path::new("/home/a")) + }, + ); + let relative: Vec = manifest + .strip_prefix(&local) + .unwrap() + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + assert!(uninstall.contains(&format!( + "Delete \"$LOCALAPPDATA\\{}\"", + relative.join("\\") + ))); + } + + #[test] + fn only_windows_needs_no_second_look() { + assert_eq!(recheck_interval(Os::Windows), None); + assert_eq!(recheck_interval(Os::Mac), Some(Duration::from_secs(60))); + assert_eq!(recheck_interval(Os::Linux), Some(Duration::from_secs(60))); + } + + #[test] + fn the_install_runs_again_after_each_wait_and_a_failure_does_not_stop_it() { + let calls = AtomicUsize::new(0); + let job = || { + if calls.fetch_add(1, Ordering::SeqCst) % 2 == 0 { + Err(Problem::plain("nope")) + } else { + Ok(vec![]) + } + }; + run_install_rounds(&job, Some(Duration::from_millis(1)), Some(4)); + assert_eq!(calls.load(Ordering::SeqCst), 4); + let once = AtomicUsize::new(0); + run_install_rounds( + &|| { + once.fetch_add(1, Ordering::SeqCst); + Ok(vec![]) + }, + None, + Some(4), + ); + assert_eq!(once.load(Ordering::SeqCst), 1); + } + + #[test] + fn keeping_the_host_installed_returns_at_once() { + let started = std::time::Instant::now(); + let handle = keep_recorder_host_installed( + || { + std::thread::sleep(std::time::Duration::from_secs(2)); + Ok(vec![]) + }, + None, + ); + assert!(started.elapsed() < std::time::Duration::from_millis(100)); + handle.join().unwrap(); + } + + #[test] + fn a_failed_install_job_neither_panics_nor_blocks() { + let handle = keep_recorder_host_installed(|| Err(Problem::plain("nope")), None); + assert!(handle.join().is_ok()); + } +} diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index 22e01c845..01ccfc522 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -2766,8 +2766,13 @@ const LOOPBACKS: [&str; 2] = ["127.0.0.1", "[::1]"]; /// Returns the address that worked rather than a boolean, so a caller that has to send somebody /// there can use the one that answered instead of guessing again. pub fn answering_at(port: u16, path: &str) -> Option { + answering_within(port, path, std::time::Duration::from_secs(3)) +} + +/// [`answering_at`], giving each loopback at most `patience`. +pub fn answering_within(port: u16, path: &str, patience: std::time::Duration) -> Option { let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(3)) + .timeout(patience) .build() .ok()?; LOOPBACKS.iter().find_map(|host| { diff --git a/desktop/src-tauri/src/vault.rs b/desktop/src-tauri/src/vault.rs index a2f48ad83..4f61ff90e 100644 --- a/desktop/src-tauri/src/vault.rs +++ b/desktop/src-tauri/src/vault.rs @@ -765,9 +765,24 @@ fn remove_secret_file(path: &Path) -> Result<(), Problem> { } } +/// Whether a secret is stored, read from the file's presence alone and creating nothing. Reading one on Windows starts +/// PowerShell, too slow for a check that only asks whether there is one (the Recorder host's ping). +pub fn stored(root: &Path, name: &str) -> bool { + let extension = if cfg!(target_os = "windows") { + "dpapi" + } else { + "secret" + }; + root.join(VAULT_DIR) + .join(format!("{name}.{extension}")) + .is_file() +} + +const VAULT_DIR: &str = ".secrets"; + /// Where the platforms that keep a file keep it. Created owner-only, not merely written so. pub(crate) fn vault_dir(root: &Path) -> Result { - let dir = root.join(".secrets"); + let dir = root.join(VAULT_DIR); let prepare = || -> std::io::Result<()> { match require_credential_directory(&dir) { Ok(()) => Ok(()), diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index a3d81e35f..bbffa00de 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -27,6 +27,11 @@ "macOS": { "entitlements": "./Entitlements.plist" }, + "windows": { + "nsis": { + "installerHooks": "./windows/hooks.nsh" + } + }, "linux": { "deb": { "depends": ["pkexec", "procps", "lsof", "ca-certificates"] }, "rpm": { diff --git a/desktop/src-tauri/tests/recorder_host.rs b/desktop/src-tauri/tests/recorder_host.rs new file mode 100644 index 000000000..eee468aef --- /dev/null +++ b/desktop/src-tauri/tests/recorder_host.rs @@ -0,0 +1,677 @@ +//! OpenBot Desktop's binary run the way its callers run it: `--install-recorder-host` as the app's setup +//! and a person would, and the native messaging host as Chrome starts it. +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, ExitStatus, Output, Stdio}; +use std::sync::mpsc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +const BINARY: &str = env!("CARGO_BIN_EXE_openbot-desktop"); +const SHARED: &str = include_str!("../../../shared/recorder-extension.json"); + +fn shared(field: &str) -> String { + serde_json::from_str::(SHARED).unwrap()[field] + .as_str() + .unwrap() + .to_string() +} + +fn extension_origin() -> String { + format!("chrome-extension://{}/", shared("extensionId")) +} + +/// A fresh, empty directory standing in for a person's home, removed when dropped. +struct TempHome(PathBuf); + +impl TempHome { + fn new(name: &str) -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "openbot-recorder-{name}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempHome { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Reads the child's output on its own threads, so a full pipe never stalls it. +fn drain(mut pipe: impl Read + Send + 'static) -> JoinHandle> { + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = pipe.read_to_end(&mut bytes); + bytes + }) +} + +/// Waits for the child to exit; None, with the child killed, when it is still running at the deadline. +fn wait_for_exit(child: &mut Child, patience: Duration) -> Option { + let deadline = Instant::now() + patience; + loop { + if let Some(status) = child.try_wait().unwrap() { + return Some(status); + } + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// Runs the child to its exit, failing the test if it is still running after `patience`. +fn finish(mut child: Child, patience: Duration) -> Output { + let stdout = drain(child.stdout.take().unwrap()); + let stderr = drain(child.stderr.take().unwrap()); + let status = wait_for_exit(&mut child, patience); + let stderr = stderr.join().unwrap(); + let Some(status) = status else { + panic!( + "openbot-desktop was still running after {patience:?}: {}", + String::from_utf8_lossy(&stderr) + ); + }; + Output { + status, + stdout: stdout.join().unwrap(), + stderr, + } +} + +/// Where a debug binary's `--install-recorder-host` writes; it writes nowhere without it. +const TEST_HOME: &str = "OPENBOT_RECORDER_HOST_TEST_HOME"; + +/// The binary with nothing of the developer's own environment that would change its answer. +fn binary(home: &Path) -> Command { + let mut command = Command::new(BINARY); + command + .env("HOME", home) + .env("USERPROFILE", home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("APPDATA", home.join("AppData").join("Roaming")) + .env("LOCALAPPDATA", home.join("AppData").join("Local")) + .env_remove("CHROME_CONFIG_HOME") + .env_remove(TEST_HOME) + .env_remove("APPIMAGE") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command +} + +// The install command writes into the browsers' per-user folders on macOS and Linux. On Windows it +// writes the registry, which a test must leave alone, so the wiring is proved on the other two. +#[cfg(unix)] +#[test] +fn the_install_command_writes_a_manifest_for_the_only_browser_present() { + let home = TempHome::new("install"); + let edge = if cfg!(target_os = "macos") { + home.path() + .join("Library") + .join("Application Support") + .join("Microsoft Edge") + } else { + home.path().join(".config").join("microsoft-edge") + }; + std::fs::create_dir_all(&edge).unwrap(); + + let output = finish( + binary(home.path()) + .env(TEST_HOME, home.path()) + .arg("--install-recorder-host") + .spawn() + .unwrap(), + Duration::from_secs(60), + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{:?}: {stderr}", output.status); + let manifest_path = edge + .join("NativeMessagingHosts") + .join(format!("{}.json", shared("nativeHost"))); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("{}\n", manifest_path.display()) + ); + let manifest: Value = + serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); + assert_eq!( + std::fs::canonicalize(manifest["path"].as_str().unwrap()).unwrap(), + std::fs::canonicalize(BINARY).unwrap() + ); + assert_eq!(manifest["allowed_origins"], json!([extension_origin()])); + assert_eq!(manifest["name"], shared("nativeHost")); + assert!(!home + .path() + .join("Library/Application Support/Google") + .exists()); + assert!(!home.path().join(".config/google-chrome").exists()); + assert!(!home.path().join(".config/chromium").exists()); +} + +// A debug build is a developer's binary: run without the test folder, it must not register itself in the browsers of +// the home it runs in, which outside a test is the developer's own. +#[cfg(unix)] +#[test] +fn a_debug_binary_installs_only_under_the_test_home() { + let home = TempHome::new("debug-install"); + let elsewhere = TempHome::new("debug-install-test-home"); + let browser = |root: &Path| { + if cfg!(target_os = "macos") { + root.join("Library") + .join("Application Support") + .join("Google") + .join("Chrome") + } else { + root.join(".config").join("google-chrome") + } + }; + for root in [home.path(), elsewhere.path()] { + std::fs::create_dir_all(browser(root)).unwrap(); + } + let manifests = |root: &Path| browser(root).join("NativeMessagingHosts"); + + let refused = finish( + binary(home.path()) + .arg("--install-recorder-host") + .spawn() + .unwrap(), + Duration::from_secs(60), + ); + if cfg!(debug_assertions) { + assert_eq!(refused.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&refused.stderr).contains(TEST_HOME), + "{}", + String::from_utf8_lossy(&refused.stderr) + ); + assert!(refused.stdout.is_empty()); + assert!(!manifests(home.path()).exists()); + + let output = finish( + binary(home.path()) + .env(TEST_HOME, elsewhere.path()) + .arg("--install-recorder-host") + .spawn() + .unwrap(), + Duration::from_secs(60), + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(manifests(elsewhere.path()) + .join(format!("{}.json", shared("nativeHost"))) + .is_file()); + assert!(!manifests(home.path()).exists()); + } else { + // A release build is the one people install, and writes into the home it runs in. + assert!( + refused.status.success(), + "{}", + String::from_utf8_lossy(&refused.stderr) + ); + assert!(manifests(home.path()).is_dir()); + } +} + +/// The host as Chrome starts it: the caller's origin first and, on Windows, the parent window after +/// it. Replies are read on their own thread, so a host that never answers fails the test instead of +/// hanging it. +struct Host { + child: Child, + stdin: Option, + replies: mpsc::Receiver, + stderr: Option>>, +} + +/// One message as Chrome frames it: a 32-bit length in native byte order, then the UTF-8 JSON. +fn read_framed(input: &mut impl Read) -> Option { + let mut prefix = [0u8; 4]; + input.read_exact(&mut prefix).ok()?; + let mut body = vec![0; u32::from_ne_bytes(prefix) as usize]; + input.read_exact(&mut body).ok()?; + Some(serde_json::from_slice(&body).expect("the host wrote a message that is not JSON")) +} + +impl Host { + fn start(home: &Path, origin: &str, test_app_url: Option<&str>) -> Self { + let mut command = binary(home); + command + .arg(origin) + .env_remove("OPENBOT_RECORDER_HOST_TEST_APP_URL") + .stdin(Stdio::piped()); + if cfg!(windows) { + command.arg("--parent-window=0"); + } + if let Some(url) = test_app_url { + command.env("OPENBOT_RECORDER_HOST_TEST_APP_URL", url); + } + let mut child = command.spawn().unwrap(); + let stdin = child.stdin.take(); + let mut stdout = child.stdout.take().unwrap(); + let stderr = Some(drain(child.stderr.take().unwrap())); + let (sender, replies) = mpsc::channel(); + std::thread::spawn(move || { + while let Some(reply) = read_framed(&mut stdout) { + if sender.send(reply).is_err() { + break; + } + } + }); + Self { + child, + stdin, + replies, + stderr, + } + } + + fn ask(&mut self, message: Value) -> Value { + let body = message.to_string(); + let stdin = self.stdin.as_mut().unwrap(); + stdin.write_all(&(body.len() as u32).to_ne_bytes()).unwrap(); + stdin.write_all(body.as_bytes()).unwrap(); + stdin.flush().unwrap(); + match self.replies.recv_timeout(Duration::from_secs(30)) { + Ok(reply) => reply, + Err(error) => panic!("no reply to {message}: {error}"), + } + } + + fn running(&mut self) -> bool { + self.child.try_wait().unwrap().is_none() + } + + /// Closes the host's input the way Chrome does when it is done, and waits for it to exit. + fn close(self) -> ExitStatus { + self.close_with_stderr().0 + } + + /// [`Host::close`], and everything the host wrote to stderr. + fn close_with_stderr(mut self) -> (ExitStatus, String) { + drop(self.stdin.take()); + let status = wait_for_exit(&mut self.child, Duration::from_secs(30)); + let stderr = self.stderr.take().unwrap().join().unwrap(); + let Some(status) = status else { + panic!( + "the host was still running after its input closed: {}", + String::from_utf8_lossy(&stderr) + ); + }; + assert!( + self.replies.try_recv().is_err(), + "the host answered a message nobody sent" + ); + (status, String::from_utf8_lossy(&stderr).into_owned()) + } +} + +impl Drop for Host { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +#[test] +fn the_host_answers_as_chrome_asks_and_exits_when_its_input_closes() { + let home = TempHome::new("host"); + let mut host = Host::start(home.path(), &extension_origin(), None); + assert_eq!( + host.ask(json!({ "type": "ping" })), + json!({ "ok": false, "available": false, "error": "not-installed" }) + ); + assert!(host.running(), "the host exited before its input closed"); + assert_eq!( + host.ask(json!({ "type": "handoff" })), + json!({ "ok": false, "error": "not-installed" }) + ); + let status = host.close(); + assert!(status.success(), "{status:?}"); +} + +#[test] +fn another_extension_is_refused_a_handoff() { + let other = format!("chrome-extension://{}/", "p".repeat(32)); + assert_ne!(other, extension_origin()); + let home = TempHome::new("foreign"); + let mut host = Host::start(home.path(), &other, None); + assert_eq!( + host.ask(json!({ "type": "handoff" })), + json!({ "ok": false, "error": "origin-not-allowed" }) + ); + assert_eq!( + host.ask(json!({ "type": "ping" })), + json!({ "ok": false, "available": false, "error": "origin-not-allowed" }) + ); + assert!(host.close().success()); +} + +/// One request as a fake server saw it: the request line, the headers and the body. +struct Seen { + line: String, + head: String, + body: String, +} + +/// A fake HTTP server on 127.0.0.1 that answers `count` requests, one per connection, each with what `route` says for +/// its request line, and hands back what it saw. +fn serve_routes( + listener: TcpListener, + count: usize, + route: impl Fn(&str) -> String + Send + 'static, +) -> JoinHandle> { + std::thread::spawn(move || { + let mut seen = Vec::new(); + for _ in 0..count { + let (mut stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let line = line.trim_end().to_string(); + let mut head = String::new(); + let mut length = 0; + loop { + let mut header = String::new(); + if reader.read_line(&mut header).unwrap() == 0 || header == "\r\n" { + break; + } + if let Some(value) = header.to_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap(); + } + head.push_str(&header); + } + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + stream.write_all(route(&line).as_bytes()).unwrap(); + seen.push(Seen { + line, + head, + body: String::from_utf8(body).unwrap(), + }); + } + seen + }) +} + +fn http(status: &str, headers: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) +} + +const MINTED: &str = r#"{"code":"obc_fixture","expiresAt":"2026-10-18T12:00:00.000Z"}"#; + +// A debug binary lets a test point the host at an OpenBot it started itself. A release binary has no +// such seam: CI runs this file with `--release` on Windows, where the same variable changes nothing. +#[test] +fn the_test_app_address_reaches_only_a_debug_binary() { + let home = TempHome::new("seam"); + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let mut host = Host::start(home.path(), &extension_origin(), Some(&url)); + if cfg!(debug_assertions) { + let app = serve_routes(listener, 2, |line| { + if line.starts_with("GET /api/capabilities ") { + http("200 OK", "", "{}") + } else { + http("201 Created", "", MINTED) + } + }); + assert_eq!( + host.ask(json!({ "type": "ping" })), + json!({ "ok": true, "available": true }) + ); + assert_eq!( + host.ask(json!({ "type": "handoff" })), + json!({ "ok": true, "openbotUrl": url, "code": "obc_fixture" }) + ); + let seen = app.join().unwrap(); + assert_eq!(seen[0].line, "GET /api/capabilities HTTP/1.1"); + assert_eq!(seen[1].line, "POST /api/recorder/connect-codes HTTP/1.1"); + assert!( + !seen[1].head.to_lowercase().contains("cookie:"), + "{}", + seen[1].head + ); + assert_eq!( + serde_json::from_str::(&seen[1].body).unwrap(), + json!({ "via": "desktop" }) + ); + } else { + assert_eq!( + host.ask(json!({ "type": "ping" })), + json!({ "ok": false, "available": false, "error": "not-installed" }) + ); + assert_eq!( + host.ask(json!({ "type": "handoff" })), + json!({ "ok": false, "error": "not-installed" }) + ); + listener.set_nonblocking(true).unwrap(); + match listener.accept() { + Err(error) => assert_eq!(error.kind(), ErrorKind::WouldBlock), + Ok((_, peer)) => panic!("a release binary dialled the test app from {peer}"), + } + } + assert!(host.close().success()); +} + +// An app that takes the connection and never answers still gets the extension an answer, inside the time it waits: +// 3 s for a ping and 8 s for a handoff. +#[test] +fn a_silent_app_is_answered_for_within_the_extensions_wait() { + if !cfg!(debug_assertions) { + return; + } + let home = TempHome::new("silent"); + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let held = std::thread::spawn(move || { + let mut streams = Vec::new(); + listener.set_nonblocking(true).unwrap(); + let until = Instant::now() + Duration::from_secs(15); + while Instant::now() < until { + if let Ok((stream, _)) = listener.accept() { + streams.push(stream); + } + std::thread::sleep(Duration::from_millis(10)); + } + }); + let mut host = Host::start(home.path(), &extension_origin(), Some(&url)); + let asked = Instant::now(); + assert_eq!( + host.ask(json!({ "type": "ping" })), + json!({ "ok": false, "available": false, "error": "not-running" }) + ); + assert!( + asked.elapsed() < Duration::from_secs(3), + "{:?}", + asked.elapsed() + ); + let asked = Instant::now(); + assert_eq!( + host.ask(json!({ "type": "handoff" })), + json!({ "ok": false, "error": "timed-out" }) + ); + assert!( + asked.elapsed() < Duration::from_secs(8), + "{:?}", + asked.elapsed() + ); + assert!(host.close().success()); + drop(held); +} + +/// A selected deployment in organization mode under `home`, its sign-in saved for `authority` when `cookie` is Some. +#[cfg(unix)] +fn organization_deployment(home: &Path, authority: &str, cookie: Option<&str>) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let config = if cfg!(target_os = "macos") { + home.join("Library").join("Application Support") + } else { + home.join(".config") + } + .join("ai.copilotkit.openbot.desktop"); + let root = home.join("deployment"); + std::fs::create_dir_all(&config).unwrap(); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + config.join("last-launched-root.json"), + serde_json::to_string(&root).unwrap(), + ) + .unwrap(); + std::fs::write( + root.join(".env"), + format!("OPENBOT_ORGANIZATION_AUTH_URL={authority}\n"), + ) + .unwrap(); + if let Some(cookie) = cookie { + let secrets = root.join(".secrets"); + std::fs::create_dir_all(&secrets).unwrap(); + std::fs::set_permissions(&secrets, std::fs::Permissions::from_mode(0o700)).unwrap(); + let saved = secrets.join("OPENBOT_ORGANIZATION_SESSION.secret"); + std::fs::write( + &saved, + json!({ "authority": authority, "cookie": cookie }).to_string(), + ) + .unwrap(); + std::fs::set_permissions(&saved, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + root +} + +// Organization mode: the ping reads whether Desktop holds a sign-in, without the network. +#[cfg(unix)] +#[test] +fn an_organization_ping_without_a_saved_sign_in_is_not_signed_in() { + if !cfg!(debug_assertions) { + return; + } + let home = TempHome::new("organization-ping"); + let authority = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let authority_url = format!("http://{}", authority.local_addr().unwrap()); + organization_deployment(home.path(), &authority_url, None); + let app = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let url = format!("http://{}", app.local_addr().unwrap()); + let probe = serve_routes(app, 1, |_| http("200 OK", "", "{}")); + let mut host = Host::start(home.path(), &extension_origin(), Some(&url)); + assert_eq!( + host.ask(json!({ "type": "ping" })), + json!({ "ok": false, "available": false, "error": "not-signed-in" }) + ); + assert_eq!( + probe.join().unwrap()[0].line, + "GET /api/capabilities HTTP/1.1" + ); + assert!(host.close().success()); + authority.set_nonblocking(true).unwrap(); + match authority.accept() { + Err(error) => assert_eq!(error.kind(), ErrorKind::WouldBlock), + Ok((_, peer)) => panic!("a ping dialled the organization from {peer}"), + } +} + +// The host's stderr reaches Chrome's log and the terminal of whoever started the browser: never a connect code or +// any part of a cookie, on success or on failure. +#[cfg(unix)] +#[test] +fn the_host_never_logs_a_code_or_a_cookie() { + if !cfg!(debug_assertions) { + return; + } + let home = TempHome::new("stderr"); + let authority = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let authority_url = format!("http://{}", authority.local_addr().unwrap()); + organization_deployment( + home.path(), + &authority_url, + Some("better-auth.session_token=saved-secret"), + ); + let me = r#"{"user":{"id":"u1","email":"a@b.c","name":null,"role":"user"}}"#; + let organization = serve_routes(authority, 2, move |_| http("200 OK", "", me)); + let app = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let url = format!("http://{}", app.local_addr().unwrap()); + let minted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let app = serve_routes(app, 7, move |line| { + let minted_code = r#"{"code":"obc_secretcode","expiresAt":"2026-10-18T12:00:00.000Z"}"#; + if line.starts_with("GET /api/capabilities ") { + http("200 OK", "", "{}") + } else if line.starts_with("POST /api/auth/organization/session ") { + http("200 OK", "", r#"{"ticket":"t"}"#) + } else if line.starts_with("GET /api/auth/organization/session?ticket=t ") { + http( + "302 Found", + "Location: /\r\nSet-Cookie: openbot.organization-session=local-secret; Path=/; HttpOnly\r\n", + "", + ) + } else if minted.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + http("201 Created", "", minted_code) + } else { + // A refusal that repeats the code and the cookie in its body. + http( + "500 Internal Server Error", + "", + r#"{"error":"obc_secretcode openbot.organization-session=local-secret"}"#, + ) + } + }); + let mut host = Host::start(home.path(), &extension_origin(), Some(&url)); + assert_eq!( + host.ask(json!({ "type": "ping" })), + json!({ "ok": true, "available": true }) + ); + assert_eq!( + host.ask(json!({ "type": "handoff" })), + json!({ "ok": true, "openbotUrl": url, "code": "obc_secretcode" }) + ); + assert_eq!( + host.ask(json!({ "type": "handoff" })), + json!({ "ok": false, "error": "handoff-failed" }) + ); + let (status, stderr) = host.close_with_stderr(); + assert!(status.success(), "{status:?}: {stderr}"); + let seen = app.join().unwrap(); + assert!(seen[3] + .head + .to_lowercase() + .contains("cookie: openbot.organization-session=local-secret\r\n")); + organization.join().unwrap(); + assert!( + stderr.contains("answered the handoff with 500"), + "the failure was not logged at all: {stderr}" + ); + for secret in [ + "obc_", + "secret", + "session_token", + "organization-session", + "ticket", + ] { + assert!( + !stderr.contains(secret), + "stderr holds {secret:?}: {stderr}" + ); + } +} diff --git a/desktop/src-tauri/windows/hooks.nsh b/desktop/src-tauri/windows/hooks.nsh new file mode 100644 index 000000000..8a0679729 --- /dev/null +++ b/desktop/src-tauri/windows/hooks.nsh @@ -0,0 +1,18 @@ +; OpenBot Desktop's NSIS installer hooks: bundle.windows.nsis.installerHooks in tauri.conf.json. +; https://v2.tauri.app/distribute/windows-installer/ + +; Uninstalling takes away what the Recorder host install wrote for this person (src/recorder_install.rs): each +; browser's native messaging host key and the one manifest they all point at, which sits beside the app in +; %LOCALAPPDATA%\OpenBot. Not on an update, which runs the old uninstaller with /UPDATE; the new version writes them +; again when it starts. +!macro NSIS_HOOK_POSTUNINSTALL + ${If} $UpdateMode <> 1 + DeleteRegKey HKCU "Software\Google\Chrome\NativeMessagingHosts\ai.copilotkit.openbot.recorder" + DeleteRegKey HKCU "Software\Microsoft\Edge\NativeMessagingHosts\ai.copilotkit.openbot.recorder" + DeleteRegKey HKCU "Software\Chromium\NativeMessagingHosts\ai.copilotkit.openbot.recorder" + SetShellVarContext current + Delete "$LOCALAPPDATA\OpenBot\recorder\ai.copilotkit.openbot.recorder.json" + RMDir "$LOCALAPPDATA\OpenBot\recorder" + RMDir "$LOCALAPPDATA\OpenBot" + ${EndIf} +!macroend diff --git a/docs/automatic-learning.md b/docs/automatic-learning.md index b68ae4c27..ebb62e7ca 100644 --- a/docs/automatic-learning.md +++ b/docs/automatic-learning.md @@ -57,6 +57,16 @@ Intelligence creates Learning containers (`POST /api/learning/projects/:projectI With none of them, the `learning-auto-assign` log line and the Admin page name the exact steps for your Intelligence: for managed, sign in with the CLI and run the helper (or give the chart a CLI login); for self-hosted, run the helper and sign in, or set a service token on a local evaluation install. Creating a container with ID `openbot` in Intelligence by hand works everywhere. +## Each person's own Learning space + +Recordings made with the OpenBot Recorder do not belong to a Bot. Every recording a person makes joins their own Learning container, `user-` and the first 32 hex digits of the SHA-256 of their OpenBot user id, whichever Bot they later use. The id is derived, so every replica names the same container for the same person without looking anything up, and two people never share one. + +- **Made on first need.** When a person connects a browser, OpenBot makes their container in the background with the same credential it uses for `openbot`: a service token on a local evaluation install, or a CopilotKit CLI login against managed Intelligence (see the table above). A capture pass that finds it still missing makes it on the spot when the credential is a service token, which answers well inside the pass's deadline; the CLI is slower, which is why connecting starts it first. Intelligence's container id is unique per project, so replicas racing to make the same container all end up with it. With no such credential, a missing container cannot be made, and the Recorder's popup says Learning will not see the recording (it still goes to Intelligence). Creating a container with that id in Intelligence by hand works too. +- **Every Bot the person uses draws on it.** A run gets the Bot's own published Skills (its Learning target, as above), then the person's own, then any Skills another person opened. On a name clash the Bot's own wins, then the person's own; when two people opened Skills with the same name, both are kept, each named `/`. A person's container that is missing, refusing or slow never fails a run: the run goes on with the Skills it has, and runs leave a refusing container alone for a minute. Learning switched off here, or a Bot excluded here, still means no learned Skills at all for that Bot. +- **Who else may use them.** In **Settings → Learning** each person chooses: only their own Bots (the default), every Bot in the deployment, or the Skills they open one by one. Runs read what is open from an index held in memory, read again at once on the replica that saved a change and within 30 seconds everywhere else, so no run asks each person's container who shares what. Each change writes a `learning.sharing_changed`, `learning.skill_opened` or `learning.skill_closed` audit row. + +Thread assignment is unchanged: a conversation's evidence still goes to its Bot's container. + ## Credentials and permissions OpenBot reuses its **server-side** `INTELLIGENCE_API_KEY` and `INTELLIGENCE_API_URL`. Setup creates containers using the authenticated account, not the runtime key. Automatic assignment uses the runtime key only to read whether `openbot` exists in its own project. Never put the project key in browser environment variables. The Learning settings and inspection endpoints require an OpenBot administrator; settings writes also require same-origin JSON requests (or an explicitly trusted app origin). @@ -69,7 +79,7 @@ The admin page reports evidence and published-skill counts, analysis status, and For managed Intelligence, keep the existing managed API and gateway configuration. Configure the container in its associated project. For an Enterprise self-hosted Intelligence deployment, use its API and gateway endpoints and the same project's key. Install the Intelligence release and migrations that support Automatic Learning, the Inspector Learning endpoint (`GET /api/inspector/learning`), and published skill delivery before assigning a container in OpenBot. Links come from that server's configured web-app origin, so self-hosted administration opens the correct UI. -OpenBot's database migration adds `learning_settings` and `learning_thread_bindings`; run the normal deployment migrations. The settings are read from the database across replicas, and thread assignment is atomic. +OpenBot's database migrations add `learning_settings` and `learning_thread_bindings`, and for each person's own space `learning_person_spaces`, `learning_skill_sharing` and `learning_open_skills`; run the normal deployment migrations. The settings are read from the database across replicas, and thread assignment is atomic. Operators may seed an initial default in the API server environment: diff --git a/docs/configuration.md b/docs/configuration.md index 79ff05ee3..aff2a5b3d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -347,6 +347,7 @@ policies apply. See [OpenAI WebRTC](https://developers.openai.com/api/docs/guide | `OPENBOT_APP_URL` | Where the browser app is served. Defaults to the first `TRUSTED_ORIGINS` entry. | | `SIGNIN_ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains admitted at sign-in. Empty means no opinion. Exact, no wildcards. | | `OPENBOT_ORGANIZATION_AUTH_URL` | An OpenBot deployment that verifies employee identity and roles. Decides sign-in ahead of `OPENBOT_SINGLE_USER`. | +| `OPENBOT_RECORDER_EXTRA_EXTENSION_IDS` | Extra extension ids OpenBot trusts besides the Recorder's own, for a fork or a build signed with another key. Comma separated, each 32 letters from a to p, or the server refuses to start. Unset trusts only the Recorder's own id. | **Some variables belong to the desktop app, not to you.** A desktop installation writes these into its own deployment's `.env` and owns their values: `OPENBOT_MODEL_OAUTH_FILE`, `CHATGPT_AUTH_FILE`, diff --git a/docs/development.md b/docs/development.md index f6da20edd..505da6e60 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,7 @@ ## Setup -Install Docker, [Bun](https://bun.sh) 1.3+, `lsof`, `openssl`, and `curl`. The +Install Docker, [Bun](https://bun.sh) 1.4+ (the tests need it), `lsof`, `openssl`, and `curl`. The Intelligence provisioning below also needs `npx` (Node); `scripts/start.sh` uses `openssl` to mint the generated secrets on a first run. diff --git a/extension/keys/public-key.txt b/extension/keys/public-key.txt new file mode 100644 index 000000000..7b63ea812 --- /dev/null +++ b/extension/keys/public-key.txt @@ -0,0 +1 @@ +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3gg17W2ObK9mwIBOxiyvvtTm05By3W4hqoYEuASkE2G0jcc9d1GdjjxzuCxRNjhlEumXFD5/IvhbnxpJXTMY6GR3qEG1hcyzBSDQCEZcBrL/EJtRddp7P5ik3NEr2qHVBfLoarp4skbKOQ82NttISJKHSCKziga5vcmZGWXOz3BSEMf1c9NzkUThOtxZMoQJQi/I5kR5qSmCHJ5omLVBxVzzA1LDIvH4Np4ddYohAgFt+0LiUxPnP8cFycHcYmNklU4bagArmj42588U3XMfQ4c0l/bFlM0NbwGleTh5tKX4RG2hqYDaurlDm6goQTLYDTZ3PNoPkDUWOdGpoJtjfQIDAQAB \ No newline at end of file diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 000000000..80e57b0f3 --- /dev/null +++ b/extension/package.json @@ -0,0 +1,47 @@ +{ + "name": "@openbot/recorder-extension", + "license": "MIT", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "bun scripts/build.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@ag-ui/client": "1.0.2", + "@ag-ui/core": "1.0.2", + "@ag-ui/proto": "1.0.2", + "@base-ui/react": "^1.6.0", + "@copilotkit/learning": "0.0.1", + "@fontsource-variable/inter": "^5.3.0", + "@happy-dom/global-registrator": "^20.11.2", + "@openbot/shared": "workspace:*", + "@tabler/icons-react": "^3.36.1", + "@tailwindcss/vite": "^4.3.3", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.4", + "@types/chrome": "0.3.4", + "@types/phoenix": "1.6.7", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "eventsource-parser": "3.1.1", + "fake-indexeddb": "6.2.5", + "happy-dom": "^20.11.2", + "phoenix": "1.8.15", + "prompt-area": "^0.6.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "rxjs": "7.8.1", + "shadcn": "^4.17.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "vite": "^7.1.12", + "zod": "^4.4.3" + } +} diff --git a/extension/public/icons/128.png b/extension/public/icons/128.png new file mode 100644 index 000000000..dfc36770a Binary files /dev/null and b/extension/public/icons/128.png differ diff --git a/extension/public/icons/16.png b/extension/public/icons/16.png new file mode 100644 index 000000000..cec1fbc32 Binary files /dev/null and b/extension/public/icons/16.png differ diff --git a/extension/public/icons/32.png b/extension/public/icons/32.png new file mode 100644 index 000000000..67385411c Binary files /dev/null and b/extension/public/icons/32.png differ diff --git a/extension/public/icons/48.png b/extension/public/icons/48.png new file mode 100644 index 000000000..c4a58e80b Binary files /dev/null and b/extension/public/icons/48.png differ diff --git a/extension/public/managed_schema.json b/extension/public/managed_schema.json new file mode 100644 index 000000000..3c2e452ab --- /dev/null +++ b/extension/public/managed_schema.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "OpenBotUrl": { + "title": "OpenBot address", + "description": "The HTTPS address of your organization's OpenBot. The Recorder connects only to this address. Chrome ignores a value that is not a string, so check that chrome://policy shows it with no error.", + "type": "string" + }, + "AllowedIntelligenceUrls": { + "title": "Allowed Intelligence addresses", + "description": "WSS addresses of your Intelligence realtime gateway, as a list. The Recorder refuses a capture pass that points anywhere else. Chrome ignores a value that is not a list of strings (on Windows, numbered values under a subkey, or one JSON list), and an ignored value means no restriction, so check that chrome://policy shows it with no error.", + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/extension/scripts/agent-traffic/record.ts b/extension/scripts/agent-traffic/record.ts new file mode 100644 index 000000000..89ca4b069 --- /dev/null +++ b/extension/scripts/agent-traffic/record.ts @@ -0,0 +1,619 @@ +// Records real AG-UI traffic and what CopilotKit itself emits for it, as the fixtures of the agent events' parity test +// (tests/agent-events-parity.test.ts). Nothing is scripted: a real CopilotKit runtime (runtime.ts, its own process) runs +// real OpenAI agents, and the real CopilotKitCore drives each conversation through its real agent with the legacy +// `learning: { sink }`, so the sink holds exactly the events CopilotKit emits. Both come from the CopilotKit checkout +// named by COPILOTKIT_CHECKOUT (built), so the reference is CopilotKit's current source, not an older npm release. +// Every request body and response stream is captured as it crosses fetch, and every AG-UI frame of the Intelligence +// WebSocket as it arrives, each with when it arrived. +// +// COPILOTKIT_CHECKOUT= bun extension/scripts/agent-traffic/record.ts +// [--intelligence ] [name] +// +// Keys are read from those files into the runtime process's environment only. No header, socket address or join token +// is recorded, and a fixture that contains a key is refused. +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { AbstractAgent } from "@ag-ui/client"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +const checkout = process.env.COPILOTKIT_CHECKOUT; +if (!checkout) throw new Error("COPILOTKIT_CHECKOUT is not set"); +const args = process.argv.slice(2); +const intelligenceAt = args.indexOf("--intelligence"); +const intelligenceFile = + intelligenceAt === -1 ? undefined : args.splice(intelligenceAt, 2)[1]; +const [envFile, only] = args; +if (!envFile) throw new Error("usage: record.ts "); + +const readEnv = async (file: string, names: string[]) => { + const values: Record = {}; + for (const line of (await Bun.file(file).text()).split("\n")) { + const at = line.indexOf("="); + const name = line.slice(0, at).trim(); + if (at > 0 && names.includes(name)) + values[name] = line + .slice(at + 1) + .trim() + .replace(/^["']|["']$/g, ""); + } + for (const name of names) + if (!values[name]) throw new Error(`no ${name} in ${file}`); + return values; +}; +const secrets = { + ...(await readEnv(envFile, ["OPENAI_API_KEY"])), + ...(intelligenceFile + ? await readEnv(intelligenceFile, [ + "INTELLIGENCE_API_URL", + "INTELLIGENCE_GATEWAY_WS_URL", + "INTELLIGENCE_API_KEY", + ]) + : {}), +}; +const transport = intelligenceFile ? "intelligence" : "sse"; +const OUT = join( + import.meta.dir, + "../../tests/fixtures/agent-traffic", + transport, +); + +const runtimeProcess = Bun.spawn(["bun", join(import.meta.dir, "runtime.ts")], { + env: { ...process.env, ...secrets, COPILOTKIT_TELEMETRY_DISABLED: "true" }, + stdout: "pipe", + stderr: "inherit", +}); +const port = await (async () => { + const reader = runtimeProcess.stdout.getReader(); + const decoder = new TextDecoder(); + let text = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) throw new Error("the runtime exited before it was ready"); + text += decoder.decode(value, { stream: true }); + const ready = /ready (\d+)/.exec(text); + if (ready) return Number(ready[1]); + } +})(); +const runtimeUrl = `http://127.0.0.1:${port}/api/copilotkit`; + +// CopilotKitCore connects to its runtime only where a window exists, as in a page. Bun's own network and stream classes +// stay, so the traffic is real HTTP and a real WebSocket. +const bunGlobals = { + fetch, + Request, + Response, + Headers, + ReadableStream, + WritableStream, + TransformStream, + TextDecoder, + TextEncoder, + URL, + AbortController, + AbortSignal, + WebSocket, +}; +GlobalRegistrator.register({ url: `http://127.0.0.1:${port}/` }); +Object.assign(globalThis, bunGlobals); + +const { CopilotKitCore } = await import( + `${checkout}/packages/core/dist/index.mjs` +); +const { z } = await import( + `${checkout}/packages/core/node_modules/zod/index.js` +); +type Core = InstanceType; + +type Exchange = { + at: number; + url: string; + method: string; + body: string | null; + status: number; + contentType: string | null; + chunks: { at: number; text: string }[]; + end: { at: number; outcome: "complete" | "failed" } | null; +}; +/** One Phoenix frame of the Intelligence WebSocket: an AG-UI event, a replay control, or a channel join or leave. */ +type Frame = { + at: number; + socket: number; + direction: "in" | "out"; + joinRef: string | null; + topic: string; + event: string; + payload?: unknown; +}; + +let clockZero = 0; +const clock = () => Math.round(performance.now() - clockZero); +let exchanges: Exchange[] = []; +let frames: Frame[] = []; +const pending = new Set>(); + +/** The join credentials a run or connect answers with in Intelligence mode hold a join token: it is never recorded. */ +const withoutTokens = (text: string): string => { + try { + return JSON.stringify(JSON.parse(text), (key, value) => + /token/i.test(key) ? "[omitted]" : value, + ); + } catch { + return text; + } +}; + +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const exchange: Exchange = { + at: clock(), + url, + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? init.body : null, + status: 0, + contentType: null, + chunks: [], + end: null, + }; + exchanges.push(exchange); + const response = await bunGlobals.fetch(input, init); + exchange.status = response.status; + exchange.contentType = response.headers.get("content-type"); + if (!response.body) { + exchange.end = { at: clock(), outcome: "complete" }; + return response; + } + const [mine, theirs] = response.body.tee(); + const reading = (async () => { + const reader = mine.getReader(); + const decoder = new TextDecoder(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + exchange.chunks.push({ + at: clock(), + text: decoder.decode(value, { stream: true }), + }); + } + exchange.end = { at: clock(), outcome: "complete" }; + } catch { + exchange.end = { at: clock(), outcome: "failed" }; + } + })(); + pending.add(reading); + void reading.finally(() => pending.delete(reading)); + return new Response(theirs, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +}) as typeof fetch; + +let sockets = 0; +const PHOENIX_FRAMES = new Set([ + "ag_ui_event", + "replay_complete", + "replay_failed", + "stream_idle", + "phx_join", + "phx_leave", + "phx_close", + "phx_error", +]); +const recordFrame = ( + socket: number, + direction: Frame["direction"], + data: unknown, +) => { + if (typeof data !== "string") return; + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return; + } + if (!Array.isArray(parsed) || parsed.length !== 5) return; + const [joinRef, , topic, event, payload] = parsed as [ + string | null, + string | null, + string, + string, + unknown, + ]; + if (!PHOENIX_FRAMES.has(event)) return; + frames.push({ + at: clock(), + socket, + direction, + joinRef, + topic, + event, + // A join's params name the replay cursor and the mode; nothing in them is a credential. + ...(event === "ag_ui_event" || event === "phx_join" ? { payload } : {}), + }); +}; +class RecordingWebSocket extends bunGlobals.WebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + super(url, protocols); + const socket = ++sockets; + this.addEventListener("message", (event) => + recordFrame(socket, "in", (event as MessageEvent).data), + ); + const send = this.send.bind(this); + this.send = (data) => { + recordFrame(socket, "out", data); + send(data); + }; + } +} +globalThis.WebSocket = RecordingWebSocket as typeof WebSocket; +if (typeof window !== "undefined") + (window as unknown as { WebSocket: unknown }).WebSocket = RecordingWebSocket; + +/** What the legacy sink receives: a batch of the collector's events, each stamped with Date.now(). */ +type SinkBatch = { trajectoryId: string; events: unknown[]; dropped?: number }; + +async function startCore( + clicks = false, +): Promise<{ core: Core; sink: SinkBatch[] }> { + const sink: SinkBatch[] = []; + const core: Core = new CopilotKitCore({ + runtimeUrl, + runtimeTransport: "single", + learning: { + sink: (batch: SinkBatch) => { + sink.push(batch); + }, + capture: { + clicks, + inputs: false, + navigation: false, + network: false, + agentText: true, + }, + }, + }); + const started = await core.startTrajectory(); + if (started.status !== "started") + throw new Error(`startTrajectory: ${JSON.stringify(started)}`); + return { core, sink }; +} + +async function agentOf(core: Core, id: string): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + const agent = core.getAgent(id); + if (agent) return agent; + await Bun.sleep(50); + } + throw new Error(`agent ${id} never appeared`); +} + +async function say( + core: Core, + agent: AbstractAgent, + content: string, +): Promise { + agent.addMessage({ id: crypto.randomUUID(), role: "user", content }); + await core.runAgent({ agent }); +} + +async function settle(): Promise { + await Bun.sleep(transport === "intelligence" ? 1500 : 300); + await Promise.all([...pending]); + await Bun.sleep(50); +} + +/** A run or connect of an agent: a REST path, or the single route's `{ method, params, body }` envelope. */ +const agentMethod = (exchange: Exchange): string | null => { + const path = /\/agent\/[^/]+\/(run|connect)$/.exec( + new URL(exchange.url).pathname, + ); + if (path) return `agent/${path[1]}`; + try { + const method = (JSON.parse(exchange.body ?? "") as { method?: unknown }) + .method; + return method === "agent/run" || method === "agent/connect" ? method : null; + } catch { + return null; + } +}; + +async function record( + name: string, + conversation: (helpers: { + core: Core; + sink: SinkBatch[]; + }) => Promise, +): Promise { + exchanges = []; + frames = []; + clockZero = performance.now(); + const { core, sink } = await startCore(name === "clicks"); + /** What a conversation adds to its fixture beside the traffic and the sink. */ + const extra = ((await conversation({ core, sink })) ?? {}) as Record< + string, + unknown + >; + await settle(); + core.stopTrajectory(); + const fixture = { + name, + transport, + recordedWith: { + copilotkit: ( + await Bun.$`git -C ${checkout} rev-parse --short HEAD`.text() + ).trim(), + "@ag-ui/client": "1.0.2", + }, + exchanges: exchanges + .filter((exchange) => agentMethod(exchange) !== null) + .map((exchange) => ({ + ...exchange, + ...(transport === "intelligence" + ? { + chunks: exchange.chunks.map((chunk) => ({ + ...chunk, + text: withoutTokens(chunk.text), + })), + } + : {}), + })), + frames, + sink, + ...extra, + }; + const text = `${JSON.stringify(fixture, null, 2)}\n`.replaceAll( + `127.0.0.1:${port}`, + "127.0.0.1:4000", + ); + for (const secret of [secrets.OPENAI_API_KEY, secrets.INTELLIGENCE_API_KEY]) + if (secret && text.includes(secret)) + throw new Error(`${name}: the fixture holds a secret; not written`); + if (/sk-[A-Za-z0-9_-]{20,}|join_token|Bearer /i.test(text)) + throw new Error(`${name}: the fixture holds a credential; not written`); + await mkdir(OUT, { recursive: true }); + await writeFile(join(OUT, `${name}.json`), text); + console.log( + `${transport}/${name}: ${fixture.exchanges.length} exchanges, ${frames.length} frames, ${sink.flatMap((batch) => batch.events).length} sink events`, + ); +} + +const conversations: Record[1]> = { + "text-answer": async ({ core }) => { + const agent = await agentOf(core, "default"); + await say( + core, + agent, + "Reply with exactly five words about the sea. Call no tools.", + ); + }, + "backend-tool": async ({ core }) => { + const agent = await agentOf(core, "default"); + await say( + core, + agent, + "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.", + ); + }, + "frontend-tool": async ({ core }) => { + core.addTool({ + name: "showGreeting", + description: "Show a greeting card to the person in the page.", + parameters: z.object({ name: z.string(), mood: z.string() }), + handler: async ({ name }: { name: string }) => + `Shown a greeting card for ${name}.`, + }); + const agent = await agentOf(core, "default"); + await say( + core, + agent, + "Call showGreeting with name Ada and mood cheerful, then say done in one word.", + ); + }, + // A frontend tool that fails: CopilotKit puts `Error: ` in the history as the tool's result and runs no + // follow-up, so the result reaches the runtime only with the person's next message. + "frontend-tool-error": async ({ core }) => { + core.addTool({ + name: "showGreeting", + description: "Show a greeting card to the person in the page.", + parameters: z.object({ name: z.string(), mood: z.string() }), + handler: async () => { + throw new Error("The greeting card could not be shown."); + }, + }); + const agent = await agentOf(core, "default"); + await say( + core, + agent, + "Call showGreeting with name Ada and mood cheerful, then say done in one word.", + ); + await say(core, agent, "Say ok in one word. Call no tools."); + }, + "two-runs": async ({ core }) => { + const agent = await agentOf(core, "default"); + await say(core, agent, "Name one planet. One word only. Call no tools."); + await say(core, agent, "Name another one. One word only. Call no tools."); + }, + state: async ({ core }) => { + const agent = await agentOf(core, "default"); + agent.setState({ todos: [] }); + await say( + core, + agent, + 'First call AGUISendStateSnapshot with snapshot {"todos":["buy milk"]}. Then call AGUISendStateDelta to add "walk dog" at /todos/-. Then call AGUISendStateDelta again to add "post letter" at /todos/-. Then say done.', + ); + }, + reasoning: async ({ core }) => { + const agent = await agentOf(core, "reasoner"); + await say( + core, + agent, + "Think it through step by step first: a bat and a ball cost 1.10 dollars in total, and the bat costs 1.00 dollar more than the ball. How much is the ball? Call no tools, answer with the amount only.", + ); + }, + "run-error": async ({ core }) => { + const agent = await agentOf(core, "broken"); + await say(core, agent, "Hello?"); + }, + interrupt: async ({ core }) => { + const agent = await agentOf(core, "shopper"); + await say( + core, + agent, + "Buy one coffee for 4 dollars. Ask for approval with approvePurchase first.", + ); + const resume = agent.pendingInterrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: "resolved" as const, + payload: { approved: true }, + })); + if (resume.length === 0) + throw new Error("interrupt: the model raised no interrupt"); + await core.runAgent({ agent, resume }); + }, + // CopilotKit's suggestions: after the person's run its suggestion engine runs the agent again on a Thread of its own, + // forcing its suggestion tool. CopilotKit's capture records none of it. + suggestions: async ({ core }) => { + core.addSuggestionsConfig({ + instructions: "Suggest what the person could ask next.", + minSuggestions: 1, + maxSuggestions: 2, + available: "always", + }); + const agent = await agentOf(core, "default"); + await say(core, agent, "Name one river. One word only. Call no tools."); + for ( + let waited = 0; + waited < 60 && core.getSuggestions("default").isLoading !== false; + waited++ + ) + await Bun.sleep(250); + await Bun.sleep(500); + }, + // Secrets the person gives the agent: a password (the test digests it, as if typed into a password field earlier) and a + // card number the agent passes to a tool. + secrets: async ({ core }) => { + const agent = await agentOf(core, "default"); + await say( + core, + agent, + "This is a test system with test data. Pay invoice INV-7 with card 4111 1111 1111 1111 by calling payInvoice once. My account password is Tr0ub4dor-horse-77, do not repeat it. Then say paid.", + ); + }, + // A message far over Intelligence's 16 KiB event limit: the person pastes a long order log and asks for one line. + "long-message": async ({ core }) => { + const agent = await agentOf(core, "default"); + const log = Array.from( + { length: 600 }, + (_, index) => + `Order ${index + 1}: ${(index % 7) + 1} widgets shipped to warehouse ${(index % 5) + 1}.`, + ).join("\n"); + await say( + core, + agent, + `Here is our order log:\n${log}\nIn one short sentence, how many orders are there? Call no tools.`, + ); + }, + // The agent context CopilotKit adds to a click: CopilotKit's own click capture (its legacy collector, clicks on) records + // real clicks on a chat built from the agent's own messages, with one chat open and then with two. `clicks` says where + // each click landed, in the order the sink holds their `click` events. + clicks: async ({ core }) => { + const first = await agentOf(core, "default"); + core.registerOpenThread({ agentId: "default", threadId: first.threadId }); + await say( + core, + first, + "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.", + ); + const chat = (agent: AbstractAgent) => + agent.messages + .map((message) => { + const calls = + message.role === "assistant" + ? (message.toolCalls ?? []) + .map( + (call) => + `
${call.function.name}
`, + ) + .join("") + : ""; + const text = + typeof message.content === "string" ? message.content : ""; + return `

${text.replaceAll("<", "<")}

${calls}
`; + }) + .join(""); + const page = (agents: AbstractAgent[]) => + `
${agents.map(chat).join("")}
${chat(agents[0] as AbstractAgent)}
`; + const selectors = [ + "section [data-message-id] p", + "section [data-tool-call-id] button", + "section [data-tool-call-id] span", + '[role="log"] [data-message-id] p', + '[data-message-id="not-a-message"] button', + "#elsewhere", + ]; + // happy-dom's events are not trusted, and CopilotKit's click capture ignores untrusted clicks: these are made trusted, + // as a person's are. + const press = (element: Element, type: string) => { + const event = new MouseEvent(type, { + bubbles: true, + composed: true, + detail: 1, + }); + Object.defineProperty(event, "isTrusted", { value: true }); + element.dispatchEvent(event); + }; + const clickAll = (html: string) => { + document.body.innerHTML = html; + for (const selector of selectors) { + const element = document.querySelector(selector); + if (!element) throw new Error(`clicks: nothing at ${selector}`); + press(element, "pointerdown"); + press(element, "click"); + } + return { at: clock(), html, selectors }; + }; + const oneOpen = clickAll(page([first])); + // A second chat on another registered agent. (A per-thread clone of the first would not do: CopilotKit tracks a clone + // only once it runs, so it never emits the clone's first message, which the network cannot tell from any other.) + const second = await agentOf(core, "shopper"); + core.registerOpenThread({ agentId: "shopper", threadId: second.threadId }); + await say(core, second, "Name one planet. One word only. Call no tools."); + await settle(); + return { clicks: [oneOpen, clickAll(page([first, second]))] }; + }, + // A page that reloads during a recording: a second page (its own CopilotKitCore) restores the Thread of the first, + // and the runtime replays the Thread's history to it. `replaySink` is what the second page's CopilotKit emits. + reload: async ({ core }) => { + const agent = await agentOf(core, "default"); + await say( + core, + agent, + "Name three oceans, separated by commas. Call no tools.", + ); + await settle(); + const replayFromAt = clock(); + const { core: second, sink: replaySink } = await startCore(); + const restored = await agentOf(second, "default"); + restored.threadId = agent.threadId; + await second.connectAgent({ agent: restored }); + await settle(); + second.stopTrajectory(); + return { replaySink, replayFromAt }; + }, +}; + +try { + for (const [name, conversation] of Object.entries(conversations)) { + if (only && only !== name) continue; + await record(name, conversation); + } +} finally { + runtimeProcess.kill(); +} +process.exit(0); diff --git a/extension/scripts/agent-traffic/runtime.ts b/extension/scripts/agent-traffic/runtime.ts new file mode 100644 index 000000000..31bbd7a30 --- /dev/null +++ b/extension/scripts/agent-traffic/runtime.ts @@ -0,0 +1,120 @@ +// A real CopilotKit runtime serving real OpenAI agents, for record.ts. It runs as its own process, from the CopilotKit +// checkout named by COPILOTKIT_CHECKOUT (built), so the recording client's browser globals never reach it. With +// INTELLIGENCE_API_URL, INTELLIGENCE_GATEWAY_WS_URL and INTELLIGENCE_API_KEY set it runs through Intelligence, and the +// browser side receives AG-UI over the Intelligence WebSocket instead of SSE. Prints `ready ` once it listens on +// 127.0.0.1. +const checkout = process.env.COPILOTKIT_CHECKOUT; +if (!checkout) throw new Error("COPILOTKIT_CHECKOUT is not set"); +const apiKey = process.env.OPENAI_API_KEY; +if (!apiKey) throw new Error("OPENAI_API_KEY is not set"); + +const { + BuiltInAgent, + CopilotKitIntelligence, + CopilotRuntime, + InMemoryAgentRunner, + createCopilotEndpointSingleRoute, + defineTool, +} = await import(`${checkout}/packages/runtime/dist/v2/index.mjs`); +const { z } = await import( + `${checkout}/packages/runtime/node_modules/zod/index.js` +); + +const lookupWeather = defineTool({ + name: "lookupWeather", + description: "Look up today's weather for a city.", + parameters: z.object({ city: z.string().describe("The city") }), + execute: async ({ city }: { city: string }) => ({ + city, + forecast: "sunny", + celsius: 21, + }), +}); +const payInvoice = defineTool({ + name: "payInvoice", + description: "Pay an invoice with a payment card.", + parameters: z.object({ + invoice: z.string(), + cardNumber: z.string().describe("The card number exactly as given"), + }), + execute: async ({ invoice }: { invoice: string }) => ({ + invoice, + paid: true, + }), +}); +const approvePurchase = defineTool({ + name: "approvePurchase", + description: + "Ask the person to approve a purchase before it is made. Always call this before buying anything.", + parameters: z.object({ + item: z.string(), + amount: z.number().describe("Price in dollars"), + }), + interrupt: true, + interruptMessage: "Approve this purchase?", +}); + +const instructions = + "You are a terse assistant inside a test harness. Follow the person's instructions about which tools to call exactly."; +const agents = { + default: new BuiltInAgent({ + model: "openai/gpt-4.1-mini", + apiKey, + maxSteps: 6, + prompt: instructions, + tools: [lookupWeather, payInvoice], + }), + shopper: new BuiltInAgent({ + model: "openai/gpt-4.1-mini", + apiKey, + prompt: instructions, + tools: [approvePurchase], + }), + reasoner: new BuiltInAgent({ + model: "openai/gpt-5-mini", + apiKey, + maxSteps: 4, + prompt: instructions, + tools: [lookupWeather], + providerOptions: { + openai: { reasoningEffort: "medium", reasoningSummary: "detailed" }, + }, + }), + // A real error from the model provider: OpenAI refuses a model that does not exist. + broken: new BuiltInAgent({ + model: "openai/gpt-recorder-no-such-model", + apiKey, + prompt: instructions, + }), +}; + +const intelligence = process.env.INTELLIGENCE_API_URL + ? new CopilotKitIntelligence({ + apiUrl: process.env.INTELLIGENCE_API_URL, + wsUrl: process.env.INTELLIGENCE_GATEWAY_WS_URL, + apiKey: process.env.INTELLIGENCE_API_KEY, + }) + : undefined; +const runtime = intelligence + ? new CopilotRuntime({ + agents, + intelligence, + identifyUser: () => ({ + id: "recorder-agent-traffic", + name: "Recorder agent traffic", + }), + }) + : new CopilotRuntime({ agents, runner: new InMemoryAgentRunner() }); +const app = createCopilotEndpointSingleRoute({ + runtime, + basePath: "/api/copilotkit", +}); +const server = Bun.serve({ + hostname: "127.0.0.1", + port: Number(process.env.PORT ?? 0), + fetch: app.fetch, + idleTimeout: 120, +}); +console.log(`ready ${server.port}`); + +export {}; diff --git a/extension/scripts/build.ts b/extension/scripts/build.ts new file mode 100644 index 000000000..4e42580ca --- /dev/null +++ b/extension/scripts/build.ts @@ -0,0 +1,155 @@ +import { + cpSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { build, type InlineConfig } from "vite"; +import { type BuildVariant, buildManifest } from "../src/manifest"; +import { verifyDist } from "./verify-dist"; + +const PAGES = ["popup", "connect", "setup", "options"] as const; + +export function parseBuildVariant( + value: string | undefined, +): BuildVariant | null { + if (value === undefined || value === "" || value === "release") + return "release"; + if (value === "e2e") return "e2e"; + return null; +} + +export function viteConfigs(input: { + variant: BuildVariant; + root: string; +}): InlineConfig[] { + const { variant, root } = input; + const source = path.join(root, "extension", "src"); + const outDir = path.join(root, "extension", "dist"); + const shared = { + configFile: false, + root: source, + publicDir: false, + define: { + "import.meta.env.RECORDER_VARIANT": JSON.stringify(variant), + }, + resolve: { alias: { "@": path.join(root, "app", "src") } }, + } satisfies InlineConfig; + const script = (entry: string, name: string, file: string): InlineConfig => ({ + ...shared, + define: { + ...shared.define, + "process.env.NODE_ENV": JSON.stringify("production"), + }, + build: { + outDir, + emptyOutDir: false, + sourcemap: false, + lib: { + entry: path.join(source, entry), + formats: ["iife"], + name, + fileName: () => file, + }, + rollupOptions: { output: { inlineDynamicImports: true } }, + }, + }); + return [ + { + ...shared, + base: "./", + plugins: [react(), tailwindcss()], + build: { + outDir, + emptyOutDir: true, + sourcemap: false, + rollupOptions: { + input: { + ...Object.fromEntries( + PAGES.map((name) => [ + name, + path.join(source, `pages/${name}/index.html`), + ]), + ), + offscreen: path.join(source, "offscreen/offscreen.html"), + "service-worker": path.join(source, "background/index.ts"), + }, + output: { + entryFileNames: (chunk) => + chunk.name === "service-worker" + ? "service-worker.js" + : "assets/[name]-[hash].js", + }, + }, + }, + }, + script("content/index.ts", "OpenBotRecorderContent", "content.js"), + script( + "main-world/network-hook.ts", + "OpenBotRecorderPageNetwork", + "page-network.js", + ), + ]; +} + +// Vite writes each page at its source path; the manifest names them at the root of dist. +function moveHtml(dist: string, from: string, to: string) { + const source = path.join(dist, from); + const html = readFileSync(source, "utf8").replace( + /(?:\.\.\/)+assets\//g, + "./assets/", + ); + writeFileSync(path.join(dist, to), html); + rmSync(source); +} + +async function main() { + const root = path.resolve(import.meta.dir, "..", ".."); + const extension = path.join(root, "extension"); + const dist = path.join(extension, "dist"); + const requested = process.env.RECORDER_BUILD_VARIANT; + const variant = parseBuildVariant(requested); + if (variant === null) { + console.error( + `RECORDER_BUILD_VARIANT must be "release" or "e2e", got "${requested}"`, + ); + process.exit(1); + } + + for (const config of viteConfigs({ variant, root })) await build(config); + + for (const name of PAGES) + moveHtml(dist, `pages/${name}/index.html`, `${name}.html`); + moveHtml(dist, "offscreen/offscreen.html", "offscreen.html"); + rmSync(path.join(dist, "pages"), { recursive: true, force: true }); + rmSync(path.join(dist, "offscreen"), { recursive: true, force: true }); + + mkdirSync(dist, { recursive: true }); + cpSync(path.join(extension, "public"), dist, { recursive: true }); + + const rootVersion = ( + JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { + version: string; + } + ).version; + const publicKey = readFileSync( + path.join(extension, "keys", "public-key.txt"), + "utf8", + ).trim(); + writeFileSync( + path.join(dist, "manifest.json"), + `${JSON.stringify(buildManifest({ version: rootVersion, publicKey }), null, 2)}\n`, + ); + + const problems = verifyDist(dist, { variant, rootVersion }); + if (problems.length > 0) { + for (const problem of problems) console.error(problem); + process.exit(1); + } +} + +if (import.meta.main) await main(); diff --git a/extension/scripts/verify-dist.ts b/extension/scripts/verify-dist.ts new file mode 100644 index 000000000..208298790 --- /dev/null +++ b/extension/scripts/verify-dist.ts @@ -0,0 +1,116 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import type { BuildVariant } from "../src/manifest"; + +type VerifyOptions = { variant: BuildVariant; rootVersion: string }; + +const MODULE_SYNTAX = /^\s*(import|export)\s/m; +const REMOTE_URL = /^(https?:|\/\/)/i; +const TAG = /<(script|link)\b[^>]*>/gi; + +function listFiles(dir: string): string[] { + const found: string[] = []; + const walk = (current: string) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) walk(path); + else found.push(relative(dir, path).split("\\").join("/")); + } + }; + walk(dir); + return found; +} + +function attribute(tag: string, name: string): string | undefined { + const match = new RegExp( + `(?\`]+))`, + "i", + ).exec(tag); + return match ? (match[1] ?? match[2] ?? match[3]) : undefined; +} + +function loadsRemoteCode(html: string): boolean { + for (const [tag, name] of html.matchAll(TAG)) { + const url = attribute( + tag, + name.toLowerCase() === "script" ? "src" : "href", + ); + if (url !== undefined && REMOTE_URL.test(url.trim())) return true; + } + return false; +} + +function namedFiles(manifest: Record): string[] { + const names: string[] = []; + const add = (value: unknown) => { + if (typeof value === "string") names.push(value); + }; + const addValues = (value: unknown) => { + if (value && typeof value === "object") Object.values(value).forEach(add); + }; + const record = (value: unknown): Record => + value && typeof value === "object" + ? (value as Record) + : {}; + + add(record(manifest.background).service_worker); + add(record(manifest.action).default_popup); + addValues(record(manifest.action).default_icon); + add(manifest.options_page); + add(record(manifest.options_ui).page); + addValues(manifest.icons); + add(record(manifest.storage).managed_schema); + return names; +} + +/** Returns the problems with a built extension directory; empty means good. */ +export function verifyDist(dir: string, options: VerifyOptions): string[] { + let manifest: Record; + try { + manifest = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8")); + } catch { + return ["manifest.json is missing or is not valid JSON"]; + } + + const problems: string[] = []; + const required = new Set([ + ...namedFiles(manifest), + "content.js", + "page-network.js", + "offscreen.html", + ]); + for (const name of required) { + if (!existsSync(join(dir, name))) problems.push(`missing ${name}`); + } + + for (const name of ["content.js", "page-network.js"]) { + const path = join(dir, name); + if (existsSync(path) && MODULE_SYNTAX.test(readFileSync(path, "utf8"))) { + problems.push(`${name} must be a classic script`); + } + } + + const all = listFiles(dir); + for (const name of all.filter((file) => file.endsWith(".html"))) { + if (loadsRemoteCode(readFileSync(join(dir, name), "utf8"))) { + problems.push(`${name} loads remote code`); + } + } + + if (manifest.version !== options.rootVersion) { + problems.push( + `manifest version ${String(manifest.version)} is not the release version ${options.rootVersion}`, + ); + } + + if (options.variant === "release") { + for (const name of all) { + if (name.endsWith(".map")) { + problems.push(`source map ${name} in a release build`); + } else if (readFileSync(join(dir, name)).includes("RECORDER_E2E")) { + problems.push(`${name} contains an e2e-only hook (RECORDER_E2E)`); + } + } + } + return problems; +} diff --git a/extension/src/background/agent/agent-event.ts b/extension/src/background/agent/agent-event.ts new file mode 100644 index 000000000..4a49f9003 --- /dev/null +++ b/extension/src/background/agent/agent-event.ts @@ -0,0 +1,374 @@ +// `agent.event`: the part of each AG-UI event that CopilotKit's own agent events (`agent.run`, `agent.message`, +// `tool.call`) do not carry, so every piece of the agent's side lands in the Trajectory exactly once and never one event +// per streamed token. A text or reasoning message is CopilotKit's `agent.message` alone; a tool call's arguments are one +// `TOOL_CALL_ARGS` holding the whole string the library assembled, sent at TOOL_CALL_END; a tool result is the tool's +// `agent.message` (its text) beside a `TOOL_CALL_RESULT` that names the call it answers; consecutive STATE_DELTAs (and +// ACTIVITY_DELTAs of one activity) with nothing between them are one, their JSON Patch operations concatenated, which +// applies exactly as the parts did. History in a RUN_STARTED's `input` or a MESSAGES_SNAPSHOT keeps only what no event +// carried before: a message already carried is its id and role. A frontend tool's result, which the page puts into the +// history itself and CopilotKit announces nowhere, is carried there, in the next run's `input`, once. A RUN_STARTED's +// `tools` and `context` (the page's whole tool list and context, the same in every run of most pages) are carried when +// they differ from what the Thread's runs carried before. +import type { BaseEvent, Message } from "@ag-ui/client"; + +/** An AG-UI event as `agent.event` carries it. */ +export type AgentEventPayload = { type: string; [field: string]: unknown }; + +/** What a Thread's events have already carried, so nothing is carried twice; shared by every tab's agent for the Thread. + * A message maps to the text its `agent.message` carried (null when it carried none); a tool call to its name and the + * arguments its `TOOL_CALL_ARGS` carried; `setup` to the JSON of the `tools` and of the `context` a RUN_STARTED of the + * Thread carried last. */ +export type Expressed = { + messages: Map; + toolCalls: Map; + setup: Map<"tools" | "context", string>; +}; + +export const createExpressed = (): Expressed => ({ + messages: new Map(), + toolCalls: new Map(), + setup: new Map(), +}); + +/** + * Per AG-UI event type: the fields CopilotKit's own events carry, the ids kept beside what remains so it can be joined to + * them, and whether the event goes out always, only when something remains, or never (a streamed delta, carried + * assembled). An unlisted type goes out whole. + */ +const POLICY: Record< + string, + { carried: string[]; keys: string[]; when: "always" | "rest" | "never" } +> = { + RUN_STARTED: { carried: ["threadId", "runId"], keys: [], when: "rest" }, + RUN_FINISHED: { carried: ["threadId", "runId"], keys: [], when: "rest" }, + TEXT_MESSAGE_START: { carried: ["role"], keys: ["messageId"], when: "rest" }, + TEXT_MESSAGE_CONTENT: { carried: [], keys: [], when: "never" }, + TEXT_MESSAGE_END: { carried: [], keys: ["messageId"], when: "rest" }, + TOOL_CALL_START: { + carried: ["toolCallName", "parentMessageId"], + keys: ["toolCallId"], + when: "rest", + }, + TOOL_CALL_ARGS: { carried: [], keys: [], when: "never" }, + TOOL_CALL_END: { carried: [], keys: ["toolCallId"], when: "rest" }, + TOOL_CALL_RESULT: { + carried: ["role"], + keys: ["messageId", "toolCallId"], + when: "always", + }, + REASONING_START: { carried: [], keys: ["messageId"], when: "always" }, + REASONING_MESSAGE_START: { + carried: ["role"], + keys: ["messageId"], + when: "rest", + }, + REASONING_MESSAGE_CONTENT: { carried: [], keys: [], when: "never" }, + REASONING_MESSAGE_END: { carried: [], keys: ["messageId"], when: "rest" }, + REASONING_END: { carried: [], keys: ["messageId"], when: "always" }, +}; + +/** Intelligence's own bookkeeping (its event id, sequence and ingest id): not the agent's. It rides in the `metadata` of + * every event Intelligence relays, and in the `metadata` of each message the page got from it, so a run's history + * (RUN_STARTED's `input.messages`, a MESSAGES_SNAPSHOT) carries it too. */ +const INTELLIGENCE_METADATA = /^cpki_/; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** A `metadata` without Intelligence's bookkeeping; undefined when nothing of it remains. */ +const ownMetadata = ( + metadata: Record, +): Record | undefined => { + const kept = Object.fromEntries( + Object.entries(metadata).filter( + ([key]) => !INTELLIGENCE_METADATA.test(key), + ), + ); + return Object.keys(kept).length > 0 ? kept : undefined; +}; + +/** A value with Intelligence's bookkeeping taken out of every `metadata` in it, at any depth: a copy wherever something + * was taken out, the value itself where nothing was. */ +const withoutIntelligence = (value: unknown): unknown => { + if (Array.isArray(value)) { + const items = value.map(withoutIntelligence); + return items.some((item, index) => item !== value[index]) ? items : value; + } + if (!isRecord(value)) return value; + let out: Record | null = null; + for (const [key, item] of Object.entries(value)) { + const kept = + key === "metadata" && isRecord(item) + ? ownMetadata(withoutIntelligence(item) as Record) + : withoutIntelligence(item); + if (kept === item) continue; + out ??= { ...value }; + if (kept === undefined) delete out[key]; + else out[key] = kept; + } + return out ?? value; +}; + +/** A field of an event that is not the agent's: its own clock (the Trajectory event has one) and Intelligence's + * bookkeeping wherever it sits. Undefined when nothing of it remains. */ +const withoutTransport = ( + field: string, + value: unknown, +): unknown | undefined => { + if (field === "timestamp") return undefined; + if (field === "metadata" && isRecord(value)) + return ownMetadata(withoutIntelligence(value) as Record); + return withoutIntelligence(value); +}; + +type Pending = { + kind: "state" | "activity"; + payload: AgentEventPayload & { delta?: unknown[]; patch?: unknown[] }; + at: number; +}; + +export function createAgentEventBuilder(expressed: Expressed) { + const results = new Map(); + let pending: Pending | null = null; + + const textOf = (message: Message): string | null => + typeof message.content === "string" ? message.content : null; + + /** A message as history carries it: what an earlier event carried taken out, its id and role kept. Intelligence's + * bookkeeping in its `metadata` is not the message's, so a message an agent.message already carried is not carried + * again for it. */ + const reduceMessage = ( + message: Message, + ): { message: Record; carriedBefore: boolean } => { + const out = { + ...(withoutIntelligence(message) as Record), + }; + const known = expressed.messages.get(message.id); + const text = textOf(message); + if ( + out.content === undefined || + (text !== null && known !== undefined && known === text) + ) + delete out.content; + if (message.role === "assistant" && Array.isArray(message.toolCalls)) + out.toolCalls = message.toolCalls.map((call) => { + const carried = expressed.toolCalls.get(call.id); + return carried && + carried.name === call.function.name && + carried.arguments === call.function.arguments + ? { id: call.id } + : call; + }); + const remaining = Object.keys(out).filter( + (key) => + key !== "id" && + key !== "role" && + !(key === "toolCallId" && known !== undefined) && + !( + key === "toolCalls" && + (out.toolCalls as Record[]).every( + (call) => Object.keys(call).length === 1, + ) + ), + ); + return { message: out, carriedBefore: remaining.length === 0 }; + }; + + /** Everything a message carried in history is carried from now on. */ + const markCarried = (message: Message) => { + if (!expressed.messages.has(message.id)) + expressed.messages.set(message.id, textOf(message)); + if (message.role === "assistant") + for (const call of message.toolCalls ?? []) + expressed.toolCalls.set(call.id, { + name: call.function.name, + arguments: call.function.arguments, + }); + }; + + /** A RUN_STARTED's `input` without its ids (they are the run's), with its `tools` and `context` only when they changed + * from what the Thread's runs carried, and with only the history no event carried. */ + const reduceInput = (input: unknown): unknown => { + if (!isRecord(input)) return input; + const { threadId: _thread, runId: _run, messages, ...rest } = input; + for (const field of ["tools", "context"] as const) { + if (rest[field] === undefined) continue; + const json = JSON.stringify(rest[field]); + if (expressed.setup.get(field) === json) delete rest[field]; + else expressed.setup.set(field, json); + } + if (!Array.isArray(messages)) return rest; + const fresh = (messages as Message[]) + .map((message) => ({ original: message, ...reduceMessage(message) })) + .filter(({ carriedBefore }) => !carriedBefore); + for (const { original } of fresh) markCarried(original); + return fresh.length > 0 + ? { ...rest, messages: fresh.map(({ message }) => message) } + : rest; + }; + + /** The event less what CopilotKit's events carry; null when that leaves nothing the policy sends. */ + const rest = (event: BaseEvent): AgentEventPayload | null => { + const policy = POLICY[event.type] ?? { + carried: [], + keys: [], + when: "always", + }; + if (policy.when === "never") return null; + const source = event as unknown as Record; + const kept: Record = {}; + for (const key of policy.keys) + if (source[key] !== undefined) kept[key] = source[key]; + const remaining: Record = {}; + for (const [field, value] of Object.entries(source)) { + if ( + field === "type" || + policy.carried.includes(field) || + policy.keys.includes(field) + ) + continue; + let left = withoutTransport(field, value); + if (event.type === "RUN_STARTED" && field === "input") + left = reduceInput(left); + if (event.type === "TOOL_CALL_RESULT" && field === "content") { + // A string result is the tool's agent.message text. + if (typeof value === "string") continue; + } + if (event.type === "MESSAGES_SNAPSHOT" && field === "messages") { + const messages = value as Message[]; + left = messages.map((message) => reduceMessage(message).message); + for (const message of messages) markCarried(message); + } + // A copy: the library goes on to apply the event, and a patch's values become part of the state it then patches. + if (left !== undefined) remaining[field] = structuredClone(left); + } + if (policy.when === "rest" && Object.keys(remaining).length === 0) + return null; + return { type: event.type, ...kept, ...remaining }; + }; + + /** A delta merges into the one before it when it carries nothing but its operations. */ + const mergeable = (event: BaseEvent, operations: string): boolean => { + const payload = rest(event); + return ( + payload !== null && + Array.isArray(payload[operations]) && + Object.keys(payload).every((key) => + ["type", operations, "messageId", "activityType"].includes(key), + ) + ); + }; + + const continues = (held: Pending, event: BaseEvent): boolean => { + const source = event as unknown as Record; + if (held.kind === "state") + return event.type === "STATE_DELTA" && mergeable(event, "delta"); + return ( + event.type === "ACTIVITY_DELTA" && + source.messageId === held.payload.messageId && + source.activityType === held.payload.activityType && + mergeable(event, "patch") + ); + }; + + /** Holds a delta to merge with the next, or returns it to send now when it carries more than its operations. */ + const hold = ( + event: BaseEvent, + kind: Pending["kind"], + operations: "delta" | "patch", + at: number, + ): AgentEventPayload | null => { + const payload = rest(event); + if (payload === null) return null; + if (!mergeable(event, operations)) return payload; + if (pending !== null && pending.kind === kind) { + (pending.payload[operations] as unknown[]).push( + ...(payload[operations] as unknown[]), + ); + pending.at = at; + } else + pending = { + kind, + payload: { + ...payload, + [operations]: [...(payload[operations] as unknown[])], + }, + at, + }; + return null; + }; + + return { + rest, + /** Before an event is applied: a held delta it does not continue goes out first. */ + flushBefore( + event: BaseEvent, + ): { payload: AgentEventPayload; at: number }[] { + if (pending === null || continues(pending, event)) return []; + const out = pending; + pending = null; + return [out]; + }, + /** The run is over: whatever is held goes out. */ + flushAll(): { payload: AgentEventPayload; at: number }[] { + const out = pending; + pending = null; + return out === null ? [] : [out]; + }, + stateDelta: (event: BaseEvent, at: number) => + hold(event, "state", "delta", at), + activityDelta: (event: BaseEvent, at: number) => + hold(event, "activity", "patch", at), + toolCallStarted( + event: BaseEvent & { toolCallId: string; toolCallName: string }, + ) { + expressed.toolCalls.set(event.toolCallId, { + name: event.toolCallName, + arguments: null, + }); + }, + /** TOOL_CALL_END: the arguments the library assembled for the call, as one TOOL_CALL_ARGS, then what is left of END. */ + toolCallEnded( + event: BaseEvent & { toolCallId: string }, + messages: readonly Readonly[], + ): AgentEventPayload[] { + const out: AgentEventPayload[] = []; + for (const message of messages) { + if (message.role !== "assistant") continue; + const call = message.toolCalls?.find( + ({ id }) => id === event.toolCallId, + ); + if (!call) continue; + const carried = expressed.toolCalls.get(call.id); + expressed.toolCalls.set(call.id, { + name: carried?.name ?? call.function.name, + arguments: call.function.arguments, + }); + if (call.function.arguments !== "") + out.push({ + type: "TOOL_CALL_ARGS", + toolCallId: call.id, + delta: call.function.arguments, + }); + break; + } + const end = rest(event); + if (end !== null) out.push(end); + return out; + }, + /** TOOL_CALL_RESULT goes out after the tool's agent.message, which the library announces once it holds the result. */ + toolCallResult(event: BaseEvent & { messageId: string }) { + const payload = rest(event); + if (payload !== null) results.set(event.messageId, payload); + }, + afterMessage(message: Message): AgentEventPayload | null { + const payload = results.get(message.id) ?? null; + results.delete(message.id); + return payload; + }, + /** An agent.message carried this message's text. */ + messageExpressed(message: Message) { + expressed.messages.set(message.id, textOf(message)); + }, + }; +} diff --git a/extension/src/background/agent/events.ts b/extension/src/background/agent/events.ts new file mode 100644 index 000000000..a8419c6a8 --- /dev/null +++ b/extension/src/background/agent/events.ts @@ -0,0 +1,963 @@ +// The agent's side of a recording: the AG-UI traffic the network capture observes (observation.ts), turned into exactly +// the events the CopilotKit SDK emits for the same traffic, so Intelligence cannot tell a Recorder trajectory from a +// CopilotKit one: `thread.linked`, `agent.run`, `agent.message` and `tool.call` as CopilotKit's LearningBridge emits them +// through a legacy sink, the agent context CopilotKit adds to a click, and `agent.event` for every piece of AG-UI the +// others leave out (agent-event.ts). +// +// LearningBridge links a Thread when a chat opens it (`thread.linked`, reason "open") and when the same chat moves to +// another Thread (reason "switch"), once per change of Thread in the page. The network shows a chat's Thread when the +// page first talks to its agent about it, so the page's chat for an agent is on that agent's latest Thread in the page: +// its first Thread in a page view is linked "open", a later one "switch", just before its first event; `hasMessages` +// says whether the Thread held messages before the page's new ones. A Thread the chat moved away from is no longer open. +// +// Each Thread of each agent in a tab is an AG-UI agent of its own (thread-agent.ts): a run's request adds the page's new +// messages to it as CopilotKit adds them before it runs, and the run's events are fed to it as they arrive, whatever the +// transport (an SSE or protobuf response, or the Intelligence WebSocket). A stream the recording joined mid-run waits for +// its next RUN_STARTED, and a run that never ends is left open, never finished for it: AG-UI forbids inventing a +// RUN_FINISHED. When a Thread's history is replayed (a page that reloads, a channel that joins again), a run already +// recorded for the Thread, or an event already recorded (Intelligence's event id), is not recorded again; a replay of a +// run cut short carries on where the recording left it. Runs CopilotKit makes for its suggestions are left out, as +// CopilotKit's own capture leaves them out. Whatever is dropped is counted in `recorder.dropped`. +// +// What a click's context and the links depend on survives a service-worker restart (`snapshot`, kept with the held +// steps, and `restore`): the page's open Threads, its run requests, the Thread linked last and each agent's last run. +// The AG-UI streams and the Threads' conversations are taken up again from the page's next traffic. +import type { Message, RunAgentInput } from "@ag-ui/client"; +import type { BaseEvent } from "@ag-ui/core"; +import type { + CapturedEvent, + JsonValue, +} from "@openbot/shared/recorder-masking"; +import { ReplaySubject } from "rxjs"; +import { + AGENT_TARGET_FIELD, + type AgentTarget, +} from "../../shared/agent-target"; +import { createExpressed, type Expressed } from "./agent-event"; +import { finishAgentEvent } from "./mask"; +import type { + AgentRunRequest, + AgentStreamEnd, + AgentStreamEvent, + CaptureObservation, +} from "./observation"; +import { type FedEvent, type PageAgent, ThreadAgent } from "./thread-agent"; + +export type AgentEvents = { + /** One observation from the network capture, in the order the capture saw it. */ + observe(observation: CaptureObservation): void; + /** A click from a followed tab, before the steps see it: the agent context CopilotKit adds to a click. */ + enrichClick(tabId: number, click: CapturedEvent): CapturedEvent; + /** A click as it leaves the steps, by what the page did after it. `joins` says whether a run the page asked for at a + * time is the click's doing (after it, before the person's next step, within the steps' join): a click with the + * open-Thread context takes the Thread of the first such run, the run it sent; one whose Thread the page has since + * left, by no doing of the click, names none, since the page may have left it before the click. */ + settleClick( + tabId: number, + click: CapturedEvent, + joins: (at: number) => boolean, + ): CapturedEvent; + /** Whether a click held in the steps still waits for the run it may have sent: one with the open-Thread context in a + * tab whose page runs CopilotKit, whose join holds no run yet. */ + awaitsRun( + tabId: number, + click: CapturedEvent, + joins: (at: number) => boolean, + ): boolean; + /** The tab's top frame reached another page: its open Threads close. */ + tabNavigated(tabId: number): void; + /** What a click's context and the links depend on, as JSON, for a service worker that restarts to take up again. */ + snapshot(): AgentSnapshot; + /** Takes up what another service worker's agent events knew (their `snapshot`), before any traffic or click. */ + restore(snapshot: AgentSnapshot): void; + /** The recording ended: forget everything held. */ + stop(): void; +}; + +/** What the agent events know that a click's agent context and the Thread links depend on: per tab, that its page runs + * CopilotKit, the Thread each chat has open and the Threads it left, the Thread linked last, the latest run requests and + * each agent's last run; and the runs recorded per Thread, so a replay of one is never recorded again. Ids and times + * only, never a message. The AG-UI streams and the Threads' conversations are not kept: a service worker that restarts + * takes them up from the page's next traffic. */ +export type AgentSnapshot = { + tabs: (LinkState & { + tabId: number; + runs: { at: number; threadId: string }[]; + lastRunIds: [string, string][]; + })[]; + recordedRuns: [string, string[]][]; +}; + +/** How many of a tab's latest run requests (and Threads its chats left) are kept for the clicks still held in their + * steps. */ +const MAX_RUNS_KEPT = 20; + +/** CopilotKit's suggestion tool (`@copilotkit/core` suggestion-engine.ts): a run that forces it is a suggestion run. */ +const SUGGEST_TOOL = "copilotkitSuggest"; + +/** The routing fields Intelligence's runner stamps on every event it relays, removed as CopilotKit's IntelligenceAgent + * removes them (`stripIntelligenceRoutingFields`) and as AG-UI's enforcement removes the rest before anyone sees them. */ +const ROUTING_FIELDS = ["thread_id", "run_id", "organization_id", "project_id"]; +const RUN_IDENTITY_EVENTS = new Set(["RUN_STARTED", "RUN_FINISHED"]); + +/** What the page sent for a run, as far as the agent events read it. */ +type RunInput = Pick< + RunAgentInput, + "threadId" | "tools" | "context" | "forwardedProps" | "state" | "resume" +> & { runId: string | undefined; messages: Message[]; raw: unknown }; + +type PageRun = { input: RunInput; at: number; followUp: boolean }; + +type Run = { + thread: Thread; + runId: string | undefined; + events: ReplaySubject; +}; + +type Thread = { + agentId: string | null; + threadId: string; + /** The recording it belongs to: what it emits after that recording stopped goes nowhere. */ + born: number; + agent: ThreadAgent; + /** Each step of the Thread (a request's messages, a run) waits for the one before it to be applied. */ + chain: Promise; + /** The run being fed, or one cut short that a replay may carry on. */ + open: Run | null; + /** A run the page asked for whose RUN_STARTED has not arrived. */ + pending: PageRun | null; + /** The run id CopilotKit reports the last run under (RunHandler's logical run id), which a follow-up continues. */ + lastLogicalRunId: string | undefined; +}; + +type Stream = { + agentId: string | null; + threadId: string | null; + /** A request of the page opened it, so it starts at its beginning. */ + requested: boolean; + suggestion: boolean; + events: number; + thread: Thread | null; + run: Run | null; + /** Inside a replayed run that is already recorded. */ + skipping: boolean; + /** The RUN_STARTED of a stream no request opened, held until the next event shows whether it is a suggestion run. */ + held: { fed: FedEvent; id: string | null } | null; +}; + +type Tab = { + /** The tab's page talks to a CopilotKit runtime: its clicks carry the open-Thread context. */ + traffic: boolean; + /** The Thread each agent's chat in the page has open (CopilotKit's open-Thread registrations), in the order the chats + * opened; a new document starts with none. */ + views: Map; + /** Threads a chat moved away from, and when the page showed the Thread it moved to. */ + left: { threadId: string; at: number }[]; + /** The Thread the page linked last (LearningBridge.lastLinkedThreadId); a new document starts with none. */ + lastLinked: string | null; + /** The page's latest run requests, when each was seen and for which Thread: a click's step window may hold one. */ + runs: { at: number; threadId: string }[]; + /** Links decided whose `thread.linked` the Thread's steps have not emitted yet, oldest first, each with what the tab's + * Threads were before it: `snapshot` gives the Threads as they were before the oldest, so a service worker that + * restarts before a link is emitted makes it again, and never takes up a link that went nowhere. */ + pendingLinks: { before: LinkState; done: boolean }[]; + /** CopilotKit's agent in the page, per agent id. */ + agents: Map; + streams: Map; + threads: Map; + drops: Map; +}; + +/** A tab's open Threads, the Threads its chats left and the Thread linked last, as a snapshot keeps them. */ +type LinkState = { + views: [string, string][]; + left: { threadId: string; at: number }[]; + lastLinked: string | null; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isMessage = (value: unknown): value is Message => + isRecord(value) && + typeof value.id === "string" && + typeof value.role === "string"; + +function readInput(raw: unknown): RunInput | null { + if (!isRecord(raw) || typeof raw.threadId !== "string" || raw.threadId === "") + return null; + return { + threadId: raw.threadId, + runId: typeof raw.runId === "string" ? raw.runId : undefined, + messages: Array.isArray(raw.messages) ? raw.messages.filter(isMessage) : [], + tools: Array.isArray(raw.tools) ? (raw.tools as RunInput["tools"]) : [], + context: Array.isArray(raw.context) + ? (raw.context as RunInput["context"]) + : [], + forwardedProps: raw.forwardedProps, + state: raw.state, + ...(Array.isArray(raw.resume) + ? { resume: raw.resume as RunInput["resume"] } + : {}), + raw, + }; +} + +/** A run CopilotKit's suggestion engine makes: it forces the suggestion tool, and offers no other. */ +function isSuggestion(input: unknown): boolean { + if (!isRecord(input)) return false; + const choice = isRecord(input.forwardedProps) + ? input.forwardedProps.toolChoice + : undefined; + const forced = + isRecord(choice) && isRecord(choice.function) + ? choice.function.name + : undefined; + const tools = Array.isArray(input.tools) ? input.tools : []; + return ( + forced === SUGGEST_TOOL || + (tools.length > 0 && + tools.every((tool) => isRecord(tool) && tool.name === SUGGEST_TOOL)) + ); +} + +/** The event the library applies: a copy, since applying a state patch can change the values it inserts, and the + * capture's own observation is never changed. */ +function normalise(raw: unknown): BaseEvent | null { + if (!isRecord(raw) || typeof raw.type !== "string") return null; + const event: Record = structuredClone(raw); + for (const field of ROUTING_FIELDS) delete event[field]; + if (!RUN_IDENTITY_EVENTS.has(event.type as string)) { + delete event.threadId; + delete event.runId; + } + return event as unknown as BaseEvent; +} + +/** Intelligence's durable id of an event it relays (IntelligenceAgent.readEventId); an id it has not stored yet + * (`cpki_ingested...`) is none. */ +function eventIdOf(event: BaseEvent): string | null { + const metadata = (event as { metadata?: unknown }).metadata; + const id = isRecord(metadata) ? metadata.cpki_event_id : undefined; + return typeof id === "string" && + id.trim() !== "" && + !id.startsWith("cpki_ingested") + ? id + : null; +} + +const isTerminal = (event: BaseEvent) => + event.type === "RUN_FINISHED" || event.type === "RUN_ERROR"; + +function readTarget(raw: unknown): AgentTarget | null { + if (!isRecord(raw)) return null; + const text = (value: unknown) => (typeof value === "string" ? value : null); + return { messageId: text(raw.messageId), toolCallId: text(raw.toolCallId) }; +} + +export function createAgentEvents(options: { + /** A masked event ready for the trajectory, in its tab's order. */ + emit(tabId: number, event: CapturedEvent): void; + /** The recording's sensitive-value digests. */ + digests(): ReadonlySet; + now(): number; + /** What `snapshot` reads changed: called as each change is made. */ + onChange?(): void; +}): AgentEvents { + const tabs = new Map(); + /** Per Thread, across tabs: the runs recorded, and what has been carried. */ + const recordedRuns = new Map>(); + const expressedByThread = new Map(); + /** Intelligence event ids already recorded. */ + const recordedEvents = new Set(); + /** Bumped when the recording stops: what the library still applies afterwards belongs to no recording and is not + * emitted. */ + let recording = 0; + const changed = () => options.onChange?.(); + + /** CopilotKit's agent in a page: its last run id, which says when it changes. */ + const pageAgent = (lastRunId: string | undefined): PageAgent => { + let current = lastRunId; + return { + get lastRunId() { + return current; + }, + set lastRunId(next) { + if (next === current) return; + current = next; + changed(); + }, + }; + }; + + const tabOf = (tabId: number): Tab => { + let tab = tabs.get(tabId); + if (!tab) { + tab = { + traffic: false, + views: new Map(), + left: [], + lastLinked: null, + runs: [], + pendingLinks: [], + agents: new Map(), + streams: new Map(), + threads: new Map(), + drops: new Map(), + }; + tabs.set(tabId, tab); + } + return tab; + }; + + const drop = (tab: Tab, reason: string, count = 1) => + tab.drops.set(reason, (tab.drops.get(reason) ?? 0) + count); + + const reportDrops = (tabId: number, tab: Tab) => { + for (const [reason, count] of tab.drops) + options.emit(tabId, { + type: "CUSTOM", + name: "recorder.dropped", + timestamp: options.now(), + value: { count, reason }, + }); + tab.drops.clear(); + }; + + const runsOf = (threadId: string): Set => { + let runs = recordedRuns.get(threadId); + if (!runs) { + runs = new Set(); + recordedRuns.set(threadId, runs); + } + return runs; + }; + + const expressedOf = (threadId: string): Expressed => { + let expressed = expressedByThread.get(threadId); + if (!expressed) { + expressed = createExpressed(); + expressedByThread.set(threadId, expressed); + } + return expressed; + }; + + const keyOf = (agentId: string | null) => JSON.stringify(agentId); + + const linkStateOf = (tab: Tab): LinkState => ({ + views: [...tab.views], + left: tab.left.map((entry) => ({ ...entry })), + lastLinked: tab.lastLinked, + }); + + /** The Threads open in the tab, each once, in the order their chats opened (LearningBridge.distinctOpenThreadIds). */ + const openIds = (tab: Tab): string[] => [...new Set(tab.views.values())]; + + const pageAgentOf = (tab: Tab, agentId: string | null): PageAgent => { + const key = keyOf(agentId); + let agent = tab.agents.get(key); + if (!agent) { + agent = pageAgent(undefined); + tab.agents.set(key, agent); + } + return agent; + }; + + /** The page shows the Thread in its agent's chat: CopilotKit's registerOpenThread for the chat's first Thread in the + * page, its registration's update when the chat moves to another. Says how LearningBridge links it: "open" for a + * chat's first Thread, "switch" for a later one, null when that changes nothing or the Thread is open in another + * chat. */ + const showThread = ( + tab: Tab, + thread: Thread, + at: number, + ): "open" | "switch" | null => { + const key = keyOf(thread.agentId); + const previous = tab.views.get(key); + if (previous === thread.threadId) return null; + const elsewhere = openIds(tab).includes(thread.threadId); + tab.views.set(key, thread.threadId); + if (previous !== undefined && !openIds(tab).includes(previous)) { + tab.left.push({ threadId: previous, at }); + if (tab.left.length > MAX_RUNS_KEPT) tab.left.shift(); + } + changed(); + if (elsewhere) return null; + return previous === undefined ? "open" : "switch"; + }; + + /** The Thread an agent id and Thread id name in a tab; a stream that names no agent takes the tab's Thread of that id. */ + const threadOf = ( + tabId: number, + tab: Tab, + agentId: string | null, + threadId: string, + ): Thread => { + const key = JSON.stringify([agentId, threadId]); + const existing = + tab.threads.get(key) ?? + (agentId === null + ? [...tab.threads.values()].find( + (thread) => thread.threadId === threadId, + ) + : undefined); + if (existing) return existing; + const born = recording; + const thread: Thread = { + agentId, + threadId, + born, + agent: new ThreadAgent( + agentId, + threadId, + { + emit: (name, value, at) => { + if (born !== recording) return; + options.emit( + tabId, + finishAgentEvent(name, value, at, options.digests()), + ); + }, + invalid: () => drop(tab, "agent-refused"), + }, + expressedOf(threadId), + pageAgentOf(tab, agentId), + ), + chain: Promise.resolve(), + open: null, + pending: null, + lastLogicalRunId: undefined, + }; + tab.threads.set(key, thread); + return thread; + }; + + const streamOf = ( + tab: Tab, + streamId: string, + agentId: string | null, + ): Stream => { + let stream = tab.streams.get(streamId); + if (!stream) { + stream = { + agentId, + threadId: null, + requested: false, + suggestion: false, + events: 0, + thread: null, + run: null, + skipping: false, + held: null, + }; + tab.streams.set(streamId, stream); + } + stream.agentId ??= agentId; + return stream; + }; + + /** LearningBridge.emitThreadLinked for the Thread the page now talks to its agent about: nothing when its chat already + * shows it, or it is the Thread linked last in the page. Decided in the order the page's traffic came; the function + * it returns sends the link from inside the Thread's step, so it leaves before the Thread's events that follow. */ + const linkThread = ( + tabId: number, + tab: Tab, + thread: Thread, + at: number, + ): ((hasMessages: boolean, at: number) => void) => { + const before = linkStateOf(tab); + const reason = showThread(tab, thread, at); + if (reason === null || tab.lastLinked === thread.threadId) return () => {}; + tab.lastLinked = thread.threadId; + const pending = { before, done: false }; + tab.pendingLinks.push(pending); + changed(); + return (hasMessages, at) => { + // The link is decided for good as it goes to the steps. + pending.done = true; + while (tab.pendingLinks[0]?.done) tab.pendingLinks.shift(); + changed(); + if (thread.born !== recording) return; + options.emit( + tabId, + finishAgentEvent( + "thread.linked", + { + threadId: thread.threadId, + agentId: thread.agentId, + hasMessages, + reason, + }, + at, + options.digests(), + ), + ); + }; + }; + + /** A step of the Thread, after the steps before it; a step the library refuses is counted, never thrown. */ + const step = (tab: Tab, thread: Thread, work: () => void | Promise) => { + thread.chain = thread.chain.then(work).catch(() => { + drop(tab, "agent-refused"); + }); + }; + + /** The Thread's open run is over without its end (cut short, or replaced by a new run): its events stop, and nothing is + * invented for it. */ + const endOpenRun = (tab: Tab, thread: Thread) => { + const run = thread.open; + if (!run) return; + thread.open = null; + run.events.complete(); + for (const stream of tab.streams.values()) + if (stream.run === run) stream.run = null; + }; + + const feed = (tab: Tab, run: Run, event: BaseEvent, at: number) => { + run.events.next({ event, at }); + if (!isTerminal(event)) return; + run.events.complete(); + if (run.thread.open === run) run.thread.open = null; + for (const stream of tab.streams.values()) + if (stream.run === run) stream.run = null; + }; + + /** CopilotKit adds the person's new messages with `addMessage` before it runs (an `agent.message` each), and a frontend + * tool's result straight into the history: the request shows both, after what the Thread already holds. The page's + * `link` goes first, saying whether the Thread held messages before the person's new ones. */ + const addPageMessages = ( + thread: Thread, + page: PageRun, + link: (hasMessages: boolean, at: number) => void, + ) => { + const held = new Set(thread.agent.messages.map(({ id }) => id)); + const carried = expressedOf(thread.threadId).messages; + const { messages } = page.input; + let start = messages.length; + while (start > 0) { + const message = messages[start - 1] as Message; + if ( + message.role !== "user" || + held.has(message.id) || + carried.has(message.id) + ) + break; + start--; + } + link(start > 0, page.at); + const typed = messages.slice(start); + const added = messages + .slice(0, start) + .map((message, index) => ({ + message: structuredClone(message), + after: index > 0 ? (messages[index - 1] as Message).id : null, + })) + .filter(({ message }) => !held.has(message.id)); + page.followUp = + typed.length === 0 && + added.some(({ message }) => message.role === "tool"); + thread.agent.addFromPage( + added, + typed.map((message) => structuredClone(message)), + page.at, + ); + }; + + const startRun = ( + tabId: number, + tab: Tab, + stream: Stream, + thread: Thread, + first: FedEvent, + id: string | null, + ) => { + endOpenRun(tab, thread); + const page = thread.pending; + thread.pending = null; + // A run no request of the page's showed (a channel joined on its own): its Thread shows itself here. + const link = page ? null : linkThread(tabId, tab, thread, first.at); + const started = first.event as BaseEvent & { + runId?: string; + input?: unknown; + }; + const runId = started.runId ?? page?.input.runId; + const run: Run = { thread, runId, events: new ReplaySubject() }; + if (runId !== undefined && !runsOf(thread.threadId).has(runId)) { + runsOf(thread.threadId).add(runId); + changed(); + } + thread.open = run; + stream.run = run; + const input = + page?.input ?? + readInput(started.input) ?? + ({ + threadId: thread.threadId, + runId, + messages: [], + tools: [], + context: [], + forwardedProps: undefined, + state: undefined, + raw: undefined, + } satisfies RunInput); + step(tab, thread, async () => { + link?.(thread.agent.messages.length > 0, first.at); + const resumed = thread.agent.continuationRunIdFor(input); + const followUp = page?.followUp === true; + const continuation = + resumed !== undefined || followUp + ? { expectedRunId: resumed ?? thread.lastLogicalRunId } + : null; + const runIdOnInput = input.runId ?? runId ?? crypto.randomUUID(); + thread.lastLogicalRunId = continuation + ? continuation.expectedRunId + : runIdOnInput; + await thread.agent.applyRun( + run.events, + { ...input, runId: runIdOnInput }, + continuation, + page?.input.raw, + ); + }); + if (id !== null) recordedEvents.add(id); + feed(tab, run, first.event, first.at); + }; + + const onRequest = (tabId: number, tab: Tab, request: AgentRunRequest) => { + const input = readInput(request.input); + const stream = streamOf(tab, request.streamId, request.agentId); + stream.requested = true; + if (!input) { + drop(tab, "agent-unreadable"); + return; + } + stream.threadId = input.threadId; + if (isSuggestion(request.input)) { + stream.suggestion = true; + return; + } + const thread = threadOf(tabId, tab, request.agentId, input.threadId); + stream.thread = thread; + const link = linkThread(tabId, tab, thread, request.at); + if (request.method !== "agent/run") { + step(tab, thread, () => link(input.messages.length > 0, request.at)); + return; + } + tab.runs.push({ at: request.at, threadId: input.threadId }); + if (tab.runs.length > MAX_RUNS_KEPT) tab.runs.shift(); + changed(); + // The page started a new run: a run of the Thread still open was cut short. + endOpenRun(tab, thread); + const page: PageRun = { input, at: request.at, followUp: false }; + thread.pending = page; + step(tab, thread, () => addPageMessages(thread, page, link)); + }; + + const onRunStarted = ( + tabId: number, + tab: Tab, + stream: Stream, + fed: FedEvent, + id: string | null, + ) => { + const event = fed.event as BaseEvent & { + threadId?: string; + runId?: string; + input?: unknown; + }; + const threadId = event.threadId || stream.threadId; + if (!threadId) { + drop(tab, "agent-unreadable"); + return; + } + if (!stream.requested && isSuggestion(event.input)) { + stream.suggestion = true; + return; + } + const thread = threadOf(tabId, tab, stream.agentId, threadId); + stream.thread = thread; + stream.skipping = false; + const replayed = + (event.runId !== undefined && runsOf(threadId).has(event.runId)) || + (id !== null && recordedEvents.has(id)); + if (replayed) { + showThread(tab, thread, fed.at); + // The page's agent applies the replay, so the run it saw last is this one, though the recording has it already. + if (event.runId !== undefined) + pageAgentOf(tab, thread.agentId).lastRunId = event.runId; + drop(tab, "agent-replayed"); + // A run cut short whose replay names each event can carry on: its recorded events are skipped one by one. + if ( + id !== null && + thread.open !== null && + thread.open.runId === event.runId + ) + stream.run = thread.open; + else stream.skipping = true; + return; + } + if ( + !stream.requested && + event.input === undefined && + thread.pending === null + ) { + stream.held = { fed, id }; + return; + } + startRun(tabId, tab, stream, thread, fed, id); + }; + + const onEvent = (tabId: number, tab: Tab, observation: AgentStreamEvent) => { + const stream = streamOf(tab, observation.streamId, observation.agentId); + if (observation.threadId) stream.threadId ??= observation.threadId; + if (stream.suggestion) return; + const event = normalise(observation.event); + if (!event) { + drop(tab, "agent-unreadable"); + return; + } + stream.events++; + const fed: FedEvent = { event, at: observation.at }; + if (stream.held) { + const { held } = stream; + stream.held = null; + const toolCallName = (event as { toolCallName?: unknown }).toolCallName; + if (event.type === "TOOL_CALL_START" && toolCallName === SUGGEST_TOOL) { + stream.suggestion = true; + return; + } + if (stream.thread) + startRun(tabId, tab, stream, stream.thread, held.fed, held.id); + } + const id = eventIdOf(event); + if (event.type === "RUN_STARTED") { + onRunStarted(tabId, tab, stream, fed, id); + return; + } + if (stream.skipping) { + if (id !== null) recordedEvents.add(id); + drop(tab, "agent-replayed"); + if (isTerminal(event)) stream.skipping = false; + return; + } + if (id !== null && recordedEvents.has(id)) { + drop(tab, "agent-replayed"); + return; + } + if (!stream.run) { + // A run the page asked for that failed before it started still ends in CopilotKit's `agent.run` error. + if ( + event.type === "RUN_ERROR" && + stream.requested && + stream.events === 1 && + stream.thread?.pending + ) + startRun(tabId, tab, stream, stream.thread, fed, id); + else drop(tab, "agent-mid-run"); + return; + } + if (id !== null) recordedEvents.add(id); + feed(tab, stream.run, event, observation.at); + }; + + const onEnd = (tabId: number, tab: Tab, end: AgentStreamEnd) => { + const stream = tab.streams.get(end.streamId); + if (!stream) return; + if (stream.held && stream.thread) + startRun( + tabId, + tab, + stream, + stream.thread, + stream.held.fed, + stream.held.id, + ); + tab.streams.delete(end.streamId); + reportDrops(tabId, tab); + }; + + /** The agent context CopilotKit adds to a click (LearningBridge.enrich): the exact message first, then the open + * Threads. */ + const clickContext = ( + tab: Tab, + target: AgentTarget | null, + ): Record => { + if (target?.messageId) { + for (const thread of tab.threads.values()) { + const message = thread.agent.messages.find( + ({ id }) => id === target.messageId, + ); + if (!message) continue; + const toolCall = + target.toolCallId !== null && message.role === "assistant" + ? message.toolCalls?.find(({ id }) => id === target.toolCallId) + : undefined; + const answered = + toolCall !== undefined && + thread.agent.messages.some( + (other) => + other.role === "tool" && other.toolCallId === toolCall.id, + ); + return { + threadId: thread.agent.threadId, + agentId: thread.agent.id, + messageId: message.id, + runId: thread.agent.runIdForMessage(message.id), + ...(toolCall === undefined + ? {} + : { + toolCallId: toolCall.id, + toolName: toolCall.function.name, + toolStatus: thread.agent.isExecuting(toolCall.id) + ? "executing" + : answered + ? "complete" + : "inProgress", + }), + }; + } + } + const open = openIds(tab); + if (open.length > 1) + return { + threadId: null, + threadAmbiguity: { reason: "multiple-open", candidates: open }, + }; + return { threadId: open[0] ?? null }; + }; + + /** A click with the open-Thread context (no message of its own, no ambiguity) in a tab whose page runs CopilotKit. */ + const openContextOf = ( + tabId: number, + click: CapturedEvent, + ): { tab: Tab; value: Record } | null => { + const value = click.value; + const tab = tabs.get(tabId); + if ( + !tab?.traffic || + !isRecord(value) || + !Object.hasOwn(value, "threadId") || + value.messageId !== undefined || + value.threadAmbiguity !== undefined + ) + return null; + return { tab, value }; + }; + + return { + observe(observation) { + const tab = tabOf(observation.tabId); + if (!tab.traffic) { + tab.traffic = true; + changed(); + } + // The page asked its CopilotKit runtime what it serves: it runs CopilotKit, which is all this says. + if (observation.kind === "runtime") return; + try { + if (observation.kind === "request") + onRequest(observation.tabId, tab, observation); + else if (observation.kind === "event") + onEvent(observation.tabId, tab, observation); + else onEnd(observation.tabId, tab, observation); + } catch { + drop(tab, "agent-unreadable"); + } + }, + enrichClick(tabId, click) { + const value = click.value; + if (!isRecord(value)) return click; + const { [AGENT_TARGET_FIELD]: target, ...rest } = value; + const tab = tabs.get(tabId); + if (!tab?.traffic) + return target === undefined ? click : { ...click, value: rest }; + return { + ...click, + value: { ...rest, ...clickContext(tab, readTarget(target)) }, + }; + }, + settleClick(tabId, click, joins) { + const context = openContextOf(tabId, click); + if (!context) return click; + const { tab, value } = context; + // The run the click sent: a run goes out on its chat's open Thread, so that is the Thread the click was made in. + const run = tab.runs.find(({ at }) => joins(at)); + if (run) + return run.threadId === value.threadId + ? click + : { ...click, value: { ...value, threadId: run.threadId } }; + // The page showed the click's chat on another Thread after the click, by no doing of the click's: the chat may + // have left the click's Thread at any moment since that Thread's own traffic (a New thread button calls nothing), + // so which Thread the click was made in is not known, and a stale one is never given. + const left = tab.left.some( + ({ threadId, at }) => + threadId === value.threadId && at >= click.timestamp && !joins(at), + ); + return left ? { ...click, value: { ...value, threadId: null } } : click; + }, + awaitsRun(tabId, click, joins) { + const context = openContextOf(tabId, click); + return context !== null && !context.tab.runs.some(({ at }) => joins(at)); + }, + tabNavigated(tabId) { + const tab = tabs.get(tabId); + if (!tab) return; + // A new document: its chats open their Threads anew, and CopilotKit's agents in it have run nothing yet. + tab.views.clear(); + tab.lastLinked = null; + changed(); + for (const agent of tab.agents.values()) agent.lastRunId = undefined; + for (const thread of tab.threads.values()) thread.agent.pageGone(); + reportDrops(tabId, tab); + }, + snapshot() { + return { + tabs: [...tabs] + .filter(([, tab]) => tab.traffic) + .map(([tabId, tab]) => ({ + tabId, + ...(tab.pendingLinks[0]?.before ?? linkStateOf(tab)), + runs: tab.runs.map((run) => ({ ...run })), + lastRunIds: [...tab.agents].flatMap(([key, agent]) => + agent.lastRunId === undefined + ? [] + : [[key, agent.lastRunId] as [string, string]], + ), + })), + recordedRuns: [...recordedRuns].map(([threadId, runs]) => [ + threadId, + [...runs], + ]), + }; + }, + restore(snapshot) { + for (const kept of snapshot.tabs) { + const tab = tabOf(kept.tabId); + tab.traffic = true; + tab.views = new Map(kept.views); + tab.left = kept.left.map((entry) => ({ ...entry })); + tab.lastLinked = kept.lastLinked; + tab.runs = kept.runs.map((run) => ({ ...run })); + tab.agents = new Map( + kept.lastRunIds.map(([key, runId]) => [key, pageAgent(runId)]), + ); + } + for (const [threadId, runs] of snapshot.recordedRuns) + for (const runId of runs) runsOf(threadId).add(runId); + }, + stop() { + for (const [tabId, tab] of tabs) { + for (const thread of tab.threads.values()) { + thread.agent.flushHeld(); + if (thread.open) thread.open.events.complete(); + } + reportDrops(tabId, tab); + } + recording++; + tabs.clear(); + recordedRuns.clear(); + expressedByThread.clear(); + recordedEvents.clear(); + }, + }; +} diff --git a/extension/src/background/agent/mask.ts b/extension/src/background/agent/mask.ts new file mode 100644 index 000000000..e244cf0bc --- /dev/null +++ b/extension/src/background/agent/mask.ts @@ -0,0 +1,225 @@ +// The agent events as they leave: masked by the Recorder's shared rules (shared/recorder-masking.ts) with the recording's +// sensitive-value digests, so a password the person typed earlier and then pasted into the chat comes out [redacted], and +// cut to fit one Intelligence event. CopilotKit's collector drops an event over 16 KiB; here the longest text is cut +// instead, never an id, a name or an enum-like field, so nothing vanishes, the event still joins its Thread, run, messages +// and tool calls, and `textLength` keeps the full length. +import { + type CapturedEvent, + EVENT_BUDGET_BYTES, + type JsonValue, + maskBody, + maskJsonValue, + sanitiseForIntelligence, + trimEvent, +} from "@openbot/shared/recorder-masking"; + +/** The fields that join one AG-UI event or message to another: kept as they are, since the masking's long-run rule would + * otherwise take every UUID. */ +const IDS: ReadonlySet = new Set([ + "id", + "messageId", + "toolCallId", + "parentMessageId", + "threadId", + "runId", + "parentRunId", + "entityId", + "interruptId", + "subagentRunId", + "agentId", +]); + +/** The fields never cut to fit, at any depth: the ids above; the names of a tool, a step or an activity; and the short + * enum-like fields an event, a message, a JSON Patch operation or a tool's JSON Schema hold (an event's type, a role, an + * operation and its paths, a schema's types, required properties and enum values). What is cut is the bulky text: + * descriptions, contents, arguments, context values, state. */ +const KEPT_WHOLE: ReadonlySet = new Set([ + ...IDS, + "name", + "toolCallName", + "toolName", + "stepName", + "activityType", + "type", + "role", + "phase", + "status", + "outcome", + "reason", + "code", + "op", + "path", + "from", + "format", + "mimeType", + "protocolVersion", + "required", + "enum", +]); + +type Record_ = { [key: string]: JsonValue }; + +const isRecord = (value: unknown): value is Record_ => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Text a person or an agent wrote, or a tool's JSON: masked as a body is, so JSON is masked field by field. JSON the + * masking leaves alone keeps its own layout. */ +const maskText = (text: string, digests: ReadonlySet): string => { + const masked = maskBody(text, undefined, { digests }); + if (masked === text) return text; + try { + if (JSON.stringify(JSON.parse(text)) === masked) return text; + } catch { + // Not JSON: the masked text stands. + } + return masked; +}; + +/** One AG-UI event as agent.event carries it: its ids as they are, every text as a body, every structure as JSON. */ +const maskPayload = ( + payload: Record_, + digests: ReadonlySet, +): Record_ => { + const out: Record_ = {}; + for (const [field, value] of Object.entries(payload)) + out[field] = + field === "type" || (IDS.has(field) && typeof value === "string") + ? value + : typeof value === "string" + ? maskText(value, digests) + : maskJsonValue(value, { digests }, IDS); + return out; +}; + +const bytes = (event: CapturedEvent): number => + new TextEncoder().encode(JSON.stringify(event)).byteLength; + +/** An agent.event still over budget once its strings are cut has more structure than fits (a large state or history): + * its large fields go as their JSON text, which can be cut. Its ids and type stay. */ +const flattened = (event: CapturedEvent): CapturedEvent => { + const value = event.value as Record_; + const payload = isRecord(value.event) ? value.event : {}; + const flat: Record_ = {}; + for (const [field, item] of Object.entries(payload)) + flat[field] = + typeof item === "string" || typeof item === "number" || item === null + ? item + : JSON.stringify(item); + return { ...event, value: { ...value, event: flat } }; +}; + +const isMinimal = (event: CapturedEvent): boolean => + isRecord(event.value) && event.value.trimmed === true; + +/** Cuts at a code unit count without leaving the first half of a surrogate pair at the end. */ +const cutText = (text: string, length: number): string => + text.slice( + 0, + length > 0 && + text.charCodeAt(length - 1) >= 0xd800 && + text.charCodeAt(length - 1) <= 0xdbff + ? length - 1 + : length, + ); + +/** Every string under a value that is not one of the KEPT_WHOLE fields, with where it sits so it can be cut in place. */ +const cuttable = ( + value: JsonValue, + out: { holder: Record_ | JsonValue[]; key: string | number; text: string }[], + field: string | null, +): void => { + const visit = ( + holder: Record_ | JsonValue[], + key: string | number, + item: JsonValue, + itemField: string | null, + ) => { + if (typeof item === "string") { + if (itemField === null || !KEPT_WHOLE.has(itemField)) + out.push({ holder, key, text: item }); + } else cuttable(item, out, itemField); + }; + if (Array.isArray(value)) + for (const [index, item] of value.entries()) + visit(value, index, item, field); + else if (isRecord(value)) + for (const [key, item] of Object.entries(value)) + visit(value, key, item, key); +}; + +/** A RUN_STARTED over budget has the page's setup in its `input` (its tools' descriptions and schemas, its context) cut + * first, to the longest one length that fits, so the conversation it carries (the person's messages, a frontend tool's + * result, which nothing else carries) stays whole when cutting the setup is enough. */ +const cutSetup = (event: CapturedEvent): CapturedEvent => { + const value = event.value as Record_; + const payload = isRecord(value.event) ? value.event : null; + if (payload?.type !== "RUN_STARTED" || !isRecord(payload.input)) return event; + const copy = structuredClone(event); + const input = ((copy.value as Record_).event as Record_).input as Record_; + const leaves: Parameters[1] = []; + for (const field of ["tools", "context"]) + if (input[field] !== undefined) + cuttable(input[field] as JsonValue, leaves, field); + if (leaves.length === 0) return event; + const capAt = (length: number) => { + for (const { holder, key, text } of leaves) + (holder as Record)[key] = + text.length > length ? cutText(text, length) : text; + }; + let fits = 0; + let low = 0; + let high = Math.max(...leaves.map(({ text }) => text.length)) - 1; + while (low <= high) { + const length = Math.floor((low + high) / 2); + capAt(length); + if (bytes(copy) <= EVENT_BUDGET_BYTES) { + fits = length; + low = length + 1; + } else high = length - 1; + } + capAt(fits); + (copy.value as Record_).truncated = true; + return copy; +}; + +/** What survives of an event nothing could make fit: its ids, and that it was cut. */ +const skeleton = (event: CapturedEvent): CapturedEvent => { + const value = event.value as Record_; + const kept: Record_ = { trimmed: true }; + for (const [field, item] of Object.entries(value)) + if (IDS.has(field) || field === "phase" || field === "role") + kept[field] = item; + if (isRecord(value.event) && typeof value.event.type === "string") + kept.event = { type: value.event.type }; + return { ...event, value: kept }; +}; + +/** + * One agent event ready for the Trajectory. Undefined fields are left out as JSON leaves them out (CopilotKit's events + * pass through JSON on their way to Intelligence), the text and the AG-UI event are masked, and the result fits + * EVENT_BUDGET_BYTES. + */ +export function finishAgentEvent( + name: string, + value: Record, + at: number, + digests: ReadonlySet, +): CapturedEvent { + const plain = JSON.parse(JSON.stringify(value)) as Record_; + if (typeof plain.text === "string") + plain.text = maskText(plain.text, digests); + if (isRecord(plain.event)) plain.event = maskPayload(plain.event, digests); + const event: CapturedEvent = { + type: "CUSTOM", + name, + timestamp: at, + value: sanitiseForIntelligence(plain), + }; + if (bytes(event) <= EVENT_BUDGET_BYTES) return event; + const fit = { strings: true, keep: KEPT_WHOLE }; + const setupCut = name === "agent.event" ? cutSetup(event) : event; + let fitted = trimEvent(setupCut, EVENT_BUDGET_BYTES, fit).event; + if (isMinimal(fitted) && name === "agent.event") + fitted = trimEvent(flattened(setupCut), EVENT_BUDGET_BYTES, fit).event; + return isMinimal(fitted) ? skeleton(event) : fitted; +} diff --git a/extension/src/background/agent/observation.ts b/extension/src/background/agent/observation.ts new file mode 100644 index 000000000..4a90c6e05 --- /dev/null +++ b/extension/src/background/agent/observation.ts @@ -0,0 +1,64 @@ +// What the network capture sees of the AG-UI traffic in a followed tab, over any transport (an SSE or protobuf response +// to a run, or the Intelligence WebSocket), handed to the agent events (events.ts) that turn it into the events the +// CopilotKit SDK emits. Everything here is raw and unmasked: it stays in the service worker's memory, and only the agent +// events' masked output leaves. + +/** The run a page started: the JSON it posted, already unwrapped from CopilotKit's single-route `{ method, params, body }` + * envelope. `input` is the AG-UI `RunAgentInput` as the page sent it (threadId, runId, messages, state, tools, context, + * forwardedProps), unvalidated. */ +export type AgentRunRequest = { + kind: "request"; + tabId: number; + /** One per response or channel: every observation of the same SSE response, protobuf response or WebSocket channel + * shares it. */ + streamId: string; + /** Epoch milliseconds when the capture saw it. */ + at: number; + url: string; + /** From the URL (`/agent//run`, `/agent//connect`) or the envelope's `params.agentId`; null when neither has it. */ + agentId: string | null; + method: "agent/run" | "agent/connect"; + input: unknown; +}; + +/** One AG-UI event, as received, in the order its stream delivered it. */ +export type AgentStreamEvent = { + kind: "event"; + tabId: number; + streamId: string; + at: number; + /** Known from the stream's request or channel; null when the stream named none. */ + agentId: string | null; + /** Known from the stream's request or channel (the Intelligence channel's join); null when the stream named none. */ + threadId: string | null; + /** The event object (`type`, and the fields of that type), unvalidated. */ + event: unknown; +}; + +/** A stream is over: its response finished or failed, or its channel closed. */ +export type AgentStreamEnd = { + kind: "end"; + tabId: number; + streamId: string; + at: number; + outcome: "complete" | "failed"; +}; + +/** The page asked its CopilotKit runtime what it serves (CopilotKit's `info` call: a GET or POST of `/info`, or + * the single route's `{ method: "info" }`), and the runtime answered as CopilotKit's does: the page runs CopilotKit, + * so a click there carries the open-Thread context CopilotKit adds, `threadId: null` while no Thread is open. */ +export type AgentRuntimeInfo = { + kind: "runtime"; + tabId: number; + /** Epoch milliseconds when the answer was seen. */ + at: number; + url: string; +}; + +export type AgentObservation = + | AgentRunRequest + | AgentStreamEvent + | AgentStreamEnd; + +/** Everything the network capture hands the agent events: the AG-UI streams, and that the page runs CopilotKit. */ +export type CaptureObservation = AgentObservation | AgentRuntimeInfo; diff --git a/extension/src/background/agent/thread-agent.ts b/extension/src/background/agent/thread-agent.ts new file mode 100644 index 000000000..5b948941e --- /dev/null +++ b/extension/src/background/agent/thread-agent.ts @@ -0,0 +1,571 @@ +// One Thread of one agent in one tab, kept the way CopilotKit keeps it inside the page: an AG-UI agent (@ag-ui/client's +// AbstractAgent) whose runs are the runs the network capture observed, their events fed to it live. The library applies +// them exactly as it does for CopilotKit (chunk expansion, verification, message assembly, state patches, snapshots) and +// calls its subscribers at the same moments, so the agent events below fire when CopilotKit's do and carry what it +// carries. The subscriber is CopilotKit's (`@copilotkit/core`): its StateManager's bookkeeping of which run each message +// belongs to, then its LearningBridge's `agent.run`, `agent.message` and `tool.call`; after each, `agent.event` carries +// what those leave out of the AG-UI event (agent-event.ts). +// +// A frontend tool runs inside the page, where the network cannot see it; what CopilotKit reports of it is read off the +// traffic the way CopilotKit's own behaviour implies. Its RunHandler runs a tool the page offered in the run's `tools` +// once the run that called it has finished without a result for it (`tool.call` executing, at that RUN_FINISHED), one +// call at a time, and when the tool returns it puts the result into the Thread's history, which the page's next run +// request carries (`tool.call` completed, at that request, before the run it starts). +import { + AbstractAgent, + type AgentStateMutation, + type AgentSubscriber, + type BaseEvent, + defaultApplyEvents, + type Message, + type RunAgentInput, + randomUUID, +} from "@ag-ui/client"; +import { type Observable, of, type ReplaySubject } from "rxjs"; +import { map, tap } from "rxjs/operators"; +import { + type AgentEventPayload, + createAgentEventBuilder, + type Expressed, +} from "./agent-event"; + +type Json = AgentEventPayload; + +/** Where this agent's events go: a CopilotKit event name, its value (undefined fields are left out, as JSON leaves them + * out), and when the capture saw what caused it. */ +export type ThreadAgentOutput = { + emit(name: string, value: Record, at: number): void; + /** The library refused part of a run (a stream that breaks the AG-UI rules): what was left of it is dropped. */ + invalid(error: unknown): void; +}; + +/** One observed AG-UI event, as it is fed to the run, with when the capture saw it. */ +export type FedEvent = { event: BaseEvent; at: number }; + +/** CopilotKit's agent in the page for one agent id, which every Thread of that agent in the page shares: the page keeps + * one instance and moves it from Thread to Thread, so LearningBridge's `lastRunIds.get(agent)` (the run it gives a + * message, a tool call or an error with no run of its own) carries over to the agent's next Thread. */ +export type PageAgent = { lastRunId: string | undefined }; + +/** A run that continues an earlier one (CopilotKit's StateManager `markNextRunAsContinuation`): a frontend tool's + * follow-up or the answer to an interrupt. Its messages belong to the run it continues. */ +export type Continuation = { expectedRunId: string | undefined }; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Text the person or the agent wrote, as LearningBridge reads it: only string content is text. */ +const textOf = (message: Message): string => + typeof message.content === "string" ? message.content : ""; + +/** CopilotKit's FORWARDED_TO_CLIENT (tool-result-content.ts): a runtime's stand-in for a frontend tool's result, which + * RunHandler does not count as one. */ +const FORWARDED_TO_CLIENT = "Forwarded to client"; + +/** CopilotKit's normalizeToolResultContent: a tool result's text, or null when it has none. */ +const resultText = (content: unknown): string | null => { + if (typeof content === "string") return content.trim(); + if (Array.isArray(content)) { + const text = content + .flatMap((part) => + typeof part === "string" + ? [part] + : isRecord(part) && typeof part.text === "string" + ? [part.text] + : [], + ) + .join("") + .trim(); + return text.length > 0 ? text : null; + } + if (isRecord(content) && typeof content.text === "string") + return content.text.trim(); + return null; +}; + +/** A tool's result in the history, as RunHandler counts one: never the runtime's stand-in. */ +const isResultFor = (message: Message, toolCallId: string): boolean => + message.role === "tool" && + message.toolCallId === toolCallId && + resultText(message.content) !== FORWARDED_TO_CLIENT; + +/** RunHandler.executeToolHandler: a tool that threw, or whose arguments did not parse, returns `Error: `. */ +const failed = (message: Message): boolean => + typeof message.content === "string" && message.content.startsWith("Error: "); + +/** StateManager's `isContinuation`: an input that answers an interrupt, by the standard `resume` or a legacy command. */ +const isContinuation = ( + input: Pick, +): boolean => + input.resume !== undefined || + Object.hasOwn( + (input.forwardedProps as { command?: object } | undefined)?.command ?? {}, + "resume", + ); + +export class ThreadAgent extends AbstractAgent { + /** The live events of the run the library is about to apply; read by run() when its pipeline starts. */ + private next: ReplaySubject | null = null; + /** When the capture saw the event being fed right now, and so every event it expands into. */ + private feedAt = 0; + private readonly seenAt = new WeakMap(); + /** When the capture saw what is being applied right now: the time of everything emitted for it. */ + private at = 0; + private continuation: Continuation | null = null; + /** What the page sent for the run being applied, for a RUN_STARTED that does not echo its input. */ + private requestInput: unknown; + private readonly builder: ReturnType; + + // CopilotKit StateManager.subscribeToAgent, for this agent and Thread. + private subRunId: string | undefined; + private runFinished = false; + private legacyInterrupted = false; + private activeRun: string | undefined; + private interrupted: + | { runId: string; interruptIds: string[] | null } + | undefined; + private readonly messageRuns = new Map(); + /** The run this Thread's events belong to: the run it started last, which `agent.event` names. */ + private lastRunId: string | undefined; + // CopilotKit LearningBridge.toolCalls: the message and run each tool call came from. + private readonly toolOrigins = new Map< + string, + { messageId: string | undefined; runId: string | undefined } + >(); + // CopilotKit LearningBridge.executingTools: the frontend tool the page is running, from when. + private readonly executingTools = new Map< + string, + { name: string; startedAt: number } + >(); + /** Frontend tool calls a finished run left without a result, after the one running: RunHandler runs them in turn. */ + private waitingTools: { id: string; name: string }[] = []; + /** When the run being applied finished (its RUN_FINISHED was seen); undefined while it has not. */ + private finishedAt: number | undefined; + + constructor( + agentId: string | null, + threadId: string, + private readonly output: ThreadAgentOutput, + expressed: Expressed, + /** CopilotKit LearningBridge.lastRunIds, for the page's agent this Thread is one of. */ + private readonly page: PageAgent, + ) { + super({ threadId, ...(agentId === null ? {} : { agentId }) }); + this.builder = createAgentEventBuilder(expressed); + this.subscribe(this.copilotKitSubscriber(this.builder)); + } + + /** The id the agent events carry for this agent: LearningBridge's `agent.agentId`. */ + get id(): string | null { + return this.agentId ?? null; + } + + /** Applies one run: its events arrive on `events` as the capture sees them, and the returned promise settles when the + * library has applied the last of them (the run ended, or `events` completed short of its end). */ + async applyRun( + events: ReplaySubject, + input: Pick< + RunAgentInput, + "runId" | "tools" | "context" | "forwardedProps" | "resume" | "state" + >, + continuation: Continuation | null, + requestInput: unknown, + ): Promise { + this.next = events; + this.continuation = continuation; + this.requestInput = requestInput; + // The state the page ran with is the agent's, as CopilotKit's agent.state is what it sends. + if (requestInput !== undefined && input.state !== undefined) + this.state = structuredClone(input.state); + // A run the page started was refused if it left interrupts unanswered: the page holds none of them any more. + const answered = new Set( + (input.resume ?? []).map((entry) => entry.interruptId), + ); + if (this.pendingInterrupts.some((interrupt) => !answered.has(interrupt.id))) + this.pendingInterrupts = []; + this.finishedAt = undefined; + const { newMessages } = await this.runAgent({ + runId: input.runId, + tools: input.tools ?? [], + context: input.context ?? [], + forwardedProps: input.forwardedProps ?? {}, + ...(input.resume === undefined ? {} : { resume: input.resume }), + }); + const finishedAt = this.finishedAt; + this.finishedAt = undefined; + // RunHandler.processAgentResult, once a run the page started has finished: the frontend tools it called and left + // without a result run in the page. + if (finishedAt !== undefined && requestInput !== undefined) + this.runPageTools(newMessages, input.tools ?? [], finishedAt); + } + + /** Whether the page is running a frontend tool call (LearningBridge.toolStatus's `executing`). */ + isExecuting(toolCallId: string): boolean { + return this.executingTools.has(toolCallId); + } + + /** The page is gone (its tab reached another document): the tools it was running there never finish. */ + pageGone(): void { + this.executingTools.clear(); + this.waitingTools = []; + } + + /** Messages the page added itself before a run, as CopilotKit adds them: the person's own through `addMessage` (an + * `agent.message` each), anything else (a frontend tool's result) straight into the history, where the library reads + * it but announces nothing. `at` is when the request carrying them was seen. */ + addFromPage( + silent: { message: Message; after: string | null }[], + typed: Message[], + at: number, + ): void { + this.pageToolResults( + silent.map(({ message }) => message), + at, + ); + for (const { message, after } of silent) { + const index = + after === null ? -1 : this.messages.findIndex((m) => m.id === after); + this.messages.splice(index + 1, 0, message); + } + this.at = at; + for (const message of typed) this.addMessage(message); + } + + /** The recording ended: a merged delta still held goes out now. */ + flushHeld(): void { + for (const pending of this.builder.flushAll()) this.emitPending(pending); + } + + /** CopilotKit's run id for a message: the StateManager's, else the page agent's last run, whichever Thread it ran on + * (LearningBridge.runIdForMessage): the person's first message in a new Thread names the previous Thread's last run. */ + runIdForMessage(messageId: string): string | null { + return this.messageRuns.get(messageId) ?? this.page.lastRunId ?? null; + } + + /** StateManager.getContinuationRunId: the interrupted run an input answers, without consuming it. */ + continuationRunIdFor( + input: Pick, + ): string | undefined { + const pending = this.interrupted; + if (!pending) return undefined; + if (pending.interruptIds === null) + return input.resume === undefined && isContinuation(input) + ? pending.runId + : undefined; + const submitted = new Set(input.resume?.map((entry) => entry.interruptId)); + const matches = + submitted.size > 0 && + submitted.size === input.resume?.length && + submitted.size === pending.interruptIds.length && + pending.interruptIds.every((id) => submitted.has(id)); + return matches ? pending.runId : undefined; + } + + run(): Observable { + const events = this.next; + this.next = null; + if (!events) return of(); + return events.pipe( + map(({ event, at }) => { + this.feedAt = at; + return event; + }), + ); + } + + // Everything between run() and here is synchronous, so each event the library will apply is stamped with when the + // capture saw the event it came from. + protected override apply( + input: RunAgentInput, + events$: Observable, + subscribers: AgentSubscriber[], + ): Observable { + return defaultApplyEvents( + input, + events$.pipe(tap((event) => this.seenAt.set(event, this.feedAt))), + this, + subscribers, + this.debugLogger, + ); + } + + // The library logs a refused stream as "Agent execution failed" and rethrows; here it is counted instead. + protected override onError( + _input: RunAgentInput, + error: Error, + ): Observable { + this.output.invalid(error); + return of({}); + } + + private emit(name: string, value: Record): void { + this.output.emit(name, value, this.at); + } + + /** The frontend tool calls among a finished run's new messages that the page runs: a tool it offered in the run, a + * call with no result in the history, not already running or waiting (RunHandler.processAgentResult). */ + private runPageTools( + newMessages: Message[], + tools: readonly { name: string }[], + at: number, + ): void { + const offered = new Set(tools.map(({ name }) => name)); + for (const message of newMessages) { + if (message.role !== "assistant") continue; + for (const call of message.toolCalls ?? []) { + if ( + !offered.has(call.function.name) || + this.executingTools.has(call.id) || + this.waitingTools.some(({ id }) => id === call.id) || + this.messages.some((other) => isResultFor(other, call.id)) + ) + continue; + this.waitingTools.push({ id: call.id, name: call.function.name }); + } + } + this.nextPageTool(at); + } + + /** The next waiting tool call starts once none is running (LearningBridge's `executing`). */ + private nextPageTool(at: number): void { + if (this.executingTools.size > 0) return; + const next = this.waitingTools.shift(); + if (!next) return; + this.executingTools.set(next.id, { name: next.name, startedAt: at }); + this.emitToolCall("executing", next.id, next.name, {}, at); + } + + /** The results the page put into the history, as its next run request carries them: each running tool call they + * answer is completed (LearningBridge's `completed`), and the next waiting one starts. */ + private pageToolResults(messages: Message[], at: number): void { + for (;;) { + const running = this.executingTools.entries().next(); + if (running.done) return; + const [id, { name, startedAt }] = running.value; + const result = messages.find((message) => isResultFor(message, id)); + if (!result) return; + this.executingTools.delete(id); + this.emitToolCall( + "completed", + id, + name, + { + durationMs: at - startedAt, + outcome: failed(result) ? "error" : "ok", + }, + at, + ); + this.toolOrigins.delete(id); + this.nextPageTool(at); + } + } + + /** LearningBridge.emitToolCall: the call's origin is where TOOL_CALL_START named it. */ + private emitToolCall( + phase: "executing" | "completed", + toolCallId: string, + toolName: string, + extra: { durationMs?: number; outcome?: "ok" | "error" }, + at: number, + ): void { + const origin = this.toolOrigins.get(toolCallId); + this.output.emit( + "tool.call", + { + phase, + toolCallId, + toolName, + agentId: this.id, + threadId: origin ? this.threadId : null, + messageId: origin?.messageId, + runId: origin?.runId, + ...extra, + }, + at, + ); + } + + private copilotKitSubscriber( + builder: ReturnType, + ): AgentSubscriber { + /** What CopilotKit's events leave out of an AG-UI event, after them. */ + const extra = (payload: Json | null) => { + if (payload === null) return; + this.emit("agent.event", { + threadId: this.threadId, + runId: this.lastRunId, + agentId: this.id, + event: payload, + }); + }; + const effective = (input: RunAgentInput) => this.subRunId ?? input.runId; + return { + onEvent: ({ event }) => { + this.at = this.seenAt.get(event) ?? this.at; + for (const pending of builder.flushBefore(event)) + this.emitPending(pending); + }, + onRunStartedEvent: ({ event, input }) => { + // StateManager + const continuation = this.continuation; + this.continuation = null; + const started = event.input ?? { ...input, threadId: event.threadId }; + const resumedRunId = this.continuationRunIdFor(started); + this.interrupted = undefined; + this.legacyInterrupted = false; + if (continuation) + this.subRunId = + continuation.expectedRunId ?? event.runId ?? input.runId; + else if (resumedRunId) this.subRunId = resumedRunId; + else if ( + this.runFinished && + input.runId === this.subRunId && + !isContinuation(input) && + (event.runId == null || event.runId === this.subRunId) + ) + this.subRunId = randomUUID(); + else this.subRunId = event.runId || input.runId; + this.runFinished = false; + this.activeRun = effective(input); + // LearningBridge + this.lastRunId = event.runId; + this.page.lastRunId = event.runId; + this.emit("agent.run", { + phase: "started", + runId: event.runId, + threadId: event.threadId, + agentId: this.id, + }); + extra( + builder.rest( + event.input === undefined && isRecord(this.requestInput) + ? ({ ...event, input: this.requestInput } as BaseEvent) + : event, + ), + ); + }, + onRunFinishedEvent: ({ event, input }) => { + this.runFinished = true; + this.finishedAt = this.at; + if (event.outcome?.type === "interrupt" || this.legacyInterrupted) + this.interrupted = { + runId: effective(input), + interruptIds: + event.outcome?.type === "interrupt" + ? event.outcome.interrupts.map((interrupt) => interrupt.id) + : null, + }; + else this.interrupted = undefined; + this.activeRun = undefined; + this.emit("agent.run", { + phase: "finished", + runId: event.runId, + threadId: event.threadId, + agentId: this.id, + }); + extra(builder.rest(event)); + }, + onRunErrorEvent: ({ event }) => { + this.runFinished = true; + this.activeRun = undefined; + this.emit("agent.run", { + phase: "error", + runId: this.page.lastRunId ?? null, + threadId: this.threadId, + agentId: this.id, + }); + extra(builder.rest(event)); + }, + onCustomEvent: ({ event }) => { + if (event.name === "on_interrupt") this.legacyInterrupted = true; + extra(builder.rest(event)); + }, + onMessagesSnapshotEvent: ({ event, input }) => { + for (const message of event.messages) + if (!this.messageRuns.has(message.id)) + this.messageRuns.set(message.id, effective(input)); + extra(builder.rest(event)); + }, + onToolCallStartEvent: ({ event }) => { + this.toolOrigins.set(event.toolCallId, { + messageId: event.parentMessageId, + runId: this.page.lastRunId, + }); + this.emit("tool.call", { + phase: "started", + toolCallId: event.toolCallId, + toolName: event.toolCallName, + agentId: this.id, + threadId: this.threadId, + messageId: event.parentMessageId, + runId: this.page.lastRunId, + }); + builder.toolCallStarted(event); + extra(builder.rest(event)); + }, + onToolCallEndEvent: ({ event, messages }) => { + for (const payload of builder.toolCallEnded(event, messages)) + extra(payload); + }, + onToolCallResultEvent: ({ event }) => { + // LearningBridge forgets a call's origin once it has a result, unless the page is running it. + if (!this.executingTools.has(event.toolCallId)) + this.toolOrigins.delete(event.toolCallId); + builder.toolCallResult(event); + }, + onTextMessageStartEvent: ({ event }) => extra(builder.rest(event)), + onTextMessageEndEvent: ({ event }) => extra(builder.rest(event)), + onReasoningStartEvent: ({ event }) => extra(builder.rest(event)), + onReasoningMessageStartEvent: ({ event }) => extra(builder.rest(event)), + onReasoningMessageEndEvent: ({ event }) => extra(builder.rest(event)), + onReasoningEndEvent: ({ event }) => extra(builder.rest(event)), + onReasoningEncryptedValueEvent: ({ event }) => extra(builder.rest(event)), + onStateSnapshotEvent: ({ event }) => extra(builder.rest(event)), + onStateDeltaEvent: ({ event }) => + extra(builder.stateDelta(event, this.at)), + onActivitySnapshotEvent: ({ event }) => extra(builder.rest(event)), + onActivityDeltaEvent: ({ event }) => + extra(builder.activityDelta(event, this.at)), + onStepStartedEvent: ({ event }) => extra(builder.rest(event)), + onStepFinishedEvent: ({ event }) => extra(builder.rest(event)), + onRawEvent: ({ event }) => extra(builder.rest(event)), + onSubagentStartedEvent: ({ event }) => extra(builder.rest(event)), + onSubagentFinishedEvent: ({ event }) => extra(builder.rest(event)), + onSubagentErrorEvent: ({ event }) => extra(builder.rest(event)), + onRunFinalized: () => { + for (const pending of builder.flushAll()) this.emitPending(pending); + }, + onNewMessage: ({ message, input }) => { + if (input) this.messageRuns.set(message.id, effective(input)); + else if (this.activeRun) + this.messageRuns.set(message.id, this.activeRun); + const text = textOf(message); + this.emit("agent.message", { + messageId: message.id, + runId: this.runIdForMessage(message.id), + threadId: this.threadId, + agentId: this.id, + role: message.role, + textLength: text.length, + ...(typeof message.content === "string" ? { text } : {}), + }); + builder.messageExpressed(message); + extra(builder.afterMessage(message)); + }, + }; + } + + /** A merged delta, held until something other than its next part arrives, with the time of its last part. */ + private emitPending({ payload, at }: { payload: Json; at: number }): void { + this.output.emit( + "agent.event", + { + threadId: this.threadId, + runId: this.lastRunId, + agentId: this.id, + event: payload, + }, + at, + ); + } +} diff --git a/extension/src/background/badge.ts b/extension/src/background/badge.ts new file mode 100644 index 000000000..2a4d36ce7 --- /dev/null +++ b/extension/src/background/badge.ts @@ -0,0 +1,19 @@ +import type { RecorderState } from "../protocol/messages"; + +/** OpenBot's destructive token. */ +const RECORDING_COLOR = "#e7000b"; +const MAX_SHOWN = 999; + +function badgeText(state: RecorderState): string { + if (state.phase !== "recording") return ""; + if (state.actions === 0) return "REC"; + return state.actions > MAX_SHOWN ? `${MAX_SHOWN}+` : String(state.actions); +} + +/** The toolbar badge: the action count while recording, empty otherwise. */ +export async function showBadge(state: RecorderState): Promise { + const text = badgeText(state); + if (text) + await chrome.action.setBadgeBackgroundColor({ color: RECORDING_COLOR }); + await chrome.action.setBadgeText({ text }); +} diff --git a/extension/src/background/connection.ts b/extension/src/background/connection.ts new file mode 100644 index 000000000..960f8a9a7 --- /dev/null +++ b/extension/src/background/connection.ts @@ -0,0 +1,503 @@ +import { + type ExchangeResponse, + type RecorderMe, + recorderPaths, +} from "@openbot/shared/recorder-api"; +import type { + ExternalResponse, + ExternalToBackground, + PageResponse, + PageToBackground, +} from "../protocol/messages"; +import { OpenBotError, openbotJson } from "../shared/openbot-client"; +import { normaliseOpenBotOrigin } from "../shared/origin"; +import { + allowsOpenBot, + onPolicyChanged, + type RecorderPolicy, + readPolicy, +} from "../shared/policy"; +import { + type ConnectionRecord, + installIdFor, + installSecret, + type PendingConnect, + readLocal, + readSession, + removeLocal, + removeSession, + writeLocal, + writeSession, +} from "../shared/storage"; +import type { DesktopHandoff, DesktopRefusal } from "./native-host"; +import { askDesktopForHandoff, desktopAvailable } from "./native-host"; + +/** A connection that ended without the person's Disconnect, for the recording controller (index.ts wires it in): OpenBot + * answered the keep-alive signed out (nothing is forgotten yet), or the organization's policy moved the Recorder away + * from the connection's OpenBot (it is forgotten already). */ +export type ConnectionLoss = + | { + kind: "signed-out"; + reason: "revoked" | "removed" | "organization"; + connection: ConnectionRecord; + } + | { kind: "policy"; connection: ConnectionRecord }; + +type ConnectionDeps = { + askDesktop: () => Promise; + desktopAvailable: () => Promise; + readPolicy: () => Promise; + openTab: (url: string) => Promise; // the new tab's id, kept in pendingConnect.tabId + /** Stops what the lost connection still has running: the live recording and whatever the document sends with its + * token. Without the recording controller (a test of this module alone), a sign-out only forgets the connection. */ + connectionLost: (loss: ConnectionLoss) => Promise; +}; + +const PENDING_MAX_AGE_MS = 5 * 60 * 1000; +const OUT_OF_DATE = + "This page is out of date. Close it and choose Connect this browser in OpenBot again."; +const POLICY_REFUSED = + "Your organization's Recorder policy does not allow this OpenBot. Ask your IT team."; +/** Web connects one OpenBot origin may ask for in any ten minutes: enough that an ordinary run (connect, connect again, + * again after a Disconnect or a revoke, a retry or two) never meets it, while a page asking over and over is held off. */ +const CONNECT_WINDOW_MS = 10 * 60 * 1000; +const CONNECT_LIMIT = 10; +const KEEP_ALIVE_ALARM = "recorder-session-keepalive"; +const KEEP_ALIVE_PERIOD_MINUTES = 720; +const KEEP_ALIVE_PERIOD_MS = KEEP_ALIVE_PERIOD_MINUTES * 60 * 1000; + +const defaultDeps: ConnectionDeps = { + askDesktop: askDesktopForHandoff, + desktopAvailable, + readPolicy, + openTab: async (url) => (await chrome.tabs.create({ url })).id, + connectionLost: async (loss) => { + if (loss.kind === "signed-out") + await forgetConnection(loss.reason, loss.connection.token); + }, +}; + +const withDefaults = (deps?: Partial): ConnectionDeps => ({ + ...defaultDeps, + ...deps, +}); + +const connectUrl = () => chrome.runtime.getURL("connect.html"); + +/** A web connect, Desktop's handoff and a decision's clear (`clearPendingConnect`) each read, then write, the pending + * connect (and a web connect the request count) across storage awaits, so they run one at a time: run side by side, + * ten connects in one tick would each find the slot empty and open a tab, and the limit would count one of them. A + * forget told which token asked (`forgetConnection` with `askedWith`) and approve's store of the connection queue here + * too. A job that throws rejects its own caller and the next job still runs. */ +let queue: Promise = Promise.resolve(); +function serially(work: () => Promise): Promise { + const run = queue.then(work, work); + queue = run.catch(() => undefined); + return run; +} + +export function installConnection(deps?: Partial): void { + const resolved = withDefaults(deps); + chrome.runtime.onInstalled.addListener(async (details) => { + if (details.reason !== "install") return; + await installSecret(); + await offerDesktopConnection(resolved); + }); + chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === KEEP_ALIVE_ALARM) void keepAlive(resolved); + }); + void reconcilePolicy(resolved); + onPolicyChanged(() => void reconcilePolicy(resolved), resolved.readPolicy); + void scheduleKeepAlive(resolved); +} + +/** Alarms can be cleared by a browser restart or an update, so every start makes sure it exists. */ +async function scheduleKeepAlive(deps: ConnectionDeps): Promise { + if (!(await chrome.alarms.get(KEEP_ALIVE_ALARM))) + await chrome.alarms.create(KEEP_ALIVE_ALARM, { + periodInMinutes: KEEP_ALIVE_PERIOD_MINUTES, + }); + const last = await readLocal("lastKeepAliveAt"); + if (last === undefined || Date.now() - last > KEEP_ALIVE_PERIOD_MS) + await keepAlive(deps); +} + +/** Keeps the organization session in use (Decision 2). Only a signed-out answer changes anything: OpenBot being + * unreachable, or an organization's sign-in server that cannot answer, waits for the next alarm. A signed-out answer + * goes to the recording controller, which forgets the connection and stops what it still has running. */ +async function keepAlive(deps: ConnectionDeps): Promise { + const connection = await currentConnection(); + if (!connection) return; + try { + await openbotJson(connection, recorderPaths.me); + } catch (error) { + if (error instanceof OpenBotError && error.signedOut) + await deps.connectionLost({ + kind: "signed-out", + reason: error.body?.reason ?? "revoked", + connection, + }); + return; + } + await writeLocal("lastKeepAliveAt", Date.now()); +} + +export async function currentConnection(): Promise { + return (await readLocal("connection")) ?? null; +} + +type ForgetReason = "revoked" | "removed" | "organization" | "disconnected"; + +/** Forgets the stored connection, with a signed-out notice giving the reason unless the person disconnected. + * + * A caller handling a failed request (a signed-out answer, or a Disconnect's DELETE) must pass the token that request + * used as `askedWith`. Reconnecting this copy revokes its old token, so a request sent with the old token can finish + * after approve stored the new connection: with `askedWith`, only the connection that asked is forgotten, notice and + * all. The check and the forget are one queued job, and approve stores through the queue, so no new connection lands + * between them. Without `askedWith`, whatever connection is stored is forgotten at once. + * + * Resolves true when it forgot a connection: false when `askedWith` is no longer the stored token, or when nothing was + * stored, so a caller can leave alone what belongs to a connection that stays. */ +export async function forgetConnection( + reason: ForgetReason, + askedWith?: string, +): Promise { + if (askedWith === undefined) { + const stored = await currentConnection(); + await forget(reason); + return stored !== null; + } + return serially(async () => { + if ((await currentConnection())?.token !== askedWith) return false; + await forget(reason); + return true; + }); +} + +async function forget(reason: ForgetReason): Promise { + await removeLocal("connection"); + if (reason !== "disconnected") + await writeLocal("notice", { kind: "signed-out", at: Date.now(), reason }); +} + +/** page.connect.*, page.disconnect, page.desktop.available, page.connect.desktop; undefined for anything else. */ +export async function handleConnectionMessage( + message: PageToBackground, + deps?: Partial, +): Promise { + const resolved = withDefaults(deps); + switch (message.type) { + case "page.connect.pending": { + const pending = await readSession("pendingConnect"); + return { + ok: true, + pending: pending ? { origin: pending.origin, via: pending.via } : null, + }; + } + case "page.connect.decide": { + const pending = await readSession("pendingConnect"); + // A connect page reads the pending origin once, on load: a tab Chrome did not close when another origin + // replaced its pending connect (closeConnectTab) still shows the old one, and neither connects nor cancels the + // site it does not show. + if (pending && message.origin !== pending.origin) + return { ok: false, error: OUT_OF_DATE }; + if (message.approve) return approve(pending, message.label, resolved); + if (pending) await clearPendingConnect(pending); + return { ok: true }; + } + case "page.disconnect": { + const connection = await currentConnection(); + if (connection) { + // Best effort: the token is forgotten here either way, and the person can revoke it in OpenBot. + await revoke(connection); + // A reconnect approved while OpenBot was being told is a newer connection, and stays. + await forgetConnection("disconnected", connection.token); + } + return { ok: true }; + } + case "page.desktop.available": + return { ok: true, available: await resolved.desktopAvailable() }; + case "page.connect.desktop": + return { ok: true, ...(await offerDesktopConnection(resolved)) }; + default: + return undefined; + } +} + +async function approve( + pending: PendingConnect | undefined, + label: string, + deps: ConnectionDeps, +): Promise { + if (!pending) return { ok: false, error: "There is nothing to connect." }; + // The policy may have changed since this connect page opened: a pin elsewhere, or a policy gone invalid, refuses it. + if (!allowsOpenBot(await deps.readPolicy(), pending.origin)) { + await clearPendingConnect(pending); + return { ok: false, error: POLICY_REFUSED }; + } + let exchanged: ExchangeResponse; + try { + exchanged = await openbotJson( + { openbotUrl: pending.origin }, + recorderPaths.connections, + { + body: { + code: pending.code, + label, + installId: await installIdFor(pending.origin), + }, + }, + ); + } catch (error) { + // The server's own sentence, or a plain one: a network failure's message ("Failed to fetch") means nothing here. + return { + ok: false, + error: + error instanceof OpenBotError + ? error.message + : "OpenBot did not answer. Try again.", + }; + } + const connection: ConnectionRecord = { + openbotUrl: pending.origin, + token: exchanged.token, + connectionId: exchanged.connection.id, + user: exchanged.user, + via: pending.via, + connectedAt: Date.now(), + }; + // Checked again in the job that stores it: a pin that landed during the exchange keeps it out, and the token OpenBot + // just made is revoked there. + const stored = await serially(async () => { + if (!allowsOpenBot(await deps.readPolicy(), pending.origin)) return false; + await writeLocal("connection", connection); + return true; + }); + if (!stored) { + await clearPendingConnect(pending); + await revoke(connection); + return { ok: false, error: POLICY_REFUSED }; + } + await clearPendingConnect(pending); + if ((await readLocal("notice"))?.kind === "signed-out") + await removeLocal("notice"); + return { ok: true }; +} + +/** A decision clears only the pending connect it read when it arrived. Approve's exchange runs outside the queue, so + * a connect never waits on OpenBot's answer, and a decision can wait in the queue behind a connect or Desktop's + * handoff: either way another origin may have replaced the pending connect by now, and that newer one, with its open + * tab, is left alone. The check and the clear are one queued job, so no connect lands between them. */ +function clearPendingConnect(decided: PendingConnect): Promise { + return serially(async () => { + const stored = await readSession("pendingConnect"); + if (stored?.origin === decided.origin && stored.code === decided.code) + await removeSession("pendingConnect"); + }); +} + +async function tabIsOpen(tabId: number | undefined): Promise { + if (tabId === undefined) return false; + try { + await chrome.tabs.get(tabId); + return true; + } catch { + return false; + } +} + +/** A connect page reads the pending origin once, on load. So the tab of a pending connect being replaced is closed + * first, or it would go on showing a site that is no longer pending (its decide names that site, and is refused). + * Best effort: a tab the person closed in the meantime leaves nothing to close. */ +async function closeConnectTab(tabId: number | undefined): Promise { + if (tabId === undefined) return; + try { + await chrome.tabs.remove(tabId); + } catch { + // Closed already. + } +} + +/** openbot.ping and openbot.connect from OpenBot's web page. The origin is only ever the one Chrome reports. */ +export async function handleExternalConnect( + message: ExternalToBackground, + sender: chrome.runtime.MessageSender, + deps?: Partial, +): Promise { + const resolved = withDefaults(deps); + const origin = sender.origin; + if (message.type === "openbot.ping") return ping(origin); + if (message.type !== "openbot.connect") + return { ok: false, error: "invalid" }; + if (!origin) return { ok: false, error: "invalid" }; + const checked = normaliseOpenBotOrigin(origin); + if (!checked.ok) return { ok: false, error: "not-https" }; + + const policy = await resolved.readPolicy(); + if (policy.openbotUrlInvalid) return { ok: false, error: "policy-invalid" }; + if (policy.openbotUrl && !sameOrigin(policy.openbotUrl, checked.origin)) + return { ok: false, error: "pinned-elsewhere" }; + + return serially(() => webConnect(checked.origin, message.code, resolved)); +} + +/** Everything from the request count through the connect tab, in one queued job (see `serially`). */ +async function webConnect( + origin: string, + code: string, + deps: ConnectionDeps, +): Promise { + const now = Date.now(); + const requests = await readSession("connectRequests"); + const recent = (requests?.[origin] ?? []).filter( + (at) => now - at < CONNECT_WINDOW_MS, + ); + // Not "busy": nothing is pending, and the wait is until the oldest of these leaves the window, which the page says. + if (recent.length >= CONNECT_LIMIT) + return { + ok: false, + error: "throttled", + retryAfterMs: Math.min(...recent) + CONNECT_WINDOW_MS - now, + }; + await writeSession("connectRequests", { + ...requests, + [origin]: [...recent, now], + }); + + const pending = await readSession("pendingConnect"); + if (pending && (await tabIsOpen(pending.tabId))) { + // Whatever the pending connect's age, so no page can open more than one connect tab however often it asks. + if (pending.origin === origin) { + await writeSession("pendingConnect", { + ...pending, + code, + requestedAt: now, + }); + await chrome.tabs.update(pending.tabId as number, { active: true }); + return awaitingConfirmationAnswer(origin); + } + if (now - pending.requestedAt < PENDING_MAX_AGE_MS) + return { ok: false, error: "busy" }; + await closeConnectTab(pending.tabId); + } + const next: PendingConnect = { + origin, + code, + via: "web", + requestedAt: now, + }; + await writeSession("pendingConnect", next); + const tabId = await deps.openTab(connectUrl()); + if (tabId !== undefined) + await writeSession("pendingConnect", { ...next, tabId }); + return awaitingConfirmationAnswer(origin); +} + +async function awaitingConfirmationAnswer( + origin: string, +): Promise { + return { + ok: true, + version: chrome.runtime.getManifest().version, + installId: await installIdFor(origin), + awaitingConfirmation: true, + }; +} + +/** Version and install id only to the connected origin or the origin of a connect flow still pending (Decision 22). */ +async function ping(origin: string | undefined): Promise { + if (!origin) return { ok: true }; + const connection = await currentConnection(); + const pending = await readSession("pendingConnect"); + const known = + connection?.openbotUrl === origin || + (pending?.origin === origin && + Date.now() - pending.requestedAt < PENDING_MAX_AGE_MS); + if (!known) return { ok: true }; + return { + ok: true, + version: chrome.runtime.getManifest().version, + installId: await installIdFor(origin), + }; +} + +function sameOrigin(raw: string, origin: string): boolean { + const checked = normaliseOpenBotOrigin(raw); + return checked.ok && checked.origin === origin; +} + +/** Best effort: OpenBot revokes the token (the person can also revoke it in OpenBot). */ +async function revoke(connection: ConnectionRecord): Promise { + try { + await openbotJson(connection, recorderPaths.currentConnection, { + method: "DELETE", + }); + } catch { + // Unreachable, or already revoked. + } +} + +/** Compares the stored connection with the policy: a policy that pins another origin, or is invalid, forgets the + * connection and leaves a `policy-moved` notice (its origin empty for an invalid policy), then stops what the connection + * still has running (the live recording, and whatever the document sends with its token; everything recorded is kept + * for its person) and revokes the token. Called at every start and on every managed-storage change. The token check + * keeps a reconnect that lands meanwhile. */ +async function reconcilePolicy(deps: ConnectionDeps): Promise { + const policy = await deps.readPolicy(); + const connection = await currentConnection(); + if (!connection || allowsOpenBot(policy, connection.openbotUrl)) return; + const moved = policy.openbotUrlInvalid ? "" : (policy.openbotUrl ?? ""); + if (!(await forgetConnection("disconnected", connection.token))) return; + await writeLocal("notice", { + kind: "policy-moved", + at: Date.now(), + origin: moved, + }); + await deps.connectionLost({ kind: "policy", connection }); + await revoke(connection); +} + +/** Whether Desktop's connect page opened, or why not, as a code the popup turns into a sentence: OpenBot Desktop's own + * error code (see DesktopRefusal), or `policy-invalid`, `pinned-elsewhere` or `invalid-address` from the checks here. */ +export type DesktopOffer = + | { offered: true } + | { offered: false; reason: string }; + +/** A managed browser connects only where its policy points, on the Desktop path too. Desktop's local stack is always + * a loopback http address, so a policy naming any other kind of address can never match and Desktop is not asked. */ +export function offerDesktopConnection( + deps?: Partial, +): Promise { + const resolved = withDefaults(deps); + return serially(() => desktopConnect(resolved)); +} + +async function desktopConnect(deps: ConnectionDeps): Promise { + const refused = (reason: string): DesktopOffer => ({ + offered: false, + reason, + }); + const policy = await deps.readPolicy(); + if (policy.openbotUrlInvalid) return refused("policy-invalid"); + if (policy.openbotUrl && !policy.openbotUrl.startsWith("http:")) + return refused("pinned-elsewhere"); + const handoff = await deps.askDesktop(); + if (!handoff) return refused("no-answer"); + if ("error" in handoff) return refused(handoff.error); + const checked = normaliseOpenBotOrigin(handoff.openbotUrl); + if (!checked.ok) return refused("invalid-address"); + if (!allowsOpenBot(policy, checked.origin)) + return refused("pinned-elsewhere"); + await closeConnectTab((await readSession("pendingConnect"))?.tabId); + const pending: PendingConnect = { + origin: checked.origin, + code: handoff.code, + via: "desktop", + requestedAt: Date.now(), + }; + await writeSession("pendingConnect", pending); + const tabId = await deps.openTab(connectUrl()); + if (tabId !== undefined) + await writeSession("pendingConnect", { ...pending, tabId }); + return { offered: true }; +} diff --git a/extension/src/background/content-masking.ts b/extension/src/background/content-masking.ts new file mode 100644 index 000000000..453bd2beb --- /dev/null +++ b/extension/src/background/content-masking.ts @@ -0,0 +1,64 @@ +// What a content script sends is masked once more in the service worker, with every sensitive-value digest the recording +// holds. A content script knows only what was typed into sensitive fields of its own document: a password typed on one +// page and then typed or pasted into an ordinary field on the next page, in another tab or in another frame, leaves its +// page unmasked. The service worker holds the digests of every document of the recording (content.sensitive), so here the +// value goes wherever it appears later: the edit's value and text, the attributes, label and name that copy it, its +// selectors, and anything else in the step. +import { + type CapturedEvent, + MIN_DIGEST_LENGTH, + maskCollectorEvent, + normaliseSecret, + sha256HexSync, +} from "@openbot/shared/recorder-masking"; +import { AGENT_TARGET_FIELD } from "../shared/agent-target"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * The content event masked with `digests`, or null for one that cannot be read. A field the masking judges sensitive here + * (its label names a one-time code, say) that still holds its value had it redacted, and `keep` receives that value's + * digest, so it goes from everything after too (the save's body and answer, the agent's events, the narration). The click's + * private agent target is left as the page wrote it: the agent events read its ids and remove it. + */ +export function maskContentEvent( + event: CapturedEvent, + digests: ReadonlySet, + keep: (digest: string) => void, +): CapturedEvent | null { + const value = event?.value; + const target = + isRecord(value) && AGENT_TARGET_FIELD in value + ? value[AGENT_TARGET_FIELD] + : undefined; + const bare = + target === undefined || !isRecord(value) + ? event + : { + ...event, + value: Object.fromEntries( + Object.entries(value).filter(([key]) => key !== AGENT_TARGET_FIELD), + ) as CapturedEvent["value"], + }; + const masked = maskCollectorEvent( + bare, + { + sensitive: false, + onSensitive: (typed) => { + for (const item of typed) { + const normalised = normaliseSecret(item); + if (normalised.length >= MIN_DIGEST_LENGTH) + keep(sha256HexSync(normalised)); + } + }, + }, + { digests }, + ); + if (masked === null || target === undefined || !isRecord(masked.value)) + return masked; + return { + ...masked, + value: { ...masked.value, [AGENT_TARGET_FIELD]: target }, + } as CapturedEvent; +} diff --git a/extension/src/background/content-scripts.ts b/extension/src/background/content-scripts.ts new file mode 100644 index 000000000..2d9c19c8f --- /dev/null +++ b/extension/src/background/content-scripts.ts @@ -0,0 +1,76 @@ +// The content script runs only in the tabs a recording follows (Decision 4): it is injected at Record and on each +// committed frame of a followed tab, never registered for every site, so an unrelated tab never runs it. +import type { BackgroundToContent } from "../protocol/messages"; + +/** Runs in the page's ISOLATED world before content.js, so the script starts capturing the moment it loads, before its + * port says anything (the injection race, Task 9). Chrome serializes it: it must use nothing but its argument. */ +function markRecordingActive(recordingId: string): void { + (globalThis as { __openbotRecorderActive?: string }).__openbotRecorderActive = + recordingId; +} + +/** The active flag, then content.js, both as soon as possible; every frame of the tab when `frameIds` is absent. */ +export async function injectRecordingScripts( + tabId: number, + recordingId: string, + frameIds?: number[], +): Promise { + const target = + frameIds === undefined ? { tabId, allFrames: true } : { tabId, frameIds }; + await chrome.scripting.executeScript({ + target, + func: markRecordingActive, + args: [recordingId], + injectImmediately: true, + }); + await chrome.scripting.executeScript({ + target, + files: ["content.js"], + injectImmediately: true, + }); +} + +/** The content ports open in each tab, so messages reach every frame of the tabs a recording follows. */ +const portsByTab = new Map>(); + +/** Keeps a content port until it disconnects. A port from no tab (never a content script) is not kept. */ +export function trackContentPort(port: chrome.runtime.Port): void { + const tabId = port.sender?.tab?.id; + if (tabId === undefined) return; + const ports = portsByTab.get(tabId) ?? new Set(); + ports.add(port); + portsByTab.set(tabId, ports); + port.onDisconnect.addListener(() => { + ports.delete(port); + if (ports.size === 0 && portsByTab.get(tabId) === ports) + portsByTab.delete(tabId); + }); +} + +/** A new service worker holds no ports: the content scripts reconnect theirs. */ +export function forgetContentPorts(): void { + portsByTab.clear(); +} + +/** Posts to every content port of the tabs, or only to their top frames' ports. A port that closed meanwhile is skipped. */ +export function postToContentPorts( + tabIds: readonly number[], + message: BackgroundToContent, + options: { topFrameOnly?: boolean } = {}, +): void { + for (const tabId of tabIds) { + for (const port of portsByTab.get(tabId) ?? []) { + if (options.topFrameOnly && port.sender?.frameId !== 0) continue; + try { + port.postMessage(message); + } catch { + // The frame went away; its port's disconnect removes it. + } + } + } +} + +/** Tells the content scripts of these tabs to stop capturing. */ +export async function stopRecordingScripts(tabIds: number[]): Promise { + postToContentPorts(tabIds, { type: "background.capture", active: false }); +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts new file mode 100644 index 000000000..bd0b322b0 --- /dev/null +++ b/extension/src/background/index.ts @@ -0,0 +1,19 @@ +import { restrictStorageToTrustedContexts } from "../shared/storage"; +import { installConnection } from "./connection"; +import { installRecording } from "./recording"; +import { recoverInterruptedRecording } from "./recovery"; +import { installRouter } from "./router"; +import { installSetup } from "./setup"; + +// Every listener is registered synchronously at the top level: a service worker the browser wakes for an event only +// delivers it to listeners registered before its first turn ends. +const recording = installRecording(); +installRouter(recording); +// A connection that ends without the person's Disconnect (the keep-alive's sign-out, a policy move) also ends what it +// still has running: the recording controller stops the live recording and the document's streams for it. +installConnection({ connectionLost: recording.connectionLost }); +// A fresh install opens the page that asks for the microphone; website access came with the install itself. +installSetup(); +chrome.runtime.onStartup.addListener(recoverInterruptedRecording); +void restrictStorageToTrustedContexts(); +void recoverInterruptedRecording(); diff --git a/extension/src/background/native-host.ts b/extension/src/background/native-host.ts new file mode 100644 index 000000000..77b78ca37 --- /dev/null +++ b/extension/src/background/native-host.ts @@ -0,0 +1,62 @@ +import { RECORDER_NATIVE_HOST } from "@openbot/shared/recorder-extension"; + +export type DesktopHandoff = { openbotUrl: string; code: string }; +/** Why OpenBot Desktop did not hand over a connect code: the host's own error code (`not-running`, `not-signed-in` and + * the rest), `no-answer` when the host is missing or did not answer in time, or `handoff-failed` for a reply of + * another shape. */ +export type DesktopRefusal = { error: string }; + +async function ask( + message: object, + timeoutMs: number, +): Promise | null> { + let timer: ReturnType | undefined; + try { + const reply = await Promise.race([ + chrome.runtime.sendNativeMessage(RECORDER_NATIVE_HOST, message), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }), + ]); + return reply && typeof reply === "object" + ? (reply as Record) + : null; + } catch { + return null; // host not installed, or refused + } finally { + clearTimeout(timer); + } +} + +/** The host answers a ping within its own 2 s deadline (DEADLINES in desktop/src-tauri/src/recorder_host.rs); the + * extension waits a little longer. */ +const PING_TIMEOUT_MS = 3_000; +/** The host answers a handoff, its request to the local OpenBot for a connect code included, within its own 7 s + * deadline (DEADLINES in recorder_host.rs); the extension waits a little longer. */ +const HANDOFF_TIMEOUT_MS = 8_000; + +/** A ping that mints nothing. */ +export async function desktopAvailable( + timeoutMs = PING_TIMEOUT_MS, +): Promise { + return (await ask({ type: "ping" }, timeoutMs))?.available === true; +} + +/** The host's handoff, or why there is none. A good ping does not promise a good handoff: the ping is cheap, and only + * the handoff checks that OpenBot answers and that the organization sign-in can be handed over. */ +export async function askDesktopForHandoff( + timeoutMs = HANDOFF_TIMEOUT_MS, +): Promise { + const reply = await ask({ type: "handoff" }, timeoutMs); + if (!reply) return { error: "no-answer" }; + if ( + reply.ok !== false && + typeof reply.openbotUrl === "string" && + typeof reply.code === "string" && + reply.code.startsWith("obc_") + ) + return { openbotUrl: reply.openbotUrl, code: reply.code }; + if (typeof reply.error === "string" && reply.error.length > 0) + return { error: reply.error }; + return { error: "handoff-failed" }; +} diff --git a/extension/src/background/navigation.ts b/extension/src/background/navigation.ts new file mode 100644 index 000000000..db76a5688 --- /dev/null +++ b/extension/src/background/navigation.ts @@ -0,0 +1,58 @@ +import { type CapturedEvent, scrubUrl } from "@openbot/shared/recorder-masking"; + +export type NavigationDetails = { + tabId: number; + frameId: number; + parentFrameId?: number; + url: string; + transitionType?: string; + transitionQualifiers?: string[]; + timeStamp?: number; +}; + +type NavigationKind = "commit" | "history"; +type NavigationType = "push" | "replace" | "traverse" | "reload"; + +function goesBackOrForward(details: NavigationDetails): boolean { + return details.transitionQualifiers?.includes("forward_back") ?? false; +} + +function navigationType( + details: NavigationDetails, + kind: NavigationKind, +): NavigationType { + if (kind === "history") return "push"; + if (goesBackOrForward(details)) return "traverse"; + if (details.transitionType === "reload") return "reload"; + return "push"; +} + +/** The collector's navigation event, for a commit or a history update in any frame: `navigationType` in CopilotKit's + * own words (push, replace, traverse, reload), and beside it how Chrome says the page was reached (webNavigation's + * transitionType and transitionQualifiers: an address typed, a link, a form, a reload, a redirect). `options.digests`, + * the recording's sensitive-value digests, mask a value typed into a sensitive field wherever an address holds it later. */ +export function navigationEvent( + details: NavigationDetails, + kind: NavigationKind, + fromUrl: string | undefined, + options?: { digests?: ReadonlySet }, +): CapturedEvent { + return { + type: "CUSTOM", + name: "navigation", + timestamp: details.timeStamp ?? Date.now(), + value: { + from: fromUrl === undefined ? "" : scrubUrl(fromUrl, options), + to: scrubUrl(details.url, options), + navigationType: navigationType(details, kind), + ...(details.transitionType + ? { transitionType: details.transitionType } + : {}), + ...(details.transitionQualifiers + ? { transitionQualifiers: [...details.transitionQualifiers] } + : {}), + frameId: details.frameId, + parentFrameId: details.parentFrameId ?? (details.frameId === 0 ? -1 : 0), + }, + }; +} diff --git a/extension/src/background/network/agent.ts b/extension/src/background/network/agent.ts new file mode 100644 index 000000000..5d5bf523a --- /dev/null +++ b/extension/src/background/network/agent.ts @@ -0,0 +1,585 @@ +// The AG-UI traffic Chrome's debugger shows in a followed tab, as the agent events read it (agent/observation.ts): each +// agent run a page posts, the events of its SSE or protobuf answer read live as they arrive (Network.streamResourceContent, +// then each Network.dataReceived carries its bytes), the AG-UI events its WebSockets receive, and CopilotKit's runtime +// `info` call when its runtime answers it (the page runs CopilotKit). The request tracker +// (debugger.ts) asks this which requests are runs: a run is never a step's save, nor a write a step waits for. +import type { CaptureObservation } from "../agent/observation"; +import { + createChannels, + createHttpStream, + createSocketReader, + isRuntimeInfoAnswer, + matchRun, + matchRuntimeInfoRequest, + type PageObservation, + type RunMatch, + runChannel, + streamFormat, +} from "../../shared/agui"; +import type { Debuggee } from "./debugger"; + +type Send = ( + target: Debuggee, + method: string, + params?: Record, +) => Promise; + +type Json = Record; +const isRecord = (value: unknown): value is Json => + typeof value === "object" && value !== null && !Array.isArray(value); +const string = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; +const number = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** The key the request tracker and this capture share for one request. */ +export const requestKey = (target: Debuggee, requestId: string) => + `${target.tabId}/${target.sessionId ?? ""}/${requestId}`; + +function headerValue(headers: unknown, name: string): string | undefined { + if (!isRecord(headers)) return undefined; + for (const [key, value] of Object.entries(headers)) + if (key.toLowerCase() === name && typeof value === "string") return value; + return undefined; +} + +function bytesOf(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +const encoder = new TextEncoder(); +/** A body Chrome hands over, as base64 or as text. */ +const bodyBytes = (body: string, base64: boolean) => + base64 ? bytesOf(body) : encoder.encode(body); +const bodyText = (body: string, base64: boolean) => + base64 ? new TextDecoder().decode(bytesOf(body)) : body; + +/** The resource types an AG-UI run or stream can be: the page's own requests and event sources, never a script, + * a style sheet, an image or a document. */ +const FOLLOWED_TYPES = new Set(["Fetch", "XHR", "EventSource", "Other"]); +/** How many verdicts wait for the tracker at most: it asks for each POST it follows, once. */ +const MAX_VERDICTS = 512; + +/** CDP's monotonic seconds against the wall clock (epoch ms) at one moment, to read later timestamps as wall time. */ +type Clock = { monotonic: number; wall: number }; +const wallAt = (clock: Clock, monotonic: number | undefined) => + monotonic === undefined + ? Date.now() + : Math.round(clock.wall + (monotonic - clock.monotonic) * 1000); + +type Request = { + target: Debuggee; + requestId: string; + url: string; + clock: Clock; + /** A POST whose body Chrome left out of requestWillBeSent: what it is waits for Network.getRequestPostData. */ + match: RunMatch | null | "pending"; + stream?: ReturnType; + /** Everything that happens to the request, in the order Chrome reported it: a body still being read holds what + * came after it. */ + work: Promise; + /** The answer's binding, once its headers came: an SSE or protobuf answer is read for events. */ + format: "sse" | "proto" | null; + /** live: the answer streams through dataReceived; waiting: streamResourceContent has not answered yet. */ + reading: "off" | "waiting" | "live"; + /** dataReceived bytes that came while streamResourceContent had not answered, after what it had buffered. */ + early: { bytes: Uint8Array; at: number }[]; + /** Whether the request is an agent run, for the tracker. */ + verdict: Verdict; + /** CopilotKit's runtime `info` call: its answer is read once it finished. */ + info: boolean; + /** Followed for its `info` call alone (`infoOnly`): never read as AG-UI or as a run. */ + infoOnly: boolean; + /** The answer's status, once its headers came. */ + status: number | null; +}; + +/** Whether a request is an agent run: known at once for most, after its answer's first event for a candidate. */ +export type RunVerdict = { + readonly value?: boolean; + readonly promise: Promise; + /** Chrome's answer to Network.getRequestPostData for a body it left out of requestWillBeSent: asked once, by the + * AG-UI capture, for the tracker too. */ + readonly postData?: Promise; +}; +type Verdict = { + value?: boolean; + promise: Promise; + decide(run: boolean): void; + postData?: Promise; +}; + +function verdict(): Verdict { + let decide: (run: boolean) => void = () => {}; + const result: Verdict = { + promise: new Promise((resolve) => { + decide = resolve; + }), + decide(run) { + if (result.value !== undefined) return; + result.value = run; + decide(run); + }, + }; + return result; +} + +type Socket = { + tabId: number; + clock?: Clock; + reader: ReturnType; + /** Its frames, in order: each waits for the run answers its tab is still reading, which name the channels. */ + work: Promise; +}; + +export type AgentCapture = ReturnType; + +/** + * Follows the AG-UI traffic per debuggee from the Network domain's events, and hands each observation to `observe`. + * `ignore` names URLs never followed (OpenBot's own); `infoOnly` names URLs followed only when the request is + * CopilotKit's runtime `info` call, and then only for its answer (OpenBot's own runtime); `onRun` says a request whose + * body was read late is a run, so the tracker stops holding steps for it. + */ +export function createAgentCapture(options: { + send: Send; + observe(observation: CaptureObservation): void; + ignore(url: string): boolean; + infoOnly(url: string): boolean; + onRun(key: string): void; +}) { + const requests = new Map(); + const sockets = new Map(); + /** Each tab's Intelligence channels, from its run answers, for the sockets that join them. */ + const channels = new Map>(); + const channelsOf = (tabId: number) => { + let known = channels.get(tabId); + if (!known) { + known = createChannels(); + channels.set(tabId, known); + } + return known; + }; + /** What the tracker asks about a request after this capture let it go: its verdict, until the tracker had it. */ + const verdicts = new Map(); + /** Each tab's run answers still being read: a socket's frames wait for them, so a channel is known before its events. */ + const reading = new Map>(); + + const emitFor = + (tabId: number) => + (observation: PageObservation): void => { + try { + options.observe({ ...observation, tabId } as CaptureObservation); + } catch (error) { + console.error("Recorder: an agent observation failed", error); + } + }; + + /** Runs `step` after everything the request had queued, so its observations keep Chrome's order. */ + const then = (request: Request, step: () => Promise | void) => { + request.work = request.work.then(step).catch((error) => { + console.error("Recorder: an agent stream could not be read", error); + }); + }; + + /** A post body's text from Chrome's answer to Network.getRequestPostData. */ + const postText = (answer: unknown): string | undefined => + isRecord(answer) && typeof answer.postData === "string" + ? bodyText(answer.postData, answer.base64Encoded === true) + : undefined; + + function begin(request: Request, match: RunMatch | null, at: number) { + request.match = match; + // Not a run, the request is still followed: an event stream it answers with is AG-UI from its agent. + request.stream = createHttpStream({ + streamId: requestKey(request.target, request.requestId), + url: request.url, + match, + at, + emit: emitFor(request.target.tabId), + }); + if (match?.kind === "run") request.verdict.decide(true); + else if (match?.kind !== "candidate") request.verdict.decide(false); + } + + function onRequest(target: Debuggee, raw: Json): void { + const requestId = string(raw.requestId); + const sent = isRecord(raw.request) ? raw.request : null; + const url = string(sent?.url); + if (requestId === undefined || !sent || url === undefined) return; + const key = requestKey(target, requestId); + // A redirect reuses the request id: the one it lands on is followed from here. + requests.delete(key); + const type = string(raw.type); + if ( + options.ignore(url) || + (type !== undefined && !FOLLOWED_TYPES.has(type)) + ) + return; + const method = (string(sent.method) ?? "GET").toUpperCase(); + const inline = string(sent.postData); + const infoOnly = options.infoOnly(url); + // A URL followed for the info call alone: any other request to it is not followed at all. + if (infoOnly && !matchRuntimeInfoRequest(url, method, inline)) return; + const wallTime = number(raw.wallTime); + const clock: Clock = { + monotonic: number(raw.timestamp) ?? 0, + wall: wallTime === undefined ? Date.now() : Math.round(wallTime * 1000), + }; + const request: Request = { + target, + requestId, + url, + clock, + match: null, + work: Promise.resolve(), + format: null, + reading: "off", + early: [], + verdict: verdict(), + info: infoOnly, + infoOnly, + status: null, + }; + requests.set(key, request); + if (infoOnly) { + request.verdict.decide(false); + return; + } + if (method === "POST") { + verdicts.set(key, request.verdict); + for (const oldest of verdicts.keys()) { + if (verdicts.size <= MAX_VERDICTS) break; + verdicts.delete(oldest); + } + } + request.info = matchRuntimeInfoRequest(url, method, inline); + if ( + method !== "POST" || + inline !== undefined || + sent.hasPostData !== true + ) { + begin(request, matchRun(url, method, inline), clock.wall); + return; + } + // CDP leaves out a body over Network.enable's maxPostDataSize (CopilotKit's run bodies grow with the Thread). + request.match = "pending"; + const answer = options.send(target, "Network.getRequestPostData", { + requestId, + }); + request.verdict.postData = answer; + // What the body is decides the tracker's verdict as soon as it is read; the stream begins in its turn. + const matched = answer + .then(postText, () => undefined) + .then((body) => { + request.info = matchRuntimeInfoRequest(url, method, body); + const match = matchRun(url, method, body); + if (match?.kind !== "run" && match?.kind !== "candidate") + request.verdict.decide(false); + return match; + }); + then(request, async () => { + const match = await matched; + begin(request, match, clock.wall); + if (match && match.kind !== "none") options.onRun(key); + }); + } + + /** Reads the answer live from here on: what Chrome buffered so far comes back, every later dataReceived carries + * its bytes. A response that already finished cannot stream; its body is read at loadingFinished instead. */ + function stream(request: Request, at: number): void { + request.reading = "waiting"; + const answer = options.send( + request.target, + "Network.streamResourceContent", + { requestId: request.requestId }, + ); + then(request, async () => { + let buffered: string | undefined; + try { + const result = await answer; + buffered = isRecord(result) ? string(result.bufferedData) : undefined; + } catch { + request.reading = "off"; + request.early = []; + return; + } + request.reading = "live"; + if (buffered) request.stream?.data(bytesOf(buffered), at); + for (const chunk of request.early.splice(0)) + request.stream?.data(chunk.bytes, chunk.at); + }); + } + + function onResponse(target: Debuggee, raw: Json): void { + const requestId = string(raw.requestId); + const response = isRecord(raw.response) ? raw.response : null; + if (requestId === undefined || !response) return; + const request = requests.get(requestKey(target, requestId)); + if (!request) return; + const contentType = + string(response.mimeType) || + headerValue(response.headers, "content-type"); + const status = number(response.status) ?? 0; + const at = wallAt(request.clock, number(raw.timestamp)); + request.status = status; + request.format = streamFormat(contentType); + then(request, () => { + request.stream?.response(contentType, status); + if (request.stream?.isRun() === false) request.verdict.decide(false); + }); + if (request.format && !request.infoOnly) stream(request, at); + } + + function onData(target: Debuggee, raw: Json): void { + const requestId = string(raw.requestId); + const data = string(raw.data); + if (requestId === undefined || data === undefined) return; + const request = requests.get(requestKey(target, requestId)); + // A stream that showed it is not AG-UI (another app's event stream) keeps coming: its bytes are not even decoded. + if (!request || request.reading === "off" || request.stream?.ignored()) + return; + const chunk = { + bytes: bytesOf(data), + at: wallAt(request.clock, number(raw.timestamp)), + }; + if (request.reading === "waiting") { + request.early.push(chunk); + return; + } + then(request, () => { + request.stream?.data(chunk.bytes, chunk.at); + const run = request.stream?.isRun(); + if (run !== null && run !== undefined) request.verdict.decide(run); + }); + } + + function onDone(target: Debuggee, raw: Json, ok: boolean): void { + const requestId = string(raw.requestId); + if (requestId === undefined) return; + const key = requestKey(target, requestId); + const request = requests.get(key); + if (!request) return; + requests.delete(key); + const at = wallAt(request.clock, number(raw.timestamp)); + const { tabId } = target; + let done: () => void = () => {}; + const finished = new Promise((resolve) => { + done = resolve; + }); + reading.set(tabId, Promise.all([reading.get(tabId), finished])); + then(request, async () => { + try { + await finish(request, ok, at); + } finally { + done(); + } + }); + } + + /** CopilotKit's runtime `info` call answered: when its answer is CopilotKit's RuntimeInfo, the page runs CopilotKit. */ + async function runtimeInfo(request: Request, at: number) { + const body = await options + .send(request.target, "Network.getResponseBody", { + requestId: request.requestId, + }) + .catch(() => null); + if ( + isRecord(body) && + typeof body.body === "string" && + isRuntimeInfoAnswer( + request.status, + bodyText(body.body, body.base64Encoded === true), + ) + ) + emitFor(request.target.tabId)({ kind: "runtime", url: request.url, at }); + } + + async function finish(request: Request, ok: boolean, at: number) { + if (ok && request.info) await runtimeInfo(request, at); + const { stream, match } = request; + if (!stream || match === "pending") return; + const run = match?.kind === "run"; + // An event stream that could not be read live (it finished first) is read whole now; a run's JSON answer may + // name the Intelligence channel its events stream on. + if (ok && request.reading !== "live" && (run || request.format)) { + const body = await options + .send(request.target, "Network.getResponseBody", { + requestId: request.requestId, + }) + .catch(() => null); + if (isRecord(body) && typeof body.body === "string") { + const base64 = body.base64Encoded === true; + if (request.format) stream.data(bodyBytes(body.body, base64), at); + const channel = run ? runChannel(bodyText(body.body, base64)) : null; + if (channel) + channelsOf(request.target.tabId).set(channel.topic, { + agentId: match?.agentId ?? null, + threadId: channel.threadId ?? match?.threadId ?? null, + }); + } + } + stream.finish(ok, at); + request.verdict.decide(stream.isRun() === true); + } + + function socketFor(target: Debuggee, requestId: string): Socket | undefined { + return sockets.get(requestKey(target, requestId)); + } + + function onSocketCreated(target: Debuggee, raw: Json): void { + const requestId = string(raw.requestId); + const url = string(raw.url); + if (requestId === undefined || url === undefined) return; + let followed = true; + try { + const parsed = new URL(url); + parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:"; + followed = !options.ignore(parsed.href); + } catch { + followed = false; + } + const key = requestKey(target, requestId); + if (!followed) { + sockets.delete(key); + return; + } + sockets.set(key, { + tabId: target.tabId, + work: Promise.resolve(), + reader: createSocketReader({ + streamId: (topic, n) => + topic === null ? `${key}/ws/${n}` : `${key}/ws/${n}/${topic}`, + emit: emitFor(target.tabId), + channels: channelsOf(target.tabId), + }), + }); + } + + /** Frames carry only CDP's monotonic time: the handshake ties it to the wall clock. */ + function onHandshake(target: Debuggee, raw: Json): void { + const socket = socketFor(target, string(raw.requestId) ?? ""); + const monotonic = number(raw.timestamp); + const wall = number(raw.wallTime); + if (socket && monotonic !== undefined && wall !== undefined) + socket.clock = { monotonic, wall: Math.round(wall * 1000) }; + } + + function onFrame(target: Debuggee, raw: Json, received: boolean): void { + const socket = socketFor(target, string(raw.requestId) ?? ""); + const frame = isRecord(raw.response) ? raw.response : null; + // Text frames only (opcode 1): a binary frame's payload is base64 and never a JSON AG-UI event. + if (!socket || !frame || frame.opcode !== 1) return; + const data = string(frame.payloadData); + if (data === undefined) return; + const timestamp = number(raw.timestamp); + const at = socket.clock ? wallAt(socket.clock, timestamp) : Date.now(); + queue(socket, () => { + if (received) socket.reader.received(data, at); + else socket.reader.sent(data, at); + }); + } + + function queue(socket: Socket, step: () => void): void { + const pending = reading.get(socket.tabId); + socket.work = socket.work + .then(() => pending) + .then(step) + .catch((error) => { + console.error("Recorder: a WebSocket frame could not be read", error); + }); + } + + function onSocketClosed(target: Debuggee, raw: Json): void { + const key = requestKey(target, string(raw.requestId) ?? ""); + const socket = sockets.get(key); + if (!socket) return; + sockets.delete(key); + const timestamp = number(raw.timestamp); + const at = socket.clock ? wallAt(socket.clock, timestamp) : Date.now(); + queue(socket, () => socket.reader.closed(at)); + } + + return { + onEvent(target: Debuggee, method: string, raw: unknown): void { + if (!isRecord(raw)) return; + switch (method) { + case "Network.requestWillBeSent": + onRequest(target, raw); + return; + case "Network.responseReceived": + onResponse(target, raw); + return; + case "Network.dataReceived": + onData(target, raw); + return; + case "Network.loadingFinished": + onDone(target, raw, true); + return; + case "Network.loadingFailed": + onDone(target, raw, false); + return; + case "Network.webSocketCreated": + onSocketCreated(target, raw); + return; + case "Network.webSocketWillSendHandshakeRequest": + onHandshake(target, raw); + return; + case "Network.webSocketFrameReceived": + onFrame(target, raw, true); + return; + case "Network.webSocketFrameSent": + onFrame(target, raw, false); + return; + case "Network.webSocketClosed": + onSocketClosed(target, raw); + return; + } + }, + /** What the tracker reads at requestWillBeSent: a run or a candidate is never a write a step waits for; a body + * still being read is, until onRun says otherwise. */ + kind(key: string): "run" | "candidate" | "pending" | null { + const match = requests.get(key)?.match; + if (match === "pending") return "pending"; + if (match?.kind === "run" || match?.kind === "candidate") + return match.kind; + return null; + }, + /** Whether the request is an agent run (`value` once known, `promise` until then), and the body this capture read + * for it when Chrome left it out. The tracker never makes a run a step's call. */ + inspect(key: string): RunVerdict | undefined { + return verdicts.get(key); + }, + /** The tracker had the request: its verdict is no longer kept. */ + release(key: string): void { + verdicts.delete(key); + }, + /** A tab that closed or navigated away: its requests and sockets are dropped, their streams unended. */ + forgetTab(tabId: number): void { + for (const [key, request] of requests) + if (request.target.tabId === tabId) { + requests.delete(key); + request.verdict.decide(false); + } + for (const [key, socket] of sockets) + if (socket.tabId === tabId) sockets.delete(key); + for (const [key, known] of verdicts) + if (key.startsWith(`${tabId}/`)) { + known.decide(false); + verdicts.delete(key); + } + channels.delete(tabId); + reading.delete(tabId); + }, + clear(): void { + for (const known of verdicts.values()) known.decide(false); + requests.clear(); + sockets.clear(); + verdicts.clear(); + channels.clear(); + reading.clear(); + }, + }; +} diff --git a/extension/src/background/network/debugger.ts b/extension/src/background/network/debugger.ts new file mode 100644 index 000000000..0fc0b2c70 --- /dev/null +++ b/extension/src/background/network/debugger.ts @@ -0,0 +1,523 @@ +// What Chrome's debugger (CDP's Network domain) reports about the calls a followed tab and its frames make: when each +// finished fetch, XHR or top-frame document request ran, so a step can tell which calls it caused, and for a write its +// bodies read through the debugger as the collector's `network` event, so it can be that step's save. Nothing here is +// recorded on its own: the event leaves unmasked, and the capture masks it before the steps (steps.ts) see it. +import type { + CapturedEvent, + JsonValue, +} from "@openbot/shared/recorder-masking"; +import { + isReadableBody, + type NetworkBody, + textBody, + unavailableBody, +} from "../../shared/network-body"; +import { requestKey, type RunVerdict } from "./agent"; +import { callSignature, type RepeatMemory, screenCall } from "./screen"; +import { isPrefetch, isTelemetry } from "./telemetry"; + +/** A tab, or a frame's own session inside it (flattened auto-attach: cross-site iframes run in their own process). */ +export type Debuggee = { tabId: number; sessionId?: string }; + +type Send = ( + target: Debuggee, + method: string, + params?: Record, +) => Promise; + +type Pending = { + target: Debuggee; + requestId: string; + url: string; + method: string; + headers: Record; + postData?: string; + hasPostData: boolean; + type?: string; + frameId?: string; + /** CDP's monotonic seconds when the request was sent, and the wall clock (epoch ms) at that moment. */ + startedAt: number; + wallStart: number; + /** Monotonic seconds when the response headers arrived. */ + respondedAt?: number; + /** The tab already made this exact call before (polling, a refetch): see screen.ts createRepeatMemory. */ + repeat: boolean; + /** Answered with a redirect: its response is the redirect's, and it has no body of its own to read. */ + redirected?: true; + response?: { + url: string; + status: number; + headers: Record; + mimeType: string; + }; +}; + +/** The calls a step can cause or save through are the page's API calls and the top frame's own document requests (a + * link followed, a form posted): images, fonts, stylesheets, scripts, media, beacons (Ping), CSP reports, preflights, + * prefetches, event streams and websockets never are. */ +const RECORDED_TYPES = new Set(["XHR", "Fetch", "Document"]); +/** CDP's own cap on post data it sends inline; anything longer is asked for with Network.getRequestPostData. */ +export const MAX_POST_DATA_SIZE = 65_536; +const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +/** A finished call that passed the screens: when it ran, and for a write its unmasked exchange as a network event. */ +export type TrackedCall = { + tabId: number; + startedAt: number; + respondedAt?: number; + endedAt: number; + method: string; + url: string; + /** A request of the top frame's own document: a link followed or a form posted as a page load. */ + document?: true; + event?: CapturedEvent; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const text = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; +const number = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** CDP header objects map names to strings; anything else is dropped. */ +function headers(value: unknown): Record { + const out: Record = {}; + if (!isRecord(value)) return out; + for (const [name, item] of Object.entries(value)) + if (typeof item === "string") out[name] = item; + return out; +} + +function headerValue( + values: Record, + name: string, +): string | undefined { + for (const [key, item] of Object.entries(values)) + if (key.toLowerCase() === name) return item; + return undefined; +} + +/** Chrome hands some bodies over as base64; a text body is decoded as UTF-8, replacing what is not. */ +function decodeBase64(data: string): string { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} + +/** What the AG-UI capture (agent.ts) knows of a request: an agent run is never a step's call, nor a write in flight. */ +export type AgentRuns = { + /** At requestWillBeSent: a run or a likely one (a candidate) is no write in flight; a body still being read is. */ + kind(key: string): "run" | "candidate" | "pending" | null; + /** Whether the request is an agent run, once its answer decided, and its body when that capture read it. */ + inspect(key: string): RunVerdict | undefined; + release(key: string): void; +}; + +/** + * Follows requests per debuggee from `Network.requestWillBeSent` to `Network.loadingFinished` or `loadingFailed`, and + * hands every finished fetch, XHR or top-frame document request that passed the screens to `call`; a write comes with + * its bodies, read only then, and a write answered by a redirect to a read (a form posted, then sent on with 303) is + * handed over when its redirect comes. `writeStarted` and `writeEnded` bracket each write from its start until `call` + * had it, it was screened out, or the top frame's next document is answered (which ends the page that sent it), so a + * step waits for a save still in flight; a write the cheap screens (telemetry, a prefetch) drop never starts one. + * `ignore` names URLs never recorded (OpenBot's own); `topFrame` answers a tab's main frame id. An AG-UI run (`agent`) + * is none of these: the agent events have it. + */ +export function createRequestTracker(options: { + ignore(url: string): boolean; + send: Send; + call(call: TrackedCall): void; + writeStarted(id: string, tabId: number, startedAt: number): void; + writeEnded(id: string): void; + repeats: RepeatMemory; + topFrame(tabId: number): Promise; + agent?: AgentRuns; +}) { + const pending = new Map(); + /** The writes in flight, by key, until `call` had them. */ + const writes = new Set(); + const endWrite = (key: string) => { + if (writes.delete(key)) options.writeEnded(key); + }; + const keyOf = requestKey; + + /** The request's body; `asked` is Chrome's answer to the AG-UI capture's Network.getRequestPostData, when it + * already asked for this one. */ + async function requestBody( + request: Pending, + asked?: Promise, + ): Promise { + const type = headerValue(request.headers, "content-type"); + // A body sent without a content type is the page's own text (fetch labels a string body text/plain). + if (type !== undefined && !isReadableBody(type)) + return unavailableBody("binary"); + if (request.postData !== undefined) return textBody(request.postData); + if (!request.hasPostData) return { status: "empty" }; + try { + const answer = await (asked ?? + options.send(request.target, "Network.getRequestPostData", { + requestId: request.requestId, + })); + return textBody( + text(isRecord(answer) ? answer.postData : undefined) ?? "", + ); + } catch { + return unavailableBody("request-body-unavailable"); + } + } + + async function responseBody(request: Pending): Promise { + const response = request.response; + if (!response) return unavailableBody("no-response"); + // A redirect's own body is never kept by Chrome: the request id now reads the page it led to. + if (request.redirected) return { status: "empty" }; + if ( + !isReadableBody( + response.mimeType || headerValue(response.headers, "content-type"), + ) + ) + return unavailableBody("binary"); + try { + const answer = await options.send( + request.target, + "Network.getResponseBody", + { + requestId: request.requestId, + }, + ); + if (!isRecord(answer) || typeof answer.body !== "string") + return unavailableBody("response-body-unavailable"); + return textBody( + answer.base64Encoded === true ? decodeBase64(answer.body) : answer.body, + ); + } catch { + return unavailableBody("response-body-unavailable"); + } + } + + /** Wall-clock milliseconds for one of the request's monotonic timestamps. */ + const wall = (request: Pending, monotonic: number) => + request.wallStart + Math.max(0, (monotonic - request.startedAt) * 1000); + + async function finish( + request: Pending, + key: string, + outcome: "ok" | "error" | "aborted", + finishedAt: number, + ): Promise { + if (request.type === "Document") { + // A document request counts only in the top frame: an iframe loading is the page's own doing. + if (request.target.sessionId !== undefined) return; + const top = await options.topFrame(request.target.tabId); + if (top === undefined || request.frameId !== top) return; + } + // An agent run is the agent events' (agent.ts): never a step's call. + const agent = + request.method === "POST" ? options.agent?.inspect(key) : undefined; + options.agent?.release(key); + if (agent?.value === true) return; + const verdict = screenCall({ + url: request.url, + method: request.method, + outcome, + headers: request.headers, + body: request.postData, + repeat: request.repeat, + }); + if (verdict) return; + const endedAt = wall(request, finishedAt); + const timing = { + tabId: request.target.tabId, + startedAt: request.wallStart, + respondedAt: + request.respondedAt === undefined + ? undefined + : Math.min(endedAt, wall(request, request.respondedAt)), + endedAt, + method: request.method, + url: request.url, + ...(request.type === "Document" ? { document: true as const } : {}), + }; + // A read can only lead to a save: when it ran is all a step needs, and its bodies are never read. + if (!WRITE_METHODS.has(request.method)) { + options.call(timing); + return; + } + const event = await networkEvent( + request, + outcome, + finishedAt, + agent?.postData, + ); + // A candidate is a run once its answer's first event was AG-UI: decided by now, unless its answer is still open. + if (agent && (agent.value ?? (await agent.promise))) return; + // A body Chrome left out of requestWillBeSent is screened once read: one only the page's reporting on itself sends + // (CopilotKit's own capture batch, past 64 KiB) is never a save either. + const sentText = (event.value as { request: { body: NetworkBody } }).request + .body.text; + if ( + request.postData === undefined && + sentText !== undefined && + isTelemetry(request.url, sentText) + ) + return; + options.call({ ...timing, event }); + endWrite(key); + } + + /** Reads the call's bodies into the collector's network event. */ + async function networkEvent( + request: Pending, + outcome: "ok" | "error" | "aborted", + finishedAt: number, + asked?: Promise, + ): Promise { + const [sent, received] = await Promise.all([ + requestBody(request, asked), + outcome === "ok" + ? responseBody(request) + : Promise.resolve(unavailableBody("no-response")), + ]); + const url = new URL(request.url); + const completedAt = Date.now(); + const response = request.response; + return { + type: "CUSTOM", + name: "network", + timestamp: completedAt, + value: { + transport: + request.type === "XHR" + ? "xhr" + : request.type === "Document" + ? "document" + : "fetch", + method: request.method, + url: request.url, + origin: url.origin, + route: url.pathname, + status: outcome === "ok" ? (response?.status ?? null) : null, + durationMs: Math.max( + 0, + Math.round((finishedAt - request.startedAt) * 1000), + ), + completedAt, + outcome, + request: { + headers: request.headers, + body: sent as unknown as JsonValue, + }, + response: { + ...(response ? { url: response.url } : {}), + headers: response?.headers ?? {}, + body: received as unknown as JsonValue, + }, + capturedBy: "debugger", + }, + }; + } + + /** A write answered by a redirect to a read: the write is finished as its own call, the redirect its answer. */ + function redirect( + request: Pending, + key: string, + response: Record, + timestamp: unknown, + ): void { + pending.delete(key); + const at = number(timestamp) ?? request.startedAt; + request.redirected = true; + request.respondedAt = at; + request.response = { + url: request.url, + status: number(response.status) ?? 0, + headers: headers(response.headers), + mimeType: text(response.mimeType) ?? "", + }; + if (!request.type || !RECORDED_TYPES.has(request.type)) { + endWrite(key); + return; + } + void finish(request, key, "ok", at) + .catch((error) => + console.error("Recorder: a network call could not be read", error), + ) + .finally(() => endWrite(key)); + } + + /** The top frame's next document is being answered, and the page that sent the tab's writes still in flight goes with + * it: Chrome cancels them, and may never report it once the old document is gone. No step waits for them any longer; + * one that still finishes reaches the steps as any call does. */ + async function committing(document: Pending, documentKey: string) { + const { tabId, sessionId } = document.target; + if (sessionId !== undefined) return; + const top = await options.topFrame(tabId); + if (top === undefined || document.frameId !== top) return; + for (const key of [...writes]) { + const request = pending.get(key); + if ( + key !== documentKey && + request?.target.tabId === tabId && + request.type !== "Document" + ) + endWrite(key); + } + } + + function settle( + target: Debuggee, + params: Record, + outcome: "ok" | "error" | "aborted", + ): void { + const requestId = text(params.requestId); + if (requestId === undefined) return; + const key = keyOf(target, requestId); + const request = pending.get(key); + pending.delete(key); + if (!request) { + endWrite(key); + return; + } + const type = text(params.type) ?? request.type; + if (!type || !RECORDED_TYPES.has(type)) { + endWrite(key); + return; + } + request.type = type; + void finish( + request, + key, + outcome, + number(params.timestamp) ?? request.startedAt, + ) + .catch((error) => + console.error("Recorder: a network call could not be read", error), + ) + .finally(() => endWrite(key)); + } + + return { + onEvent(target: Debuggee, method: string, raw: unknown): void { + if (!isRecord(raw)) return; + switch (method) { + case "Network.requestWillBeSent": { + const requestId = text(raw.requestId); + const request = isRecord(raw.request) ? raw.request : null; + const url = text(request?.url); + if (requestId === undefined || !request || url === undefined) return; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") + return; + const key = keyOf(target, requestId); + const method = (text(request.method) ?? "GET").toUpperCase(); + // A redirect reuses the request id: the call that matters is the one it lands on, except a write answered by + // a redirect to a read (a form posted, then 303 See Other to the page it leads to), which is a call of its own. + const redirected = pending.get(key); + if ( + redirected && + isRecord(raw.redirectResponse) && + WRITE_METHODS.has(redirected.method) && + !WRITE_METHODS.has(method) + ) + redirect(redirected, key, raw.redirectResponse, raw.timestamp); + if (options.ignore(url)) { + pending.delete(key); + endWrite(key); + return; + } + const postData = text(request.postData); + const type = text(raw.type); + const wallTime = number(raw.wallTime); + const wallStart = + wallTime === undefined ? Date.now() : Math.round(wallTime * 1000); + const sent = headers(request.headers); + pending.set(key, { + target, + requestId, + url, + method, + headers: sent, + postData, + hasPostData: request.hasPostData === true, + type, + frameId: text(raw.frameId), + startedAt: number(raw.timestamp) ?? 0, + wallStart, + // Only the kinds a step can cause are remembered: a script or an image is never a repeat that matters. + repeat: + type !== undefined && RECORDED_TYPES.has(type) + ? options.repeats.seen( + target.tabId, + callSignature(method, url, postData), + wallStart, + ) + : false, + }); + const agent = options.agent?.kind(key); + // A write the screens already drop (telemetry, a prefetch) is never a save, so no step waits for it. + if ( + type !== undefined && + RECORDED_TYPES.has(type) && + WRITE_METHODS.has(method) && + agent !== "run" && + agent !== "candidate" && + !writes.has(key) && + !isTelemetry(url, postData) && + !isPrefetch(sent) + ) { + writes.add(key); + options.writeStarted(key, target.tabId, wallStart); + } + return; + } + case "Network.responseReceived": { + const requestId = text(raw.requestId); + if (requestId === undefined) return; + const key = keyOf(target, requestId); + const request = pending.get(key); + const response = isRecord(raw.response) ? raw.response : null; + if (!request || !response) return; + request.type = text(raw.type) ?? request.type; + if (request.type === "Document") void committing(request, key); + request.respondedAt = number(raw.timestamp) ?? request.respondedAt; + request.response = { + url: text(response.url) ?? request.url, + status: number(response.status) ?? 0, + headers: headers(response.headers), + mimeType: text(response.mimeType) ?? "", + }; + return; + } + case "Network.loadingFinished": + settle(target, raw, "ok"); + return; + case "Network.loadingFailed": + settle(target, raw, raw.canceled === true ? "aborted" : "error"); + return; + } + }, + /** The AG-UI capture read a body late and found an agent run: no step waits for it any longer. */ + agentRun(key: string): void { + endWrite(key); + }, + /** A tab that closed: its calls still in flight never finish. */ + forgetTab(tabId: number): void { + for (const [key, request] of pending) + if (request.target.tabId === tabId) { + pending.delete(key); + endWrite(key); + } + }, + clear(): void { + pending.clear(); + for (const key of [...writes]) endWrite(key); + }, + }; +} diff --git a/extension/src/background/network/fallback.ts b/extension/src/background/network/fallback.ts new file mode 100644 index 000000000..11756d709 --- /dev/null +++ b/extension/src/background/network/fallback.ts @@ -0,0 +1,114 @@ +import { + type CapturedEvent, + type JsonValue, + maskNetworkEvent, +} from "@openbot/shared/recorder-masking"; + +export type PageFallback = { + start(tabIds: number[]): Promise; + addTab(tabId: number): Promise; + onCommitted(tabId: number, frameId: number): Promise; + stop(): Promise; + active(): boolean; +}; + +/** The hook (main-world/network-hook.ts) defines the symbols openbot.recorder.configure and openbot.recorder.stop on globalThis. */ + +type Hook = Record void) | undefined>; + +const configureHook = (id: string): void => + (globalThis as unknown as Hook)[Symbol.for("openbot.recorder.configure")]?.( + id, + ); +const stopHook = (): void => + (globalThis as unknown as Hook)[Symbol.for("openbot.recorder.stop")]?.(); + +/** + * Injects the page-network hook into the MAIN world of the recorded tabs only: never registerContentScripts, so an + * unrelated tab never gets the hook. A page Chrome refuses (a restricted or opaque sandboxed frame) is skipped. + */ +export function createPageFallback(): PageFallback { + const tabs = new Set(); + let running = false; + + const inject = async (tabId: number, frameIds?: number[]): Promise => { + const target = frameIds ? { tabId, frameIds } : { tabId, allFrames: true }; + await chrome.scripting.executeScript({ + target, + world: "MAIN", + injectImmediately: true, + files: ["page-network.js"], + }); + await chrome.scripting.executeScript({ + target, + world: "MAIN", + injectImmediately: true, + func: configureHook, + args: [chrome.runtime.id], + }); + }; + + return { + async start(tabIds) { + tabs.clear(); + for (const tabId of tabIds) tabs.add(tabId); + try { + for (const tabId of tabIds) await inject(tabId); + } catch { + tabs.clear(); + return false; + } + running = true; + return true; + }, + async addTab(tabId) { + tabs.add(tabId); + if (!running) return; + await inject(tabId).catch(() => {}); + }, + async onCommitted(tabId, frameId) { + if (!running || !tabs.has(tabId)) return; + await inject(tabId, [frameId]).catch(() => {}); + }, + async stop() { + const stopping = [...tabs]; + running = false; + tabs.clear(); + for (const tabId of stopping) + await chrome.scripting + .executeScript({ + target: { tabId, allFrames: true }, + world: "MAIN", + injectImmediately: true, + func: stopHook, + }) + .catch(() => {}); + }, + active: () => running, + }; +} + +const isObject = (value: unknown): value is { [key: string]: JsonValue } => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** A recorder.page-network event from a recorded tab, re-masked with the digests (the page cannot know them) and marked + * `capturedBy: "page"`; null drops it. The router hands it to NetworkCapture.recordPageEvent, which screens it and hands + * it to the steps, where a write can be a step's save. */ +export function acceptPageNetworkEvent( + event: CapturedEvent, + digests: ReadonlySet, +): CapturedEvent | null { + if (!isObject(event) || event.name !== "network") return null; + const value = event.value; + if ( + !isObject(value) || + typeof value.url !== "string" || + typeof value.method !== "string" || + (typeof value.status !== "number" && value.status !== null) + ) + return null; + const masked = maskNetworkEvent(event, { digests }); + if (!isObject(masked.value)) return null; + // Page scripts can forge these messages: consumers read capturedBy to know they are lower trust. + return { ...masked, value: { ...masked.value, capturedBy: "page" } }; +} diff --git a/extension/src/background/network/index.ts b/extension/src/background/network/index.ts new file mode 100644 index 000000000..1167037c2 --- /dev/null +++ b/extension/src/background/network/index.ts @@ -0,0 +1,577 @@ +// Network capture for the tabs a recording follows: Chrome's debugger (CDP's Network domain) on each followed tab and +// its frames, or, where Chrome refuses the debugger on a real site, the page-level fetch, XHR and WebSocket hook +// (fallback.ts), which this capture owns. No call is recorded on its own: each one that passes the screens (screen.ts) +// goes to the steps (steps.ts) with when it ran, and a write with its exchange masked with the shared rules (and marked +// when its body held what the person typed into a sensitive field in that tab), so it can be the save of the step that +// sent it. The AG-UI traffic (agent.ts) goes to the agent events as observations; an agent run is never a step's save. +// Nothing is stored or sent anywhere else. +import { + type CapturedEvent, + holdsTypedSecret, + type JsonValue, + maskNetworkEvent, + scrubUrl, +} from "@openbot/shared/recorder-masking"; +import { isRecordablePage } from "../../shared/origin"; +import type { CaptureObservation } from "../agent/observation"; +import type { StepCall } from "../steps"; +import { createAgentCapture } from "./agent"; +import { + createRequestTracker, + type Debuggee, + MAX_POST_DATA_SIZE, + type TrackedCall, +} from "./debugger"; +import type { PageFallback } from "./fallback"; +import { callSignature, createRepeatMemory, screenCall } from "./screen"; + +export type NetworkMode = "debugger" | "page" | "waiting" | "off"; +export type NetworkCaptureDeps = { + /** The OpenBot the recording's passes come from: its own calls are never a step's, and the AG-UI capture follows only its + * chat with a Bot. Null once nothing records. */ + openbotUrl(): string | null; + /** A call that passed the screens, masked: for the steps to tell which they caused and which saved something. */ + call(call: StepCall): void; + /** A write in flight (debugger only), from its start until `call` had it or it was screened out. */ + writeStarted(id: string, tabId: number, startedAt: number): void; + writeEnded(id: string): void; + status(mode: NetworkMode | "stopped", reason?: string): void; + digests(): ReadonlySet; + /** The digests of what the person lately typed into sensitive fields in the tab (none when absent): a write whose body + * held one is marked, as it is masked, as carrying what they entered. */ + typedSecrets?(tabId: number): ReadonlySet; + /** What the capture saw of AG-UI in a followed tab, raw, in the order it saw it (agent/observation.ts). */ + agent(observation: CaptureObservation): void; +}; +export type NetworkCapture = { + start(tabId: number, url: string): Promise; + addTab(tabId: number, url: string): Promise; + /** Every committed navigation in a followed tab (any frame): lets a tab that started on chrome:// attach once it reaches a site. */ + onCommitted(tabId: number, frameId: number, url: string): Promise; + /** After a service-worker restart: re-attach to every followed tab (detaching a stale session of ours first), resume + * the page capture when that was the mode, and stay stopped when the person had closed the debugging bar. */ + resume( + tabs: { tabId: number; url: string }[], + previous: NetworkMode | "stopped", + ): Promise; + /** A page-level fallback event (Task 18) from a followed tab, already re-masked: dropped when it is a call to OpenBot + * itself (the debugger never sees those either) or fails the screens, else handed to the steps like a debugger call. */ + recordPageEvent(tabId: number, event: CapturedEvent): Promise; + /** An AG-UI observation the page hook made in a followed tab: handed to the agent events like a debugger one. */ + recordPageAgent(tabId: number, observation: CaptureObservation): void; + stop(): Promise; +}; + +const WAITING = + "What each step saves is recorded once this tab opens a website."; +const CANCELLED = + "You closed Chrome's debugging bar, so what each step saves is no longer recorded."; +/** The popup's Network row already says what each step saves is captured from inside the page: this says why. */ +const PAGE = "Chrome refused its debugger here."; +const refusedSentence = (message: string) => + `Chrome would not let the Recorder see what each step saves here (${message}).`; + +/** OpenBot's CopilotKit runtime routes that run a Bot's agent or join its run: the runtime is mounted at /api/copilotkit + * (server/src/copilot.ts) with REST routes only, `/agent//run` and `/agent//connect`. */ +const OPENBOT_AGENT_RUN = /^\/api\/copilotkit\/agent\/[^/]+\/(?:run|connect)$/; +/** Where the page's CopilotKit asks OpenBot's runtime what it serves (`info`): a GET of /api/copilotkit/info, or, were + * its transport to fall back to the single route, a POST of `{ method: "info" }` to /api/copilotkit. The answer names no + * secret, only the runtime's version, mode and agents. */ +const OPENBOT_RUNTIME_INFO = /^\/api\/copilotkit(?:\/info)?\/?$/; +/** CopilotKit Intelligence's client socket, as the page opens it from a run's join credentials (`/websocket? + * join_token=...`): on OpenBot's own origin when a deployment serves Intelligence's gateway there. */ +const INTELLIGENCE_SOCKET = /\/websocket$/; + +const AUTO_ATTACH = { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, +}; +/** The frames followed too (a cross-site iframe runs in its own session): an action there causes calls there. */ +const FRAME_TARGETS = new Set(["iframe", "page"]); + +type TabState = "attached" | "waiting" | "refused" | "page"; + +/** The parts of a network event the steps read. */ +type NetworkValue = { + url?: unknown; + method?: unknown; + status?: unknown; + outcome?: unknown; + completedAt?: unknown; + durationMs?: unknown; + request?: { + headers?: Record; + body?: JsonValue; + } | null; + response?: { url?: unknown; body?: JsonValue } | null; +}; + +const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +/** Whether a write's body, as it was sent, held one of `typed` (what the person typed into a sensitive field in its tab), + * which the masking took: read on the unmasked event, so only the answer goes on. */ +function sentTyped(event: CapturedEvent, typed: ReadonlySet): boolean { + const request = (event.value as NetworkValue | null)?.request; + const body = request?.body; + const text = + typeof body === "object" && + body !== null && + !Array.isArray(body) && + typeof body.text === "string" + ? body.text + : undefined; + if (text === undefined || typed.size === 0) return false; + const contentType = Object.entries(request?.headers ?? {}).find( + ([name]) => name.toLowerCase() === "content-type", + )?.[1]; + return holdsTypedSecret(text, contentType, typed); +} + +/** A masked network event's exchange, as a step carries its save. */ +function exchangeOf(value: NetworkValue): StepCall["exchange"] { + return { + url: typeof value.url === "string" ? value.url : "", + status: typeof value.status === "number" ? value.status : null, + body: value.request?.body ?? null, + response: value.response?.body ?? null, + }; +} + +const number = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +const errorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +/** In the e2e variant only: `storage.local.RECORDER_E2E_FORCE_ATTACH_REFUSAL` makes every attach refused (Task 26). */ +async function forcedRefusal(): Promise { + if (import.meta.env.RECORDER_VARIANT !== "e2e") return false; + const items = await chrome.storage.local.get( + "RECORDER_E2E_FORCE_ATTACH_REFUSAL", + ); + return items.RECORDER_E2E_FORCE_ATTACH_REFUSAL === true; +} + +/** The capture owns the page-level fallback it is given: it alone starts it (when attach is refused on a real site), + * forwards addTab and onCommitted to it while the mode is `page`, resumes it, and stops it. The recording controller + * creates the fallback with createPageFallback() and hands it over here; it never calls the fallback itself. */ +export function createNetworkCapture( + deps: NetworkCaptureDeps, + options: { fallback?: PageFallback } = {}, +): NetworkCapture { + const { fallback } = options; + const repeats = createRepeatMemory(); + const tabs = new Map(); + /** Each followed tab's main frame id (its page target's id), read once from Chrome when a document request needs it. */ + const topFrames = new Map(); + let mode: NetworkMode | "stopped" = "off"; + /** stop() was called: nothing attaches, starts or emits from here on. */ + let ended = false; + let listening = false; + + const send = ( + target: Debuggee, + method: string, + params?: Record, + ) => chrome.debugger.sendCommand(target, method, params); + + /** OpenBot's own calls are never a step's call or save: its API hands the page secrets (a live Recorder connect code) + * that must never reach Intelligence. */ + const ignore = (url: string): boolean => { + const openbot = deps.openbotUrl(); + if (!openbot) return false; + try { + return new URL(url).origin === new URL(openbot).origin; + } catch { + return false; + } + }; + + /** OpenBot's runtime `info` route: followed for CopilotKit's info call alone (openbotInfo). */ + const openbotInfo = (url: string): boolean => { + if (!ignore(url)) return false; + try { + return OPENBOT_RUNTIME_INFO.test(new URL(url).pathname); + } catch { + return false; + } + }; + + /** The AG-UI capture follows OpenBot's own page as it follows any other site's, but only in its chat with a Bot: the + * runs and joins it posts to OpenBot's CopilotKit runtime, whose answers are the agent's events or the Intelligence + * channel they stream on, an Intelligence client socket it opens with a run's join credentials, and the runtime's + * answer to CopilotKit's `info` call (read for that alone: the page runs CopilotKit, so its clicks carry the open + * Thread). Every other call to OpenBot stays ignored. The Recorder's own calls to OpenBot (its connection, its capture + * pass) come from the extension, never from a tab, and match none of these. */ + const ignoreAgent = (url: string): boolean => { + if (!ignore(url)) return false; + try { + const { pathname, searchParams } = new URL(url); + return !( + OPENBOT_AGENT_RUN.test(pathname) || + OPENBOT_RUNTIME_INFO.test(pathname) || + (INTELLIGENCE_SOCKET.test(pathname) && searchParams.has("join_token")) + ); + } catch { + return true; + } + }; + + async function topFrame(tabId: number): Promise { + const known = topFrames.get(tabId); + if (known !== undefined) return known; + const targets = await chrome.debugger.getTargets().catch(() => []); + const page = targets.find( + (target) => target.tabId === tabId && target.type === "page", + ); + if (page) topFrames.set(tabId, page.id); + return page?.id; + } + + const agent = createAgentCapture({ + send, + ignore: ignoreAgent, + infoOnly: openbotInfo, + observe: (observation) => { + if (!ended && mode !== "stopped") deps.agent(observation); + }, + onRun: (key) => tracker.agentRun(key), + }); + + const tracker = createRequestTracker({ + ignore, + send, + repeats, + topFrame, + agent, + call: (call: TrackedCall) => { + if (ended || mode === "stopped") return; + const { event, ...timing } = call; + if (!event) { + deps.call({ ...timing, url: scrubUrl(timing.url) }); + return; + } + const masked = maskNetworkEvent(event, { digests: deps.digests() }); + const value = (masked.value ?? {}) as NetworkValue; + const exchange = exchangeOf(value); + // A body that held what the person typed into a sensitive field reads [redacted] there now: the mark says so. + const typed = sentTyped( + event, + deps.typedSecrets?.(timing.tabId) ?? new Set(), + ); + deps.call({ + ...timing, + url: exchange?.url ?? "", + exchange: exchange && typed ? { ...exchange, typed: true } : exchange, + }); + }, + writeStarted: (id, tabId, startedAt) => { + if (!ended && mode !== "stopped") deps.writeStarted(id, tabId, startedAt); + }, + writeEnded: (id) => deps.writeEnded(id), + }); + + function setMode(next: NetworkMode | "stopped", reason?: string): void { + mode = next; + deps.status(next, reason); + } + + const attachedTabs = () => + [...tabs].filter(([, tab]) => tab === "attached").map(([tabId]) => tabId); + + async function enable(target: Debuggee): Promise { + try { + await send(target, "Network.enable", { + maxPostDataSize: MAX_POST_DATA_SIZE, + }); + await send(target, "Target.setAutoAttach", AUTO_ATTACH); + } catch (error) { + console.error("Recorder: network capture could not be turned on", error); + } + } + + function onEvent( + source: chrome.debugger.Debuggee, + method: string, + params?: object, + ) { + // A flattened child session's events carry its sessionId beside the tab (Chrome 125; not in @types/chrome 0.3.4). + const { tabId, sessionId } = source as Debuggee; + if ( + tabId === undefined || + tabs.get(tabId) !== "attached" || + mode === "stopped" + ) + return; + const target: Debuggee = sessionId ? { tabId, sessionId } : { tabId }; + if (method === "Target.attachedToTarget") { + const attached = params as + | { sessionId?: unknown; targetInfo?: { type?: unknown } } + | undefined; + const child = attached?.sessionId; + const type = attached?.targetInfo?.type; + if ( + typeof child === "string" && + typeof type === "string" && + FRAME_TARGETS.has(type) + ) + void enable({ tabId, sessionId: child }); + return; + } + // The AG-UI capture first: the tracker asks it whether a request is an agent run. + agent.onEvent(target, method, params); + tracker.onEvent(target, method, params); + } + + function onDetach(source: chrome.debugger.Debuggee, reason: string) { + const tabId = source.tabId; + if (tabId === undefined || tabs.get(tabId) !== "attached") return; + if (reason === "canceled_by_user") { + // The person closed Chrome's debugging bar: capture ends for this recording, and never moves to page-level + // patching behind their back (Review Focus 5). + const others = attachedTabs().filter((other) => other !== tabId); + tabs.clear(); + tracker.clear(); + agent.clear(); + repeats.clear(); + unlisten(); + setMode("stopped", CANCELLED); + for (const other of others) + void chrome.debugger.detach({ tabId: other }).catch(() => undefined); + return; + } + // target_closed: that tab is gone; the others keep capturing. + tabs.delete(tabId); + topFrames.delete(tabId); + tracker.forgetTab(tabId); + agent.forgetTab(tabId); + repeats.forgetTab(tabId); + } + + function listen(): void { + if (listening) return; + listening = true; + chrome.debugger.onEvent.addListener(onEvent); + chrome.debugger.onDetach.addListener(onDetach); + } + + function unlisten(): void { + if (!listening) return; + listening = false; + chrome.debugger.onEvent.removeListener(onEvent); + chrome.debugger.onDetach.removeListener(onDetach); + } + + /** Attaches to one tab and turns its network capture on; the refusal's message when Chrome says no. */ + async function attach(tabId: number): Promise<{ refused: string } | null> { + if (await forcedRefusal()) + return { refused: "the e2e build refused the debugger" }; + listen(); + try { + await chrome.debugger.attach({ tabId }, "1.3"); + } catch (error) { + return { refused: errorMessage(error) }; + } + if (ended || mode === "stopped") { + await chrome.debugger.detach({ tabId }).catch(() => undefined); + return null; + } + tabs.set(tabId, "attached"); + await enable({ tabId }); + return null; + } + + async function startFallback(refusal: string): Promise { + const followed = [...tabs.keys()]; + if (fallback && (await fallback.start(followed))) { + for (const tabId of followed) tabs.set(tabId, "page"); + unlisten(); + setMode("page", PAGE); + return "page"; + } + setMode("off", refusedSentence(refusal)); + return "off"; + } + + /** Attaches to every recordable tab of the list; the rest wait for their first website. With nothing attached and a + * refusal on a real site, the page capture takes over. */ + async function follow( + list: { tabId: number; url: string }[], + ): Promise { + let refusal: string | null = null; + for (const { tabId, url } of list) { + tabs.set(tabId, "waiting"); + if (!isRecordablePage(url)) continue; + const refused = await attach(tabId); + if (refused) { + tabs.set(tabId, "refused"); + refusal ??= refused.refused; + } + } + if (attachedTabs().length > 0) { + setMode("debugger"); + return "debugger"; + } + if (refusal !== null) return startFallback(refusal); + setMode("waiting", WAITING); + return "waiting"; + } + + /** A tab that becomes recordable while the mode is not `page`: attach it, or fall back when nothing else is. */ + async function attachLater(tabId: number): Promise { + const refused = await attach(tabId); + if (!refused) { + if (tabs.get(tabId) === "attached" && mode !== "debugger") + setMode("debugger"); + return; + } + tabs.set(tabId, "refused"); + if (attachedTabs().length > 0) { + console.error( + "Recorder: Chrome refused the debugger on a followed tab", + refused.refused, + ); + return; + } + await startFallback(refused.refused); + } + + return { + async start(tabId, url) { + tabs.clear(); + return follow([{ tabId, url }]); + }, + + async addTab(tabId, url) { + if (ended || mode === "stopped" || tabs.has(tabId)) return; + if (mode === "page") { + tabs.set(tabId, "page"); + await fallback?.addTab(tabId); + return; + } + tabs.set(tabId, "waiting"); + if (isRecordablePage(url)) await attachLater(tabId); + }, + + async onCommitted(tabId, frameId, url) { + if (ended || mode === "stopped") return; + if (mode === "page") { + if (tabs.has(tabId)) await fallback?.onCommitted(tabId, frameId); + return; + } + if ( + frameId === 0 && + tabs.get(tabId) === "waiting" && + isRecordablePage(url) + ) + await attachLater(tabId); + }, + + async resume(list, previous) { + tabs.clear(); + if (previous === "stopped") { + setMode("stopped", CANCELLED); + return "stopped"; + } + if (previous === "page") { + for (const { tabId } of list) tabs.set(tabId, "page"); + if ( + fallback && + (await fallback.start(list.map(({ tabId }) => tabId))) + ) { + setMode("page", PAGE); + return "page"; + } + tabs.clear(); + } + // The sessions of the worker that went away lost their listeners and CDP domains with it: detach them first. + const targets = await chrome.debugger.getTargets(); + for (const { tabId } of list) + if ( + targets.some( + (target) => + target.tabId === tabId && + target.attached && + target.extensionId === chrome.runtime.id, + ) + ) + await chrome.debugger.detach({ tabId }).catch(() => undefined); + return follow(list); + }, + + async recordPageEvent(tabId, event) { + if (ended) return; + // The URLs are already masked, which keeps their origin. A call a redirect landed on OpenBot is OpenBot's too. + const value = event.value as NetworkValue | null; + const urls = [value?.url, value?.response?.url]; + if (urls.some((url) => typeof url === "string" && ignore(url))) return; + if (!value || typeof value.url !== "string") return; + const method = ( + typeof value.method === "string" ? value.method : "GET" + ).toUpperCase(); + const body = value.request?.body; + const text = + typeof body === "object" && + body !== null && + !Array.isArray(body) && + typeof body.text === "string" + ? body.text + : undefined; + // The hook reports a call once it is answered: it started its duration before that. + const endedAt = number(value.completedAt) ?? event.timestamp; + const startedAt = endedAt - Math.max(0, number(value.durationMs) ?? 0); + const repeat = repeats.seen( + tabId, + callSignature(method, value.url, text), + startedAt, + ); + const outcome = + value.outcome === "aborted" || value.outcome === "error" + ? value.outcome + : "ok"; + const headers = value.request?.headers; + if ( + screenCall({ + url: value.url, + method, + outcome, + headers: + typeof headers === "object" && headers !== null ? headers : {}, + body: text, + repeat, + }) + ) + return; + deps.call({ + tabId, + startedAt, + endedAt, + method, + url: value.url, + ...(WRITE_METHODS.has(method) ? { exchange: exchangeOf(value) } : {}), + }); + }, + + recordPageAgent(tabId, observation) { + if (ended || mode !== "page" || tabs.get(tabId) !== "page") return; + deps.agent(observation); + }, + + async stop() { + if (ended) return; + ended = true; + const attached = attachedTabs(); + const wasPage = mode === "page"; + tabs.clear(); + topFrames.clear(); + tracker.clear(); + agent.clear(); + repeats.clear(); + unlisten(); + for (const tabId of attached) + await chrome.debugger.detach({ tabId }).catch(() => undefined); + if (wasPage || fallback?.active()) await fallback?.stop(); + }, + }; +} diff --git a/extension/src/background/network/screen.ts b/extension/src/background/network/screen.ts new file mode 100644 index 000000000..7cf736913 --- /dev/null +++ b/extension/src/background/network/screen.ts @@ -0,0 +1,125 @@ +// The checks a network call passes before it can be a step's save: never an aborted call, telemetry, analytics, ads or +// monitoring, a prefetch, or a read the tab already made (polling, a refresh of what the page has). A write the tab +// already made is still the person's: archiving twice is two saves. They need no body and no timing, so a call they +// drop costs nothing more. +import { isPrefetch, isTelemetry } from "./telemetry"; + +/** A call the tab already made this recently is the page's own repeat. */ +export const REPEAT_LOOKBACK_MS = 10 * 60_000; + +/** Why a call can never be a save. */ +export type Screened = "aborted" | "telemetry" | "prefetch" | "repeat"; + +/** What the checks need. */ +export type CallFacts = { + url: string; + method: string; + outcome: "ok" | "error" | "aborted"; + headers: Record; + body?: string; + /** The tab already made this exact call: see `createRepeatMemory`. Screens a read only. */ + repeat: boolean; +}; + +const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +/** Query parameters that only bust a cache: two calls differing only in these are the same call. */ +const CACHE_BUSTERS = new Set([ + "_", + "t", + "ts", + "timestamp", + "cb", + "cachebuster", + "cache_buster", + "nocache", + "rnd", + "rand", + "random", + "_t", + "_ts", +]); +const EPOCH = /^\d{10}(?:\d{3})?$/; + +/** A 53-bit hash (cyrb53): calls are remembered by hash only, never their URL or body. */ +function hash(text: string): number { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < text.length; i++) { + const ch = text.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +/** One call's identity: method, URL without its fragment or cache busters, and request body. */ +export function callSignature( + method: string, + url: string, + body: string | undefined, +): number { + let address = url; + try { + const parsed = new URL(url); + parsed.hash = ""; + for (const [key, item] of [...parsed.searchParams]) + if (CACHE_BUSTERS.has(key.toLowerCase()) || EPOCH.test(item)) + parsed.searchParams.set(key, "*"); + address = parsed.href; + } catch { + // Not a URL: the text itself is the identity. + } + return hash(`${method.toUpperCase()} ${address}\n${body ?? ""}`); +} + +export function screenCall(facts: CallFacts): Screened | null { + if (facts.outcome === "aborted") return "aborted"; + if (isTelemetry(facts.url, facts.body)) return "telemetry"; + if (isPrefetch(facts.headers)) return "prefetch"; + // A page polls and refreshes by reading; a write is the person's doing each time, however often it was made before. + if (facts.repeat && !WRITE_METHODS.has(facts.method.toUpperCase())) + return "repeat"; + return null; +} + +const MAX_SEEN = 4_000; +const SEEN_STARTS = 4; + +/** Which calls each tab already made, by signature: a repeat is the page polling or refreshing what it has. */ +export function createRepeatMemory() { + const tabs = new Map>(); + return { + /** Remembers that the tab made this call at `startedAt`; true when it already made the same one before then. */ + seen(tabId: number, signature: number, startedAt: number): boolean { + let seen = tabs.get(tabId); + if (!seen) { + seen = new Map(); + tabs.set(tabId, seen); + } + const starts = seen.get(signature) ?? []; + const repeat = starts.some( + (at) => at < startedAt && startedAt - at <= REPEAT_LOOKBACK_MS, + ); + seen.delete(signature); + seen.set(signature, [...starts, startedAt].slice(-SEEN_STARTS)); + if (seen.size > MAX_SEEN) { + const oldest = seen.keys().next(); + if (!oldest.done) seen.delete(oldest.value); + } + return repeat; + }, + forgetTab(tabId: number): void { + tabs.delete(tabId); + }, + clear(): void { + tabs.clear(); + }, + }; +} + +export type RepeatMemory = ReturnType; diff --git a/extension/src/background/network/telemetry.ts b/extension/src/background/network/telemetry.ts new file mode 100644 index 000000000..ffa4d5db2 --- /dev/null +++ b/extension/src/background/network/telemetry.ts @@ -0,0 +1,235 @@ +// The calls a page makes about itself rather than for the person: analytics, telemetry, ads, error reports and real-user +// monitoring, and CopilotKit's own capture of the page. A recording never keeps one, even when it starts right after a click, +// because a Bot doing the task through the site's API never needs it. Matched by host, then by path, then by a body only +// such a call sends; each list stays narrow so a real API call is never caught. + +/** Hosts (and their subdomains) that only ever receive analytics, ads, error reports or monitoring. */ +const TELEMETRY_HOSTS = [ + // Analytics and tag managers + "google-analytics.com", + "analytics.google.com", + "googletagmanager.com", + "app-measurement.com", + "segment.io", + "api.segment.io", + "cdn.segment.com", + "api-js.mixpanel.com", + "api.mixpanel.com", + "api.amplitude.com", + "api2.amplitude.com", + "api.eu.amplitude.com", + "sr-client-cfg.amplitude.com", + "i.posthog.com", + "heapanalytics.com", + "clarity.ms", + "hotjar.com", + "hotjar.io", + "rs.fullstory.com", + "edge.fullstory.com", + "mouseflow.com", + "plausible.io", + "usefathom.com", + "mc.yandex.ru", + "mc.yandex.com", + "cloudflareinsights.com", + "chartbeat.net", + "parsely.com", + "quantserve.com", + "scorecardresearch.com", + "tealiumiq.com", + "omtrdc.net", + "demdex.net", + "2o7.net", + "data.pendo.io", + "app.pendo.io", + "esp.aptrinsic.com", + "logx.optimizely.com", + "events.launchdarkly.com", + "events.split.io", + "featuregates.org", + "prodregistryv2.org", + "track.hubspot.com", + "hscollectedforms.net", + "browser.events.data.microsoft.com", + "dc.services.visualstudio.com", + "js.monitor.azure.com", + "applicationinsights.azure.com", + // Ads and conversion pixels + "doubleclick.net", + "googlesyndication.com", + "googleadservices.com", + "adservice.google.com", + "connect.facebook.net", + "ads.linkedin.com", + "snap.licdn.com", + "analytics.tiktok.com", + "bat.bing.com", + "analytics.twitter.com", + "static.ads-twitter.com", + "ct.pinterest.com", + "alb.reddit.com", + "adnxs.com", + "adsrvr.org", + "criteo.com", + "criteo.net", + "taboola.com", + "outbrain.com", + "rubiconproject.com", + "pubmatic.com", + "casalemedia.com", + "everesttech.net", + "m.stripe.com", + "m.stripe.network", + "r.stripe.com", + "q.stripe.com", + // Error reports and real-user monitoring + "sentry.io", + "sentry-cdn.com", + "nr-data.net", + "js-agent.newrelic.com", + "bugsnag.com", + "api.rollbar.com", + "trackjs.com", + "raygun.io", + "lr-ingest.io", + "lr-ingest.com", + "lr-in.com", + "lr-in-prod.com", + "go-mpulse.net", + "akstat.io", + "lux.speedcurve.com", + "eum-appdynamics.com", +]; + +/** A host whose first label says what it is for: `metrics-fe-na1.`, `exceptions-na2.`, `px.ads.`, `browser-intake-`. */ +const TELEMETRY_LABEL = + /^(?:analytics|telemetry|metrics|tracking|tracker|beacons?|pixel|px|rum|collector|exceptions|sentry|capig)(?:-|$)|^browser-intake-/; + +/** Paths that only ever take telemetry, whatever the host. */ +const TELEMETRY_PATHS = [ + // Google Analytics (`/collect`, `/g/collect`, `/j/collect`) on any host it is proxied through, and Matomo + /^(?:\/[gjr])?\/collect\/?$/, + /\/(?:matomo|piwik)\.php$/, + // Meta's pixel and the bare beacons of fraud and bot scripts + /^\/(?:tr|mon|beacon|pixel|ping|track|t\.gif)\/?$/, + // OpenTelemetry's export paths + /\/v1\/(?:traces|metrics|logs)\/?$/, + // CopilotKit's own capture asking its runtime for a capture pass (the REST route; the single route is known by its body) + /\/trajectory\/[^/]+\/connect\/?$/, + // Sentry's ingest, Elastic's RUM intake, Vercel and Cloudflare insights + /\/api\/\d+\/(?:envelope|store|security|minidump)\/?$/, + /\/intake\/v2\/rum\//, + /^\/_vercel\/(?:insights|speed-insights)\//, + /^\/cdn-cgi\/(?:rum|challenge-platform)\b/, + // Words a real API path never carries + /(?:^|[/._-])(?:telemetry|observability|beacons?|heartbeat|keepalive)(?:[/._-]|$)/, + /(?:^|\/)(?:rum|pixel|pixels)(?:\/|$)/, + /session[-_]?replay/, + /(?:^|[/_-])(?:usage|client|action|system|event|page)[-_](?:logs?|logging)(?:[/_-]|$)/, + /(?:^|\/)log[-_]?events?(?:\/|$)/, + // A versioned tracking API (`/nudge/tracking/v1/...`), never a parcel's `/orders/42/tracking` + /\/tracking\/v\d+\//, +]; + +/** A body that is an error report: Sentry's envelope, sent to a tunnel route on the page's own host. */ +const ERROR_ENVELOPE = /^\{[^\n]*"(?:dsn|sent_at)"\s*:/; + +/** The keys of CopilotKit's capture batch (`@copilotkit/learning` LearningBatch): `{ trajectoryId, learningContainerIds?, + * events, dropped }`, and nothing else. */ +const BATCH_KEYS: ReadonlySet = new Set([ + "trajectoryId", + "learningContainerIds", + "events", + "dropped", +]); +/** The start of a capture batch as `httpSink` writes it (`JSON.stringify` of the batch the collector builds, in that key + * order), for a body cut short: its trajectory, its containers when it has any, then its first event or none. */ +const BATCH_START = + /^\s*\{"trajectoryId":"(?:[^"\\]|\\.)*"(?:,"learningContainerIds":\[[^\]]*\])?,"events":\[(?:\]|\{"type":"CUSTOM",)/; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** One captured event of a batch (`LearningEvent`): an AG-UI CUSTOM event with its name, value and time. */ +const isLearningEvent = (value: unknown): boolean => + isRecord(value) && + value.type === "CUSTOM" && + typeof value.name === "string" && + typeof value.timestamp === "number"; + +/** CopilotKit's own capture of the page (`@copilotkit/learning`'s collector, sent by its `httpSink` to whatever route the + * app chose): the page reporting on itself, and another copy of what the recording captures, never a person's save. Known + * by its shape, since the route is the app's. */ +export function isLearningBatch(body: string): boolean { + if (!body.includes('"trajectoryId"')) return false; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return BATCH_START.test(body); + } + if (!isRecord(parsed)) return false; + const { trajectoryId, learningContainerIds, events, dropped } = parsed; + return ( + Object.keys(parsed).every((key) => BATCH_KEYS.has(key)) && + typeof trajectoryId === "string" && + typeof dropped === "number" && + Array.isArray(events) && + events.every(isLearningEvent) && + (learningContainerIds === undefined || + (Array.isArray(learningContainerIds) && + learningContainerIds.every((id) => typeof id === "string"))) + ); +} + +const onHost = (host: string, suffix: string) => + host === suffix || host.endsWith(`.${suffix}`); + +/** CopilotKit's own capture asking a single-route runtime for its capture pass (`@copilotkit/core`'s TrajectoryConnection): + * the page setting up its report on itself. */ +function isCapturePassRequest(body: string): boolean { + if (!body.includes('"trajectory/connect"')) return false; + try { + const parsed: unknown = JSON.parse(body); + return isRecord(parsed) && parsed.method === "trajectory/connect"; + } catch { + return false; + } +} + +/** True for a call that only reports on the page (analytics, ads, errors, monitoring, CopilotKit's own capture), never one + * doing the task. */ +export function isTelemetry(url: string, body?: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + const host = parsed.hostname.toLowerCase(); + if (TELEMETRY_HOSTS.some((suffix) => onHost(host, suffix))) return true; + if (TELEMETRY_LABEL.test(host.split(".")[0] ?? "")) return true; + const path = parsed.pathname.toLowerCase(); + if (TELEMETRY_PATHS.some((pattern) => pattern.test(path))) return true; + return ( + body !== undefined && + (ERROR_ENVELOPE.test(body.slice(0, 2_048)) || + isLearningBatch(body) || + isCapturePassRequest(body)) + ); +} + +/** The request headers a page or the browser sets on a speculative fetch: a prefetch is the page's guess, not the person's. */ +export function isPrefetch(headers: Record): boolean { + for (const [name, value] of Object.entries(headers)) { + const key = name.toLowerCase(); + if (key === "next-router-prefetch") return true; + if ( + (key === "purpose" || key === "sec-purpose" || key === "x-moz") && + /prefetch|prerender/i.test(value) + ) + return true; + if (key === "x-purpose" && /preview/i.test(value)) return true; + } + return false; +} diff --git a/extension/src/background/offscreen-document.ts b/extension/src/background/offscreen-document.ts new file mode 100644 index 000000000..a3a20be03 --- /dev/null +++ b/extension/src/background/offscreen-document.ts @@ -0,0 +1,40 @@ +// The one offscreen document: it holds the IndexedDB outbox and the socket that streams events to Intelligence +// (Task 11), so a service worker that sleeps loses nothing. +const OFFSCREEN_PATH = "offscreen.html"; + +let creating: Promise | null = null; + +export async function hasOffscreenDocument(): Promise { + const contexts = await chrome.runtime.getContexts({ + contextTypes: ["OFFSCREEN_DOCUMENT"], + }); + return contexts.length > 0; +} + +/** Single-flight: concurrent callers share one `chrome.offscreen.createDocument`, so a second call never throws "Only a single + * offscreen document may be created". */ +export function ensureOffscreenDocument(): Promise { + creating ??= (async () => { + if (await hasOffscreenDocument()) return; + await chrome.offscreen.createDocument({ + url: OFFSCREEN_PATH, + // Chrome names no offscreen reason for a socket or an IndexedDB outbox, and only AUDIO_PLAYBACK limits a document's + // lifetime (https://developer.chrome.com/docs/extensions/reference/api/offscreen#type-Reason). LOCAL_STORAGE, + // durable storage in the document, is the nearest reason to what it does; USER_MEDIA is the microphone it + // transcribes the person's narration from. + reasons: ["LOCAL_STORAGE", "USER_MEDIA"], + justification: + "Keeps the recording in this browser, turns the person's narration into text, and sends it to Intelligence.", + }); + })().finally(() => { + creating = null; + }); + return creating; +} + +/** Closes the document if one is open; waits for a creation in flight first, so the two never cross. */ +export async function closeOffscreenDocument(): Promise { + if (creating) await creating.catch(() => undefined); + if (!(await hasOffscreenDocument())) return; + await chrome.offscreen.closeDocument(); +} diff --git a/extension/src/background/recording.ts b/extension/src/background/recording.ts new file mode 100644 index 000000000..f6fbb9262 --- /dev/null +++ b/extension/src/background/recording.ts @@ -0,0 +1,2710 @@ +// The recording controller: Record, the tabs a recording follows, the 50-action notice, Stop, Start over and the +// drains that keep sending after it. A recording goes to Intelligence only: Record takes a new trajectory id, the +// offscreen document asks OpenBot for a capture pass (the trajectory joins the person's own Learning space) and streams +// every event to Intelligence, and +// Stop queues the outcome and lets the outbox drain. OpenBot hears nothing of the recording itself. The phase lives in a +// reducer (`reduceRecorder`), persisted to storage.session after every transition so a restarted service worker finds +// it; what must survive a browser restart lives in storage.local (`activeRecording`, `drains`) and in the document's +// IndexedDB outbox. +import { + type LearningSpaceState, + type RecorderLearningResponse, + type RecorderMe, + recorderPaths, +} from "@openbot/shared/recorder-api"; +import { + type CapturedEvent, + scrubText, + scrubUrl, +} from "@openbot/shared/recorder-masking"; +import type { + BackgroundToContent, + BackgroundToOffscreen, + CaptureStatus, + ContentToBackground, + ExternalResponse, + ExternalToBackground, + OffscreenSignedOut, + OffscreenToBackground, + PageResponse, + PageStateResponse, + PageToBackground, + RecorderState, + StreamSession, +} from "../protocol/messages"; +import { IDLE_STATE, parseOffscreenSignedOut } from "../protocol/messages"; +import { hasHostPermission } from "../shared/host-access"; +import { RECORDER_ACTION_NOTICE_AT } from "../shared/limits"; +import { OpenBotError, openbotJson } from "../shared/openbot-client"; +import { + allowsOpenBot, + onPolicyChanged, + type RecorderPolicy, + readPolicy, +} from "../shared/policy"; +import { narrationLanguage } from "../shared/speech-language"; +import { + type ConnectionRecord, + type DrainRecord, + type HeldEvent, + type HeldSteps, + type KeptEvents, + readLocal, + readSession, + removeLocal, + removeSession, + writeLocal, + writeSession, +} from "../shared/storage"; +import { + type AgentEvents, + type AgentSnapshot, + createAgentEvents, +} from "./agent/events"; +import type { CaptureObservation } from "./agent/observation"; +import { showBadge } from "./badge"; +import { + type ConnectionLoss, + currentConnection, + forgetConnection, +} from "./connection"; +import { maskContentEvent } from "./content-masking"; +import { + forgetContentPorts, + injectRecordingScripts, + postToContentPorts, + stopRecordingScripts, + trackContentPort, +} from "./content-scripts"; +import { type NavigationDetails, navigationEvent } from "./navigation"; +import { + createNetworkCapture, + type NetworkCapture, + type NetworkCaptureDeps, + type NetworkMode, +} from "./network"; +import { acceptPageNetworkEvent, createPageFallback } from "./network/fallback"; +import { + closeOffscreenDocument, + ensureOffscreenDocument, + hasOffscreenDocument, +} from "./offscreen-document"; +import type { RouterHandlers } from "./router"; +import { + createStepAssembler, + type StepAssembler, + type StepSnapshot, +} from "./steps"; + +type Timers = { + set(fn: () => void, ms: number): unknown; + clear(handle: unknown): void; +}; + +export type RecordingDeps = { + createNetworkCapture: typeof createNetworkCapture; + createAgentEvents: typeof createAgentEvents; + createPageFallback: typeof createPageFallback; + acceptPageNetworkEvent: typeof acceptPageNetworkEvent; + readPolicy: typeof readPolicy; + hasHostPermission: typeof hasHostPermission; + now(): number; + /** Every wait (the document's ready, the stop watchdog) goes through these. */ + timers: Timers; + /** The held steps' own timers (steps.ts) and the writes that keep them (`heldSteps`): the browser's timers unless a + * test passes its own. */ + stepTimers?: Timers; +}; + +/** Called by the router after Task 8's handler answered: `connected` after a successful `page.connect.decide` (web or Desktop), + * `disconnected` after `page.disconnect` (the person's own Disconnect). `connectionLost` is connection.ts's: the + * keep-alive's signed-out answer, or the organization's policy moving the Recorder away from the connection's OpenBot. */ +type ConnectionEvents = { + connected(): Promise; + disconnected(): Promise; + connectionLost(loss: ConnectionLoss): Promise; +}; + +/** What recovery.ts drives after a browser or service-worker restart (Task 9's Recovery), on the controller installed last. */ +export type RecordingRecovery = { + /** The phase this service worker found in storage.session when it started: idle after a browser restart. */ + loadedPhase(): Promise; + /** A browser restart mid-recording: the recording moves into the drains, to be sent with an interrupted outcome, and + * the popup says it was interrupted. */ + interrupt(): Promise; + /** A Record a service-worker restart cut short is discarded, as a Record that fails is. */ + abandonStart(): Promise; + /** Finishes a Stop a service-worker restart cut short, through the one stop path; resolves with the recording it + * stopped. */ + resumeStop(): Promise; + /** The live recording after a service-worker restart: network capture again and, when it is gone, the document. */ + resumeRecording(): Promise; + /** Finishes every discard a restart interrupted, whoever is connected: the document clears what it holds of those + * recordings (a document that outlived the service worker would otherwise keep sending them). */ + finishDiscards(): Promise; + /** offscreen.recover for every drain of the connected person. Drains of anyone else wait for their person to connect + * again. */ + sendDrains(): Promise; + /** Asks the open document to report on one drain again: answers it sent before that recording reached the drains were + * held in the memory of the service worker that stopped. */ + recoverDrain(recordingId: string): Promise; +}; + +let installedRecovery: RecordingRecovery | null = null; + +/** The controller a restarted service worker installed, for recovery.ts. */ +export function recordingRecovery(): RecordingRecovery | null { + return installedRecovery; +} + +const READY_TIMEOUT_MS = 5_000; +const STOP_WATCHDOG_MS = 120_000; +const ME_FRESH_MS = 60_000; +/** How often a running recording asks OpenBot whether its connection still stands. */ +const CONNECTION_CHECK_MS = 30_000; +/** While the offscreen port is down, content events wait here; beyond either cap the oldest go, counted (Decision 19). */ +const QUEUE_EVENTS = 2_000; +const QUEUE_BYTES = 8 * 1024 * 1024; +/** The collector events that are something the person did; page loads in a followed tab's top frame count too. */ +const ACTION_EVENTS = new Set(["click", "input", "submit", "key"]); +/** The content events that are the person's steps: held until each one's save is known (steps.ts). */ +const STEP_EVENTS = ACTION_EVENTS; +/** The held steps are written at once when they change, then at most this often while they keep changing: a step + * reaches storage within this long of arriving, and a chatty page costs at most ten writes a second. */ +const HELD_WRITE_MS = 100; +/** The most digests of what was typed into sensitive fields kept per tab, latest last: a digest comes with each keystroke, + * so this is a few secrets typed lately. */ +const TYPED_SECRETS = 64; +/** How long Stop waits for the document to hand over the phrase being spoken (its speech service has 1.5 s). */ +const VOICE_END_MS = 2_000; +const browserTimers: Timers = { + set: (fn, ms) => setTimeout(fn, ms), + clear: (handle) => clearTimeout(handle as ReturnType), +}; +/** Timers for an assembler that only takes up held steps to send them at once (a browser restart): none ever fires. */ +const NO_TIMERS: Timers = { set: () => null, clear: () => {} }; +/** Held steps that hold nothing, written beside an agent context. */ +const NOTHING_HELD: StepSnapshot = { + queue: [], + calls: [], + typed: [], + speech: null, +}; + +/** The events a snapshot of held steps sends, in order, with what each owns and its key: after a browser quit nothing + * more can arrive for them. */ +export function heldEvents( + snapshot: StepSnapshot, + now: () => number, +): HeldEvent[] { + const events: HeldEvent[] = []; + const assembler = createStepAssembler({ + emit: (event, key) => + events.push(key === undefined ? { event } : { event, key }), + now, + timers: NO_TIMERS, + }); + assembler.restore(snapshot); + assembler.flush(); + assembler.clear(); + return events; +} + +const SENTENCES = { + noConnection: "Connect this browser to OpenBot first.", + // The install grants website access; only the person's own Site access setting can withhold it afterwards. + noAccess: + "Site access is off for OpenBot Recorder. Open your browser's Extensions page, choose Details on OpenBot Recorder and set Site access to On all sites.", + busy: "A recording is already running.", + notRecording: "Nothing is being recorded.", + noVoice: "This recording has no voice.", + tabClosed: + "That tab is closed. Open the Recorder on the tab you want to record.", + startFailed: "The recording could not start. Try again.", + documentFailed: "The Recorder could not start recording. Try again.", + restartUnreachable: + "The Recorder could not start over just now. Your recording goes on; try again in a moment.", + signedOut: + "This browser was disconnected from OpenBot. Your recording stopped, and everything in it is kept in this browser until you connect again.", + organizationSignedOut: + "Your organization sign-in ended. Your recording stopped, and everything in it is kept in this browser until you connect again.", + policyMoved: + "Your organization moved the Recorder to another OpenBot. Your recording stopped, and everything in it is kept in this browser.", + policyRefused: + "Your organization's Recorder policy does not allow this OpenBot. Ask your IT team.", + keptSignedOut: "This browser was disconnected from OpenBot.", + keptOrganization: "Your organization sign-in ended.", + keptPolicy: "Your organization moved the Recorder to another OpenBot.", +} as const; + +type SignedOutReason = "revoked" | "removed" | "organization"; +/** Why a connection went without the person's Disconnect: OpenBot signed it out, or the policy moved the Recorder. */ +type LostReason = SignedOutReason | "policy"; + +/** The sentences that say a stopped recording is kept until something happens: a Disconnect discards it, so they go. */ +const KEPT_SENTENCES: readonly string[] = [ + SENTENCES.signedOut, + SENTENCES.organizationSignedOut, + SENTENCES.policyMoved, +]; + +const stoppedSentence = (reason: LostReason): string => + reason === "organization" + ? SENTENCES.organizationSignedOut + : reason === "policy" + ? SENTENCES.policyMoved + : SENTENCES.signedOut; + +const keptSentence = (reason: LostReason): string => + reason === "organization" + ? SENTENCES.keptOrganization + : reason === "policy" + ? SENTENCES.keptPolicy + : SENTENCES.keptSignedOut; + +/** Record found the policy no longer allows the connection's OpenBot (connection.ts forgets it in a moment). */ +const POLICY_REFUSED = new Error("The policy does not allow this OpenBot."); + +/** Record ended by a stop (Stop, Disconnect, a sign-out) before its recording was live: it rolls back. */ +const CANCELLED = new Error("Record was cancelled before it started."); + +// ---- The state machine ------------------------------------------------------------------------- + +type RecorderEvent = + | { + type: "record"; + title: string; + learning?: LearningSpaceState; + } + | { + type: "started"; + recordingId: string; + tabId: number; + topUrl: string; + startedAt: number; + capture: CaptureStatus; + } + | { type: "action" } + | { type: "notice" } + | { type: "notice-answered" } + | { type: "follow"; tabId: number; url: string } + | { type: "page"; tabId: number; url: string } + | { type: "capture"; capture: Partial } + | { type: "stop"; since: number } + | { + type: "finished"; + /** Absent for a recording a Disconnect discards: nothing of it is sent. */ + lastStopped?: NonNullable; + error?: string; + } + | { type: "restart"; recordingId: string; startedAt: number } + | { type: "drains"; draining: number; kept?: RecorderState["kept"] } + | { type: "send-failed"; error: string } + | { type: "error"; error: string } + | { type: "cancelled" } + | { type: "clear-error" }; + +/** Drops keys whose value is undefined, so the state reads the same before and after a JSON round trip. */ +function defined(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as T; +} + +/** The phase transitions, pure, so they are testable without timers. */ +export function reduceRecorder( + state: RecorderState, + event: RecorderEvent, +): RecorderState { + const kept = { draining: state.draining, kept: state.kept }; + switch (event.type) { + case "record": + return defined({ + ...IDLE_STATE, + ...kept, + lastStopped: state.lastStopped, + phase: "starting", + title: event.title, + learning: event.learning, + }); + case "started": + return { + ...state, + phase: "recording", + recordingId: event.recordingId, + tabIds: [event.tabId], + topUrls: { [event.tabId]: event.topUrl }, + startedAt: event.startedAt, + actions: 0, + noticeShown: false, + noticeOpen: false, + capture: event.capture, + }; + case "action": + return { ...state, actions: state.actions + 1 }; + case "notice": + return { + ...state, + noticeShown: true, + noticeOpen: true, + noticeAt: state.actions, + }; + case "notice-answered": + return { ...state, noticeOpen: false }; + case "follow": + if (state.tabIds.includes(event.tabId)) return state; + return { + ...state, + tabIds: [...state.tabIds, event.tabId], + topUrls: { ...state.topUrls, [event.tabId]: event.url }, + }; + case "page": + return { + ...state, + topUrls: { ...state.topUrls, [event.tabId]: event.url }, + }; + case "capture": { + const capture = { ...state.capture, ...event.capture }; + // The document's Intelligence status comes whole, its reason left out when there is none (a message drops an + // undefined field): streaming again after a retry says nothing more of the retry. + if ( + "intelligence" in event.capture && + !("intelligenceReason" in event.capture) + ) + delete capture.intelligenceReason; + return { ...state, capture: defined(capture) }; + } + case "stop": + return { + ...state, + phase: "stopping", + noticeOpen: false, + stopping: { since: event.since }, + }; + case "finished": + return defined({ + ...IDLE_STATE, + ...kept, + lastStopped: event.lastStopped, + // The Learning space belongs to the recording that went to Intelligence; with none, there is none to show. + learning: event.lastStopped ? state.learning : undefined, + error: event.error, + }); + case "restart": { + const { noticeAt: _previous, ...rest } = state; + return { + ...rest, + recordingId: event.recordingId, + startedAt: event.startedAt, + actions: 0, + noticeShown: false, + noticeOpen: false, + }; + } + case "drains": + return defined({ ...state, draining: event.draining, kept: event.kept }); + case "send-failed": + // The recording Stop ended last never reached Intelligence: the popup says so instead of where it went. + return defined({ + ...state, + lastStopped: undefined, + learning: undefined, + error: event.error, + }); + case "error": + return defined({ + ...IDLE_STATE, + ...kept, + lastStopped: state.lastStopped, + error: event.error, + }); + case "cancelled": + return defined({ + ...IDLE_STATE, + ...kept, + lastStopped: state.lastStopped, + }); + case "clear-error": + return defined({ ...state, error: undefined }); + } +} + +/** What the popup shows about stopped recordings whose events are kept (Decision 19). */ +function keptSummary(drains: DrainRecord[]): RecorderState["kept"] { + const kept = drains.filter( + (drain): drain is DrainRecord & { kept: KeptEvents } => + drain.kept !== undefined, + ); + const newest = kept.reduce<(DrainRecord & { kept: KeptEvents }) | undefined>( + (latest, drain) => + latest === undefined || drain.kept.at > latest.kept.at ? drain : latest, + undefined, + ); + if (!newest) return undefined; + const waiting = kept.filter((drain) => drain.kept.waitingForReconnect); + return defined({ + recordings: kept.length, + events: kept.reduce((sum, drain) => sum + drain.kept.events, 0), + reason: newest.kept.reason, + parked: kept.every((drain) => drain.kept.parked), + waitingFor: waiting[0] + ? { name: waiting[0].userName, recordings: waiting.length } + : undefined, + }); +} + +let lastPersist: Promise = Promise.resolve(); + +/** The recorder state as a restarted service worker would find it. */ +export async function recorderState(): Promise { + await lastPersist; + return (await readSession("recorder")) ?? { ...IDLE_STATE }; +} + +const defaultDeps: RecordingDeps = { + createNetworkCapture, + createAgentEvents, + createPageFallback, + acceptPageNetworkEvent, + readPolicy, + hasHostPermission, + now: () => Date.now(), + timers: { + set: (fn, ms) => setTimeout(fn, ms), + clear: (handle) => clearTimeout(handle as ReturnType), + }, +}; + +/** The live recording: whom it belongs to and the token its passes are asked for with. */ +type Live = { + recordingId: string; + openbotUrl: string; + token: string; + userId: string; +}; + +type StopMode = + | { kind: "person" } // Stop: the outcome is queued, then the outbox drains + | { kind: "signed-out"; reason: LostReason; forgot: boolean } // kept for the person (only this recording when the connection was not forgotten) + | { kind: "disconnect" }; // the person's Disconnect: discarded right after + +const signedOutReason = (error: unknown): SignedOutReason | null => + error instanceof OpenBotError && error.signedOut + ? (error.body?.reason ?? "revoked") + : null; + +const sentenceFor = (error: unknown, fallback: string): string => + error instanceof OpenBotError ? error.message : fallback; + +const ownedBy = (drain: DrainRecord, connection: ConnectionRecord): boolean => + drain.userId === connection.user.id && + drain.openbotUrl === connection.openbotUrl; + +function report(what: string): (error: unknown) => void { + return (error) => console.error(`Recorder: ${what} failed`, error); +} + +function liveFrom(record: DrainRecord, token: string): Live { + return { + recordingId: record.recordingId, + openbotUrl: record.openbotUrl, + token, + userId: record.userId, + }; +} + +/** The page/content/offscreen handlers the router calls. Defaults are the real modules; tests pass doubles. */ +export function installRecording( + overrides: Partial = {}, +): RouterHandlers & ConnectionEvents { + const deps: RecordingDeps = { ...defaultDeps, ...overrides }; + forgetContentPorts(); + + let state: RecorderState = IDLE_STATE; + let live: Live | null = null; + let network: NetworkCapture | null = null; + let networkMode: NetworkMode | "stopped" = "off"; + const digests = new Set(); + /** Each followed tab's digests of what the person typed into sensitive fields there, latest last (`TYPED_SECRETS` at + * most): a save whose body held one carries what they entered, though it reads [redacted] (network/index.ts). Kept as + * `digests` are. */ + const typedSecrets = new Map(); + /** The person's own Learning space as OpenBot last described it: a recording starts with it, for its popup line. */ + let learningSpace: LearningSpaceState | undefined; + /** Each followed tab's last recorded top-frame address: the same one again is not recorded twice. */ + const recordedNavigations = new Map(); + const forgetAddresses = () => { + recordedNavigations.clear(); + }; + /** A tab a followed tab opened, until its first page change is recorded: the opener's top page when Chrome said it + * opened it (webNavigation.onCreatedNavigationTarget), which that page change comes from. */ + const openers = new Map(); + + /** The event with the tab's number in the recording, the Recorder's own field `tab`: 1 for the tab the recording + * started in, then 2, 3 in the order tabs join, so an agent can tell a multi-tab task's tabs apart. Never Chrome's tab + * id, which changes when the browser starts again. */ + function inTab(tabId: number, event: CapturedEvent): CapturedEvent { + const index = state.tabIds.indexOf(tabId); + const value = event.value; + if ( + index < 0 || + typeof value !== "object" || + value === null || + Array.isArray(value) + ) + return event; + return { ...event, value: { ...value, tab: index + 1 } }; + } + /** The live recording's steps, held until each one's save (if any) is known (steps.ts). */ + let steps: StepAssembler | null = null; + /** What a previous service worker held for the recording it left live or stopping, until this one takes it up. */ + let restoredSteps: HeldSteps | null = null; + /** The live recording's agent side: the AG-UI the network capture observes, as CopilotKit's own events, placed among + * the steps by time. A fresh one with each set of steps. */ + let agentEvents: AgentEvents | null = null; + /** The recording a Start over is leaving, while it waits for the document's port: a second Start over is refused. */ + let restartingFrom: Live | null = null; + /** Set while Start over saves and makes the switch to the new recording: a stop waits for it, so the document and the + * tabs always hear of the switch before they hear of the stop. */ + let switching: Promise | null = null; + /** Set from Record's first step until it finished starting (or rolled back). A stop (Stop, Disconnect, a sign-out) + * and Start over wait for it. A stop that comes before the recording is live cancels it: Record checks just before + * making the recording live and rolls back. One that comes later stops the recording once Record finished, so the + * network capture and the tab's script Record starts are there for the stop to end. */ + let starting: { done: Promise; cancelled: boolean } | null = null; + /** The stop under way, from the moment it takes the live recording until it finished: Disconnect waits for it, so the + * recording it moves into the drains is discarded with the rest. */ + let stopInFlight: Promise | null = null; + /** How many Disconnects are under way: a stop that finds the connection gone meanwhile is being discarded, not kept + * for a person who was signed out. */ + let disconnecting = 0; + /** The stop message a Stop waits on the document to answer: a document reopened meanwhile is told it again. */ + let pendingStop: BackgroundToOffscreen | null = null; + /** The recording whose capture the popup shows and the trajectories it streams to: a status about any other + * trajectory (a recording Start over replaced, still draining) leaves that capture alone. */ + let shown: { recordingId: string; trajectoryIds: Set } | null = null; + + // ---- Persistence --------------------------------------------------------------------------- + + let persisted: Promise = Promise.resolve(); + function dispatch(event: RecorderEvent): void { + const before = state; + state = reduceRecorder(state, event); + const snapshot = state; + persisted = persisted + .then(() => writeSession("recorder", snapshot)) + .catch(report("saving the recorder state")); + lastPersist = persisted; + if (before.phase !== state.phase || before.actions !== state.actions) + void showBadge(state).catch(report("updating the badge")); + } + + /** storage.local read-then-write work (the active recording and the drains) runs one job at a time. */ + let localQueue: Promise = Promise.resolve(); + function serially(work: () => Promise): Promise { + const run = localQueue.then(work, work); + localQueue = run.catch(() => undefined); + return run; + } + + async function writeDrains(drains: DrainRecord[]): Promise { + if (drains.length > 0) await writeLocal("drains", drains); + else await removeLocal("drains"); + } + + function publishDrains(drains: DrainRecord[]): void { + dispatch({ + type: "drains", + draining: drains.length, + kept: keptSummary(drains), + }); + } + + async function updateDrains( + change: (drains: DrainRecord[]) => DrainRecord[], + ): Promise { + const next = await serially(async () => { + const changed = change((await readLocal("drains")) ?? []); + await writeDrains(changed); + return changed; + }); + publishDrains(next); + return next; + } + + // ---- What the person did ------------------------------------------------------------------- + + /** One more click, input or page load; at the 50th the notice goes to every followed tab's top frame. */ + function countAction(): void { + if (!live || state.phase !== "recording") return; + dispatch({ type: "action" }); + if (state.actions >= RECORDER_ACTION_NOTICE_AT && !state.noticeShown) { + dispatch({ type: "notice" }); + postToContentPorts( + state.tabIds, + { type: "background.notice", show: true, actions: state.actions }, + { topFrameOnly: true }, + ); + } + } + + /** What a previous service worker left in storage.session: recovery finishes it (recovery.ts). */ + let loadedPhase: RecorderState["phase"] = "idle"; + const ready = (async () => { + const stored = await readSession("recorder"); + if (stored) state = stored; + loadedPhase = state.phase; + for (const digest of (await readSession("digests")) ?? []) + digests.add(digest); + for (const [tabId, typed] of (await readSession("typedSecrets")) ?? []) + typedSecrets.set(tabId, typed); + if (state.phase === "idle") return; + const record = await readLocal("activeRecording"); + if (record) + shown = { + recordingId: record.recordingId, + trajectoryIds: new Set(record.trajectoryIds), + }; + // What the previous service worker held for this recording is taken up as soon as it can be: a step that comes + // meanwhile takes its place behind them. + const held = record ? await readLocal("heldSteps") : undefined; + if (held && held.recordingId === record?.recordingId) restoredSteps = held; + if (state.phase !== "recording") return; + const connection = await currentConnection(); + if (!record || !connection || !ownedBy(record, connection)) return; + live = liveFrom(record, connection.token); + openSteps(live.recordingId, takeRestored(live.recordingId)); + })(); + ready.catch(report("loading the recorder state")); + + // ---- The offscreen document ---------------------------------------------------------------- + + let offscreen: chrome.runtime.Port | null = null; + /** The port has spoken (offscreen.ready on load, or any message from a document that reconnected). */ + let documentReady = false; + let readyWaiters: ((port: chrome.runtime.Port) => void)[] = []; + /** Waiting for the document's port to connect again (`documentPort`). */ + let connectWaiters: ((port: chrome.runtime.Port) => void)[] = []; + /** Set while a document is opened for a start or a recovery: events wait until its session message is posted. */ + let holdEvents = false; + const queued: { event: CapturedEvent; key?: string; bytes: number }[] = []; + let queuedBytes = 0; + let droppedEvents = 0; + const stopWaiters = new Map void>(); + /** Offscreen answers that arrived before their recording reached `drains`, applied when it does. */ + const offscreenDone = new Set(); + /** Recordings the document could not send at all, with the sentence that says so. */ + const failedSends = new Map(); + const earlyKept = new Map(); + + /** Opening and closing the document never cross. */ + let documentQueue: Promise = Promise.resolve(); + function documentWork(work: () => Promise): Promise { + const run = documentQueue.then(work, work); + documentQueue = run.catch(() => undefined); + return run; + } + + function postOffscreen(message: BackgroundToOffscreen): boolean { + if (!offscreen) return false; + try { + offscreen.postMessage(message); + return true; + } catch { + return false; + } + } + + /** The document's port. While a document exists but its port is down (the service worker restarted and the document + * reconnects with its backoff), waits up to `ms` for the port to connect again; a connected port is enough, since the + * document opened it and listens on it. Null when no document exists, or its port did not come back in time. */ + async function documentPort(ms: number): Promise { + if (offscreen) return offscreen; + if (!(await hasOffscreenDocument())) return null; + if (offscreen) return offscreen; + return new Promise((resolve) => { + const waiter = (port: chrome.runtime.Port) => { + deps.timers.clear(handle); + resolve(port); + }; + const handle = deps.timers.set(() => { + connectWaiters = connectWaiters.filter((entry) => entry !== waiter); + resolve(null); + }, ms); + connectWaiters.push(waiter); + }); + } + + function waitForReady(): Promise { + if (offscreen && documentReady) return Promise.resolve(offscreen); + return new Promise((resolve, reject) => { + const waiter = (port: chrome.runtime.Port) => { + deps.timers.clear(handle); + resolve(port); + }; + const handle = deps.timers.set(() => { + readyWaiters = readyWaiters.filter((entry) => entry !== waiter); + reject(new Error(SENTENCES.documentFailed)); + }, READY_TIMEOUT_MS); + readyWaiters.push(waiter); + }); + } + + /** Makes sure the document exists and has said ready, holding events until the caller posted its session message and + * called `releaseEvents`. */ + async function openDocument(): Promise { + holdEvents = true; + try { + await documentWork(() => ensureOffscreenDocument()); + return await waitForReady(); + } catch (error) { + releaseEvents(); + throw error; + } + } + + function releaseEvents(): void { + holdEvents = false; + flushEvents(); + } + + /** `key`: a held step's (steps.ts), which the document stores once. */ + function forwardEvent(event: CapturedEvent, key?: string): void { + const message: BackgroundToOffscreen = + key === undefined + ? { type: "offscreen.event", event } + : { type: "offscreen.event", event, key }; + if (offscreen && !holdEvents && queued.length === 0 && droppedEvents === 0) + if (postOffscreen(message)) return; + const bytes = JSON.stringify(event).length; + queued.push(key === undefined ? { event, bytes } : { event, key, bytes }); + queuedBytes += bytes; + while (queued.length > QUEUE_EVENTS || queuedBytes > QUEUE_BYTES) { + const oldest = queued.shift(); + if (!oldest) break; + queuedBytes -= oldest.bytes; + droppedEvents += 1; + } + } + + function flushEvents(): void { + if (!offscreen || holdEvents) return; + if (droppedEvents > 0) { + const dropped: CapturedEvent = { + type: "CUSTOM", + name: "recorder.dropped", + timestamp: deps.now(), + value: { count: droppedEvents, reason: "offscreen-unreachable" }, + }; + if (!postOffscreen({ type: "offscreen.event", event: dropped })) return; + droppedEvents = 0; + } + while (queued.length > 0) { + const next = queued[0]; + if ( + !next || + !postOffscreen( + next.key === undefined + ? { type: "offscreen.event", event: next.event } + : { type: "offscreen.event", event: next.event, key: next.key }, + ) + ) + return; + queued.shift(); + queuedBytes -= next.bytes; + } + } + + /** The document is closed once nothing it holds is still sending: every drain is done, parked, or waiting for its + * person to connect again (a held drain keeps it open for its retries). */ + function maybeCloseDocument(): Promise { + return documentWork(async () => { + if (state.phase !== "idle" || holdEvents) return; + const drains = (await readLocal("drains")) ?? []; + const busy = drains.some( + (drain) => + !offscreenDone.has(drain.recordingId) && + !( + drain.kept && + (drain.kept.parked || drain.kept.waitingForReconnect) + ), + ); + if (busy || !(await hasOffscreenDocument())) return; + if (state.phase !== "idle" || holdEvents) return; + await closeOffscreenDocument(); + }); + } + + function awaitStopped( + recordingId: string, + since: number, + message: BackgroundToOffscreen, + ): Promise { + return new Promise((resolve) => { + let handle: unknown = null; + const finish = () => { + stopWaiters.delete(recordingId); + if (handle !== null) deps.timers.clear(handle); + resolve(); + }; + stopWaiters.set(recordingId, finish); + // The watchdog counts from the persisted start of the stop, so a restarted worker waits no longer in total. + const remaining = Math.max(0, since + STOP_WATCHDOG_MS - deps.now()); + handle = deps.timers.set(finish, remaining); + // A document whose port is reconnecting is waited for within the watchdog. With no document there is nothing to + // tell: what it holds is recovered with the drain. A wait Disconnect released still tells the document to stop. + void documentPort(remaining).then((port) => { + const posted = port !== null && postOffscreen(message); + if (!posted && stopWaiters.get(recordingId) === finish) finish(); + }); + }); + } + + /** Posts a message the document must get, waiting up to the ready timeout for its port while it reconnects. */ + async function sendToDocument(message: BackgroundToOffscreen): Promise { + if (await documentPort(READY_TIMEOUT_MS)) postOffscreen(message); + } + + /** How long the document's port may stay down before the document counts as gone: longer than its own reconnect + * backoff's first tries. */ + const DOCUMENT_GONE_MS = READY_TIMEOUT_MS; + + /** The document's port went down while a recording runs or a Stop waits on it. A document that comes back reconnects + * its port within the window; one that crashed never does, and nothing else would reopen it while the service worker + * stays awake (recovery runs only when the service worker starts), so the queue would fill and drop events. */ + function watchForLostDocument(): void { + if (state.phase !== "recording" && state.phase !== "stopping") return; + deps.timers.set(() => { + void reopenLostDocument().catch(report("reopening the document")); + }, DOCUMENT_GONE_MS); + } + + /** Reopens a document that is gone on the stored session, the same outbox: a live recording goes on, and a Stop that + * was waiting tells the new document again. A document that still exists reconnects its port itself. */ + async function reopenLostDocument(): Promise { + if (offscreen) return; + const stopping = state.phase === "stopping" ? pendingStop : null; + if (state.phase !== "recording" && !stopping) return; + if (await hasOffscreenDocument()) return; + if (offscreen || !(await resumeDocument())) return; + if (stopping && pendingStop === stopping) postOffscreen(stopping); + } + + /** Opens a document that went away with a previous service worker on the stored session: the same outbox. False when + * no session is stored. */ + async function resumeDocument(): Promise { + const stream = await readSession("stream"); + if (!stream) return false; + const port = await openDocument(); + try { + port.postMessage({ + type: "offscreen.resume", + session: stream, + } satisfies BackgroundToOffscreen); + } finally { + releaseEvents(); + } + return true; + } + + function onOffscreenMessage( + port: chrome.runtime.Port, + message: OffscreenToBackground, + ): void { + if (offscreen === port && !documentReady) { + documentReady = true; + const waiters = readyWaiters; + readyWaiters = []; + for (const waiter of waiters) waiter(port); + } + switch (message.type) { + case "offscreen.ready": + flushEvents(); + if (state.phase === "recording" && digests.size > 0) + postOffscreen({ type: "offscreen.digests", digests: [...digests] }); + return; + case "offscreen.status": + if ( + state.phase !== "idle" && + shown?.trajectoryIds.has(message.trajectoryId) + ) + dispatch({ type: "capture", capture: message.capture }); + return; + case "offscreen.trajectory": + if (shown?.recordingId === message.recordingId) + shown.trajectoryIds.add(message.trajectoryId); + void addTrajectory(message.recordingId, message.trajectoryId).catch( + report("keeping a trajectory"), + ); + return; + case "offscreen.stopped": + stopWaiters.get(message.recordingId)?.(); + return; + case "offscreen.done": + if (message.failed !== undefined) + sendFailed(message.recordingId, message.failed); + void documentDone(message.recordingId).catch( + report("finishing a drain"), + ); + return; + case "offscreen.kept": + void keepEvents(message.recordingId, { + reason: message.reason, + parked: message.parked, + events: message.events, + at: deps.now(), + }).catch(report("keeping events")); + return; + case "offscreen.transcript": + placeTranscript(message.event); + return; + case "offscreen.speaking": + speechHold = message.since; + steps?.hold(message.since); + return; + case "offscreen.voice.ended": + voiceEndWaiters.get(message.recordingId)?.(); + return; + case "offscreen.held": + void updateDrains((drains) => + drains.map((drain) => { + if (drain.recordingId !== message.recordingId) return drain; + const { held: _sent, ...rest } = drain; + return rest; + }), + ).catch(report("forgetting a drain's held steps")); + return; + case "offscreen.signed-out": { + const signedOut = parseOffscreenSignedOut(message); + if (signedOut) + void documentSignedOut(signedOut).catch(report("signing out")); + else + report("signing out")( + new Error("offscreen.signed-out without a valid token"), + ); + return; + } + } + } + + async function addTrajectory( + recordingId: string, + trajectoryId: string, + ): Promise { + const append = (record: DrainRecord): DrainRecord => + record.trajectoryIds.includes(trajectoryId) + ? record + : { ...record, trajectoryIds: [...record.trajectoryIds, trajectoryId] }; + await serially(async () => { + const record = await readLocal("activeRecording"); + if (record?.recordingId === recordingId) { + await writeLocal("activeRecording", append(record)); + return; + } + const drains = (await readLocal("drains")) ?? []; + await writeDrains( + drains.map((drain) => + drain.recordingId === recordingId ? append(drain) : drain, + ), + ); + }); + } + + /** None of a recording could be sent (its streamer never started): the popup must never say it went to Intelligence. + * The answer can come before Stop finishes (`endRecording` reads it then) or after. */ + function sendFailed(recordingId: string, sentence: string): void { + failedSends.set(recordingId, sentence); + if (state.lastStopped?.recordingId === recordingId) + dispatch({ type: "send-failed", error: sentence }); + } + + /** Intelligence has every event of a stopped recording: its drain goes. An answer for the recording a stop is still + * moving into the drains is remembered and applied by that move; the lookup runs in the same storage queue as the + * move, so the two never cross. */ + async function documentDone(recordingId: string): Promise { + offscreenDone.add(recordingId); + const drain = await serially(async () => { + const found = ((await readLocal("drains")) ?? []).find( + (entry) => entry.recordingId === recordingId, + ); + const active = + (await readLocal("activeRecording"))?.recordingId === recordingId; + if (!found && !active) offscreenDone.delete(recordingId); + return found; + }); + // A discard in progress removes its drain itself, once the document has been told to clear it. + if (drain && !drain.discarding) { + await updateDrains((drains) => + drains.filter((entry) => entry.recordingId !== recordingId), + ); + offscreenDone.delete(recordingId); + } + await maybeCloseDocument(); + } + + async function keepEvents( + recordingId: string, + kept: KeptEvents, + ): Promise { + const updated = await serially(async () => { + const drains = (await readLocal("drains")) ?? []; + if (drains.some((drain) => drain.recordingId === recordingId)) { + const next = drains.map((drain) => + drain.recordingId === recordingId ? { ...drain, kept } : drain, + ); + await writeDrains(next); + return next; + } + if ((await readLocal("activeRecording"))?.recordingId === recordingId) + earlyKept.set(recordingId, kept); + return null; + }); + if (updated) publishDrains(updated); + await maybeCloseDocument(); + } + + // ---- Record ------------------------------------------------------------------------------- + + function refuse(error: string): PageResponse { + dispatch({ type: "error", error }); + return { ok: false, error }; + } + + /** A tab's top page as a step carries it: masked as every step is. */ + async function pageOf( + tabId: number, + ): Promise<{ url: string; title?: string } | null> { + const tab = await chrome.tabs.get(tabId).catch(() => null); + if (!tab?.url) return null; + const options = { digests }; + return { + url: scrubUrl(tab.url, options), + ...(tab.title ? { title: scrubText(tab.title, options) } : {}), + }; + } + + // ---- The held steps, kept in storage ---------------------------------------------------------- + // + // A service worker's memory goes when Chrome stops it, and with it every step it still held (waiting out its window + // for the call that saves it). So the live recording's held steps are written to storage.local as they change: a + // restarted service worker takes them up again, and after a browser quit they go with the recording's drain, as the + // rest of the recording is kept (storage.session would not outlive the quit). The first change is written at once, + // later ones at most every HELD_WRITE_MS. + // + // The agent events' context (the Threads open in each tab, the runs the page asked for, the Thread linked last) goes + // in the same write, so a restarted service worker's clicks carry the Thread they would have. A link takes its place + // in that context only as it goes to the steps, and when the context changed, the write comes before anything leaves: + // a restarted service worker never makes a link again that left, nor takes up one that went nowhere. + + const stepTimers = deps.stepTimers ?? browserTimers; + let heldSource: { recordingId: string; assembler: StepAssembler } | null = + null; + /** The live recording's agent events, whose context is written with its held steps. */ + let heldAgent: AgentEvents | null = null; + /** The agent context in storage, as JSON (null: none). */ + let agentWritten: string | null = null; + let heldTimer: unknown = null; + let heldWrittenAt = Number.NEGATIVE_INFINITY; + /** What storage holds, as JSON (null: nothing); undefined until this service worker wrote it. */ + let heldWritten: string | null | undefined; + let heldWrites: Promise = Promise.resolve(); + + function heldChanged(recordingId: string, assembler: StepAssembler): void { + heldSource = { recordingId, assembler }; + if (heldTimer !== null) return; + heldTimer = stepTimers.set( + () => void writeHeld(), + Math.max(0, heldWrittenAt + HELD_WRITE_MS - deps.now()), + ); + } + + /** The agent events' context as it is written, or null when it holds nothing a restart needs. */ + function agentContext(): AgentSnapshot | null { + const agent = heldAgent?.snapshot(); + return agent && (agent.tabs.length > 0 || agent.recordedRuns.length > 0) + ? agent + : null; + } + + /** Writes what the steps hold now and the agent context (or removes them when they hold nothing). The write is issued + * at once, in order with the ones before it; resolves once it is in storage. */ + function writeHeld(): Promise { + if (heldTimer !== null) stepTimers.clear(heldTimer); + heldTimer = null; + heldWrittenAt = deps.now(); + const source = heldSource; + const snapshot = source?.assembler.snapshot() ?? null; + const agent = source ? agentContext() : null; + const value: HeldSteps | null = + source && (snapshot || agent) + ? { + recordingId: source.recordingId, + steps: snapshot ?? NOTHING_HELD, + ...(agent ? { agent } : {}), + } + : null; + const text = value ? JSON.stringify(value) : null; + if (text === heldWritten) return heldWrites; + heldWritten = text; + agentWritten = agent ? JSON.stringify(agent) : null; + const write = ( + value ? writeLocal("heldSteps", value) : removeLocal("heldSteps") + ).catch(report("keeping the held steps")); + heldWrites = Promise.all([heldWrites, write]).then(() => undefined); + return heldWrites; + } + + /** Held items are about to leave: when the agent context changed since it was written, it is written first, with + * the items still in it, so nothing leaves ahead of the context it came from. */ + function beforeLeaving(): void { + const agent = agentContext(); + if ((agent ? JSON.stringify(agent) : null) !== agentWritten) + void writeHeld(); + } + + /** A new set of held steps for the live recording, with its own agent events; the previous one's are sent first. + * `restore` takes up what a previous service worker held for it, and its agent context. */ + function openSteps(recordingId: string, restore?: HeldSteps): StepAssembler { + steps?.flush(); + steps?.clear(); + agentEvents?.stop(); + let paired: AgentEvents | null = null; + const assembler = createStepAssembler({ + emit: (event, key) => { + if (steps === assembler) forwardEvent(event, key); + }, + // A click takes the Thread of the run it sent, seen while it was held: one with the open-Thread context is held + // for its run as long as its join is open. + settle: (tabId, event, joins) => + event.name === "click" && paired + ? paired.settleClick(tabId, event, joins) + : event, + awaits: (tabId, event, joins) => + event.name === "click" && + paired !== null && + paired.awaitsRun(tabId, event, joins), + pageOf, + onChange: () => heldChanged(recordingId, assembler), + leaving: () => { + if (steps === assembler) beforeLeaving(); + }, + now: deps.now, + timers: stepTimers, + }); + steps = assembler; + const events = deps.createAgentEvents({ + emit: (tabId, event) => { + if (steps === assembler) assembler.place(tabId, event); + }, + digests: () => digests, + now: deps.now, + onChange: () => { + if (agentEvents === events) heldChanged(recordingId, assembler); + }, + }); + agentEvents = paired = events; + heldAgent = events; + // What the agent events of a previous service worker knew of this recording (the Threads open in each tab, the runs + // the page asked for, the Thread linked last) is taken up before anything reaches them, and its steps after. + if (restore?.agent) events.restore(restore.agent); + if (restore) assembler.restore(restore.steps); + // A phrase still being spoken holds the new steps too (Start over); a restored one keeps its own hold until the + // document says again. + if (speechHold !== null) assembler.hold(speechHold); + return assembler; + } + + /** Sends every step held and holds no more (the recording stops). */ + function closeSteps(): void { + const held = steps; + if (!held) return; + held.flush(); + held.clear(); + if (steps === held) steps = null; + agentEvents?.stop(); + agentEvents = null; + } + + /** The steps a previous service worker held for this recording, taken up by this one (once). */ + function takeRestored(recordingId: string): HeldSteps | undefined { + const restored = restoredSteps; + if (restored?.recordingId !== recordingId) return undefined; + restoredSteps = null; + return restored; + } + + // ---- The narration among the steps -------------------------------------------------------- + // + // The document transcribes the person's narration. Each phrase's transcript is stamped with its start but exists only + // once the phrase ends, so the document says when a phrase begins (offscreen.speaking: the steps from then on wait) + // and sends the transcript here to be placed among the held steps. + + /** When the phrase being spoken began, as the document last said (null: none is). */ + let speechHold: number | null = null; + const voiceEndWaiters = new Map void>(); + + function placeTranscript(event: CapturedEvent): void { + // A phrase that began before Start over is stamped no earlier than the recording it lands in (its own startedAt + // still says when it began). + const startedAt = state.startedAt; + const placed = + startedAt !== undefined && event.timestamp < startedAt + ? { ...event, timestamp: startedAt } + : event; + if (steps) steps.transcript(state.tabIds[0] ?? -1, placed); + else forwardEvent(placed); + } + + /** Before Stop sends the held steps: the document hands over the phrase being spoken, placed among them, and lets go + * of the microphone. Only while a phrase is being spoken, and never longer than VOICE_END_MS. */ + function endVoiceFirst(recordingId: string): Promise { + if (speechHold === null || !steps || !offscreen) return Promise.resolve(); + return new Promise((resolve) => { + let handle: unknown = null; + const finish = () => { + if (voiceEndWaiters.get(recordingId) === finish) + voiceEndWaiters.delete(recordingId); + if (handle !== null) deps.timers.clear(handle); + resolve(); + }; + voiceEndWaiters.set(recordingId, finish); + handle = deps.timers.set(finish, VOICE_END_MS); + if (!postOffscreen({ type: "offscreen.voice.end", recordingId })) + finish(); + }); + } + + /** The page a tab is on when a recording starts in it: its first step. */ + function startStep(tabId: number, url: string): void { + if (!/^https?:/i.test(url)) return; + const to = scrubUrl(url, { digests }); + steps?.navigation( + tabId, + inTab(tabId, { + type: "CUSTOM", + name: "navigation", + timestamp: deps.now(), + value: { + step: "navigate", + from: "", + to, + navigationType: "start", + frameId: 0, + parentFrameId: -1, + }, + }), + ); + } + + const networkDeps: NetworkCaptureDeps = { + openbotUrl: () => live?.openbotUrl ?? null, + call: (call) => { + if (live) steps?.call(call); + }, + writeStarted: (id, tabId, startedAt) => + steps?.writeStarted(id, tabId, startedAt), + writeEnded: (id) => steps?.writeEnded(id), + agent: (observation) => { + if (live) agentEvents?.observe(observation); + }, + status: (mode, reason) => { + networkMode = mode; + if (state.phase === "starting" || state.phase === "recording") + dispatch({ + type: "capture", + capture: { network: mode, networkReason: reason }, + }); + }, + digests: () => digests, + typedSecrets: (tabId) => new Set(typedSecrets.get(tabId)), + }; + + async function record( + message: Extract, + ): Promise { + if (state.phase !== "idle") return { ok: false, error: SENTENCES.busy }; + dispatch({ + type: "record", + title: message.title, + learning: learningSpace, + }); + let finish = () => {}; + const attempt = { + done: new Promise((resolve) => { + finish = resolve; + }), + cancelled: false, + }; + starting = attempt; + try { + const connection = await currentConnection(); + const allowed = connection !== null && (await deps.hasHostPermission()); + if (attempt.cancelled) return cancelled(); + if (!connection) return refuse(SENTENCES.noConnection); + if (!allowed) return refuse(SENTENCES.noAccess); + return await start(message, connection, attempt); + } finally { + if (starting === attempt) starting = null; + finish(); + } + } + + /** Record ended by a stop before anything recorded: idle, with no error (the person asked for it). */ + function cancelled(): PageResponse { + dispatch({ type: "cancelled" }); + return { ok: false, error: SENTENCES.notRecording }; + } + + async function start( + message: Extract, + connection: ConnectionRecord, + attempt: { cancelled: boolean }, + ): Promise { + const { title, tabId } = message; + let tab: chrome.tabs.Tab; + try { + tab = await chrome.tabs.get(tabId); + } catch { + return refuse(SENTENCES.tabClosed); + } + // Chrome withholds the URL of a page the Recorder may not read (chrome:// pages included): such a tab starts with + // the network waiting, and topUrls holds "" until its first top-frame commit. + const url = tab.url; + const recordingId = crypto.randomUUID(); + const trajectoryId = crypto.randomUUID(); + let saved = false; + let documentStarted = false; + let injected = false; + let capture: NetworkCapture | null = null; + try { + const startedAt = deps.now(); + const active: DrainRecord = { + recordingId, + title, + openbotUrl: connection.openbotUrl, + userId: connection.user.id, + userName: connection.user.name, + trajectoryIds: [trajectoryId], + startedAt, + }; + await serially(() => writeLocal("activeRecording", active)); + saved = true; + const policy = await deps.readPolicy(); + if (attempt.cancelled) throw CANCELLED; + if (!allowsOpenBot(policy, connection.openbotUrl)) throw POLICY_REFUSED; + const session: StreamSession = { + recordingId, + title, + openbotUrl: connection.openbotUrl, + token: connection.token, + trajectoryId, + startedAt, + allowedIntelligenceUrls: policy.allowedIntelligenceUrls, + version: chrome.runtime.getManifest().version, + // Every recording narrates: the person mutes it in the popup if they want to. + voice: { + language: narrationLanguage(message.language, navigator.language), + muted: false, + }, + }; + if (import.meta.env.RECORDER_VARIANT === "e2e") { + const items = await chrome.storage.local.get( + "RECORDER_E2E_HOLD_RETRY_MS", + ); + const holdRetryMs: unknown = items.RECORDER_E2E_HOLD_RETRY_MS; + if (typeof holdRetryMs === "number") + session.e2eHoldRetryMs = holdRetryMs; + } + shown = { recordingId, trajectoryIds: new Set([trajectoryId]) }; + const port = await openDocument(); + port.postMessage({ + type: "offscreen.start", + session, + } satisfies BackgroundToOffscreen); + documentStarted = true; + releaseEvents(); + await writeSession("stream", session); + // Nothing records yet. A stop that came while Record waited, or a connection that went (a sign-out, Disconnect) + // or changed meanwhile, ends Record here; nothing is awaited between this check and the recording going live. + if ( + attempt.cancelled || + (await currentConnection())?.token !== connection.token + ) + throw CANCELLED; + live = { + recordingId, + openbotUrl: connection.openbotUrl, + token: connection.token, + userId: connection.user.id, + }; + watchConnection(); + // Recording from here on, before the script is injected: a content script that says hello is told to capture. + recordedNavigations.set(tabId, url ?? ""); + dispatch({ + type: "started", + recordingId, + tabId, + topUrl: url ?? "", + startedAt, + capture: { + intelligence: "connecting", + network: "off", + voice: { state: "starting" }, + }, + }); + injected = true; + openSteps(recordingId); + startStep(tabId, url ?? ""); + // A page the Recorder may not read refuses the script; its first commit to a website injects it. + await injectRecordingScripts(tabId, recordingId).catch(() => undefined); + // The capture owns the fallback: it alone starts, extends, resumes and stops it. + capture = deps.createNetworkCapture(networkDeps, { + fallback: deps.createPageFallback(), + }); + network = capture; + networkMode = await capture.start(tabId, url ?? ""); + dispatch({ type: "capture", capture: { network: networkMode } }); + postToContentPorts([tabId], { + type: "background.capture", + active: true, + recordingId, + }); + return { ok: true }; + } catch (error) { + // Roll back in reverse. + live = null; + unwatchConnection(); + network = null; + networkMode = "off"; + steps?.clear(); + steps = null; + agentEvents?.stop(); + agentEvents = null; + forgetAddresses(); + if (capture) + await capture.stop().catch(report("stopping network capture")); + if (injected) await stopRecordingScripts([tabId]); + releaseEvents(); + await removeSession("stream"); + if (documentStarted) { + // The document holds the recording's first events: it clears them, and nothing of it is ever sent. + await moveIntoDrains(recordingId, { discarding: true }); + await finishDiscards().catch(report("discarding a Record")); + } else if (saved) await serially(() => removeLocal("activeRecording")); + if (error === CANCELLED) return cancelled(); + if (error === POLICY_REFUSED) return refuse(SENTENCES.policyRefused); + return refuse(sentenceFor(error, SENTENCES.startFailed)); + } + } + + // ---- Stop ---------------------------------------------------------------------------------- + + async function moveIntoDrains( + recordingId: string, + flags: { discarding?: boolean; held?: HeldEvent[] } = {}, + ): Promise { + const drains = await serially(async () => { + const record = await readLocal("activeRecording"); + const current = (await readLocal("drains")) ?? []; + if (record?.recordingId !== recordingId) return current; + const kept = earlyKept.get(recordingId); + earlyKept.delete(recordingId); + const drain: DrainRecord = defined({ + ...record, + discarding: flags.discarding ? true : undefined, + kept: flags.discarding ? undefined : kept, + held: flags.held?.length ? flags.held : undefined, + }); + const next = [ + ...current.filter((entry) => entry.recordingId !== recordingId), + drain, + ]; + await writeDrains(next); + await removeLocal("activeRecording"); + return next; + }); + publishDrains(drains); + } + + async function stopRecording(mode: StopMode): Promise { + // A stop that comes while Record starts ends Record before its recording is live, or else waits for Record to + // finish; one that comes while Start over makes its switch waits for it. Either way it then stops the recording + // that is live. + if (starting) { + starting.cancelled = true; + await starting.done; + } + if (switching) await switching; + if (state.phase !== "recording" || !live) return; + const current = live; + live = null; + unwatchConnection(); + await trackStop(endRecording(current, mode)); + } + + /** Disconnect waits for the stop under way, a resumed one included. */ + async function trackStop(run: Promise): Promise { + stopInFlight = run; + try { + await run; + } finally { + if (stopInFlight === run) stopInFlight = null; + } + } + + /** The stop of the recording `stopRecording` took from `live`, or, with `resumed`, the rest of a stop a previous service + * worker persisted before it went away. */ + async function endRecording( + current: Live, + mode: StopMode, + resumed?: NonNullable, + ): Promise { + const since = resumed?.since ?? deps.now(); + const tabIds = state.tabIds; + const { recordingId } = current; + if (!resumed) { + // Persisted before anything else: awaiting does not keep a service worker alive, and a restarted one finishes the stop. + dispatch({ type: "stop", since }); + await persisted; + } + await stopRecordingScripts(tabIds); + digests.clear(); + typedSecrets.clear(); + forgetAddresses(); + openers.clear(); + await removeSession("digests"); + await removeSession("typedSecrets"); + const capture = network; + network = null; + networkMode = "off"; + if (capture) await capture.stop().catch(report("stopping network capture")); + // A stop this service worker finishes for one before it: what that one held is sent as this one's would be. + const restored = steps ? undefined : takeRestored(recordingId); + if (restored) openSteps(recordingId, restored); + // The phrase being said is placed among the held steps, before they go (not for a Disconnect, which discards it). + if (mode.kind !== "disconnect") await endVoiceFirst(recordingId); + // The steps still held go before the outcome, with the saves they have; storage forgets them at once. + closeSteps(); + // The recording's voice has ended with it: a phrase it was hearing holds nothing of the next recording. + speechHold = null; + await writeHeld(); + // Only the person's Stop succeeds. A Disconnect or a sign-out ends the recording midway: what it streamed live is in + // Intelligence already, and its outcome says why it ended. + const message: BackgroundToOffscreen = + mode.kind === "person" + ? { type: "offscreen.stop", outcome: "succeeded" } + : { + type: "offscreen.stop", + outcome: "failed", + reason: + mode.kind === "disconnect" || mode.reason === "policy" + ? "disconnected" + : "signed-out", + }; + // The document went away (with the service worker, or on its own): the person's Stop reopens it on the stored + // session, however long ago the stop began, so the outcome is queued and the rest is sent now rather than at the + // next browser start. A recording kept for its person or discarded needs no document to be told. + if ( + mode.kind === "person" && + disconnecting === 0 && + !(await hasOffscreenDocument()) + ) + await resumeDocument().catch(report("reopening the document")); + if (since + STOP_WATCHDOG_MS <= deps.now()) { + // The watchdog has run out (it counts from the persisted start): a document is still told, waiting for one that + // is reconnecting after the restart, but nothing waits for it to answer, and the drain holds whatever it has not + // sent. + await sendToDocument(message); + } else if (mode.kind === "person" && disconnecting === 0) { + // The outcome is queued after every event before the recording counts as stopped. A document that goes away + // meanwhile is reopened and told again (`reopenLostDocument`). + pendingStop = message; + try { + await awaitStopped(recordingId, since, message); + } finally { + pendingStop = null; + } + } else { + // A recording kept for its person, or discarded (a Disconnect is under way), waits only for a reconnecting + // document to hear of it. + await sendToDocument(message); + } + + let signedOut: LostReason | null = null; + if (mode.kind === "signed-out") signedOut = mode.reason; + else if (mode.kind === "person") { + // Its person's connection went while it stopped: its events are kept for them, never sent under another + // connection. + const record = await readLocal("activeRecording"); + const connection = await currentConnection(); + if (record && !(connection && ownedBy(record, connection))) + signedOut = await signedOutMeanwhile(); + } + + await moveIntoDrains(recordingId); + await removeSession("stream"); + const failed = failedSends.get(recordingId); + dispatch({ + type: "finished", + // A recording a Disconnect stops is discarded right after: what it had not sent yet never goes to Intelligence, so + // the popup does not say it was sent (what it streamed live is there, ended with a failed outcome). + lastStopped: + mode.kind === "disconnect" || failed !== undefined + ? undefined + : { recordingId }, + error: signedOut ? stoppedSentence(signedOut) : failed, + }); + if (signedOut) + // A sign-out that forgot nothing (the person connected again) leaves their other drains to the new connection. + await markWaiting( + (drain) => + drain.userId === current.userId && + drain.openbotUrl === current.openbotUrl && + (mode.kind !== "signed-out" || + mode.forgot || + drain.recordingId === recordingId), + signedOut, + ); + if (offscreenDone.has(recordingId)) await documentDone(recordingId); + await maybeCloseDocument(); + } + + /** Why the connection a stop drained under went away: a sign-out or a policy move leaves its notice, the person's own + * Disconnect leaves none (and is under way while the stop finishes, since Disconnect waits for it). */ + async function signedOutMeanwhile(): Promise { + if (disconnecting > 0) return null; + const notice = await readLocal("notice"); + if (notice?.kind === "policy-moved") return "policy"; + return notice?.kind === "signed-out" ? (notice.reason ?? "revoked") : null; + } + + /** Why a recording waits for its person when no answer said so (found after a restart): the notice's reason, else a + * plain disconnect. */ + async function waitingReason(): Promise { + const notice = await readLocal("notice"); + if (notice?.kind === "policy-moved") return "policy"; + return notice?.kind === "signed-out" + ? (notice.reason ?? "revoked") + : "revoked"; + } + + // ---- The connection check while recording ------------------------------------------------- + + /** While a recording runs, OpenBot is asked every 30 s whether the connection it records under still stands: a + * browser revoked in OpenBot's Settings, or a connection OpenBot signed out, would otherwise go on streaming until the + * popup or the 12-hour keep-alive asked. A signed-out answer ends the recording through the sign-out path (a failed + * outcome, reason signed-out, its streams halted, everything kept for its person). OpenBot not answering, or + * answering anything else, says nothing about the connection: the recording goes on through an outage, and the next + * check asks again. */ + let connectionCheck: unknown = null; + function watchConnection(): void { + unwatchConnection(); + connectionCheck = deps.timers.set(() => { + connectionCheck = null; + void ready.then(checkConnection).catch(report("checking the connection")); + }, CONNECTION_CHECK_MS); + } + + function unwatchConnection(): void { + if (connectionCheck !== null) deps.timers.clear(connectionCheck); + connectionCheck = null; + } + + async function checkConnection(): Promise { + const current = live; + if (!current || state.phase !== "recording") return; + try { + await openbotJson( + { openbotUrl: current.openbotUrl, token: current.token }, + recorderPaths.me, + ); + } catch (error) { + const reason = signedOutReason(error); + if (reason) + await handleSignedOut( + reason, + current.token, + current.userId, + current.openbotUrl, + ); + } + // Still recording (OpenBot answered, could not answer, or the person connected again first): asked again later. + if (live && state.phase === "recording" && connectionCheck === null) + watchConnection(); + } + + // ---- Signed out ---------------------------------------------------------------------------- + + /** Every drain `belongs` picks waits for its person to connect this browser again; nothing is discarded (Decision 19). */ + async function markWaiting( + belongs: (drain: DrainRecord) => boolean, + reason: LostReason, + ): Promise { + await updateDrains((drains) => + drains.map((drain) => + !belongs(drain) || drain.discarding + ? drain + : { + ...drain, + kept: { + reason: keptSentence(reason), + parked: true, + events: drain.kept?.events ?? 0, + at: deps.now(), + waitingForReconnect: true, + }, + }, + ), + ); + } + + /** No stream in the document sends with a lost connection's token any more: a socket still streaming stops now, + * not at its next pass, and keeps its events for the person. */ + function haltStreams(token: string): void { + void sendToDocument({ type: "offscreen.halt", token }).catch( + report("stopping a lost connection's streams"), + ); + } + + /** OpenBot answered that the connection a request used is gone: forget it (only if it is still the stored one), stop + * a recording that used it, and keep everything for its person on that OpenBot (their drains on another OpenBot are + * another connection's). */ + async function handleSignedOut( + reason: SignedOutReason, + token: string, + userId: string, + openbotUrl: string, + ): Promise { + const forgot = await forgetConnection(reason, token); + haltStreams(token); + if (live && live.token === token && state.phase === "recording") { + // Its person connected this browser again (which revoked this token) before the sign-out came back: the + // recording goes on under the new connection. + if (!forgot && (await adoptConnection())) return; + // The live recording used this token, which is dead: it stops (and marks the person's other drains waiting). + await stopRecording({ kind: "signed-out", reason, forgot }); + } else if (forgot) + await markWaiting( + (drain) => drain.userId === userId && drain.openbotUrl === openbotUrl, + reason, + ); + } + + /** The live recording's person connected this browser again: connecting revoked the token its passes were asked + * with, so it takes the new one, as do the stored session (for a reopened document) and the document's stream. + * Resolves true when it switched; a recording of another person, or on another OpenBot, keeps its own token. */ + async function adoptConnection(): Promise { + const connection = await currentConnection(); + const current = live; + if ( + !connection || + !current || + state.phase !== "recording" || + current.userId !== connection.user.id || + current.openbotUrl !== connection.openbotUrl || + current.token === connection.token + ) + return false; + current.token = connection.token; + const stream = await readSession("stream"); + if (stream) + await writeSession("stream", { ...stream, token: connection.token }); + await sendToDocument({ + type: "offscreen.token", + recordingId: current.recordingId, + token: connection.token, + }); + return true; + } + + /** The document's pass was answered signed out for `token`. A pass taken with an older token, after the person + * connected again, changes nothing: the newer connection's recover may still be sending. A reconnect can land between + * the read here and the forget, so drains wait only when the forget really happened. */ + async function documentSignedOut({ + reason, + token, + }: OffscreenSignedOut): Promise { + if (live && live.token === token && state.phase === "recording") { + await handleSignedOut(reason, token, live.userId, live.openbotUrl); + return; + } + const connection = await currentConnection(); + if (connection?.token !== token) return; + if (!(await forgetConnection(reason, token))) return; + haltStreams(token); + await markWaiting((drain) => ownedBy(drain, connection), reason); + } + + /** The organization's policy moved the Recorder away from this connection's OpenBot (connection.ts forgot it): the + * live recording that used it stops, the document stops sending with its token, and everything is kept for its person + * on that OpenBot, as a sign-out keeps it. */ + async function policyMoved(connection: ConnectionRecord): Promise { + haltStreams(connection.token); + if (live && live.token === connection.token && state.phase === "recording") + await stopRecording({ + kind: "signed-out", + reason: "policy", + forgot: true, + }); + else await markWaiting((drain) => ownedBy(drain, connection), "policy"); + } + + /** A new policy goes to the document at once, and into the stored session a reopened document starts from. */ + async function pushPolicy(policy: RecorderPolicy): Promise { + const allowedIntelligenceUrls = policy.allowedIntelligenceUrls; + const stream = await readSession("stream"); + if (stream) + await writeSession("stream", { ...stream, allowedIntelligenceUrls }); + if (offscreen) + postOffscreen({ type: "offscreen.policy", allowedIntelligenceUrls }); + else if (await hasOffscreenDocument()) + await sendToDocument({ + type: "offscreen.policy", + allowedIntelligenceUrls, + }); + } + + // ---- Start over ---------------------------------------------------------------------------- + + async function restart(): Promise { + // Start over while Record finishes (its script injection, its network capture) waits for Record: the recording it + // leaves is then the one Record started, with its tabs told, before the switch. + if (starting) await starting.done; + if (state.phase !== "recording" || !live || live === restartingFrom) + return { ok: false, error: SENTENCES.notRecording }; + const current = live; + restartingFrom = current; + try { + // The document must hear of the switch: while its port reconnects, the wait happens here, before the switch + // begins, so nothing in the switch waits. Stop, a sign-out or Disconnect can end the recording meanwhile. A + // document that cannot be reached would never hear of it, so Start over is refused (the recording goes on, and + // a lost document is reopened). + const port = await documentPort(READY_TIMEOUT_MS); + if (state.phase !== "recording" || live !== current) + return { ok: false, error: SENTENCES.notRecording }; + if (!port) return { ok: false, error: SENTENCES.restartUnreachable }; + let switched = () => {}; + switching = new Promise((resolve) => { + switched = resolve; + }); + try { + await switchTo(current); + } finally { + switching = null; + switched(); + } + return { ok: true }; + } finally { + if (restartingFrom === current) restartingFrom = null; + } + } + + /** Saves the new recording as the active one and the one it leaves as a drain (the document sends it to Intelligence + * with a failed outcome), then makes the switch in memory and tells the document and the tabs in one turn, with nothing + * awaited in between. */ + async function switchTo(current: Live): Promise { + const recordingId = crypto.randomUUID(); + const trajectoryId = crypto.randomUUID(); + const startedAt = deps.now(); + const drains = await serially(async () => { + const previous = await readLocal("activeRecording"); + const existing = (await readLocal("drains")) ?? []; + if (!previous) return existing; + const kept = earlyKept.get(previous.recordingId); + earlyKept.delete(previous.recordingId); + const changed = [ + ...existing.filter( + (drain) => drain.recordingId !== previous.recordingId, + ), + defined({ ...previous, kept }), + ]; + await writeLocal("activeRecording", { + ...previous, + recordingId, + trajectoryIds: [trajectoryId], + startedAt, + }); + await writeDrains(changed); + return changed; + }); + const stream = await readSession("stream"); + if (stream) + await writeSession("stream", { + ...stream, + recordingId, + trajectoryId, + startedAt, + }); + // The recording it leaves gets its held steps first; the new one starts on the pages the tabs are on. + steps?.flush(); + live = { ...current, recordingId }; + shown = { recordingId, trajectoryIds: new Set([trajectoryId]) }; + forgetAddresses(); + postOffscreen({ + type: "offscreen.rotate", + recordingId, + trajectoryId, + startedAt, + }); + openSteps(recordingId); + for (const tabId of state.tabIds) { + const top = state.topUrls[tabId] ?? ""; + recordedNavigations.set(tabId, top); + startStep(tabId, top); + } + dispatch({ type: "restart", recordingId, startedAt }); + publishDrains(drains); + postToContentPorts( + state.tabIds, + { type: "background.notice", show: false, actions: 0 }, + { topFrameOnly: true }, + ); + postToContentPorts(state.tabIds, { + type: "background.capture", + active: true, + recordingId, + }); + } + + // ---- Voice --------------------------------------------------------------------------------- + + /** The person mutes or unmutes the live recording's narration: the document stops or starts listening, and the stored + * session says so, so a document reopened on it starts the same way. */ + async function setVoice(muted: boolean): Promise { + if (state.phase !== "recording" || !live) + return { ok: false, error: SENTENCES.notRecording }; + const stream = await readSession("stream"); + if (!stream?.voice) return { ok: false, error: SENTENCES.noVoice }; + await writeSession("stream", { + ...stream, + voice: { ...stream.voice, muted }, + }); + dispatch({ + type: "capture", + capture: { voice: { state: muted ? "muted" : "starting" } }, + }); + await sendToDocument({ type: "offscreen.voice", muted }); + return { ok: true }; + } + + // ---- The 50-action notice ------------------------------------------------------------------ + + async function noticeChoice( + choice: "stop" | "restart" | "continue", + ): Promise { + // Answered, in a tab or in the popup: it is down everywhere. + dispatch({ type: "notice-answered" }); + postToContentPorts( + state.tabIds, + { type: "background.notice", show: false, actions: state.actions }, + { topFrameOnly: true }, + ); + if (choice === "stop") { + await stopRecording({ kind: "person" }); + return { ok: true }; + } + if (choice === "restart") return restart(); + return { ok: true }; + } + + // ---- Kept events, Disconnect and reconnecting ---------------------------------------------- + + /** The person's own discard: the drains are marked first (so a restart that interrupts the clearing finishes it and + * never sends them), then the document clears their outbox. */ + async function discardDrains(recordingIds: string[]): Promise { + if (recordingIds.length === 0) return; + await updateDrains((drains) => + drains.map((drain) => + recordingIds.includes(drain.recordingId) + ? { ...drain, discarding: true } + : drain, + ), + ); + await finishDiscards(); + } + + /** Clears every drain marked for discarding, whoever is connected: nothing of it is sent. */ + async function finishDiscards(): Promise { + const marked = ((await readLocal("drains")) ?? []) + .filter((drain) => drain.discarding) + .map((drain) => drain.recordingId); + if (marked.length === 0) return; + const port = await openDocument(); + try { + port.postMessage({ + type: "offscreen.discard", + recordingIds: marked, + } satisfies BackgroundToOffscreen); + } finally { + releaseEvents(); + } + await updateDrains((drains) => + drains.filter((drain) => !marked.includes(drain.recordingId)), + ); + } + + async function discardKept(): Promise { + const drains = (await readLocal("drains")) ?? []; + await discardDrains( + drains.filter((drain) => drain.kept).map((drain) => drain.recordingId), + ); + return { ok: true }; + } + + /** The person who recorded kept drains is connected again (or, `recovering`, the browser started): the document + * sends everything their drains still hold. Drains of another person, or of another OpenBot, keep waiting: they are + * never sent under someone else's connection, and recovery marks them waiting for their person. */ + async function sendDrains(options: { recovering: boolean }): Promise { + await finishDiscards(); + const connection = await currentConnection(); + if (options.recovering) + await markWaiting( + (drain) => + !(connection && ownedBy(drain, connection)) && + !drain.kept?.waitingForReconnect, + await waitingReason(), + ); + if (!connection) return; + const drains = await updateDrains((all) => + all.map((drain) => { + if (!ownedBy(drain, connection) || !drain.kept?.waitingForReconnect) + return drain; + const { kept: _waiting, ...rest } = drain; + return rest; + }), + ); + const own = drains.filter( + (drain) => ownedBy(drain, connection) && !drain.discarding, + ); + if (own.length === 0) return; + const port = await openDocument(); + try { + const policy = await deps.readPolicy(); + port.postMessage({ + type: "offscreen.recover", + openbotUrl: connection.openbotUrl, + token: connection.token, + allowedIntelligenceUrls: policy.allowedIntelligenceUrls, + version: chrome.runtime.getManifest().version, + drains: own, + } satisfies BackgroundToOffscreen); + } finally { + releaseEvents(); + } + } + + // ---- After a restart (recovery.ts) --------------------------------------------------------- + + async function interrupt(): Promise { + // Only what the browser restart left: a Record this service worker started since is not touched. + if (loadedPhase !== "idle" || state.phase !== "idle" || starting) return; + const record = await readLocal("activeRecording"); + const held = await readLocal("heldSteps"); + if (!record) { + // Steps of a recording already moved into the drains were sent with it. + if (held) await removeLocal("heldSteps"); + return; + } + // The steps its service worker still held when the browser quit go with it, sent as they would have been at Stop, + // ahead of its interrupted outcome. + await moveIntoDrains(record.recordingId, { + held: + held?.recordingId === record.recordingId + ? heldEvents(held.steps, deps.now) + : undefined, + }); + if (held) await removeLocal("heldSteps"); + await writeLocal("notice", { kind: "interrupted", at: deps.now() }); + } + + async function abandonStart(): Promise { + if (loadedPhase !== "starting" || state.phase !== "starting" || starting) + return; + const record = await readLocal("activeRecording"); + await removeSession("stream"); + if (record) { + // The document may have been told to start it: it clears what it holds, as Record's rollback has it. + await moveIntoDrains(record.recordingId, { discarding: true }); + await finishDiscards().catch(report("discarding a Record")); + } + dispatch({ type: "error", error: SENTENCES.startFailed }); + await maybeCloseDocument(); + } + + async function resumeStop(): Promise { + const stopping = state.stopping; + if ( + loadedPhase !== "stopping" || + state.phase !== "stopping" || + !stopping || + stopInFlight + ) + return null; + // The stop may have moved the recording into the drains just before the service worker went away. + const record = + (await readLocal("activeRecording")) ?? + ((await readLocal("drains")) ?? []).find( + (drain) => drain.recordingId === state.recordingId, + ); + if (!record) { + dispatch({ type: "cancelled" }); + return null; + } + const connection = await currentConnection(); + const token = + connection && ownedBy(record, connection) ? connection.token : ""; + await trackStop( + endRecording(liveFrom(record, token), { kind: "person" }, stopping), + ); + return record.recordingId; + } + + async function resumeRecording(): Promise { + if ( + loadedPhase !== "recording" || + state.phase !== "recording" || + network || + stopInFlight + ) + return; + if (!live) { + await stopWithoutConnection(); + return; + } + // The checks the previous service worker ran went with it. + watchConnection(); + const capture = deps.createNetworkCapture(networkDeps, { + fallback: deps.createPageFallback(), + }); + network = capture; + // Taken up when this service worker loaded, unless that could not happen then. + if (!steps) openSteps(live.recordingId, takeRestored(live.recordingId)); + const mode = await capture.resume( + state.tabIds.map((tabId) => ({ + tabId, + url: state.topUrls[tabId] ?? "", + })), + state.capture.network, + ); + // A stop that came meanwhile has stopped this capture already. + if (network !== capture) return; + networkMode = mode; + dispatch({ type: "capture", capture: { network: mode } }); + // A live document reconnects its port and re-sends its status itself. + if ((await hasOffscreenDocument()) || state.phase !== "recording") return; + await resumeDocument(); + } + + /** A live recording whose connection went while the service worker was away (a sign-out the popup or the keep-alive + * noticed): it stops here and is kept for its person, as a signed-out stop is. */ + async function stopWithoutConnection(): Promise { + const record = await readLocal("activeRecording"); + if (!record) { + dispatch({ type: "cancelled" }); + return; + } + await trackStop( + endRecording(liveFrom(record, ""), { + kind: "signed-out", + reason: await waitingReason(), + forgot: true, + }), + ); + } + + async function recoverDrain(recordingId: string): Promise { + const connection = await currentConnection(); + const drain = ((await readLocal("drains")) ?? []).find( + (entry) => entry.recordingId === recordingId, + ); + if ( + !connection || + !drain || + !ownedBy(drain, connection) || + drain.discarding || + drain.kept?.waitingForReconnect + ) + return; + const policy = await deps.readPolicy(); + await sendToDocument({ + type: "offscreen.recover", + openbotUrl: connection.openbotUrl, + token: connection.token, + allowedIntelligenceUrls: policy.allowedIntelligenceUrls, + version: chrome.runtime.getManifest().version, + drains: [drain], + }); + } + + const afterReady = + (work: (...args: A) => Promise) => + async (...args: A): Promise => { + await ready; + return work(...args); + }; + installedRecovery = { + loadedPhase: afterReady(async () => loadedPhase), + interrupt: afterReady(interrupt), + abandonStart: afterReady(abandonStart), + resumeStop: afterReady(resumeStop), + resumeRecording: afterReady(resumeRecording), + finishDiscards: afterReady(finishDiscards), + sendDrains: afterReady(() => sendDrains({ recovering: true })), + recoverDrain: afterReady(recoverDrain), + }; + + // ---- The popup's answers ------------------------------------------------------------------- + + async function currentMe( + connection: ConnectionRecord, + ): Promise { + const cached = await readSession("me"); + if ( + cached && + cached.value.connectionId === connection.connectionId && + deps.now() - cached.fetchedAt < ME_FRESH_MS + ) + return cached.value; + try { + const value = await openbotJson(connection, recorderPaths.me); + await writeSession("me", { value, fetchedAt: deps.now() }); + return value; + } catch (error) { + const reason = signedOutReason(error); + if (reason) { + await removeSession("me"); + // A recording that used this connection stops, and everything is kept for its person. + await handleSignedOut( + reason, + connection.token, + connection.user.id, + connection.openbotUrl, + ); + return null; + } + // OpenBot cannot answer for now: the last answer for this connection, if any. + return cached?.value.connectionId === connection.connectionId + ? cached.value + : null; + } + } + + async function pageState(): Promise { + const known = await currentConnection(); + const me = known ? await currentMe(known) : null; + const connection = await currentConnection(); + const policy = await deps.readPolicy(); + const notice = (await readLocal("notice")) ?? null; + const drains = (await readLocal("drains")) ?? []; + return { + ok: true, + state: defined({ + ...state, + draining: drains.length, + kept: keptSummary(drains), + }), + connection: connection + ? { + openbotUrl: connection.openbotUrl, + user: { name: connection.user.name, email: connection.user.email }, + via: connection.via, + } + : null, + me: connection ? me : null, + policy: { + openbotUrl: policy.openbotUrl, + invalid: policy.openbotUrlInvalid, + }, + notice, + }; + } + + /** The person's own Learning space, which every recording they make joins: whether Learning will see it. */ + async function readLearningSpace(): Promise { + const connection = await currentConnection(); + if (!connection) return { ok: false, error: SENTENCES.noConnection }; + try { + const answer = await openbotJson( + connection, + recorderPaths.learning, + ); + learningSpace = answer.learning; + return { ok: true, learning: answer.learning }; + } catch (error) { + const reason = signedOutReason(error); + if (reason) + await handleSignedOut( + reason, + connection.token, + connection.user.id, + connection.openbotUrl, + ); + return { + ok: false, + error: sentenceFor(error, "OpenBot did not answer. Try again."), + }; + } + } + + function flood(count: number): PageResponse { + if (state.phase !== "recording" || !live) + return { ok: false, error: SENTENCES.notRecording }; + for (let i = 0; i < count; i++) + forwardEvent({ + type: "CUSTOM", + name: "recorder.e2e", + timestamp: deps.now(), + value: { i }, + }); + return { ok: true }; + } + + // ---- Content scripts and navigation -------------------------------------------------------- + + function tellPort(port: chrome.runtime.Port, active: boolean): void { + try { + port.postMessage( + active + ? { + type: "background.capture", + active: true, + recordingId: state.recordingId, + } + : { type: "background.capture", active: false }, + ); + } catch { + // The frame went away. + } + } + + /** A sensitive value's digest, from a content script or from the masking here: every later step, save, agent event and + * narration of the recording is masked with it. It is also what the person typed into a sensitive field in the tab, so + * a save from that tab whose body held it carries their values. */ + function keepDigest(digest: string, tabId: number): void { + // Memory only: it survives a service-worker restart, vanishes with the browser, and goes at Stop. + digests.add(digest); + void writeSession("digests", [...digests]).catch( + report("keeping a sensitive-value digest"), + ); + // The document masks the person's narration with them too; a document whose port is down gets them on ready. + postOffscreen({ type: "offscreen.digests", digests: [...digests] }); + const typed = (typedSecrets.get(tabId) ?? []).filter( + (known) => known !== digest, + ); + typedSecrets.set(tabId, [...typed, digest].slice(-TYPED_SECRETS)); + void writeSession("typedSecrets", [...typedSecrets]).catch( + report("keeping what was typed into a sensitive field"), + ); + } + + function onContentMessage( + port: chrome.runtime.Port, + message: ContentToBackground, + ): void { + const tabId = port.sender?.tab?.id; + const followed = + state.phase === "recording" && + live !== null && + tabId !== undefined && + state.tabIds.includes(tabId); + if (message.type === "content.hello") { + tellPort(port, followed); + // The 50-action notice is still open: a page the tab moved to shows it again, as the one it left did. + if (followed && message.top && state.noticeOpen) + try { + port.postMessage({ + type: "background.notice", + show: true, + actions: state.noticeAt ?? state.actions, + } satisfies BackgroundToContent); + } catch { + // The frame went away. + } + return; + } + if (state.phase !== "recording" || tabId === undefined) return; + if (!followed) { + // Only a tab that navigated away from a recording and back can still hold a running script. + tellPort(port, false); + return; + } + switch (message.type) { + case "content.event": { + // A page load is recorded as the tab's page change, from the service worker. + if ((message.event as CapturedEvent)?.name === "page") return; + // Masked again with every digest of the recording: what was typed into a sensitive field on another page, in + // another tab or frame, goes here too (content-masking.ts). + const event = maskContentEvent( + message.event as CapturedEvent, + digests, + (digest) => keepDigest(digest, tabId), + ); + if (!event) return; + if (!STEP_EVENTS.has(event.name)) { + forwardEvent(event); + return; + } + // A click takes the agent context CopilotKit adds to one before the steps hold it. + const step = + event.name === "click" && agentEvents + ? agentEvents.enrichClick(tabId, event) + : event; + steps?.step(tabId, inTab(tabId, step), port.sender?.frameId !== 0); + if (ACTION_EVENTS.has(event.name)) countAction(); + return; + } + case "content.sensitive": + keepDigest(message.digest, tabId); + return; + case "content.notice": + void noticeChoice(message.choice).catch(report("the notice choice")); + return; + } + } + + /** A followed tab's top frame reached a page: recorded once per address (a reload again), never an iframe's. */ + function recordTopNavigation( + details: NavigationDetails, + kind: "commit" | "history", + ): void { + const { tabId, url } = details; + // A tab's first page change after a followed tab opened it comes from the opener's page, never from its own address. + const event = navigationEvent( + details, + kind, + openers.get(tabId) ?? (state.topUrls[tabId] || undefined), + { digests }, + ); + dispatch({ type: "page", tabId, url }); + if (!/^https?:/i.test(url)) return; + openers.delete(tabId); + const reload = + (event.value as { navigationType?: string }).navigationType === "reload"; + if (!reload && recordedNavigations.get(tabId) === url) return; + recordedNavigations.set(tabId, url); + steps?.navigation( + tabId, + inTab(tabId, { + ...event, + value: { + ...(event.value as Record), + step: "navigate", + } as CapturedEvent["value"], + }), + ); + countAction(); + } + + function onNavigation( + details: NavigationDetails, + kind: "commit" | "history", + ): void { + if (state.phase !== "recording" || !live) return; + const { tabId, frameId, url } = details; + if (!state.tabIds.includes(tabId)) return; + if (frameId === 0) recordTopNavigation(details, kind); + if (kind !== "commit") return; + // A new document in the top frame: the page's Threads went with the old one. + if (frameId === 0) agentEvents?.tabNavigated(tabId); + // The script's own guard makes a repeat harmless; a frame Chrome refuses (an opaque sandboxed one) is skipped. + void injectRecordingScripts(tabId, live.recordingId, [frameId]).catch( + () => undefined, + ); + void network + ?.onCommitted(tabId, frameId, url) + .catch(report("following a navigation")); + } + + function onCreatedTarget( + details: chrome.webNavigation.WebNavigationSourceCallbackDetails, + ): void { + if ( + state.phase !== "recording" || + !state.tabIds.includes(details.sourceTabId) || + state.tabIds.includes(details.tabId) + ) + return; + openers.set(details.tabId, state.topUrls[details.sourceTabId] ?? ""); + dispatch({ type: "follow", tabId: details.tabId, url: details.url }); + void network + ?.addTab(details.tabId, details.url) + .catch(report("following a new tab")); + } + + chrome.webNavigation.onCommitted.addListener((details) => { + void ready + .then(() => onNavigation(details, "commit")) + .catch(report("recording a navigation")); + }); + chrome.webNavigation.onHistoryStateUpdated.addListener((details) => { + void ready + .then(() => onNavigation(details, "history")) + .catch(report("recording a navigation")); + }); + chrome.webNavigation.onCreatedNavigationTarget.addListener((details) => { + void ready + .then(() => onCreatedTarget(details)) + .catch(report("following a new tab")); + }); + onPolicyChanged((policy) => { + void ready + .then(() => pushPolicy(policy)) + .catch(report("passing on a new policy")); + }, deps.readPolicy); + + // ---- The router's handlers ----------------------------------------------------------------- + + return { + async page(message) { + await ready; + if ( + import.meta.env.RECORDER_VARIANT === "e2e" && + message.type === "RECORDER_E2E_FLOOD" + ) + return flood(message.count); + switch (message.type) { + case "page.state": + return pageState(); + case "page.learning": + return readLearningSpace(); + case "page.record": + return record(message); + case "page.stop": + await stopRecording({ kind: "person" }); + return { ok: true }; + case "page.restart": + return restart(); + case "page.notice": + return noticeChoice(message.choice); + case "page.notice.dismiss": + await removeLocal("notice"); + return { ok: true }; + case "page.kept.discard": + return discardKept(); + case "page.voice": + return setVoice(message.muted); + default: + return { ok: false, error: "Unknown request." }; + } + }, + + async external( + message: ExternalToBackground, + sender: chrome.runtime.MessageSender, + ): Promise { + await ready; + if ( + message.type !== "recorder.page-network" && + message.type !== "recorder.page-agent" + ) + return { ok: false, error: "invalid" }; + const tabId = sender.tab?.id; + if ( + state.phase !== "recording" || + !live || + !network || + tabId === undefined || + !state.tabIds.includes(tabId) || + networkMode !== "page" + ) + return { ok: false, error: "not-recording" }; + if (message.type === "recorder.page-agent") { + // Stream ids are the page's own: the tab and frame keep two documents' apart. + const observation = ( + message.observation.kind === "runtime" + ? { ...message.observation, tabId } + : { + ...message.observation, + tabId, + streamId: `${tabId}/page/${sender.frameId ?? 0}/${message.observation.streamId}`, + } + ) as CaptureObservation; + network.recordPageAgent(tabId, observation); + return { ok: true }; + } + const event = deps.acceptPageNetworkEvent( + message.event as CapturedEvent, + digests, + ); + if (!event) return { ok: false, error: "invalid" }; + await network.recordPageEvent(tabId, event); + return { ok: true }; + }, + + contentPort(port) { + trackContentPort(port); + // Attached at once: the content port flushes its buffer the moment it connects. + port.onMessage.addListener((message: ContentToBackground) => { + void ready + .then(() => onContentMessage(port, message)) + .catch(report("a content message")); + }); + }, + + offscreenPort(port) { + offscreen = port; + documentReady = false; + port.onMessage.addListener((message: OffscreenToBackground) => { + void ready + .then(() => onOffscreenMessage(port, message)) + .catch(report("an offscreen message")); + }); + port.onDisconnect.addListener(() => { + if (offscreen !== port) return; + offscreen = null; + documentReady = false; + watchForLostDocument(); + }); + // The events that waited go first, then whatever waited for the port. + flushEvents(); + const waiters = connectWaiters; + connectWaiters = []; + for (const waiter of waiters) waiter(port); + }, + + async connected() { + await ready; + await adoptConnection(); + // Connected again: the sentence that said the stopped recording waits for a connection no longer holds. The kept + // row says what still waits, and for whom. + if (state.error !== undefined && KEPT_SENTENCES.includes(state.error)) + dispatch({ type: "clear-error" }); + // The connect page waits on this answer: the sending runs on its own. + void sendDrains({ recovering: false }).catch( + report("sending kept recordings"), + ); + }, + + async connectionLost(loss) { + await ready; + const { connection } = loss; + if (loss.kind === "policy") await policyMoved(connection); + else + await handleSignedOut( + loss.reason, + connection.token, + connection.user.id, + connection.openbotUrl, + ); + }, + + async disconnected() { + disconnecting += 1; + try { + await ready; + // Everything is discarded: a Stop waiting for the document to stop waits no more (the document is still told). + for (const finish of [...stopWaiters.values()]) finish(); + // Ends a Record still starting, and a recording in progress. + await stopRecording({ kind: "disconnect" }); + // A stop still under way finishes first, so the recording it moves into the drains is discarded with the rest. + if (stopInFlight) await stopInFlight; + const drains = (await readLocal("drains")) ?? []; + await discardDrains(drains.map((drain) => drain.recordingId)); + if (state.error !== undefined && KEPT_SENTENCES.includes(state.error)) + dispatch({ type: "clear-error" }); + } finally { + disconnecting -= 1; + } + }, + }; +} diff --git a/extension/src/background/recovery.ts b/extension/src/background/recovery.ts new file mode 100644 index 000000000..f9019dc23 --- /dev/null +++ b/extension/src/background/recovery.ts @@ -0,0 +1,68 @@ +// Finishes what a browser restart or a service-worker restart cut short (Task 9's Recovery, Review Focus 3): a recording +// the browser closed on is sent to Intelligence with an interrupted outcome, a Stop is finished, a live recording picks +// up again, and every drain of the connected person is sent; drains of anyone else wait for their person (Decision 19). +import { readLocal } from "../shared/storage"; +import { hasOffscreenDocument } from "./offscreen-document"; +import { type RecordingRecovery, recordingRecovery } from "./recording"; + +let running: { recording: RecordingRecovery; done: Promise } | null = + null; + +/** Single-flight: a call made while one is running returns the running one's promise (index.ts top level and + * runtime.onStartup both call it at a browser start). A controller installed since (a restarted service worker) starts + * its own. Never rejects: a failure is logged. */ +export function recoverInterruptedRecording(): Promise { + const recording = recordingRecovery(); + if (!recording) return Promise.resolve(); + if (running?.recording === recording) return running.done; + const done = recover(recording) + .catch((error) => + console.error("Recorder: recovering after a restart failed", error), + ) + .finally(() => { + if (running?.done === done) running = null; + }); + running = { recording, done }; + return done; +} + +async function recover(recording: RecordingRecovery): Promise { + const phase = await recording.loadedPhase(); + // A browser restart closes the document; a service-worker restart leaves it running, still sending what it holds. + const hadDocument = await hasOffscreenDocument(); + // A discard the restart interrupted is finished first: a document that outlived the service worker still holds that + // recording's events and keeps retrying them. With no document nothing sends them, and sendDrains below finishes it + // before anything goes out; opening one here would hide from the phase below that the document was gone, and a live + // recording or a stop would never resume it on its session. + // A document that never comes back must not stop the rest of recovery: the live recording still has to resume, and + // sendDrains (no document) or the next start finishes the discard, which no path here sends. + if (hadDocument) + await recording + .finishDiscards() + .catch((error) => + console.error( + "Recorder: finishing a discard after a restart failed", + error, + ), + ); + switch (phase) { + case "idle": + // storage.session is empty after a browser restart: a recording left in storage.local was interrupted. + await recording.interrupt(); + break; + case "starting": + await recording.abandonStart(); + break; + case "stopping": { + const stopped = await recording.resumeStop(); + if (stopped && hadDocument) await recording.recoverDrain(stopped); + break; + } + case "recording": + await recording.resumeRecording(); + break; + } + // The document that held the drains is gone: a new one recovers them from the outbox. + if (!hadDocument && ((await readLocal("drains")) ?? []).length > 0) + await recording.sendDrains(); +} diff --git a/extension/src/background/router.ts b/extension/src/background/router.ts new file mode 100644 index 000000000..f82d3d8ab --- /dev/null +++ b/extension/src/background/router.ts @@ -0,0 +1,203 @@ +// The ONLY listeners of runtime.onMessage, onMessageExternal and onConnect in the whole extension (MV3 needs them +// registered synchronously at top level, and one place keeps the trust checks in one place). +import { + type ExternalResponse, + type ExternalToBackground, + type PageResponse, + type PageToBackground, + PORT_CONTENT, + PORT_OFFSCREEN, + parseContentMessage, + parseExternalMessage, + parsePageConnectDecide, +} from "../protocol/messages"; +import { handleConnectionMessage, handleExternalConnect } from "./connection"; + +export type RouterHandlers = { + page( + message: PageToBackground, + sender: chrome.runtime.MessageSender, + ): Promise; + external( + message: ExternalToBackground, + sender: chrome.runtime.MessageSender, + ): Promise; + contentPort(port: chrome.runtime.Port): void; + offscreenPort(port: chrome.runtime.Port): void; +}; + +type RouterDeps = RouterHandlers & { + connected(): Promise; + disconnected(): Promise; +}; + +const CONNECTION_TYPES = new Set([ + "page.connect.pending", + "page.connect.decide", + "page.disconnect", + "page.desktop.available", + "page.connect.desktop", +]); +const RECORDING_TYPES = new Set([ + "page.state", + "page.learning", + "page.record", + "page.stop", + "page.restart", + "page.notice", + "page.notice.dismiss", + "page.kept.discard", + "page.voice", +]); + +const UNKNOWN: PageResponse = { ok: false, error: "Unknown request." }; +const FAILED: PageResponse = { + ok: false, + error: "The Recorder could not do that. Try again.", +}; + +function isOwnPage(sender: chrome.runtime.MessageSender): boolean { + return ( + sender.id === chrome.runtime.id && + typeof sender.url === "string" && + sender.url.startsWith(chrome.runtime.getURL("")) + ); +} + +function messageType(raw: unknown): string | null { + const type = (raw as { type?: unknown } | null)?.type; + return typeof type === "string" ? type : null; +} + +async function routePage( + raw: unknown, + sender: chrome.runtime.MessageSender, + deps: RouterDeps, +): Promise { + if (!isOwnPage(sender)) return { ok: false, error: "Not allowed." }; + const type = messageType(raw); + if (type === null) return UNKNOWN; + const message = + type === "page.connect.decide" + ? parsePageConnectDecide(raw) + : (raw as PageToBackground); + if (message === null) return UNKNOWN; + if (CONNECTION_TYPES.has(type)) { + const answer = await handleConnectionMessage(message); + if (answer === undefined) return UNKNOWN; + // The follow-up runs after the connection changed: never fail the page on it. + if (answer.ok && message.type === "page.connect.decide" && message.approve) + await deps + .connected() + .catch((error) => + console.error("Recorder: reconnect follow-up failed", error), + ); + if (answer.ok && message.type === "page.disconnect") + await deps + .disconnected() + .catch((error) => + console.error("Recorder: disconnect follow-up failed", error), + ); + return answer; + } + if ( + RECORDING_TYPES.has(type) || + (import.meta.env.RECORDER_VARIANT === "e2e" && + type === "RECORDER_E2E_FLOOD") + ) + return deps.page(message, sender); + return UNKNOWN; +} + +/** A port whose onMessage listeners only ever see messages that passed parseContentMessage. */ +function filteredContentPort(port: chrome.runtime.Port): chrome.runtime.Port { + const wrapped = new Map void>(); + const onMessage = { + addListener( + listener: (message: unknown, port: chrome.runtime.Port) => void, + ) { + const guarded = (raw: unknown) => { + const message = parseContentMessage(raw); + if (message === null) return; + listener(message, filtered); + }; + wrapped.set(listener, guarded); + port.onMessage.addListener(guarded); + }, + removeListener(listener: unknown) { + const guarded = wrapped.get(listener); + if (!guarded) return; + wrapped.delete(listener); + port.onMessage.removeListener(guarded); + }, + hasListener: (listener: unknown) => wrapped.has(listener), + }; + const disconnectWrapped = new Map void>(); + const onDisconnect = { + addListener(listener: (port: chrome.runtime.Port) => void) { + const guarded = () => listener(filtered); + disconnectWrapped.set(listener, guarded); + port.onDisconnect.addListener(guarded); + }, + removeListener(listener: unknown) { + const guarded = disconnectWrapped.get(listener); + if (!guarded) return; + disconnectWrapped.delete(listener); + port.onDisconnect.removeListener(guarded); + }, + hasListener: (listener: unknown) => disconnectWrapped.has(listener), + }; + const filtered = { + name: port.name, + sender: port.sender, + onMessage, + onDisconnect, + postMessage: (message: unknown) => port.postMessage(message), + disconnect: () => port.disconnect(), + } as unknown as chrome.runtime.Port; + return filtered; +} + +export function installRouter(deps: RouterDeps): void { + chrome.runtime.onMessage.addListener((raw, sender, sendResponse) => { + routePage(raw, sender, deps).then( + (answer) => sendResponse(answer), + () => sendResponse(FAILED), + ); + return true; + }); + + chrome.runtime.onMessageExternal.addListener((raw, sender, sendResponse) => { + const message = parseExternalMessage(raw); + if (message === null) { + sendResponse({ ok: false, error: "invalid" } satisfies ExternalResponse); + return false; + } + const answer = + message.type === "recorder.page-network" || + message.type === "recorder.page-agent" + ? deps.external(message, sender) + : handleExternalConnect(message, sender); + answer.then( + (response) => sendResponse(response), + () => sendResponse({ ok: false, error: "invalid" }), + ); + return true; + }); + + chrome.runtime.onConnect.addListener((port) => { + if (port.name === PORT_CONTENT) { + deps.contentPort(filteredContentPort(port)); + return; + } + if ( + port.name === PORT_OFFSCREEN && + port.sender?.id === chrome.runtime.id && + port.sender.url === chrome.runtime.getURL("offscreen.html") + ) { + deps.offscreenPort(port); + return; + } + port.disconnect(); + }); +} diff --git a/extension/src/background/setup.ts b/extension/src/background/setup.ts new file mode 100644 index 000000000..cf3bdc1c8 --- /dev/null +++ b/extension/src/background/setup.ts @@ -0,0 +1,16 @@ +/** The page that asks for the microphone, the one permission the manifest cannot grant at install. */ +export const SETUP_PAGE = "setup.html"; + +/** + * Opens the setup page once, when the Recorder is first installed, in Chrome and Edge alike. Website access comes with + * the install (a required host permission), but the browser asks for the microphone only on a page the person can see, + * and the Recorder listens from a hidden document that can use only a lasting grant. Asking here means no Record ever + * opens a tab: a recording without the microphone goes on, and its Voice row offers this page. An update, a browser + * update or a service worker restart opens nothing. + */ +export function installSetup(): void { + chrome.runtime.onInstalled.addListener(async (details) => { + if (details.reason !== "install") return; + await chrome.tabs.create({ url: chrome.runtime.getURL(SETUP_PAGE) }); + }); +} diff --git a/extension/src/background/steps.ts b/extension/src/background/steps.ts new file mode 100644 index 000000000..afefdd544 --- /dev/null +++ b/extension/src/background/steps.ts @@ -0,0 +1,1021 @@ +// A recording is the person's steps: each click, edit, choice, submit, Enter and top-frame page change, in order, so an +// agent can be trained to do the same clicks and enter the same data. A step that saved something carries the one +// request it sent to save it, so the agent also learns what the save looks like; the page's other traffic is never +// recorded. +// +// A step is held until its window closes (or the next step in its tab starts) and every call it may own has arrived, +// then sent with its request in place; steps leave in the order they came. An event that is not a step (the agent's +// side, agent/events.ts) is placed among them by its time: it leaves after every step that came before it and before +// every later one, so the order Intelligence numbers them in is the order they happened. A step whose `settle` still +// awaits what the page may start for it (a Send's run, after a call of the page's own) is held for its join: until that +// comes, the person's next step begins, or `horizonMs` + `settleMs` pass, as long as a step nothing follows is held. +// +// Which calls a step owns is drawn in time, per tab, since Chrome gives no reliable link from a call to the input that +// caused it: a call is the latest step's when it starts no earlier than `leadMs` before that step (a handler can run on +// pointerdown, before the click), and either within `windowMs` of it or within `chainGapMs` of the end of a call the +// step already owns (a request made from the previous one's response), at most `maxDepth` links and `horizonMs` from +// the step. Of those, the save is a write that sounds like one: +// - PUT, PATCH or DELETE; +// - a GraphQL mutation (never a query); +// - a POST whose masked body carries what the person just typed or chose, or whose path or operation is a write +// (create, update, save, delete, send, submit, mark, archive, assign, verify, confirm, redeem). What they typed into a +// sensitive field (a password, a card, a code) counts though its body holds it only as [redacted]: the network capture +// marks the exchange as it masks it (`typed`). Apps read their configuration through POSTs too, and those stay out; +// - a form the top frame posted as a page load (a classic sign-in), whatever its address. +// When several qualify, the one carrying the person's values wins, else the first. A write the page sends to keep itself +// alive or to report on itself (presence, heartbeats, pings, typing, session refreshes, telemetry, tracking, logs) is +// never a save. +// +// Page changes the page makes one after another, with no step of the person's between them and none more than +// `redirectMs` after the last (a sign-in's redirects, an app settling on its default view), are one step to the final +// page, `from` the address the chain began at. A reload, a step back or forward, or an address the person typed or +// picked in the browser is never the page's: it starts a step of its own. +// +// The person's narration is placed the same way: a phrase's transcript is stamped with the phrase's start, but exists +// only once the phrase ends, seconds later. While a phrase is being spoken (`hold`), nothing from its start on leaves +// until its transcript is placed, or `speechCapMs` after its start at most, so it goes in ahead of the steps taken while +// it was said. +// +// What is held can be written down (`snapshot`) and taken up again by a new assembler (`restore`), under the same rules: +// a service worker that restarts, or a browser that quits, still sends every step it held. +import { + type CapturedEvent, + EVENT_BUDGET_BYTES, + type JsonValue, + REDACTED, + trimEvent, +} from "@openbot/shared/recorder-masking"; + +export const STEP_TIMING = { + /** A call starting this long after a step is the step's. */ + windowMs: 1_000, + /** A call starting this long after one of the step's calls ended continues the step's chain. */ + chainGapMs: 250, + /** How many links a chain may add after the step's own calls. */ + maxDepth: 2, + /** No chain runs longer than this after its step, and no step waits longer than this for another to close it. */ + horizonMs: 3_000, + /** How long before its step a call may start and still be the step's (a pointerdown handler, before the click). */ + leadMs: 250, + /** How long a closed step waits for its calls (and a coalesced edit before it) to arrive. */ + settleMs: 1_000, + /** How long a page change waits for the next one the page makes on its own (a redirect) before it is sent, so a + * chain of them is one step to the final page, with that page's title. */ + redirectMs: 2_000, + /** The longest a step waits for a save still in flight. */ + inflightCapMs: 10_000, + /** The longest anything after a phrase's start waits for its transcript, counted from that start: a phrase longer than + * this lets the steps taken while it is said go first. */ + speechCapMs: 15_000, +} as const; + +export type StepTiming = { [key in keyof typeof STEP_TIMING]: number }; + +/** A finished network call that passed the screens (screen.ts), by when it ran. */ +export type StepCall = { + tabId: number; + startedAt: number; + /** When its response began to arrive; absent when only the end is known. */ + respondedAt?: number; + endedAt: number; + method: string; + url: string; + /** A request of the top frame's own document (debugger only): a form the page posted as a page load. */ + document?: true; + /** A write's masked exchange, read through the debugger or the page hook: absent for a read, which can only lead to a + * save, never be one. */ + exchange?: { + url: string; + status: number | null; + body: JsonValue; + response: JsonValue; + /** Its body held what the person typed into a sensitive field in the tab, which the masking redacted. */ + typed?: true; + }; +}; + +type Timers = { + set(fn: () => void, ms: number): unknown; + clear(handle: unknown): void; +}; +const realTimers: Timers = { + set: (fn, ms) => setTimeout(fn, ms), + clear: (handle) => clearTimeout(handle as ReturnType), +}; + +const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +/** The first word of a path segment or an operation that says it saves something. */ +const WRITE_WORDS = new Set( + [ + ["create", "creates", "created"], + ["update", "updates", "updated"], + ["save", "saves", "saved"], + ["delete", "deletes", "deleted"], + ["send", "sends", "sent"], + ["submit", "submits", "submitted"], + ["mark", "marks", "marked"], + ["archive", "archives", "archived"], + ["assign", "assigns", "assigned"], + // A one-time code checked, a phone or an address confirmed, a backup code or a voucher redeemed. + ["verify", "verifies", "verified"], + ["confirm", "confirms", "confirmed"], + ["redeem", "redeems", "redeemed"], + ].flat(), +); +/** A value too short to tell anything apart (a digit, "ok") is never matched in a body. */ +const MIN_TYPED = 3; +/** The text of what was clicked counts only when it is long enough to be what was said (a chosen suggestion), never a + * button's "Save". */ +const MIN_CHOSEN = 12; +const MAX_VALUES = 20; +/** The steps that send what was entered: a click, a submit, an Enter. */ +const ACTION_STEPS = new Set(["click", "submit", "key"]); +/** An edit this soon after such a step is its echo (the field's change as Enter or a click on Send blurs it): the save + * stays the action's. */ +const ECHO_MS = 50; +/** A form's submit this soon after a click is the click's own doing: a submit button submits its form as the click's + * default action, in the same task. */ +const SUBMITTED_MS = ECHO_MS; + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** The first word of a name, lower case: `sendMessage` -> `send`, `MARKED_AS_READ` -> `marked`; for a dotted RPC name only + * its last part counts (`com.example.rpc.BreezeEngineService` -> `breeze`), never a package name such as `updates`. */ +function firstWord(name: string): string | undefined { + const last = name.split(".").pop() ?? name; + return last + .replace(/([a-z\d])([A-Z])/g, "$1 $2") + .split(/[\s_-]+/)[0] + ?.toLowerCase(); +} + +/** Words that mark a write as the page keeping itself alive or reporting on itself, never a save: presence, + * heartbeats, pings, typing indicators, session refreshes, telemetry, tracking and logs. */ +const CHATTER_WORDS = new Set([ + "presence", + "heartbeat", + "heartbeats", + "ping", + "pings", + "keepalive", + "typing", + "telemetry", + "track", + "tracking", + "log", + "logs", + "logging", +]); + +/** Every word of a name, lower case: `updateBreezePresence` -> update, breeze, presence; `keep-alive` -> keepalive. */ +function words(name: string): string[] { + return name + .replace(/([a-z\d])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .toLowerCase() + .replace(/keep[\s_-]*alive/g, "keepalive") + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +/** Whether a write's path or operation says it is the page's own chatter (updateBreezePresence, /heartbeat, + * /session/refresh, trackEvent, /logs/batch) rather than something the person saved. A read receipt sent as a POST + * call (markConversationRead, markAsSeen) is the page noting what it showed; one sent as a PUT or PATCH on the thing + * itself (`/notifications/9/action/MARKED_AS_READ`) is the person's own choice, as dismissing a banner is. */ +export function isChatter( + path: string, + operation?: string, + method = "POST", +): boolean { + const all = [...words(path), ...(operation ? words(operation) : [])]; + if (all.some((word) => CHATTER_WORDS.has(word))) return true; + if (all.includes("session") && all.includes("refresh")) return true; + return ( + method.toUpperCase() === "POST" && + all.includes("mark") && + all.some((word) => word === "read" || word === "seen" || word === "viewed") + ); +} + +const ID_SEGMENT = /^(?:\d+|[0-9a-f]{8,}(?:-[0-9a-f]{4,})*)$/i; + +/** Whether an operation name, or the last two segments of a path that are not ids, say they save something: + * `/api/notes/create`, `/rpc.MessageService/sendMessage`, `/contacts/42/archive`. */ +function soundsLikeWrite(text: string | undefined, isPath = false): boolean { + if (text === undefined) return false; + const names = isPath + ? text + .split("/") + .filter((segment) => segment && !ID_SEGMENT.test(segment)) + .slice(-2) + : [text]; + return names.some((name) => { + const word = firstWord(name); + return word !== undefined && WRITE_WORDS.has(word); + }); +} + +/** What a GraphQL body is: a mutation, a query, or not GraphQL at all. */ +function graphql( + url: string, + text: string | undefined, +): { + kind: "mutation" | "query" | null; + operation?: string; +} { + if (text === undefined) return { kind: null }; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { kind: null }; + } + const operations = (Array.isArray(parsed) ? parsed : [parsed]).filter( + isObject, + ); + const looksLikeGraphql = + /graphql/i.test(url) || + operations.some( + (op) => + typeof op.query === "string" || + (isObject(op.extensions) && "persistedQuery" in op.extensions), + ); + if (!looksLikeGraphql || operations.length === 0) return { kind: null }; + const names = operations + .map((op) => op.operationName) + .filter((name): name is string => typeof name === "string"); + const queries = operations + .map((op) => op.query) + .filter((query): query is string => typeof query === "string"); + if (queries.length === 0) + // A persisted query sends only its name. + return { + kind: names.some((name) => soundsLikeWrite(name)) ? "mutation" : "query", + operation: names[0], + }; + const mutation = queries.some((query) => + /(?:^|\})\s*mutation\b/.test(query.replace(/#[^\n]*/g, "").trimStart()), + ); + // An unnamed operation is known by its first field: `mutation { updatePresence(...) }`. + const field = queries[0]?.match(/^[^{]*\{\s*(\w+)/)?.[1]; + return { + kind: mutation ? "mutation" : "query", + operation: [names[0], field].filter(Boolean).join(" ") || undefined, + }; +} + +const bodyText = (body: JsonValue | undefined): string | undefined => + isObject(body) && typeof body.text === "string" ? body.text : undefined; + +/** True when the masked body holds one of the values, as typed, JSON-escaped or form-encoded. */ +export function carriesValues( + text: string | undefined, + values: readonly string[], +): boolean { + if (!text) return false; + return values.some((value) => { + const forms = [ + value, + JSON.stringify(value).slice(1, -1), + encodeURIComponent(value), + encodeURIComponent(value).replace(/%20/g, "+"), + ]; + return forms.some((form) => text.includes(form)); + }); +} + +/** Whether a call the step owns is its save, and whether it carries the person's values: as typed, or, for what they + * typed into a sensitive field, by the capture's mark. */ +export function saveQuality( + call: StepCall, + values: readonly string[], +): { save: boolean; carries: boolean } { + const method = call.method.toUpperCase(); + if (!WRITE_METHODS.has(method) || !call.exchange) + return { save: false, carries: false }; + const text = bodyText(call.exchange.body); + const carries = call.exchange.typed === true || carriesValues(text, values); + const gql = graphql(call.url, text); + let path = call.url; + try { + path = new URL(call.url).pathname; + } catch { + // Not a URL: its text is its path. + } + // A form posted as a page load (a classic sign-in or a reset-password form) is the person sending that form, whatever + // its address says: no page keeps itself alive or reports on itself by navigating. + if (call.document) return { save: true, carries }; + if (isChatter(path, gql.operation, method)) return { save: false, carries }; + if (gql.kind === "query") return { save: false, carries }; + if (gql.kind === "mutation") return { save: true, carries }; + if (method !== "POST") return { save: true, carries }; + return { save: carries || soundsLikeWrite(path, true), carries }; +} + +type Pending = { + tabId: number; + at: number; + event: CapturedEvent; + /** Which held item it is, kept in a snapshot: it leaves with this key, so the document stores it once however often a + * restart sends it (a quit can come after it left and before the snapshot without it was written). */ + key?: string; + /** Not a step: an event placed among the steps by its time, sent as it is once every step before it left. */ + placed?: true; + /** A placed transcript: the phrase being spoken holds back what came after its start, never its own words. */ + speech?: true; + /** A step the person took in the page (it can own calls); a page change, or an edit that echoes the action just + * before it, only follows from one. */ + owner: boolean; + /** Owners: calls starting from here on belong to a later step. */ + closeAt: number; + /** What the person typed in the tab before this step, and what this step chose. */ + values: string[]; + /** A page change, or a step from an iframe: the page it belongs to, once known. */ + page?: { url: string; title?: string }; + waitingForPage: boolean; + timer?: unknown; + /** A page change: when its page's title is looked up. */ + pageTimer?: unknown; + /** A page change: when its chain's last page change came, and which lookup of its page counts. */ + lastHop?: number; + lookup?: number; +}; + +/** One held item as a snapshot keeps it: its timers are set again when it is restored. `open` marks its tab's page + * change that a redirect may still join. */ +export type HeldStep = Omit & { + open?: true; +}; + +/** Everything an assembler holds, as JSON: the held steps and placed events in their order, the calls a held step may + * still own, what the person typed in each tab, and the phrase being spoken. Saves in flight are not kept: the capture + * that would report their end went with the service worker that saw them start. */ +export type StepSnapshot = { + queue: HeldStep[]; + calls: StepCall[]; + typed: [number, string[]][]; + speech: { since: number; until: number } | null; +}; + +const stepValue = (event: CapturedEvent): Record => + isObject(event.value) ? event.value : {}; + +/** Chrome's transitions (webNavigation TransitionType) that are the person going somewhere from the browser itself: an + * address typed, a bookmark, a suggestion picked, a start page. */ +const PERSON_TRANSITIONS = new Set([ + "typed", + "auto_bookmark", + "generated", + "keyword", + "keyword_generated", + "start_page", +]); + +/** Whether a page change is the person's own: a reload, a step back or forward, or a place they went from the browser. */ +function byPerson(value: Record): boolean { + const kind = value.navigationType; + if (kind === "reload" || kind === "traverse") return true; + if ( + typeof value.transitionType === "string" && + PERSON_TRANSITIONS.has(value.transitionType) + ) + return true; + return ( + Array.isArray(value.transitionQualifiers) && + value.transitionQualifiers.includes("from_address_bar") + ); +} + +/** A typed value of an edit step, as masked: a redacted one is never matched. */ +function typedValue(event: CapturedEvent): string | undefined { + if (event.name !== "input") return undefined; + const target = stepValue(event).target; + const value = isObject(target) ? target.value : undefined; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (trimmed.length < MIN_TYPED || trimmed.includes(REDACTED)) + return undefined; + return trimmed; +} + +/** The text of what the step chose, when long enough to be what the person said (a suggestion clicked). */ +function chosenText(event: CapturedEvent): string | undefined { + if (event.name !== "click") return undefined; + const target = stepValue(event).target; + const name = isObject(target) ? (target.name ?? target.text) : undefined; + if (typeof name !== "string") return undefined; + const trimmed = name + .replace(/\s+/g, " ") + .trim() + .replace(/[?.!:]+$/, ""); + if (trimmed.length < MIN_CHOSEN || trimmed.length > 300) return undefined; + if (trimmed.includes(REDACTED)) return undefined; + return trimmed; +} + +export type StepAssembler = ReturnType; + +export function createStepAssembler(options: { + /** An item as it leaves, with its key (`Pending.key`). */ + emit(event: CapturedEvent, key?: string): void; + /** A step of the person's as it leaves, with what was seen while it was held: `joins` says whether something the page + * started at a time is the step's doing, by its join (a click's run, the agent events' `settleClick`). */ + settle?( + tabId: number, + event: CapturedEvent, + joins: (at: number) => boolean, + ): CapturedEvent; + /** Whether a step still waits for something the page may yet start in its join (a click's run, the agent events' + * `awaitsRun`): it is held until that comes or its join closes. */ + awaits?( + tabId: number, + event: CapturedEvent, + joins: (at: number) => boolean, + ): boolean; + /** The tab's top page (masked), for a page change and a step from an iframe. */ + pageOf?(tabId: number): Promise<{ url: string; title?: string } | null>; + /** A new held item's key: a random UUID unless a test passes its own. */ + newKey?(): string; + /** Something it holds changed (a step came or left, a call arrived, the phrase being spoken changed): `snapshot` + * would now read differently. */ + onChange?(): void; + /** Items are about to leave, and `snapshot` still holds them: what is kept beside the steps can be written first. */ + leaving?(): void; + now?: () => number; + timers?: Timers; + tuning?: Partial; +}) { + const now = options.now ?? Date.now; + const timers = options.timers ?? realTimers; + const tune: StepTiming = { ...STEP_TIMING, ...options.tuning }; + const newKey = options.newKey ?? (() => crypto.randomUUID()); + const queue: Pending[] = []; + const calls = new Map(); + const typed = new Map(); + const inflight = new Map(); + /** Each tab's page change still held with no step of the person's after it: a redirect joins it. */ + const openChange = new Map(); + /** The phrase being spoken: from its start until its transcript is placed, or `until` at the latest. */ + let speech: { since: number; until: number } | null = null; + let speechTimer: unknown; + let ended = false; + const changed = () => options.onChange?.(); + + /** Whether the phrase being spoken holds the item back: it came after the phrase began (its own transcript never). */ + const heldBySpeech = (step: Pending, at: number): boolean => + speech !== null && + !step.speech && + step.at >= speech.since && + at < speech.until; + + const lastOwner = (tabId: number): Pending | undefined => { + for (let i = queue.length - 1; i >= 0; i--) { + const step = queue[i]; + if (step?.owner && step.tabId === tabId) return step; + } + return undefined; + }; + + function readyAt(step: Pending): number { + return step.closeAt + tune.settleMs; + } + + /** How long after a step the page may still start what the step did: a Send whose page makes a call of its own before + * it runs the message (OpenBot's composer routes it first: 2.06 s in the end-to-end run, its run 2.18 s after the + * click). No longer than the steps already hold a step nothing follows (`horizonMs` + `settleMs`), so waiting for it + * never holds the recording longer than that. */ + const joinMs = tune.horizonMs + tune.settleMs; + + /** The person's steps in the step's tab after it, in order; the step may have left the queue already. */ + const followers = (step: Pending): Pending[] => { + const index = queue.indexOf(step); + return (index === -1 ? queue : queue.slice(index + 1)).filter( + (other) => other.owner && other.tabId === step.tabId, + ); + }; + + /** When the step's join closes: `joinMs` after it, or as the person's next step in its tab begins (less the lead a + * call may have on a step), a click's form submit being the click's own doing. */ + function joinEnd(step: Pending): number { + const later = followers(step); + const next = submitOf(step) ? later[1] : later[0]; + return Math.min( + step.at + joinMs, + next === undefined ? Number.POSITIVE_INFINITY : next.at - tune.leadMs, + ); + } + + /** Whether something the page started at a time falls in the step's join: from the step until its join closes. */ + const joinsOf = (step: Pending): ((at: number) => boolean) => { + const end = joinEnd(step); + return (at) => at >= step.at && at < end; + }; + + /** Whether the step is still held for something the page may yet start in its join, which has not come. */ + const awaiting = (step: Pending, at: number): boolean => + options.awaits !== undefined && + at < joinEnd(step) && + options.awaits(step.tabId, step.event, joinsOf(step)); + + function isReady(step: Pending, at: number): boolean { + if (step.placed) return true; + // A page change is ready once its page was looked up, `redirectMs` after its chain's last page change. + if (!step.owner) return !step.waitingForPage; + if (step.waitingForPage) return false; + if (at < readyAt(step)) return false; + if (at >= step.closeAt + tune.inflightCapMs) return true; + // A save that started inside the step's span and has not finished yet holds the step. + for (const flight of inflight.values()) + if ( + flight.tabId === step.tabId && + flight.startedAt >= step.at - tune.leadMs && + flight.startedAt < step.closeAt + ) + return false; + return !awaiting(step, at); + } + + function schedule(step: Pending): void { + if (step.timer !== undefined) timers.clear(step.timer); + const wait = Math.max(0, readyAt(step) - now()); + step.timer = timers.set(pump, wait); + } + + /** Whether something that started at `at` falls in the step's span: from `leadMs` before it until the next step. */ + const inSpan = (step: Pending, at: number): boolean => + at >= step.at - tune.leadMs && at < step.closeAt; + + /** The calls the step owns, in the order they started, and the save among them. */ + function saveOf(step: Pending): StepCall | undefined { + const mine = (calls.get(step.tabId) ?? []) + .filter((call) => inSpan(step, call.startedAt)) + .sort((a, b) => a.startedAt - b.startedAt); + const links: { respondedAt: number; endedAt: number; depth: number }[] = []; + let first: StepCall | undefined; + let carrying: StepCall | undefined; + for (const call of mine) { + const offset = call.startedAt - step.at; + let depth: number | null = offset <= tune.windowMs ? 0 : null; + if (depth === null && offset <= tune.horizonMs) + for (const link of links) + if ( + link.depth < tune.maxDepth && + call.startedAt >= link.respondedAt && + call.startedAt <= link.endedAt + tune.chainGapMs + ) { + depth = + depth === null ? link.depth + 1 : Math.min(depth, link.depth + 1); + } + if (depth === null) continue; + links.push({ + respondedAt: call.respondedAt ?? call.endedAt, + endedAt: call.endedAt, + depth, + }); + const quality = saveQuality(call, step.values); + if (!quality.save) continue; + first ??= call; + if (quality.carries) carrying ??= call; + } + return carrying ?? first; + } + + /** The submit a click made of its form, which follows it at once and so closes its span. */ + function submitOf(step: Pending): Pending | undefined { + if (step.event.name !== "click") return undefined; + const [next] = followers(step); + return next?.event.name === "submit" && + next.at >= step.at && + next.at - step.at <= SUBMITTED_MS + ? next + : undefined; + } + + /** The step's event as it is sent: its page filled in, its save attached, cut to fit one Intelligence event. */ + function finish(step: Pending): CapturedEvent { + if (step.placed) return step.event; + // A click's form submit is the click's doing, so the click's join runs past it (for `settle` only; a save in the + // submit's span stays the submit's). + const event = + step.owner && options.settle + ? options.settle(step.tabId, step.event, joinsOf(step)) + : step.event; + const value: Record = { ...stepValue(event) }; + if (step.page && !isObject(value.page)) value.page = step.page; + if (step.owner) { + const save = saveOf(step); + if (save?.exchange) { + if (carriesValues(bodyText(save.exchange.body), step.values)) + typed.delete(step.tabId); + const request = { + method: save.method.toUpperCase(), + url: save.exchange.url, + status: save.exchange.status, + body: save.exchange.body, + response: save.exchange.response, + }; + value.request = fitRequest( + { ...event, value: value as JsonValue }, + request, + ); + } + } + return trimEvent( + { ...event, value: value as JsonValue }, + EVENT_BUDGET_BYTES, + ).event; + } + + function pump(): void { + if (ended) return; + const at = now(); + let announced = false; + while (queue.length > 0) { + const head = queue[0]; + if (!head) break; + // The phrase's transcript, or the end of its hold (its own timer), pumps again. + if (heldBySpeech(head, at)) break; + if (!isReady(head, at)) { + if (head.owner && !head.waitingForPage) { + if (head.timer !== undefined) timers.clear(head.timer); + // A timer may fire a moment early: it is set again for the rest. Past its time, what it awaits in its join holds + // it until that comes or the join closes, and a save in flight until that save ends or its cap is reached. + head.timer = timers.set( + pump, + at < readyAt(head) + ? readyAt(head) - at + : awaiting(head, at) + ? joinEnd(head) - at + : Math.max(0, head.closeAt + tune.inflightCapMs - at), + ); + } + break; + } + if (!announced) { + announced = true; + options.leaving?.(); + } + queue.shift(); + if (head.timer !== undefined) timers.clear(head.timer); + if (openChange.get(head.tabId) === head) openChange.delete(head.tabId); + options.emit(finish(head), head.key); + } + prune(at); + changed(); + } + + function prune(at: number): void { + const oldest = Math.min( + at - 2 * 60_000, + ...queue.map((step) => step.at - tune.leadMs), + ); + for (const [tabId, list] of calls) { + const kept = list.filter((call) => call.startedAt >= oldest); + if (kept.length === 0) calls.delete(tabId); + else calls.set(tabId, kept); + } + } + + /** Looks up the tab's page for the step; it waits for the answer, or for `inflightCapMs` at most. A page change + * that a later redirect joined meanwhile looks again, and the earlier answer does not count. */ + function fetchPage(step: Pending): void { + if (!options.pageOf) { + step.waitingForPage = false; + pump(); + return; + } + step.waitingForPage = true; + const lookup = (step.lookup ?? 0) + 1; + step.lookup = lookup; + let done = false; + const finish = () => { + if (done || step.lookup !== lookup) return; + done = true; + timers.clear(cap); + step.waitingForPage = false; + pump(); + }; + const cap = timers.set(finish, tune.inflightCapMs); + options.pageOf(step.tabId).then((page) => { + if (page && !done && step.lookup === lookup) step.page = page; + finish(); + }, finish); + } + + /** Puts an event that is not a step among the held ones by its time, after every one from before it. */ + function insert(tabId: number, event: CapturedEvent, speaking: boolean) { + if (ended) return; + const at = event.timestamp; + let index = queue.length; + while (index > 0 && (queue[index - 1]?.at ?? 0) > at) index--; + queue.splice(index, 0, { + tabId, + at, + event, + key: newKey(), + placed: true, + ...(speaking ? { speech: true as const } : {}), + owner: false, + closeAt: at, + values: [], + waitingForPage: false, + }); + pump(); + } + + return { + /** A step the person took in the page (click, edit, choice, submit, Enter): `fromFrame` when it came from an + * iframe, whose page is the tab's. */ + step(tabId: number, event: CapturedEvent, fromFrame = false): void { + if (ended) return; + // A page change after this one is the person's doing, never a redirect of the one before. + openChange.delete(tabId); + const at = event.timestamp; + const previous = lastOwner(tabId); + const echo = + event.name === "input" && + previous !== undefined && + ACTION_STEPS.has(previous.event.name) && + at >= previous.at && + at - previous.at <= ECHO_MS; + if (!echo && previous && at - tune.leadMs < previous.closeAt) { + previous.closeAt = Math.max(previous.at, at - tune.leadMs); + schedule(previous); + } + const own = typedValue(event); + const chosen = chosenText(event); + const values = [...(typed.get(tabId) ?? [])]; + if (own) values.push(own); + if (chosen) values.push(chosen); + if (own) + typed.set(tabId, [...(typed.get(tabId) ?? []), own].slice(-MAX_VALUES)); + const step: Pending = { + tabId, + at, + event, + key: newKey(), + owner: !echo, + closeAt: at + tune.horizonMs, + values, + waitingForPage: false, + }; + queue.push(step); + if (fromFrame) fetchPage(step); + if (step.owner) schedule(step); + // The person's next step closes the join a step before it may still be held for. + pump(); + changed(); + }, + + /** A page change in the tab's top frame: sent in its place among the steps, once its page has a title. */ + navigation(tabId: number, event: CapturedEvent): void { + if (ended) return; + const open = openChange.get(tabId); + const since = open ? event.timestamp - (open.lastHop ?? open.at) : -1; + // A reload, a step back or forward, or an address the person typed or picked in the browser is the person's; a + // push or a replace this soon after the last is the page's. + if ( + open && + queue.includes(open) && + !byPerson(stepValue(event)) && + since >= 0 && + since <= tune.redirectMs + ) { + // A redirect: the held page change now ends where this one does, and still begins where and how it began. + const first = stepValue(open.event); + const { transitionType, transitionQualifiers, ...last } = + stepValue(event); + open.event = { + ...open.event, + value: { + ...last, + from: first.from ?? "", + navigationType: first.navigationType ?? "push", + ...(first.transitionType !== undefined + ? { transitionType: first.transitionType } + : {}), + ...(first.transitionQualifiers !== undefined + ? { transitionQualifiers: first.transitionQualifiers } + : {}), + } as unknown as JsonValue, + }; + open.lastHop = event.timestamp; + open.page = undefined; + open.waitingForPage = true; + open.lookup = (open.lookup ?? 0) + 1; + if (open.pageTimer !== undefined) timers.clear(open.pageTimer); + open.pageTimer = timers.set(() => fetchPage(open), tune.redirectMs); + changed(); + return; + } + const step: Pending = { + tabId, + at: event.timestamp, + event, + key: newKey(), + owner: false, + closeAt: event.timestamp, + values: [], + waitingForPage: true, + lastHop: event.timestamp, + }; + queue.push(step); + openChange.set(tabId, step); + step.pageTimer = timers.set(() => fetchPage(step), tune.redirectMs); + changed(); + }, + + /** An event that is not a step (the agent's side): it leaves after every step held from before its time and + * before every held step from after it. With nothing held from before it, it leaves at once. */ + place(tabId: number, event: CapturedEvent): void { + insert(tabId, event, false); + }, + + /** A phrase's transcript, stamped with the phrase's start: placed as `place` places an event, and the phrase being + * spoken never holds it back. */ + transcript(tabId: number, event: CapturedEvent): void { + insert(tabId, event, true); + }, + + /** A phrase began at `since` (null: none is being spoken): nothing from then on leaves until its transcript is + * placed and the hold moves on, or `speechCapMs` after `since`. */ + hold(since: number | null): void { + if (ended) return; + if (speechTimer !== undefined) timers.clear(speechTimer); + speechTimer = undefined; + speech = + since === null ? null : { since, until: since + tune.speechCapMs }; + if (speech) + speechTimer = timers.set(pump, Math.max(0, speech.until - now())); + pump(); + }, + + /** What it holds now, as JSON, or null when it holds nothing that would be lost. */ + snapshot(): StepSnapshot | null { + const values = [...typed].filter(([, list]) => list.length > 0); + if (queue.length === 0 && values.length === 0 && speech === null) + return null; + // Only the calls a held step may still own: from `leadMs` before the earliest one on. + let from = Number.POSITIVE_INFINITY; + for (const step of queue) + if (step.owner) from = Math.min(from, step.at - tune.leadMs); + return { + queue: queue.map( + ({ timer: _t, pageTimer: _p, lookup: _l, ...held }, index) => + openChange.get(held.tabId) === queue[index] + ? { ...held, open: true } + : held, + ), + calls: [...calls.values()] + .flat() + .filter((call) => call.startedAt >= from), + typed: values, + speech, + }; + }, + + /** Takes up what another assembler held (its `snapshot`), on a fresh one, and goes on under the same rules: each + * step waits out what is left of its window, a page change looks its page up again, and the phrase's hold runs to + * its end. */ + restore(snapshot: StepSnapshot): void { + if (ended) return; + const restored: Pending[] = []; + for (const { open, ...held } of snapshot.queue) { + const step: Pending = { ...held }; + restored.push(step); + if (open) openChange.set(step.tabId, step); + } + queue.push(...restored); + for (const call of snapshot.calls) { + const list = calls.get(call.tabId) ?? []; + list.push(call); + calls.set(call.tabId, list); + } + for (const [tabId, values] of snapshot.typed) typed.set(tabId, values); + if (snapshot.speech) { + speech = snapshot.speech; + speechTimer = timers.set( + pump, + Math.max(0, snapshot.speech.until - now()), + ); + } + for (const step of restored) { + if (step.placed) continue; + if (step.waitingForPage) { + if (step.lastHop !== undefined) { + // A page change: its page is looked up `redirectMs` after its chain's last hop, as before. + const wait = Math.max(0, step.lastHop + tune.redirectMs - now()); + step.pageTimer = timers.set(() => fetchPage(step), wait); + } else fetchPage(step); + } + if (step.owner) schedule(step); + } + pump(); + }, + + /** A finished call that passed the screens. */ + call(call: StepCall): void { + if (ended) return; + const list = calls.get(call.tabId) ?? []; + list.push(call); + calls.set(call.tabId, list); + pump(); + }, + + /** A write began (debugger only: the page hook reports a call once it is answered): a step it may be the save of + * waits for it. */ + writeStarted(id: string, tabId: number, startedAt: number): void { + if (ended) return; + inflight.set(id, { tabId, startedAt }); + }, + writeEnded(id: string): void { + if (!inflight.delete(id) || ended) return; + pump(); + }, + + /** Sends every step held, with what it owns so far (the recording stops or starts over). */ + flush(): void { + if (ended) return; + if (queue.length > 0) options.leaving?.(); + while (queue.length > 0) { + const step = queue.shift(); + if (!step) break; + if (step.timer !== undefined) timers.clear(step.timer); + if (step.pageTimer !== undefined) timers.clear(step.pageTimer); + options.emit(finish(step), step.key); + } + calls.clear(); + typed.clear(); + inflight.clear(); + openChange.clear(); + changed(); + }, + + forgetTab(tabId: number): void { + openChange.delete(tabId); + calls.delete(tabId); + typed.delete(tabId); + for (const [id, flight] of inflight) + if (flight.tabId === tabId) inflight.delete(id); + pump(); + }, + + /** Nothing more is sent: what was held is dropped. */ + clear(): void { + ended = true; + for (const step of queue) { + if (step.timer !== undefined) timers.clear(step.timer); + if (step.pageTimer !== undefined) timers.clear(step.pageTimer); + } + if (speechTimer !== undefined) timers.clear(speechTimer); + speechTimer = undefined; + speech = null; + queue.length = 0; + calls.clear(); + typed.clear(); + inflight.clear(); + openChange.clear(); + changed(); + }, + }; +} + +/** The save as the step carries it: its bodies cut so the whole step fits one Intelligence event. */ +function fitRequest( + step: CapturedEvent, + request: { + method: string; + url: string; + status: number | null; + body: JsonValue; + response: JsonValue; + }, +): JsonValue { + const without = JSON.stringify(step).length; + // The network event's own shape, so trimEvent cuts both bodies as it cuts a network event's. + const exchange: CapturedEvent = { + type: "CUSTOM", + name: "network", + timestamp: step.timestamp, + value: { + request: { body: request.body }, + response: { body: request.response }, + }, + }; + const budget = Math.max(512, EVENT_BUDGET_BYTES - without - 512); + const cut = trimEvent(exchange, budget).event.value as { + request?: { body?: JsonValue }; + response?: { body?: JsonValue }; + }; + return { + method: request.method, + url: request.url, + status: request.status, + body: cut.request?.body ?? { status: "unavailable", reason: "too-large" }, + response: cut.response?.body ?? { + status: "unavailable", + reason: "too-large", + }, + } as JsonValue; +} diff --git a/extension/src/content/actions.ts b/extension/src/content/actions.ts new file mode 100644 index 000000000..cee17024c --- /dev/null +++ b/extension/src/content/actions.ts @@ -0,0 +1,270 @@ +// The Recorder's own steps, for what the collector does not record: Enter pressed in a field (`key`) and a form submitted +// (`submit`), each with the targeting of its field or form (targeting.ts) and never what was typed. One action is one +// row, and where CopilotKit's collector already records an action, its event is that row and these add nothing: +// - A form submitted by a click on its submit button is that click (the submit is its default action, in the same task). +// - Enter in a field whose form has a default button is the click the browser makes on that button (`input: "keyboard"` +// in CopilotKit's click), and its submit: neither the key nor the submit is sent. Chrome makes that click as the +// default action of the Enter's keypress, so the key is held from keydown until the keypress has run. +// - Enter in a field that submits a form with no button (or one CopilotKit does not record) is the key; the submit it +// makes is the key's consequence. +// A submit is sent only when neither a click nor an Enter explains it. Only trusted events count: a page script +// dispatching its own submit is the page, not the person. +import { targetingOf } from "./targeting"; + +type Emit = (name: "submit" | "key", value: Record) => void; + +type Timers = { + set(fn: () => void, ms: number): unknown; + clear(handle: unknown): void; +}; +const realTimers: Timers = { + set: (fn, ms) => setTimeout(fn, ms), + clear: (handle) => clearTimeout(handle as ReturnType), +}; + +/** The longest an Enter is held for the click its keypress may make, when neither the keypress nor the keyup comes. */ +const ENTER_HOLD_MS = 1_000; + +const TEXT_INPUT_TYPES = new Set([ + "", + "text", + "search", + "email", + "url", + "tel", + "number", + "password", +]); +/** What identifies a field or a form beside its targeting, never what was typed in it. */ +const KEPT_ATTRIBUTES = [ + "id", + "name", + "type", + "role", + "aria-label", + "placeholder", + "method", + "data-testid", + "data-test-id", +] as const; + +/** A field a person types into: a text-like input, a textarea, or an editable element. */ +function fieldOf(target: EventTarget | null): Element | null { + if (!(target instanceof Element)) return null; + if (target instanceof HTMLInputElement) + return TEXT_INPUT_TYPES.has(target.type) ? target : null; + if (target instanceof HTMLTextAreaElement) return target; + return target.closest( + '[contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"], [role="combobox"]', + ); +} + +/** The form's default button (HTML's implicit submission): its first submit button, in tree order, of all the controls + * the form owns. */ +function defaultButton(form: HTMLFormElement): Element | null { + for (const control of Array.from(form.elements)) + if ( + (control instanceof HTMLButtonElement && control.type === "submit") || + (control instanceof HTMLInputElement && + (control.type === "submit" || control.type === "image")) + ) + return control; + return null; +} + +/** Whether Enter in the field makes the browser click its form's default button: an input of its form, with a default + * button that is not disabled (a disabled one submits nothing). */ +function entersClick(field: Element): boolean { + if (!(field instanceof HTMLInputElement) || !field.form) return false; + const button = defaultButton(field.form); + return button !== null && !button.matches(":disabled"); +} + +const liveValue = (element: Element): string | undefined => + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ? element.value + : element.matches('[contenteditable]:not([contenteditable="false"])') + ? (element.textContent ?? undefined) + : undefined; + +/** The step for an element: its tag and identifying attributes as the collector keeps them, its computed role, name and + * input type, and the rest of its targeting beside them. */ +function stepFor(step: string, element: Element): Record { + const attributes: Record = {}; + for (const name of KEPT_ATTRIBUTES) { + const value = element.getAttribute(name); + if (value !== null) attributes[name] = value.slice(0, 200); + } + const { ariaRole, name, inputType, selectedLabels, ...rest } = targetingOf( + element, + liveValue(element), + ); + return { + step, + url: location.href, + route: location.pathname, + target: { + tag: element.localName, + ariaRole, + name, + ...(inputType ? { inputType } : {}), + ...(selectedLabels ? { selectedLabels } : {}), + attributes, + }, + ...rest, + }; +} + +export type ActionCapture = { + /** The collector sent a click (`keyboard` when the browser made it for a key, as an Enter's on a default button). */ + collectorClicked(keyboard: boolean): void; + uninstall(): void; +}; + +/** Installs the listeners on `window`, in the capture phase so a page that stops propagation hides nothing; null when the + * page will not let them be added. `isTrusted` is a test seam only: a test cannot create a trusted event. */ +export function installActionCapture(options: { + emit: Emit; + isTrusted?: (event: Event) => boolean; + timers?: Timers; +}): ActionCapture | null { + const isTrusted = options.isTrusted ?? ((event: Event) => event.isTrusted); + const timers = options.timers ?? realTimers; + + // A click the collector sent in the task running now: a submit in the same task is that click's default action. Set + // when the click is sent and cleared by the next task, which cannot run before this one ends. + let clickedNow = false; + let clickTask: unknown; + // The Enter being pressed: held until its keypress has run when the browser may click a default button for it, else + // already sent. Either way it explains the submit it makes. + let enter: { + held: Record | null; + timers: unknown[]; + } | null = null; + + const send = (name: "submit" | "key", value: Record) => { + try { + options.emit(name, value); + } catch { + // Capture must never break the page's own key handling or submit. + } + }; + /** The Enter is over: a key still held is sent (no click came for it). */ + const endEnter = () => { + const current = enter; + if (!current) return; + enter = null; + for (const handle of current.timers) timers.clear(handle); + if (current.held) send("key", current.held); + }; + const later = (fn: () => void, ms = 0) => { + const current = enter; + if (current) current.timers.push(timers.set(fn, ms)); + }; + + const onSubmit = (event: Event) => { + if (!isTrusted(event) || !(event.target instanceof HTMLFormElement)) return; + if (clickedNow) return; + if (enter) { + // The Enter's own submit (a form with no button to click): the key is the row. + const held = enter.held; + enter.held = null; + if (held) send("key", held); + return; + } + try { + const submitter = (event as SubmitEvent).submitter; + send("submit", { + ...stepFor("submit", event.target), + ...(submitter instanceof Element + ? { submitter: stepFor("click", submitter).target } + : {}), + }); + } catch { + // Capture must never break the page's own submit. + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (!isTrusted(event) || event.key !== "Enter") return; + endEnter(); + // Enter that sends what was typed: a newline (Shift+Enter) or a composition being confirmed is not. + if (event.shiftKey || event.isComposing) return; + const field = fieldOf(event.composedPath()[0] ?? event.target); + if (!field) return; + let value: Record; + try { + value = { key: "Enter", ...stepFor("enter", field) }; + } catch { + // Capture must never break the page's own key handling. + return; + } + if (!entersClick(field)) { + send("key", value); + enter = { held: null, timers: [] }; + later(endEnter, ENTER_HOLD_MS); + return; + } + enter = { held: value, timers: [] }; + // A page that stops the Enter at keydown stops its keypress too, and with it the click: the key is the row. + const current = enter; + later(() => { + if (enter === current && event.defaultPrevented) endEnter(); + }); + later(endEnter, ENTER_HOLD_MS); + }; + const onKeyPress = (event: KeyboardEvent) => { + if (!isTrusted(event) || event.key !== "Enter" || !enter) return; + // The keypress's default action (the default button's click and its submit) runs in this task: by the next, the + // Enter has had its click or never will. + later(endEnter); + }; + const onKeyUp = (event: KeyboardEvent) => { + if (isTrusted(event) && event.key === "Enter") endEnter(); + }; + // Anything else the person does ends the Enter first, so the steps keep their order. + const onOther = () => endEnter(); + + const passive = { capture: true, passive: true } as const; + const listeners: [string, (event: never) => void][] = [ + ["submit", onSubmit], + ["keydown", onKeyDown], + ["keypress", onKeyPress], + ["keyup", onKeyUp], + ["pointerdown", onOther], + ["pagehide", onOther], + ]; + const removeListeners = () => { + for (const [type, listener] of listeners) + window.removeEventListener(type, listener as EventListener, { + capture: true, + }); + }; + try { + for (const [type, listener] of listeners) + window.addEventListener(type, listener as EventListener, passive); + } catch { + removeListeners(); + return null; + } + return { + collectorClicked(keyboard) { + clickedNow = true; + if (clickTask === undefined) + clickTask = timers.set(() => { + clickTask = undefined; + clickedNow = false; + }, 0); + // The click the browser made for the Enter: CopilotKit's click is the row, and the key is never sent. + if (keyboard && enter) enter.held = null; + }, + uninstall() { + endEnter(); + removeListeners(); + if (clickTask !== undefined) timers.clear(clickTask); + clickTask = undefined; + clickedNow = false; + }, + }; +} diff --git a/extension/src/content/collector.ts b/extension/src/content/collector.ts new file mode 100644 index 000000000..f62d04488 --- /dev/null +++ b/extension/src/content/collector.ts @@ -0,0 +1,171 @@ +import type { JsonValue, TrajectoryEvent } from "@copilotkit/learning"; +import { createTrajectoryCollector } from "@copilotkit/learning"; +import { + type CapturedEvent, + EVENT_BUDGET_BYTES, + maskCollectorEvent, + sanitiseForIntelligence, + trimEvent, +} from "@openbot/shared/recorder-masking"; +import { AGENT_TARGET_FIELD, type AgentTarget } from "../shared/agent-target"; +import { type ActionCapture, installActionCapture } from "./actions"; +import type { SensitivityTracker } from "./masking-context"; +import { agentTargetOf, createElementTracker, enrichStep } from "./targeting"; + +const DROP_REPORT_MS = 5_000; + +type Timers = { + set(fn: () => void, ms: number): unknown; + clear(handle: unknown): void; +}; +const realTimers: Timers = { + set: (fn, ms) => setTimeout(fn, ms), + clear: (handle) => clearTimeout(handle as ReturnType), +}; + +/** Mask, trim to EVENT_BUDGET_BYTES, then sanitise (NUL and lone surrogates), so the collector's own checks after beforeSend + * (it refuses NUL with INVALID_EVENT and anything over 16 KiB with EVENT_TOO_LARGE) never drop an event; null only for an event + * maskCollectorEvent cannot read, which is counted. `enrich` adds a click's or an edit's targeting (targeting.ts) first, so + * the masking covers it too. The collector's `page` event is not a step: the service worker records each page change. A + * click inside a chat message or tool call carries `agentTarget` (the ids CopilotKit's enricher reads) in a private field + * added after the masking, so the ids stay as the page wrote them for the service worker to match against the agent's + * messages; the service worker removes it. */ +export function collectorBeforeSend( + tracker: SensitivityTracker, + onDropped?: (reason: string) => void, + enrich: (event: CapturedEvent) => CapturedEvent = (event) => event, + agentTarget: () => AgentTarget | null = () => null, +): (event: TrajectoryEvent) => TrajectoryEvent | null { + return (event) => { + const captured = event as CapturedEvent; + if (captured?.name === "page") return null; + const masked = maskCollectorEvent( + enrich(captured), + { sensitive: tracker.sensitiveForEvent(captured?.name ?? "") }, + { digests: tracker.digests() }, + ); + if (masked === null) { + onDropped?.("unreadable"); + return null; + } + const trimmed = trimEvent(masked, EVENT_BUDGET_BYTES).event; + const value = sanitiseForIntelligence(trimmed.value); + const target = captured.name === "click" ? agentTarget() : null; + return { + ...trimmed, + value: + target && + typeof value === "object" && + value !== null && + !Array.isArray(value) + ? { ...value, [AGENT_TARGET_FIELD]: target } + : value, + } as TrajectoryEvent; + }; +} + +/** Captures the person's steps: clicks and edits through the collector, each with its targeting, plus Enter in a field + * and a form submit that no click the collector sent explains (actions.ts). Edits leave as the collector settles them + * (one per field once typing pauses, or at once when the next action, field or stop comes), exactly as CopilotKit's own + * capture sends them. `send` also receives one `recorder.dropped { count, reason }` event per 5 s while the collector + * reported errors (INVALID_EVENT, EVENT_TOO_LARGE, PERSISTENCE_UNKNOWN, CAPTURE_FAILED) or beforeSend dropped one, so no + * event vanishes uncounted. Network is off (the collector's patch of `fetch` sees nothing in this isolated world; the + * debugger or the MAIN-world bundle capture it) and navigation is off (the service worker records it from + * `webNavigation`). */ +export function startEventCapture(options: { + tracker: SensitivityTracker; + send(event: CapturedEvent): void; + timers?: Timers; +}): { stop(): void } { + const { tracker, send } = options; + const timers = options.timers ?? realTimers; + const drops = new Map(); + let dropTimer: unknown; + + const countDrop = (reason: string) => { + drops.set(reason, (drops.get(reason) ?? 0) + 1); + dropTimer ??= timers.set(reportDrops, DROP_REPORT_MS); + }; + let actions: ActionCapture | null = null; + const deliver = (event: TrajectoryEvent) => { + try { + send(event as CapturedEvent); + } catch { + countDrop("PERSISTENCE_UNKNOWN"); + } + // The Recorder's own submit and Enter steps never repeat a click CopilotKit's collector sent (actions.ts). + if (event.name === "click") { + const target = (event.value as { target?: { input?: unknown } } | null) + ?.target; + actions?.collectorClicked(target?.input === "keyboard"); + } + }; + function reportDrops() { + dropTimer = undefined; + const counted = [...drops]; + drops.clear(); + for (const [reason, count] of counted) + deliver({ + type: "CUSTOM", + name: "recorder.dropped", + timestamp: Date.now(), + value: { count, reason }, + }); + } + + // Installed before the collector's own listeners, so for every click and edit it knows the element first. + const elements = createElementTracker(); + let uninstallElements: () => void = () => {}; + try { + uninstallElements = elements.install(); + } catch { + // The collector's own start fails on the same page and counts it as CAPTURE_FAILED. + } + const collector = createTrajectoryCollector({ + capture: { clicks: true, inputs: true, navigation: false, network: false }, + beforeSend: collectorBeforeSend( + tracker, + countDrop, + (event) => + event.name === "click" + ? enrichStep(event, elements.elementFor("click")) + : event.name === "input" + ? enrichStep( + event, + elements.elementForEdit( + (event.value as { target?: unknown } | null)?.target, + ), + ) + : event, + () => agentTargetOf(elements.elementFor("click")), + ), + send: deliver, + onError: (error) => countDrop(error.code), + }); + collector.start(); + // Submit and Enter go through the collector too, so they are masked and trimmed like a click. A page that refused the + // collector's own listeners has already been counted as CAPTURE_FAILED. + const failed = drops.has("CAPTURE_FAILED"); + actions = failed + ? null + : installActionCapture({ + emit: (name, value) => collector.emit(name, value as JsonValue), + timers, + }); + if (!failed && !actions) countDrop("CAPTURE_FAILED"); + + return { + stop() { + // An Enter still held for its click goes first, while the collector can still send it. + actions?.uninstall(); + actions = null; + // The collector's stop lets every held edit go first. + collector.stop(); + uninstallElements(); + if (dropTimer !== undefined) { + timers.clear(dropTimer); + reportDrops(); + } + }, + }; +} diff --git a/extension/src/content/index.ts b/extension/src/content/index.ts new file mode 100644 index 000000000..eb407478d --- /dev/null +++ b/extension/src/content/index.ts @@ -0,0 +1,78 @@ +import type { BackgroundToContent } from "../protocol/messages"; +import { startEventCapture } from "./collector"; +import { createSensitivityTracker } from "./masking-context"; +import { showNotice } from "./notice"; +import { contentHello, createContentPort } from "./port"; + +type RecorderGlobals = typeof globalThis & { + __openbotRecorderContent?: boolean; + __openbotRecorderActive?: string; +}; + +/** + * Injected by the service worker into each committed frame of a followed tab. A frame the worker flagged + * (`__openbotRecorderActive`, set just before this script) starts capturing at once, without waiting for its + * port, so nothing clicked or typed during the round trip is lost; any other frame starts on + * `background.capture { active: true }`. + */ +function startContentScript() { + const flags = globalThis as RecorderGlobals; + if (flags.__openbotRecorderContent) return; + flags.__openbotRecorderContent = true; + + const port = createContentPort(); + port.post(contentHello()); + + let capture: { stop(): void } | null = null; + const startCapture = () => { + if (capture) return; + const tracker = createSensitivityTracker(); + const uninstall = tracker.install(); + tracker.onSensitiveValue((digest) => + port.post({ type: "content.sensitive", digest }), + ); + const events = startEventCapture({ + tracker, + send: (event) => port.post({ type: "content.event", event }), + }); + capture = { + stop() { + events.stop(); + uninstall(); + }, + }; + }; + let notice: { remove(): void } | null = null; + const stopCapture = () => { + capture?.stop(); + capture = null; + // Capture stopping for any reason ends the 50-action notice: its buttons would otherwise act on a later recording. + notice?.remove(); + notice = null; + }; + + const onNotice = ( + message: Extract, + ) => { + if (window !== window.top) return; + notice?.remove(); + notice = null; + if (!message.show) return; + notice = showNotice({ + actions: message.actions, + choose: (choice) => port.post({ type: "content.notice", choice }), + }); + }; + + // The extension was updated or removed under this page: nothing captured here can reach it any more. + port.onOrphaned(stopCapture); + if (flags.__openbotRecorderActive) startCapture(); + port.onMessage((message) => { + if (message.type === "background.capture") { + if (message.active) startCapture(); + else stopCapture(); + } else onNotice(message); + }); +} + +startContentScript(); diff --git a/extension/src/content/masking-context.ts b/extension/src/content/masking-context.ts new file mode 100644 index 000000000..2290c4d10 --- /dev/null +++ b/extension/src/content/masking-context.ts @@ -0,0 +1,282 @@ +import { describeDemonstrationTarget } from "@openbot/shared/demonstration-rules"; +import { + holdsValue, + isSensitiveTarget, + MIN_DIGEST_LENGTH, + normaliseSecret, + sha256HexSync, +} from "@openbot/shared/recorder-masking"; + +const FIELD = "input, textarea, select, [contenteditable]"; + +export type SensitivityTracker = { + /** Capture-phase pointerdown, focusin, click, input and change on window; install it BEFORE the collector so a digest + * exists, and a click's field is known, before the collector's own listener builds the event it masks. */ + install(): () => void; + /** input and change are judged by the field the event came from (else the focused field), a click by the field it + * landed on: the field a label forwards its click to, never an earlier pointer target. */ + sensitiveForEvent(name: string): boolean; + /** Values of at least MIN_DIGEST_LENGTH characters typed or filled into sensitive fields (a password manager's untrusted + * input events count too), and the code a row of one-character boxes holds together, normalised then hashed, once per + * value. */ + onSensitiveValue(listener: (digest: string) => void): void; + /** This frame's own digests, for masking this frame's events. */ + digests(): ReadonlySet; +}; + +const fieldOf = (target: EventTarget | null): Element | null => + target instanceof Element ? target.closest(FIELD) : null; + +// The collector describes a click that a re-render moved to or , or off a detached node, by its pointerdown +// target (`clicks.ts` `installClickCapture` > `onClick`), so such a click keeps the pointerdown field. +const describedByPointerdown = (target: EventTarget | null): boolean => + !(target instanceof Element) || + target.localName === "body" || + target.localName === "html" || + !target.isConnected; + +/** The longest label text read for a field: an aria-labelledby can point at a whole region. */ +const LABEL_TEXT_LIMIT = 1000; + +/** The text of the field's own labels and of what its aria-labelledby names: a label that is not a xy + + +
m
`; + expect( + [ + "#a", + "#anchor", + "#b", + "#t", + "#c", + "#s", + "#q", + "#one", + "#many", + "#ta", + "#d", + "#ce", + "#plain", + ].map((selector) => roleOf($(selector))), + ).toEqual([ + "link", + null, + "button", + "textbox", + "checkbox", + "button", + "searchbox", + "combobox", + "listbox", + "textbox", + "menuitem", + "textbox", + null, + ]); +}); + +test("the name is what a screen reader reads: aria-labelledby, aria-label, the label, the text on it, then placeholder", () => { + document.body.innerHTML = ` + Companyname + + + + + + + Ada Lovelace + +
+
+
What should I do next?
`; + expect( + [ + "#by", + "#aria", + "#for", + "#wrapped", + "#ph", + "#btn", + "#link", + "#submit", + "#f", + "#unnamed", + "#chip", + ].map((selector) => nameOf($(selector))), + ).toEqual([ + "Company name", + "Search contacts", + "Email address", + "City", + "Enter 6-digit code", + "Save changes", + "Ada Lovelace", + "Send", + "New company", + // A form is never named by the text inside it. + "", + "What should I do next?", + ]); +}); + +test("selectors come in the order Playwright's generator scores them: test attribute, role and name, text, hand-made id, name, then a short CSS path from a stable ancestor", () => { + document.body.innerHTML = ` +
+ + +
`; + const save = $('[data-testid="save-contact"]'); + expect(selectorsOf(save, "button", "Save", () => false)).toEqual([ + { by: "testid", selector: '[data-testid="save-contact"]' }, + { by: "role", selector: 'role=button[name="Save"]' }, + { by: "text", selector: 'text="Save"' }, + { by: "id", selector: "#save-contact" }, + { by: "name", selector: 'button[name="save"]' }, + { + by: "css", + selector: + '[data-testid="contact-card"] > div > span > button:nth-of-type(1)', + }, + ]); + // A made-up id is left out, and no class name ever appears. + const cancel = $("button:nth-of-type(2)"); + const selectors = selectorsOf(cancel, "button", "Cancel", () => false); + expect(selectors.map((s) => s.by)).toEqual(["role", "text", "css"]); + expect(JSON.stringify(selectors)).not.toContain("sc-kcGFou"); + expect(JSON.stringify(selectors)).not.toContain(":r7:"); +}); + +test("a selector that also finds another element ranks after every one that finds this one alone, as Playwright penalises it", () => { + // Two rows each with a Delete button and the same test id: role and name, text and the test id find both. + document.body.innerHTML = ` +
    +
  • Ada
  • +
  • Grace
  • +
`; + const ada = $("#delete-ada"); + expect(selectorsOf(ada, "button", "Delete", () => false)).toEqual([ + { by: "id", selector: "#delete-ada" }, + { by: "testid", selector: '[data-testid="row-delete"]' }, + { by: "role", selector: 'role=button[name="Delete"]' }, + { by: "text", selector: 'text="Delete"' }, + { by: "css", selector: "#contacts > li:nth-of-type(1) > button" }, + ]); +}); + +test("Wikipedia's generated ids are never a selector: run 2's click on Charles Babbage leads with its role and name, not #mwGQ", () => { + // Ada Lovelace's article as Wikipedia serves it (Parsoid gives every element an `mw` id counted in base 64), clicked in + // end-to-end run 2, row 89043: its selectors led with #mwGQ and its CSS path hung off #mwEw. + document.title = "Ada Lovelace - Wikipedia"; + document.body.innerHTML = ` +
+

Lovelace became close friends with her tutor Mary Somerville, who introduced her to Charles Babbage in 1833.

+
`; + for (const id of ["mwGQ", "mwEw", "mwAQ", "mwBA"]) + expect([id, isGeneratedId(id)]).toEqual([id, true]); + for (const id of ["mw-content-text", "searchInput", "mce_0_ifr"]) + expect([id, isGeneratedId(id)]).toEqual([id, false]); + const link = $("#mwGQ"); + const targeting = targetingOf(link); + expect(targeting).toMatchObject({ + ariaRole: "link", + name: "Charles Babbage", + }); + expect(targeting.selectors).toEqual([ + { by: "role", selector: 'role=link[name="Charles Babbage"]' }, + { by: "text", selector: 'text="Charles Babbage"' }, + { by: "href", selector: 'a[href="/wiki/Charles_Babbage"]' }, + { + by: "css", + selector: "#mw-content-text > div > p > a:nth-of-type(2)", + }, + ]); + expect(JSON.stringify(targeting.selectors)).not.toContain("mwGQ"); + expect(JSON.stringify(targeting.selectors)).not.toContain("mwEw"); +}); + +test("OpenBot's new-chat link, an icon with no name, is found by its address before its position; a volatile address never is", () => { + // The sidebar header as OpenBot renders it (app-sidebar.tsx: a Base UI Button rendered as the router's Link to + // /channel/new, holding Tabler's IconPlus with no title), clicked in the CopilotKit-site end-to-end run 3, row 044: + // its one selector was its position. + document.body.innerHTML = ` + + Join +

Refund this order

`; + const plus = $('a[href="/channel/new"]'); + const targeting = targetingOf(plus); + expect(targeting.selectors).toEqual([ + { by: "href", selector: 'a[href="/channel/new"]' }, + { + by: "css", + selector: "div > div:nth-of-type(1) > ul > li > a:nth-of-type(2)", + }, + ]); + // The page gives the link no aria-label, no title and no text: it has no name to record. + expect(targeting).toMatchObject({ ariaRole: "button", name: "" }); + // An address with a session or a random part in it changes from one visit to the next; an order number does not. + expect( + targetingOf($("#invite")).selectors.map((selector) => selector.by), + ).toEqual(["role", "text", "id", "css"]); + expect(targetingOf($("#refund")).selectors).toEqual([ + { by: "role", selector: 'role=link[name="Refund this order"]' }, + { by: "text", selector: 'text="Refund this order"' }, + { by: "id", selector: "#refund" }, + { by: "href", selector: 'a[href="/checkout?order=1043"]' }, + { by: "css", selector: "html > body > p > a" }, + ]); +}); + +test("nothing derived from a field ever repeats its value: a page that mirrors the value into its label loses that name", () => { + document.body.innerHTML = ``; + const field = $("#q") as HTMLInputElement; + field.value = "Acme Holdings"; + const targeting = targetingOf(field, field.value); + expect(targeting.name).toBe(""); + expect(leakedSecrets(targeting, ["Acme Holdings"])).toEqual([]); +}); + +test("a step carries the page, its input type, its box and the viewport in the top frame; a frame's step carries its frame instead", () => { + document.title = "Contacts"; + document.body.innerHTML = ``; + const field = $("#email"); + Object.defineProperty(field, "getBoundingClientRect", { + value: () => ({ x: 10.4, y: 20.6, width: 300, height: 32 }), + }); + expect(targetingOf(field)).toMatchObject({ + ariaRole: "textbox", + name: "Email", + inputType: "email", + box: { x: 10, y: 21, width: 300, height: 32 }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + page: { url: "https://crm.example.test/contacts", title: "Contacts" }, + }); + const inFrame = inCrossSiteFrame([], () => targetingOf(field)); + expect(inFrame.frame).toEqual({ + url: "https://crm.example.test/contacts", + path: ['iframe[src*="//crm.example.test/contacts"]'], + }); + expect(inFrame.page).toBeUndefined(); +}); + +type FakeWindow = { + frames: unknown[]; + length: number; + top: unknown; + parent: unknown; + readonly frameElement: Element | null; + readonly location: { host: string; pathname: string }; +}; + +/** A window of another site, as a frame's script sees it in Chrome: its frames and their number can be read, its + * location and its iframe element cannot (SecurityError). `children` are its frames, in its document's order. */ +function crossSiteWindow(children: unknown[]): FakeWindow { + const blocked = () => { + throw new DOMException( + "Blocked a frame from accessing a cross-origin frame.", + "SecurityError", + ); + }; + const fake = { + frames: children, + length: children.length, + top: null as unknown, + parent: null as unknown, + } as FakeWindow; + Object.defineProperty(fake, "frameElement", { get: blocked }); + Object.defineProperty(fake, "location", { get: blocked }); + fake.top = fake; + fake.parent = fake; + return fake; +} + +/** A frame of this frame's own site: its location can be read. */ +const sameSiteWindow = (href: string) => { + const url = new URL(href); + return { location: { host: url.host, pathname: url.pathname } }; +}; + +/** Runs `fn` with this page as a frame of a cross-site parent whose frames are `siblings` with this window among them + * (at `at`, last by default), and the parent itself a frame of `grandparent` when one is given. */ +function inCrossSiteFrame( + siblings: unknown[], + fn: () => T, + options: { at?: number; name?: string; grandparent?: FakeWindow } = {}, +): T { + const frames = [...siblings]; + frames.splice(options.at ?? frames.length, 0, window); + const parent = crossSiteWindow(frames); + if (options.grandparent) { + options.grandparent.frames.push(parent); + options.grandparent.length = options.grandparent.frames.length; + parent.parent = options.grandparent; + parent.top = options.grandparent; + } + const saved = ["top", "parent", "frameElement"].map( + (key) => [key, Object.getOwnPropertyDescriptor(window, key)] as const, + ); + const name = window.name; + Object.defineProperty(window, "top", { + value: parent.top, + configurable: true, + }); + Object.defineProperty(window, "parent", { + value: parent, + configurable: true, + }); + // Chrome's frameElement is null for a frame whose parent is another site. + Object.defineProperty(window, "frameElement", { + value: null, + configurable: true, + }); + window.name = options.name ?? ""; + try { + return fn(); + } finally { + window.name = name; + for (const [key, descriptor] of saved) + if (descriptor) Object.defineProperty(window, key, descriptor); + else delete (window as unknown as Record)[key]; + } +} + +test("a field in a cross-site iframe names that iframe: by its name, else the address it loaded and which of the frames loading it, and an iframe further up by its position", () => { + // The shop's address page of end-to-end run 1: a same-site iframe (#same) and the courier's form from another site + // ([::1]), whose steps recorded their frame as just ["iframe"]. + ( + window as unknown as { happyDOM: { setURL(url: string): void } } + ).happyDOM.setURL("http://[::1]:7420/frame/courier"); + document.body.innerHTML = `
`; + const slot = $("#slot"); + const sameSite = crossSiteWindow([]); + const pathOf = ( + siblings: unknown[], + options?: Parameters[2], + ) => inCrossSiteFrame(siblings, () => targetingOf(slot).frame, options); + expect(pathOf([sameSite])).toEqual({ + url: "http://[::1]:7420/frame/courier", + path: ['iframe[src*="//[::1]:7420/frame/courier"]'], + }); + // Two courier forms on the page: which one, counting the frames that load the same address from 0. + expect( + pathOf([sameSite, sameSiteWindow("http://[::1]:7420/frame/courier")])?.path, + ).toEqual(['iframe[src*="//[::1]:7420/frame/courier"] >> nth=1']); + expect( + pathOf([sameSite, sameSiteWindow("http://[::1]:7420/frame/courier")], { + at: 1, + })?.path, + ).toEqual(['iframe[src*="//[::1]:7420/frame/courier"] >> nth=0']); + // The name the iframe gave its frame is the steadiest handle. + expect(pathOf([sameSite], { name: "courier" })?.path).toEqual([ + 'iframe[name="courier"]', + ]); + // Inside a cross-site frame that is itself the third frame of a cross-site page. + const top = crossSiteWindow([crossSiteWindow([]), crossSiteWindow([])]); + expect(pathOf([sameSite], { grandparent: top })?.path).toEqual([ + "iframe >> nth=2", + 'iframe[src*="//[::1]:7420/frame/courier"]', + ]); + ( + window as unknown as { happyDOM: { setURL(url: string): void } } + ).happyDOM.setURL("https://crm.example.test/contacts"); +}); + +test("the collector's click and edits become steps with their targeting, masked: a click, a choice, a box ticked and a password", () => { + document.title = "New contact"; + document.body.innerHTML = ` +
+ + + + + +
`; + const sent: CapturedEvent[] = []; + const tracker = createSensitivityTracker({ isTrusted: () => true }); + cleanups.push(tracker.install()); + const capture = startEventCapture({ + tracker, + send: (event) => sent.push(event), + }); + cleanups.push(capture.stop); + $("#add").dispatchEvent( + trusted(new MouseEvent("click", { bubbles: true, composed: true })), + ); + const stage = $("#stage") as HTMLSelectElement; + stage.value = "customer"; + stage.dispatchEvent( + trusted(new Event("change", { bubbles: true, composed: true })), + ); + const vip = $("#vip") as HTMLInputElement; + vip.checked = true; + vip.dispatchEvent( + trusted(new Event("change", { bubbles: true, composed: true })), + ); + const pw = $("#pw") as HTMLInputElement; + pw.value = "Hunter-Two-99"; + pw.dispatchEvent( + trusted(new Event("change", { bubbles: true, composed: true })), + ); + expect( + sent.map((event) => [event.name, (event.value as { step: string }).step]), + ).toEqual([ + ["click", "click"], + ["input", "select"], + ["input", "check"], + ["input", "change"], + ]); + expect(sent[0]?.value).toMatchObject({ + // CopilotKit's role is the role attribute (none here); the Recorder's computed role sits beside it. + target: { + tag: "button", + role: null, + ariaRole: "button", + name: "Add contact", + }, + page: { url: "https://crm.example.test/contacts", title: "New contact" }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + box: { x: 0, y: 0, width: 0, height: 0 }, + }); + expect( + ( + sent[0]?.value as + | { selectors?: { by: string; selector: string }[] } + | undefined + )?.selectors, + ).toEqual([ + { by: "testid", selector: '[data-testid="add-contact"]' }, + { by: "role", selector: 'role=button[name="Add contact"]' }, + { by: "text", selector: 'text="Add contact"' }, + { by: "id", selector: "#add" }, + { by: "css", selector: "html > body > form > button" }, + ]); + expect(sent[1]?.value).toMatchObject({ + target: { + role: null, + ariaRole: "combobox", + name: "Stage", + inputType: "select-one", + value: "customer", + }, + }); + expect(sent[2]?.value).toMatchObject({ + target: { + role: null, + ariaRole: "checkbox", + name: "VIP", + inputType: "checkbox", + checked: true, + }, + }); + expect(sent[3]?.value).toMatchObject({ + target: { + role: null, + ariaRole: "textbox", + name: "Password", + inputType: "password", + value: "[redacted]", + }, + }); + expect(leakedSecrets(sent, ["Hunter-Two-99"])).toEqual([]); +}); + +/** Starts the collector's step capture and clicks `element` as a person would; the step it records. */ +function clickStep(element: HTMLElement): Record { + const sent: CapturedEvent[] = []; + const tracker = createSensitivityTracker({ isTrusted: () => true }); + cleanups.push(tracker.install()); + const capture = startEventCapture({ + tracker, + send: (event) => sent.push(event), + }); + cleanups.push(capture.stop); + element.dispatchEvent( + trusted(new MouseEvent("pointerdown", { bubbles: true, composed: true })), + ); + element.dispatchEvent( + trusted(new MouseEvent("click", { bubbles: true, composed: true })), + ); + const step = sent.find((event) => event.name === "click"); + if (!step) throw new Error("no click step"); + return step.value as Record; +} + +const boxed = (element: HTMLElement, box: Record) => + Object.defineProperty(element, "getBoundingClientRect", { + value: () => box, + }); + +test("a click on the text inside a button names and selects the button, through any wrapper, and keeps the box of what was clicked", () => { + document.body.innerHTML = ` + + `; + const label = $("#label"); + boxed(label, { x: 12, y: 40, width: 80, height: 16 }); + boxed($("#ask"), { x: 0, y: 30, width: 200, height: 36 }); + const step = clickStep(label); + // CopilotKit's own target (the presentation wrapper it described, by its role attribute) stays as it wrote it. + expect(step.target).toMatchObject({ + role: "presentation", + ariaRole: "button", + name: "Ask a question", + }); + expect(step.selectors).toEqual([ + { by: "testid", selector: '[data-testid="ask-ai"]' }, + { by: "role", selector: 'role=button[name="Ask a question"]' }, + { by: "text", selector: 'text="Ask a question"' }, + { by: "id", selector: "#ask" }, + { by: "css", selector: "html > body > button" }, + ]); + expect(step.box).toEqual({ x: 12, y: 40, width: 80, height: 16 }); + cleanups.splice(0).forEach((cleanup) => { + cleanup(); + }); + const crm = clickStep($("#crm-text")); + expect(crm.target).toMatchObject({ ariaRole: "menuitem", name: "CRM" }); + expect((crm.selectors as { by: string; selector: string }[])[0]).toEqual({ + by: "role", + selector: 'role=menuitem[name="CRM"]', + }); +}); + +test("a click that meets nothing a person acts on names the clicked element by its own first 60 characters, its pieces of text kept apart", () => { + document.body.innerHTML = ` +
+

Sign in

or create an account

Sign in with Google

See how we can help you grow.

+
+ `; + const panel = clickStep($("#panel")); + // Cut at 60 characters, at most. + expect((panel.target as { name: string }).name).toBe( + "Sign in or create an account Sign in with Google See how we", + ); + expect((panel.selectors as { by: string }[]).map((s) => s.by)).toEqual([ + "id", + "css", + ]); + cleanups.splice(0).forEach((cleanup) => { + cleanup(); + }); + const menu = clickStep($("#nav")); + expect(menu.target).toMatchObject({ + ariaRole: "menu", + name: "Bookmarks CRM Marketing Content Sales Revenue Service", + }); +}); + +test("text split across elements is never glued into one word in a name", () => { + document.body.innerHTML = ` + Signin + + `; + expect(nameOf($("#a"))).toBe("Sign in"); + expect(nameOf($("#b"))).toBe("Save changes"); + expect(nameOf($("#c"))).toBe("Close"); +}); diff --git a/extension/tests/database.test.ts b/extension/tests/database.test.ts new file mode 100644 index 000000000..45b274218 --- /dev/null +++ b/extension/tests/database.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { IDBKeyRange as FakeIDBKeyRange, IDBFactory } from "fake-indexeddb"; +import { + openRecorderDatabase, + RECORDER_DATABASE, + RECORDER_DATABASE_VERSION, + requestToPromise, + STORES, + tx, +} from "../src/shared/database"; + +// Installed per test rather than by "fake-indexeddb/auto": that import sets the globals once at module +// load, and another file's teardown in the same `bun test` process can delete them afterwards. +type IndexedDbGlobals = { + indexedDB?: IDBFactory; + IDBKeyRange?: typeof FakeIDBKeyRange; +}; +const globals = globalThis as unknown as IndexedDbGlobals; +let saved: IndexedDbGlobals = {}; + +beforeEach(() => { + saved = { indexedDB: globals.indexedDB, IDBKeyRange: globals.IDBKeyRange }; + globals.indexedDB = new IDBFactory(); + globals.IDBKeyRange = FakeIDBKeyRange; +}); + +afterEach(() => { + for (const key of ["indexedDB", "IDBKeyRange"] as const) { + if (saved[key] === undefined) Reflect.deleteProperty(globals, key); + else Object.assign(globals, { [key]: saved[key] }); + } +}); + +let counter = 0; +const freshName = () => `recorder-test-${Date.now()}-${counter++}`; + +const keyPathOf = (db: IDBDatabase, store: string) => + db.transaction(store, "readonly").objectStore(store).keyPath; + +describe("recorder database", () => { + test("declares the default name and version 2", () => { + expect(RECORDER_DATABASE).toBe("openbot-recorder"); + expect(RECORDER_DATABASE_VERSION).toBe(2); + }); + + test("a version 1 database an old build left drops its other stores, meta keys and demonstration trajectories, and keeps the outbox", async () => { + const name = freshName(); + await new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => { + const db = request.result; + db.createObjectStore("events", { keyPath: ["trajectoryId", "seq"] }); + const meta = db.createObjectStore("meta"); + db.createObjectStore("media", { keyPath: ["mediaId", "index"] }); + db.createObjectStore("voice", { + keyPath: ["demonstrationId", "index"], + }); + db.createObjectStore("steps", { + keyPath: ["demonstrationId", "index"], + }); + db.createObjectStore("bodies", { keyPath: ["mediaId", "line"] }); + meta.put(7, "seq"); + meta.put({ recordingId: "rec-1", title: "T" }, "trajectory:t-new"); + meta.put({ demonstrationId: "demo-1", title: "T" }, "trajectory:t-old"); + meta.put("media-1", "media:demo-1:screen"); + meta.put(3, "bodies-next:media-1"); + meta.put({ chunks: 1, offset: 0 }, "bodies-progress:media-1"); + meta.put(2, "steps-next:demo-1"); + const events = request.transaction?.objectStore("events"); + events?.put({ trajectoryId: "t-new", seq: 1 }); + events?.put({ trajectoryId: "t-old", seq: 2 }); + events?.put({ trajectoryId: "t-old", seq: 3 }); + }; + request.onsuccess = () => { + request.result.close(); + resolve(); + }; + request.onerror = () => reject(request.error); + }); + const db = await openRecorderDatabase(name); + expect(db.version).toBe(2); + expect([...db.objectStoreNames].sort()).toEqual(["events", "meta"]); + const metaKeys = await tx(db, ["meta"], "readonly", (t) => + requestToPromise(t.objectStore("meta").getAllKeys()), + ); + expect(metaKeys.map(String).sort()).toEqual(["seq", "trajectory:t-new"]); + const eventKeys = await tx(db, ["events"], "readonly", (t) => + requestToPromise(t.objectStore("events").getAllKeys()), + ); + expect(eventKeys).toEqual([["t-new", 1]]); + db.close(); + }); + + test("creates exactly the outbox's two stores with the documented key paths", async () => { + const db = await openRecorderDatabase(freshName()); + expect([...db.objectStoreNames].sort()).toEqual(["events", "meta"]); + expect([...STORES].sort()).toEqual(["events", "meta"]); + expect(keyPathOf(db, "events")).toEqual(["trajectoryId", "seq"]); + expect(keyPathOf(db, "meta")).toBeNull(); + db.close(); + }); + + test("opening twice upgrades once", async () => { + const name = freshName(); + const first = await openRecorderDatabase(name); + await tx(first, ["meta"], "readwrite", (t) => + requestToPromise(t.objectStore("meta").put("kept", "marker")), + ); + const second = await openRecorderDatabase(name); + expect(second.version).toBe(2); + const value = await tx(second, ["meta"], "readonly", (t) => + requestToPromise(t.objectStore("meta").get("marker")), + ); + expect(value).toBe("kept"); + first.close(); + second.close(); + }); + + test("tx resolves the work's value and rejects when the work throws", async () => { + const db = await openRecorderDatabase(freshName()); + const value = await tx(db, ["meta"], "readwrite", async (t) => { + await requestToPromise(t.objectStore("meta").put(7, "n")); + return requestToPromise(t.objectStore("meta").get("n")); + }); + expect(value).toBe(7); + await expect( + tx(db, ["meta"], "readwrite", () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + db.close(); + }); + + test("tx waits for work that awaits something other than IndexedDB", async () => { + const db = await openRecorderDatabase(freshName()); + const value = await tx(db, ["meta"], "readwrite", async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return "v"; + }); + expect(value).toBe("v"); + db.close(); + }); + + test("tx rejects when work touches the transaction after it has finished", async () => { + const db = await openRecorderDatabase(freshName()); + await expect( + tx(db, ["meta"], "readwrite", async (t) => { + await new Promise((resolve) => setTimeout(resolve, 20)); + t.objectStore("meta").put("late", "k"); + }), + ).rejects.toThrow(); + const stored = await tx(db, ["meta"], "readonly", (t) => + requestToPromise(t.objectStore("meta").get("k")), + ); + expect(stored).toBeUndefined(); + db.close(); + }); + + test("tx rejects with the failing request's error when work does not await it", async () => { + const db = await openRecorderDatabase(freshName()); + await tx(db, ["events"], "readwrite", (t) => + requestToPromise( + t.objectStore("events").add({ trajectoryId: "t", seq: 0 }), + ), + ); + const error = await tx(db, ["events"], "readwrite", (t) => { + t.objectStore("events").add({ trajectoryId: "t", seq: 0 }); + return "ok"; + }).then( + () => undefined, + (e: unknown) => e, + ); + expect((error as Error | undefined)?.name).toBe("ConstraintError"); + db.close(); + }); + + test("tx resolves when the caller handled a request error", async () => { + const db = await openRecorderDatabase(freshName()); + await tx(db, ["events"], "readwrite", (t) => { + t.objectStore("events").add({ trajectoryId: "t", seq: 0 }); + }); + await tx(db, ["events"], "readwrite", (t) => { + const duplicate = t + .objectStore("events") + .add({ trajectoryId: "t", seq: 0 }); + duplicate.onerror = (e) => e.preventDefault(); + t.objectStore("events").put({ trajectoryId: "t", seq: 1 }); + }); + const count = await tx(db, ["events"], "readonly", (t) => + requestToPromise(t.objectStore("events").count()), + ); + expect(count).toBe(2); + db.close(); + }); +}); diff --git a/extension/tests/debugger-capture.test.ts b/extension/tests/debugger-capture.test.ts new file mode 100644 index 000000000..c6a943964 --- /dev/null +++ b/extension/tests/debugger-capture.test.ts @@ -0,0 +1,1277 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + afterEach, + beforeEach, + expect, + type Mock, + spyOn, + test, +} from "bun:test"; +import { + type CapturedEvent, + normaliseSecret, + sha256HexSync, +} from "@openbot/shared/recorder-masking"; +import { + createNetworkCapture, + type NetworkCapture, + type NetworkMode, +} from "../src/background/network"; +import type { AgentObservation } from "../src/background/agent/observation"; +import type { PageFallback } from "../src/background/network/fallback"; +import type { StepCall } from "../src/background/steps"; +import { NETWORK_BODY_READ_MAX_BYTES } from "../src/shared/network-body"; +import { type ChromeFake, installChromeFake } from "./support/chrome-fake"; +import { + CARD_AND_PIN_CLOCK, + CODE_CLOCK, + leakedSecrets, + recordedText, +} from "./support/recorded-text"; + +const EXTENSION_ID = "test"; +const OPENBOT = "https://openbot.example.test"; +const MiB = 1024 * 1024; + +/** A write's exchange as the steps get it (steps.ts StepCall), seen as the request a step would carry. */ +type Value = { + method: string; + url: string; + status: number | null; + request: { body: Record }; + response: { body: Record }; +}; + +let fake: ChromeFake; +let capture: NetworkCapture; +/** Every call the capture handed to the steps, reads included. */ +let calls: StepCall[] = []; +/** The writes among them, each as the request a step would carry. */ +let emitted: CapturedEvent[] = []; +/** The writes the capture said were in flight, and the ones it said ended. */ +let started: string[] = []; +let ended: string[] = []; +let statuses: [NetworkMode | "stopped", string | undefined][] = []; +/** What the capture handed the agent events. */ +let agentSeen: AgentObservation[] = []; +let digests = new Set(); +let fallbackCalls: string[] = []; +let fallbackStarts = true; +/** Response bodies by request id, as Network.getResponseBody answers them. */ +let bodies: Record = {}; +let postData: Record = {}; +let fetches = 0; +const realFetch = globalThis.fetch; +/** Date.now(), held at a reading that holds the tests' secrets' digits (support/recorded-text.ts says why). */ +let clock: Mock; + +const fallback: PageFallback = { + start: async (tabIds) => { + fallbackCalls.push(`start ${tabIds.join(",")}`); + return fallbackStarts; + }, + addTab: async (tabId) => { + fallbackCalls.push(`addTab ${tabId}`); + }, + onCommitted: async (tabId, frameId) => { + fallbackCalls.push(`onCommitted ${tabId} ${frameId}`); + }, + stop: async () => { + fallbackCalls.push("stop"); + }, + active: () => false, +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const settle = () => sleep(20); +// performance.now(), not Date.now(): the wall clock is pinned in these tests. +async function until(check: () => boolean, label: string) { + const deadline = performance.now() + 3_000; + while (!check()) { + if (performance.now() > deadline) + throw new Error(`Timed out waiting: ${label}`); + await sleep(2); + } +} +const value = (event: CapturedEvent | undefined) => + (event?.value ?? {}) as unknown as Value; +const commandsTo = (method: string) => + fake.debugger.commands.filter((command) => command.method === method); + +beforeEach(() => { + clock = spyOn(Date, "now").mockReturnValue(CARD_AND_PIN_CLOCK); + fake = installChromeFake({ extensionId: EXTENSION_ID }); + calls = []; + emitted = []; + started = []; + ended = []; + statuses = []; + agentSeen = []; + digests = new Set(); + fallbackCalls = []; + fallbackStarts = true; + bodies = {}; + postData = {}; + fetches = 0; + globalThis.fetch = Object.assign( + (input: string | URL | Request, init?: RequestInit) => { + fetches += 1; + return realFetch(input, init); + }, + { preconnect: realFetch.preconnect }, + ); + fake.debugger.respond("Network.getResponseBody", (params) => { + const answer = bodies[(params as { requestId: string }).requestId]; + if (!answer) throw new Error("No resource with given identifier found"); + return answer; + }); + fake.debugger.respond("Network.getRequestPostData", (params) => ({ + postData: postData[(params as { requestId: string }).requestId] ?? "", + })); + capture = createNetworkCapture(captureDeps(), { fallback }); +}); + +function captureDeps() { + return { + openbotUrl: () => OPENBOT, + call: (call: StepCall) => { + calls.push(call); + if (call.exchange) + emitted.push({ + type: "CUSTOM", + name: "network", + timestamp: call.endedAt, + value: { + method: call.method, + url: call.exchange.url, + status: call.exchange.status, + request: { body: call.exchange.body }, + response: { body: call.exchange.response }, + }, + }); + }, + writeStarted: (id: string) => started.push(id), + writeEnded: (id: string) => ended.push(id), + agent: (observation: AgentObservation) => agentSeen.push(observation), + status: (mode: NetworkMode | "stopped", reason?: string) => + statuses.push([mode, reason]), + digests: () => digests, + }; +} + +afterEach(async () => { + await capture.stop(); + globalThis.fetch = realFetch; + fake.uninstall(); + clock.mockRestore(); +}); + +type RequestSpec = { + id: string; + url: string; + method?: string; + type?: string; + headers?: Record; + postData?: string; + hasPostData?: boolean; + status?: number; + responseHeaders?: Record; + mimeType?: string; + body?: string; + base64Encoded?: boolean; + failed?: { canceled: boolean }; + sessionId?: string; + frameId?: string; + /** Seconds since the epoch the request was sent (CDP's wallTime); SENT unless a test says otherwise. */ + wallTime?: number; + /** How long it ran, in seconds (0.25 unless a test says otherwise). */ + duration?: number; +}; + +/** When the requests below are sent, unless a test says otherwise (CDP's wallTime, in seconds). */ +const SENT = Math.floor(Date.now() / 1000); + +/** A request as Chrome reports it: sent, answered, then finished (or failed). A POST unless the spec names a method: a + * write's bodies are read, a read's never are. */ +function request(tabId: number, spec: RequestSpec) { + const target = spec.sessionId + ? { tabId, sessionId: spec.sessionId } + : { tabId }; + const type = spec.type ?? "Fetch"; + const wallTime = spec.wallTime ?? SENT; + // CDP's monotonic clock, in step with the wall clock. + const sentAt = 100 + (wallTime - SENT); + const endAt = sentAt + (spec.duration ?? 0.25); + if (spec.body !== undefined) + bodies[spec.id] = { + body: spec.body, + base64Encoded: spec.base64Encoded ?? false, + }; + fake.debugger.emit(target, "Network.requestWillBeSent", { + requestId: spec.id, + type, + timestamp: sentAt, + wallTime, + ...(spec.frameId !== undefined ? { frameId: spec.frameId } : {}), + request: { + url: spec.url, + method: spec.method ?? "POST", + headers: spec.headers ?? {}, + ...(spec.postData !== undefined ? { postData: spec.postData } : {}), + ...(spec.hasPostData ? { hasPostData: true } : {}), + }, + }); + if (spec.failed) { + fake.debugger.emit(target, "Network.loadingFailed", { + requestId: spec.id, + timestamp: endAt, + type, + errorText: "net::ERR_FAILED", + canceled: spec.failed.canceled, + }); + return; + } + fake.debugger.emit(target, "Network.responseReceived", { + requestId: spec.id, + type, + timestamp: endAt, + response: { + url: spec.url, + status: spec.status ?? 200, + headers: spec.responseHeaders ?? { + "content-type": spec.mimeType ?? "application/json", + }, + mimeType: spec.mimeType ?? "application/json", + }, + }); + fake.debugger.emit(target, "Network.loadingFinished", { + requestId: spec.id, + timestamp: endAt, + }); +} + +// ---- Requests -------------------------------------------------------------------------------- + +test("a write reaches the steps masked, its headers left out and its credentials removed before it leaves", async () => { + expect(await capture.start(7, "https://crm.example.test/deals")).toBe( + "debugger", + ); + request(7, { + id: "1", + url: "https://api.example.test/v1/deals/42?token=abc", + method: "PUT", + headers: { Authorization: "Bearer s", "Content-Type": "application/json" }, + postData: '{"stage":"won","password":"x"}', + responseHeaders: { + "content-type": "application/json", + "set-cookie": "a=b", + }, + body: '{"ok":true,"token":"t"}', + }); + await until(() => emitted.length === 1, "the write"); + const [call] = calls; + expect(call).toMatchObject({ + tabId: 7, + method: "PUT", + url: "https://api.example.test/v1/deals/42?token=[redacted]", + startedAt: SENT * 1000, + respondedAt: SENT * 1000 + 250, + endedAt: SENT * 1000 + 250, + }); + expect(value(emitted[0])).toMatchObject({ + method: "PUT", + url: "https://api.example.test/v1/deals/42?token=[redacted]", + status: 200, + }); + expect(JSON.parse(value(emitted[0]).request.body.text as string)).toEqual({ + stage: "won", + password: "[redacted]", + }); + expect(JSON.parse(value(emitted[0]).response.body.text as string)).toEqual({ + ok: true, + token: "[redacted]", + }); + // A step carries a save's address, status and bodies: never a header. + expect(Object.keys(calls[0]?.exchange ?? {}).sort()).toEqual([ + "body", + "response", + "status", + "url", + ]); + expect(leakedSecrets(emitted, ["Bearer s", "a=b", "Authorization"])).toEqual( + [], + ); + expect(statuses).toEqual([["debugger", undefined]]); +}); + +test("a read reaches the steps with when it ran and nothing of its bodies, which are never read", async () => { + await capture.start(7, "https://crm.example.test/"); + request(7, { + id: "1", + url: "https://crm.example.test/api/deals?token=abc", + method: "GET", + body: "[]", + }); + request(7, { + id: "2", + url: "https://crm.example.test/api/me", + method: "GET", + type: "XHR", + body: "{}", + }); + await until(() => calls.length === 2, "two reads"); + expect(calls).toEqual([ + { + tabId: 7, + method: "GET", + url: "https://crm.example.test/api/deals?token=[redacted]", + startedAt: SENT * 1000, + respondedAt: SENT * 1000 + 250, + endedAt: SENT * 1000 + 250, + }, + { + tabId: 7, + method: "GET", + url: "https://crm.example.test/api/me", + startedAt: SENT * 1000, + respondedAt: SENT * 1000 + 250, + endedAt: SENT * 1000 + 250, + }, + ]); + expect(commandsTo("Network.getResponseBody")).toEqual([]); + expect(started).toEqual([]); +}); + +test("images, scripts, stylesheets, fonts and a document request outside the top frame are not recorded", async () => { + await capture.start(7, "https://crm.example.test/"); + for (const [id, type] of [ + ["1", "Image"], + ["2", "Script"], + ["3", "Stylesheet"], + ["4", "Document"], + ["5", "Font"], + ] as const) + request(7, { + id, + url: `https://cdn.example.test/${id}`, + type, + mimeType: "text/plain", + body: "x", + }); + request(7, { id: "6", url: "https://crm.example.test/api", body: "{}" }); + await until(() => emitted.length === 1, "the fetch"); + await settle(); + expect(emitted.map((event) => value(event).url)).toEqual([ + "https://crm.example.test/api", + ]); + expect(commandsTo("Network.getResponseBody")).toHaveLength(1); +}); + +test("calls to OpenBot itself are ignored, by the steps and by the agent events alike", async () => { + await capture.start(7, "https://crm.example.test/"); + // The Recorder's own capture pass and connect code, had a tab made them: a pass answers join credentials as an + // Intelligence run does, and neither is ever followed. + request(7, { + id: "1", + url: `${OPENBOT}/api/copilotkit/trajectory/x/connect`, + method: "POST", + postData: "{}", + body: '{"joinToken":"join-token-1","realtime":{"clientUrl":"wss://intelligence.example.test/client","topic":"trajectory:x"}}', + }); + request(7, { + id: "3", + url: `${OPENBOT}/api/recorder/connect-codes`, + method: "POST", + postData: "{}", + body: '{"code":"obc_live_connect_code"}', + }); + request(7, { id: "2", url: "https://crm.example.test/api", body: "{}" }); + await until(() => emitted.length === 1, "one event"); + await settle(); + expect(value(emitted[0]).url).toBe("https://crm.example.test/api"); + expect(agentSeen).toEqual([]); +}); + +test("a value typed into a sensitive field is scrubbed from a request body, a URL and a response", async () => { + digests.add(sha256HexSync(normaliseSecret("4111111111111111"))); + digests.add(sha256HexSync(normaliseSecret("hunter-two"))); + await capture.start(7, "https://shop.example.test/"); + request(7, { + id: "1", + url: "https://shop.example.test/api/pay?code=hunter-two", + method: "POST", + headers: { "content-type": "text/plain" }, + postData: "card 4111 1111 1111 1111 please", + body: '{"echo":"hunter-two"}', + }); + await until(() => emitted.length === 1, "the event"); + const text = recordedText(emitted[0]); + expect(text).not.toContain("4111"); + expect(text).not.toContain("hunter-two"); + expect(text).toContain("[redacted]"); +}); + +test("post data that is not inline is fetched", async () => { + await capture.start(7, "https://crm.example.test/"); + postData["1"] = '{"note":"long"}'; + request(7, { + id: "1", + url: "https://crm.example.test/api/notes", + method: "POST", + headers: { "content-type": "application/json" }, + hasPostData: true, + body: "{}", + }); + await until(() => emitted.length === 1, "the event"); + expect(commandsTo("Network.getRequestPostData")).toEqual([ + { + target: { tabId: 7 }, + method: "Network.getRequestPostData", + params: { requestId: "1" }, + }, + ]); + expect(value(emitted[0]).request.body).toEqual({ + status: "complete", + text: '{"note":"long"}', + encoding: "utf-8", + }); +}); + +test("a binary response is unavailable, and its body is never asked for", async () => { + await capture.start(7, "https://crm.example.test/"); + request(7, { + id: "1", + url: "https://crm.example.test/api/export", + mimeType: "application/pdf", + body: "JVBERi0=", + base64Encoded: true, + }); + await until(() => emitted.length === 1, "the event"); + expect(value(emitted[0]).response.body).toEqual({ + status: "unavailable", + reason: "binary", + }); + expect(commandsTo("Network.getResponseBody")).toEqual([]); +}); + +test("a text response Chrome hands over as base64 is decoded", async () => { + await capture.start(7, "https://crm.example.test/"); + request(7, { + id: "1", + url: "https://crm.example.test/api/hello", + mimeType: "text/plain", + body: btoa("hello"), + base64Encoded: true, + }); + await until(() => emitted.length === 1, "the event"); + expect(value(emitted[0]).response.body.text).toBe("hello"); +}); + +test("a failed request is an error with no status; a cancelled one is not recorded at all", async () => { + await capture.start(7, "https://crm.example.test/"); + request(7, { + id: "1", + url: "https://crm.example.test/api/a", + failed: { canceled: false }, + }); + request(7, { + id: "2", + url: "https://crm.example.test/api/b", + failed: { canceled: true }, + }); + await until(() => emitted.length === 1, "the failed call"); + await settle(); + expect( + emitted.map((event) => [value(event).url, value(event).status]), + ).toEqual([["https://crm.example.test/api/a", null]]); + expect(value(emitted[0]).response.body).toEqual({ + status: "unavailable", + reason: "no-response", + }); +}); + +test("a large body reaches the steps whole, for the step to cut to fit, and is never sent or stored anywhere else", async () => { + await capture.start(7, "https://crm.example.test/"); + request(7, { + id: "1", + url: "https://crm.example.test/api/rows", + body: JSON.stringify({ rows: "x".repeat(200 * 1024) }), + }); + await until(() => emitted.length === 1, "the write"); + expect(value(emitted[0]).response.body).toMatchObject({ + status: "complete", + }); + expect(String(value(emitted[0]).response.body.text).length).toBeGreaterThan( + 200 * 1024, + ); + expect(fetches).toBe(0); +}); + +test("a body over 2 MiB is read to its first 2 MiB only, and the event says how large it was", async () => { + expect(NETWORK_BODY_READ_MAX_BYTES).toBe(2 * MiB); + await capture.start(7, "https://crm.example.test/"); + request(7, { + id: "1", + url: "https://crm.example.test/api/dump", + mimeType: "text/plain", + body: "a".repeat(3 * MiB), + }); + await until(() => emitted.length === 1, "the event"); + expect(value(emitted[0]).response.body).toMatchObject({ + status: "truncated", + bytes: 3 * MiB, + }); +}); + +// ---- Tabs, frames and the debugger bar ------------------------------------------------------- + +test("cross-site iframes are captured through their own session", async () => { + await capture.start(7, "https://crm.example.test/"); + expect(fake.debugger.attached).toEqual([{ tabId: 7 }]); + expect(commandsTo("Network.enable")).toEqual([ + { + target: { tabId: 7 }, + method: "Network.enable", + params: { maxPostDataSize: 65536 }, + }, + ]); + expect(commandsTo("Target.setAutoAttach")).toEqual([ + { + target: { tabId: 7 }, + method: "Target.setAutoAttach", + params: { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + }, + }, + ]); + fake.debugger.emit({ tabId: 7 }, "Target.attachedToTarget", { + sessionId: "frame-1", + targetInfo: { type: "iframe", url: "https://widgets.example.test/" }, + waitingForDebugger: false, + }); + fake.debugger.emit({ tabId: 7 }, "Target.attachedToTarget", { + sessionId: "worker-1", + targetInfo: { + type: "service_worker", + url: "https://crm.example.test/sw.js", + }, + waitingForDebugger: false, + }); + await until( + () => commandsTo("Network.enable").length === 2, + "the frame's Network.enable", + ); + await settle(); + expect(commandsTo("Network.enable")[1]?.target as unknown).toEqual({ + tabId: 7, + sessionId: "frame-1", + }); + expect(commandsTo("Target.setAutoAttach")[1]?.target as unknown).toEqual({ + tabId: 7, + sessionId: "frame-1", + }); + request(7, { + id: "f1", + sessionId: "frame-1", + url: "https://widgets.example.test/api", + body: '{"a":1}', + }); + await until(() => emitted.length === 1, "the frame's call"); + expect(value(emitted[0]).url).toBe("https://widgets.example.test/api"); + expect( + commandsTo("Network.getResponseBody").at(-1)?.target as unknown, + ).toEqual({ + tabId: 7, + sessionId: "frame-1", + }); +}); + +test("a detach by the person stops network capture without falling back", async () => { + await capture.start(7, "https://crm.example.test/"); + await capture.addTab(8, "https://crm.example.test/report"); + fake.debugger.detach({ tabId: 7 }, "canceled_by_user"); + await settle(); + expect(statuses.at(-1)).toEqual([ + "stopped", + "You closed Chrome's debugging bar, so what each step saves is no longer recorded.", + ]); + expect(fallbackCalls).toEqual([]); + expect(fake.debugger.attached).toEqual([]); + // Nothing attaches again for the rest of the recording. + await capture.addTab(9, "https://crm.example.test/x"); + await capture.onCommitted(7, 0, "https://crm.example.test/y"); + expect(fake.debugger.attached).toEqual([]); + request(8, { id: "late", url: "https://crm.example.test/api", body: "{}" }); + await settle(); + expect(emitted).toEqual([]); +}); + +test("a closed tab is dropped and the other followed tabs keep capturing", async () => { + await capture.start(7, "https://crm.example.test/"); + await capture.addTab(8, "https://crm.example.test/report"); + expect(fake.debugger.attached).toEqual([{ tabId: 7 }, { tabId: 8 }]); + fake.debugger.detach({ tabId: 8 }, "target_closed"); + await settle(); + request(7, { id: "1", url: "https://crm.example.test/api", body: "{}" }); + await until(() => emitted.length === 1, "tab 7's call"); + expect(statuses.map(([mode]) => mode)).not.toContain("stopped"); +}); + +test("a tab the recorded tab opened is attached the same way, and one not yet on a website waits for its first page", async () => { + await capture.start(7, "https://crm.example.test/"); + await capture.addTab(8, "https://crm.example.test/report"); + expect(fake.debugger.attached).toEqual([{ tabId: 7 }, { tabId: 8 }]); + await capture.addTab(9, "about:blank"); + expect(fake.debugger.attached).toEqual([{ tabId: 7 }, { tabId: 8 }]); + await capture.onCommitted(9, 2, "https://crm.example.test/frame"); + expect(fake.debugger.attached).toHaveLength(2); + await capture.onCommitted(9, 0, "https://crm.example.test/opened"); + expect(fake.debugger.attached).toEqual([ + { tabId: 7 }, + { tabId: 8 }, + { tabId: 9 }, + ]); +}); + +test("a recording started on the New Tab page attaches once the tab opens a website", async () => { + expect(await capture.start(7, "chrome://newtab/")).toBe("waiting"); + expect(statuses).toEqual([ + [ + "waiting", + "What each step saves is recorded once this tab opens a website.", + ], + ]); + expect(fake.debugger.attached).toEqual([]); + await capture.onCommitted(7, 0, "https://crm.example.test/"); + expect(fake.debugger.attached).toEqual([{ tabId: 7 }]); + expect(statuses.at(-1)).toEqual(["debugger", undefined]); +}); + +test("a refused attach on a real site falls back to page capture", async () => { + fake.debugger.refuseAttach = "Cannot attach to this target."; + expect(await capture.start(7, "https://crm.example.test/")).toBe("page"); + expect(fallbackCalls).toEqual(["start 7"]); + expect(statuses.at(-1)).toEqual([ + "page", + "Chrome refused its debugger here.", + ]); +}); + +test("a refused attach with no page capture either is off, with Chrome's reason", async () => { + fake.debugger.refuseAttach = "Cannot attach to this target."; + fallbackStarts = false; + expect(await capture.start(7, "https://crm.example.test/")).toBe("off"); + expect(statuses.at(-1)).toEqual([ + "off", + "Chrome would not let the Recorder see what each step saves here (Cannot attach to this target.).", + ]); +}); + +test("in page mode the capture forwards new tabs and commits to the fallback it owns, and stops it", async () => { + fake.debugger.refuseAttach = "Cannot attach to this target."; + await capture.start(7, "https://crm.example.test/"); + await capture.addTab(8, "https://crm.example.test/report"); + await capture.onCommitted(7, 3, "https://widgets.example.test/"); + await capture.stop(); + expect(fallbackCalls).toEqual([ + "start 7", + "addTab 8", + "onCommitted 7 3", + "stop", + ]); +}); + +test("a resumed capture re-attaches to every followed tab, detaching a stale session of its own first", async () => { + fake.tabs.set(7, { + id: 7, + url: "https://crm.example.test/", + } as chrome.tabs.Tab); + fake.tabs.set(8, { + id: 8, + url: "https://crm.example.test/b", + } as chrome.tabs.Tab); + await chrome.debugger.attach({ tabId: 7 }, "1.3"); // left over from the worker that went away + fake.calls.length = 0; + const mode = await capture.resume( + [ + { tabId: 7, url: "https://crm.example.test/" }, + { tabId: 8, url: "https://crm.example.test/b" }, + { tabId: 9, url: "chrome://settings/" }, + ], + "debugger", + ); + expect(mode).toBe("debugger"); + expect( + fake.calls.filter((call) => + /^debugger\.(attach|detach|sendCommand|getTargets)$/.test(call), + ), + ).toEqual([ + "debugger.getTargets", + "debugger.detach", + "debugger.attach", + "debugger.sendCommand", + "debugger.sendCommand", + "debugger.attach", + "debugger.sendCommand", + "debugger.sendCommand", + ]); + expect(fake.debugger.attached).toEqual([{ tabId: 7 }, { tabId: 8 }]); + expect(commandsTo("Network.enable").map((command) => command.target)).toEqual( + [{ tabId: 7 }, { tabId: 8 }], + ); + await capture.onCommitted(9, 0, "https://crm.example.test/c"); + expect(fake.debugger.attached).toHaveLength(3); +}); + +test("a resumed capture the person had stopped stays stopped, and one in page mode resumes the page capture", async () => { + expect( + await capture.resume( + [{ tabId: 7, url: "https://crm.example.test/" }], + "stopped", + ), + ).toBe("stopped"); + expect(fake.debugger.attached).toEqual([]); + await capture.stop(); + capture = createNetworkCapture(captureDeps(), { fallback }); + fallbackCalls = []; + expect( + await capture.resume( + [ + { tabId: 7, url: "https://crm.example.test/" }, + { tabId: 8, url: "https://crm.example.test/b" }, + ], + "page", + ), + ).toBe("page"); + expect(fallbackCalls).toEqual(["start 7,8"]); + expect(fake.debugger.attached).toEqual([]); +}); + +test("stop detaches every tab, and nothing in flight is emitted after it", async () => { + await capture.start(7, "https://crm.example.test/"); + await capture.addTab(8, "https://crm.example.test/b"); + fake.debugger.emit({ tabId: 7 }, "Network.requestWillBeSent", { + requestId: "1", + type: "Fetch", + timestamp: 1, + request: { + url: "https://crm.example.test/api", + method: "GET", + headers: {}, + }, + }); + await capture.stop(); + expect(fake.debugger.attached).toEqual([]); + fake.debugger.emit({ tabId: 7 }, "Network.loadingFinished", { + requestId: "1", + timestamp: 2, + }); + await settle(); + expect(emitted).toEqual([]); +}); + +test("a page-level write reaches the steps with its exchange, a read with when it ran", async () => { + await capture.start(7, "https://crm.example.test/"); + const now = Date.now(); + const pageCall = (method: string, url: string): CapturedEvent => ({ + type: "CUSTOM", + name: "network", + timestamp: now, + value: { + transport: "fetch", + method, + url, + status: 200, + outcome: "ok", + completedAt: now, + durationMs: 20, + capturedBy: "page", + request: { + headers: { "content-type": "application/json" }, + body: { status: "complete", text: '{"name":"Acme"}' }, + }, + response: { + headers: { "content-type": "application/json" }, + body: { status: "complete", text: '{"id":1}' }, + }, + }, + }); + await capture.recordPageEvent( + 7, + pageCall("POST", "https://crm.example.test/api/companies"), + ); + await capture.recordPageEvent( + 7, + pageCall("GET", "https://crm.example.test/api/companies/1"), + ); + expect(calls).toEqual([ + { + tabId: 7, + startedAt: now - 20, + endedAt: now, + method: "POST", + url: "https://crm.example.test/api/companies", + exchange: { + url: "https://crm.example.test/api/companies", + status: 200, + body: { status: "complete", text: '{"name":"Acme"}' }, + response: { status: "complete", text: '{"id":1}' }, + }, + }, + { + tabId: 7, + startedAt: now - 20, + endedAt: now, + method: "GET", + url: "https://crm.example.test/api/companies/1", + }, + ]); +}); + +test("a page-level event for a call to OpenBot itself is dropped, like the debugger drops it", async () => { + await capture.start(7, "https://crm.example.test/"); + const now = Date.now(); + const pageEvent = (url: string, responseUrl = url): CapturedEvent => ({ + type: "CUSTOM", + name: "network", + timestamp: now, + value: { + method: "POST", + url, + status: 200, + completedAt: now, + durationMs: 20, + capturedBy: "page", + response: { + url: responseUrl, + headers: { "content-type": "application/json" }, + body: { status: "complete", text: '{"code":"obc_live_connect_code"}' }, + }, + }, + }); + await capture.recordPageEvent( + 7, + pageEvent(`${OPENBOT}/api/recorder/connect-codes`), + ); + // A call that a redirect landed on OpenBot. + await capture.recordPageEvent( + 7, + pageEvent("https://crm.example.test/go", `${OPENBOT}/api/recorder/me`), + ); + await capture.recordPageEvent(7, pageEvent("https://crm.example.test/api")); + expect(calls.map((call) => call.url)).toEqual([ + "https://crm.example.test/api", + ]); +}); + +// ---- The screens: what never reaches the steps ------------------------------------------------- + +test("telemetry, a beacon, a prefetch, a cancelled call, a repeat, an iframe's document and images never reach the steps", async () => { + fake.tabs.set(7, { + id: 7, + url: "https://crm.example.test/deals/42", + } as chrome.tabs.Tab); + await capture.start(7, "https://crm.example.test/deals/42"); + request(7, { + id: "poll-1", + url: "https://crm.example.test/api/unread", + method: "GET", + body: "{}", + wallTime: SENT - 10, + }); + request(7, { + id: "note", + url: "https://crm.example.test/api/deals/42/notes", + headers: { "content-type": "application/json" }, + postData: '{"text":"Call back Tuesday"}', + body: '{"id":7}', + }); + request(7, { + id: "poll-2", + url: "https://crm.example.test/api/unread", + method: "GET", + body: "{}", + }); + request(7, { + id: "ga", + url: "https://www.google-analytics.com/g/collect?v=2&en=click", + }); + request(7, { + id: "beacon", + url: "https://crm.example.test/api/events", + type: "Ping", + }); + request(7, { + id: "prefetch", + url: "https://crm.example.test/deals/43", + method: "GET", + headers: { "next-router-prefetch": "1", rsc: "1" }, + body: "{}", + }); + request(7, { + id: "cancelled", + url: "https://crm.example.test/api/search?q=a", + failed: { canceled: true }, + }); + request(7, { + id: "img", + url: "https://crm.example.test/logo.png", + method: "GET", + type: "Image", + mimeType: "image/png", + }); + request(7, { + id: "frame-doc", + url: "https://widgets.example.test/embed", + method: "GET", + type: "Document", + frameId: "frame-1", + sessionId: "frame-session", + mimeType: "text/html", + body: "", + }); + request(7, { + id: "frame-ad", + url: "https://securepubads.g.doubleclick.net/gampad/ads?iu=x", + sessionId: "frame-session", + }); + await until(() => calls.length === 2, "the two calls that pass"); + await settle(); + expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + "GET https://crm.example.test/api/unread", + "POST https://crm.example.test/api/deals/42/notes", + ]); + // Only the note's bodies were read: nothing screened out cost a body read. + expect( + commandsTo("Network.getResponseBody").map( + (command) => (command.params as { requestId: string }).requestId, + ), + ).toEqual(["note"]); +}); + +test("CopilotKit's own capture batch is never a step's save, through the debugger or the page, its body inline or read late", async () => { + // The batches end-to-end runs 1 and 2 attached to Send and to Approve as their save. + const [send, , approve] = ( + JSON.parse( + readFileSync( + join(import.meta.dir, "fixtures/e2e-learning-batches.json"), + "utf8", + ), + ) as { batches: { url: string; body: string; response: string }[] } + ).batches; + if (!send || !approve) throw new Error("the fixture's batches"); + const json = { "content-type": "application/json" }; + await capture.start(7, "http://127.0.0.1:7620/learning/deals/deal-1"); + request(7, { + id: "batch", + url: send.url, + headers: json, + postData: send.body, + body: send.response, + }); + // A batch past the 64 KiB Chrome sends inline, posted to a route the app chose: its body is read, then screened. + postData.late = approve.body; + request(7, { + id: "late", + url: "http://127.0.0.1:7620/api/copilot-capture", + headers: json, + hasPostData: true, + body: approve.response, + }); + request(7, { + id: "note", + url: "http://127.0.0.1:7620/api/deals/deal-1/notes", + headers: json, + postData: '{"text":"Approved without the agent"}', + body: '{"id":7}', + }); + await until( + () => calls.some((call) => call.url.endsWith("/notes")), + "the note", + ); + // The inline batch is screened as it starts, so no step ever waits for it; the one read late waits until it is read. + await until(() => ended.length === 2, "every write ended"); + await settle(); + expect([...started].sort()).toEqual(["7//late", "7//note"]); + // The page hook's copy of the same batch, masked as the page sends it. + const now = Date.now(); + await capture.recordPageEvent(7, { + type: "CUSTOM", + name: "network", + timestamp: now, + value: { + transport: "fetch", + method: "POST", + url: send.url, + status: 200, + outcome: "ok", + completedAt: now, + durationMs: 20, + capturedBy: "page", + request: { + headers: json, + body: { status: "complete", text: send.body }, + }, + response: { + headers: json, + body: { status: "complete", text: send.response }, + }, + }, + }); + expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + "POST http://127.0.0.1:7620/api/deals/deal-1/notes", + ]); + expect([...started].sort()).toEqual([...ended].sort()); +}); + +test("a write is in flight from its start until the steps have it; one the screens drop as it starts never is", async () => { + await capture.start(7, "https://crm.example.test/"); + fake.debugger.emit({ tabId: 7 }, "Network.requestWillBeSent", { + requestId: "w1", + type: "Fetch", + timestamp: 100, + wallTime: SENT, + request: { + url: "https://crm.example.test/api/save", + method: "POST", + headers: {}, + postData: "{}", + }, + }); + fake.debugger.emit({ tabId: 7 }, "Network.requestWillBeSent", { + requestId: "r1", + type: "Fetch", + timestamp: 100, + wallTime: SENT, + request: { + url: "https://crm.example.test/api/me", + method: "GET", + headers: {}, + }, + }); + expect(started).toEqual(["7//w1"]); + expect(ended).toEqual([]); + bodies.w1 = { body: "{}", base64Encoded: false }; + fake.debugger.emit({ tabId: 7 }, "Network.responseReceived", { + requestId: "w1", + type: "Fetch", + timestamp: 100.2, + response: { + url: "https://crm.example.test/api/save", + status: 200, + headers: { "content-type": "application/json" }, + mimeType: "application/json", + }, + }); + fake.debugger.emit({ tabId: 7 }, "Network.loadingFinished", { + requestId: "w1", + timestamp: 100.2, + }); + await until(() => ended.length === 1, "the write's end"); + // The steps had the write before they heard it ended. + expect(calls.map((call) => call.url)).toEqual([ + "https://crm.example.test/api/save", + ]); + request(7, { + id: "w2", + url: "https://www.google-analytics.com/g/collect", + }); + await settle(); + expect(started).toEqual(["7//w1"]); + expect(ended).toEqual(["7//w1"]); + expect(calls).toHaveLength(1); +}); + +test("a form the person posts in the top frame reaches the steps as a document request; an iframe's document never does", async () => { + fake.tabs.set(7, { + id: 7, + url: "https://crm.example.test/login", + } as chrome.tabs.Tab); + await capture.start(7, "https://crm.example.test/login"); + request(7, { + id: "post", + url: "https://crm.example.test/session", + method: "POST", + type: "Document", + frameId: "target-7", + headers: { "content-type": "application/x-www-form-urlencoded" }, + postData: "user=ada&password=hunter2", + mimeType: "text/html", + body: "Welcome", + }); + request(7, { + id: "frame", + url: "https://crm.example.test/embed", + method: "POST", + type: "Document", + frameId: "child-frame", + mimeType: "text/html", + body: "", + }); + await until(() => emitted.length === 1, "the form post"); + await settle(); + expect(value(emitted[0])).toMatchObject({ + method: "POST", + url: "https://crm.example.test/session", + }); + expect(value(emitted[0]).request.body.text).toBe( + "user=ada&password=[redacted]", + ); +}); + +test("in the e2e build a forced refusal makes start fall back as a refused attach would", async () => { + const previous = process.env.RECORDER_VARIANT; + process.env.RECORDER_VARIANT = "e2e"; + try { + fake.storage.local.set("RECORDER_E2E_FORCE_ATTACH_REFUSAL", true); + expect(await capture.start(7, "https://crm.example.test/")).toBe("page"); + expect(fake.debugger.attached).toEqual([]); + expect(fallbackCalls).toEqual(["start 7"]); + } finally { + if (previous === undefined) delete process.env.RECORDER_VARIANT; + else process.env.RECORDER_VARIANT = previous; + } +}); + +// ---- Credentials in form and oversized bodies -------------------------------------------------- + +// A realistic sign-in and payment. None of these may reach Intelligence. +const PASSWORD = "hunter2"; +const PIN = "4921"; +const CARD = "4111 1111 1111 1111"; +const SESSION_TOKEN = "sess_9f8e7d6c"; +const signInForm = new URLSearchParams({ + username: "alice", + password: PASSWORD, + pin: PIN, + card_number: CARD, + session_token: SESSION_TOKEN, +}).toString(); +const leaked = (events: CapturedEvent[]) => + leakedSecrets(events, [PASSWORD, PIN, "4111", SESSION_TOKEN]); + +test("a form post keeps none of its credentials, whether Chrome reports it as a form or as plain text", async () => { + await capture.start(7, "https://bank.example.test/"); + request(7, { + id: "1", + url: "https://bank.example.test/session", + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + }, + postData: signInForm, + body: "{}", + }); + request(7, { + id: "2", + url: "https://bank.example.test/session", + method: "POST", + headers: { "Content-Type": "text/plain;charset=UTF-8" }, + postData: signInForm, + body: "{}", + }); + await until(() => emitted.length === 2, "two events"); + for (const event of emitted) + expect(value(event).request.body.text).toBe( + "username=alice&password=[redacted]&pin=[redacted]&card_number=[redacted]&session_token=[redacted]", + ); + expect(leaked(emitted)).toEqual([]); +}); + +test("a JSON body over 2 MiB, sent or received, keeps none of the credentials in the part that is read", async () => { + const big = JSON.stringify({ + user: { name: "alice", password: PASSWORD }, + session_token: SESSION_TOKEN, + payment: { pin: PIN, card_number: CARD }, + rows: "x".repeat(3 * MiB), + }); + await capture.start(7, "https://bank.example.test/"); + postData["1"] = big; + request(7, { + id: "1", + url: "https://bank.example.test/api/sync", + method: "POST", + headers: { "Content-Type": "application/json" }, + hasPostData: true, + body: big, + }); + request(7, { id: "2", url: "https://bank.example.test/api/me", body: big }); + await until(() => emitted.length === 2, "two events"); + const [sent, received] = emitted; + expect(value(sent).request.body.status).toBe("truncated"); + expect(value(sent).response.body.status).toBe("truncated"); + // Trimming empties the response first, so the request's opening is what is left to read. + expect(value(sent).request.body.text as string).toContain( + '"password":"[redacted]"', + ); + expect(value(received).response.body.text as string).toContain( + '"password":"[redacted]"', + ); + expect(leaked(emitted)).toEqual([]); +}); + +test("a form body over 2 MiB sent as plain text keeps none of its credentials", async () => { + await capture.start(7, "https://bank.example.test/"); + postData["1"] = `${signInForm}¬es=${"n".repeat(3 * MiB)}`; + request(7, { + id: "1", + url: "https://bank.example.test/session", + method: "POST", + headers: { "Content-Type": "text/plain;charset=UTF-8" }, + hasPostData: true, + body: "{}", + }); + await until(() => emitted.length === 1, "the event"); + expect(value(emitted[0]).request.body.status).toBe("truncated"); + expect(leaked(emitted)).toEqual([]); +}); + +test("the verification code a sign-in posts, and the code an OAuth callback carries, never leave through the debugger", async () => { + clock.mockReturnValue(CODE_CLOCK); + await capture.start(7, "https://app.example.test/login"); + request(7, { + id: "1", + url: "https://app.example.test/api/devices/confirm", + method: "POST", + headers: { "content-type": "application/json" }, + postData: '{"verificationCode":"880261","rememberDevice":true}', + body: '{"code":"NOT_FOUND","message":"No pending sign-in"}', + }); + request(7, { + id: "2", + url: "https://app.example.test/integrations/slack?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj", + body: '{"ok":true}', + }); + request(7, { + id: "3", + url: "https://id.example.test/oauth/token", + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + postData: + "grant_type=authorization_code&code=Q7XK2MPLOK&code_verifier=dBjftJeZ4CVPmB92K27uhbUJU1p1r", + body: '{"token_type":"Bearer","expires_in":3600}', + }); + await until(() => emitted.length === 3, "the events"); + const text = recordedText(emitted); + for (const secret of [ + "880261", + "SplxlOBeZQQYbYS6WxSbIA", + "af0ifjsldkj", + "Q7XK2MPLOK", + "dBjftJeZ4CVPmB92K27uhbUJU1p1r", + ]) + expect([secret, text.includes(secret)]).toEqual([secret, false]); + const [confirm] = emitted; + expect(JSON.parse(value(confirm).request.body.text as string)).toEqual({ + verificationCode: "[redacted]", + rememberDevice: true, + }); + // An ordinary response code on an address that is no sign-in endpoint survives. + expect(JSON.parse(value(confirm).response.body.text as string)).toEqual({ + code: "NOT_FOUND", + message: "No pending sign-in", + }); +}); diff --git a/extension/tests/fixtures/agent-traffic/intelligence/backend-tool.json b/extension/tests/fixtures/agent-traffic/intelligence/backend-tool.json new file mode 100644 index 000000000..8123fca93 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/intelligence/backend-tool.json @@ -0,0 +1,841 @@ +{ + "name": "backend-tool", + "transport": "intelligence", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 55, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"5fa049ed-fbcc-4ac9-8e59-bdc843946c86\",\"runId\":\"9fb80749-50d9-4d5c-9e47-61e6b589cb5e\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"b26ef2b1-4268-4457-85ca-149781a1ec17\",\"role\":\"user\",\"content\":\"What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 177, + "text": "{\"threadId\":\"5fa049ed-fbcc-4ac9-8e59-bdc843946c86\",\"runId\":\"9fb80749-50d9-4d5c-9e47-61e6b589cb5e\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86\"}}" + } + ], + "end": { + "at": 177, + "outcome": "complete" + } + } + ], + "frames": [ + { + "at": 179, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e" + } + }, + { + "at": 234, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.", + "id": "b26ef2b1-4268-4457-85ca-149781a1ec17", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "state": {}, + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "tools": [] + }, + "metadata": { + "cpki_event_id": "86491874-43be-4030-a85b-20b59cf6662b", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9d-9170-756b-8e68-cd49c9b66572" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "RUN_STARTED" + } + }, + { + "at": 238, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "replay_complete" + }, + { + "at": 960, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "02859169-c2dd-4ad9-bc1c-2aec1f006c23", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9d-9477-7332-b4ae-8e9cc11c2339" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "parentMessageId": "b8f0af98-3b1a-4087-b910-6a1e60a75c0b", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "toolCallName": "lookupWeather", + "type": "TOOL_CALL_START" + } + }, + { + "at": 961, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": "{\"", + "metadata": { + "cpki_event_id": "41c99ae1-26e7-4d21-b317-da2f33e3f375", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9d-9478-7567-876b-2617f5501fe4" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 961, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": "city", + "metadata": { + "cpki_event_id": "adf9ac2a-053a-410b-a78f-3a8f564216d7", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9d-9478-721d-8829-298eaef36c98" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 966, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": "\":\"", + "metadata": { + "cpki_event_id": "0132ca31-c41b-42b5-89bd-503a1ebfceef", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9d-947e-7266-931d-57d2bd123315" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 967, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": "Lis", + "metadata": { + "cpki_event_id": "b978fb12-84fc-40dc-9b1e-92d62febe003", + "cpki_event_seq": 6, + "cpki_ingested": "01a15b9d-947e-7043-8ff1-3b343db505ce" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 967, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": "bon", + "metadata": { + "cpki_event_id": "45149307-a476-4038-9069-3374a865a4e1", + "cpki_event_seq": 7, + "cpki_ingested": "01a15b9d-947e-7c4f-9ddb-493addfe4136" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 980, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": "\"}", + "metadata": { + "cpki_event_id": "602f3771-7fd4-42dd-9223-f99ec293a76b", + "cpki_event_seq": 8, + "cpki_ingested": "01a15b9d-948c-7517-91d3-3d5c5e3893c6" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 1033, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "c2615e41-816b-4d78-bf3a-8925b2c8c712", + "cpki_event_seq": 9, + "cpki_ingested": "01a15b9d-94c1-782a-97c0-418295f0c68b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "type": "TOOL_CALL_END" + } + }, + { + "at": 1034, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "content": "{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}", + "messageId": "0dc57c74-6df9-43bf-b248-0696883f327c", + "metadata": { + "cpki_event_id": "216ccb5e-20d7-45d9-97ec-33e215d61967", + "cpki_event_seq": 10, + "cpki_ingested": "01a15b9d-94c1-7105-aa55-2dd3fa91b390" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "tool", + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "type": "TOOL_CALL_RESULT" + } + }, + { + "at": 1807, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "15f59793-a7c7-4ccf-9b86-0e5de30fa9c1", + "cpki_event_seq": 11, + "cpki_ingested": "01a15b9d-97c7-761d-a19c-d569a9442e8e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "assistant", + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_START" + } + }, + { + "at": 1809, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": "The", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "a41b7a5e-f94a-4ba9-9fdf-5c3d63a68e36", + "cpki_event_seq": 12, + "cpki_ingested": "01a15b9d-97c7-71cf-972b-d7bc85f28a2c" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1809, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " weather", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "c14789c9-c136-4940-9298-fa2ad31aab09", + "cpki_event_seq": 13, + "cpki_ingested": "01a15b9d-97c7-787d-86d1-fd15d97bdcbf" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1810, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " in", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "ca8170de-4fd7-434d-b596-4188ce4cf78e", + "cpki_event_seq": 14, + "cpki_ingested": "01a15b9d-97c7-7465-9ad8-3ffb3fb08fd6" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1810, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " Lisbon", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "a6270119-0245-4310-83e9-70329b46d1a1", + "cpki_event_seq": 15, + "cpki_ingested": "01a15b9d-97c7-7b5e-a192-28d842fbb4bf" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1810, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " is", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "aa5f2896-108c-4f01-9301-e34da787c751", + "cpki_event_seq": 16, + "cpki_ingested": "01a15b9d-97c7-7ecc-8aa2-a492ba2715c4" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1810, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " sunny", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "8b0af105-6825-460f-9df3-d7ea5ea79d65", + "cpki_event_seq": 17, + "cpki_ingested": "01a15b9d-97c7-7fa0-862b-552295103bf5" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1833, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " with", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "8fe87fac-ba9e-4164-bd0b-b7f3c59c1c9f", + "cpki_event_seq": 18, + "cpki_ingested": "01a15b9d-97e1-76af-a1fa-4970a5019d58" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1833, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " a", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "973f1c8a-96c2-44bf-9631-b471bb2b7233", + "cpki_event_seq": 19, + "cpki_ingested": "01a15b9d-97e1-7696-b887-2973d7a22414" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1914, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " temperature", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "e37ce2f7-2529-4421-9bc5-ca7255cb4d3c", + "cpki_event_seq": 20, + "cpki_ingested": "01a15b9d-9833-7e71-a3c9-7771da414ea6" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1933, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " of", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "d479497c-3a83-489f-a9e3-9119189d3b13", + "cpki_event_seq": 21, + "cpki_ingested": "01a15b9d-9845-74cf-a82b-fb5823ebb0eb" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1934, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": " ", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "e9f4e765-2ed2-4d00-9681-e5790c22f0da", + "cpki_event_seq": 22, + "cpki_ingested": "01a15b9d-9845-7622-8d31-50e6343ced50" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1970, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": "21", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "64334e69-b039-48d3-8486-1ae1ed09829b", + "cpki_event_seq": 23, + "cpki_ingested": "01a15b9d-986b-7373-81f6-8044e1458694" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 2016, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": "°C", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "8364f9c6-f680-4bfe-b9b0-a9708676e42f", + "cpki_event_seq": 24, + "cpki_ingested": "01a15b9d-9898-77b7-b2dd-304feb93578e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 2049, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "delta": ".", + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "e6595706-4068-4a0b-a32d-84941da0f326", + "cpki_event_seq": 25, + "cpki_ingested": "01a15b9d-98ba-7f61-ad49-c0c064da3a85" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 2192, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "metadata": { + "cpki_event_id": "75b776bb-4172-4a5a-8afe-bf4e2062e2f8", + "cpki_event_seq": 26, + "cpki_ingested": "01a15b9d-9947-7a7f-8f9d-e55db19d9d70" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "TEXT_MESSAGE_END" + } + }, + { + "at": 2193, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "8c7ba013-55a1-4d17-9da3-6ad213f27d32", + "cpki_event_seq": 27, + "cpki_ingested": "01a15b9d-9948-7ea9-9c22-04f33bc42747", + "finishReason": "stop" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "run_id": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "thread_id": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 472, + "model": "gpt-4.1-mini", + "outputTokens": 32, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 504 + } + ] + } + }, + { + "at": 2195, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "event": "phx_leave" + } + ], + "sink": [ + { + "trajectoryId": "9ff0f428-d9ec-4c51-9d6b-935f945a0c64", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538415299 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "b26ef2b1-4268-4457-85ca-149781a1ec17", + "runId": null, + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "agentId": "default", + "role": "user", + "textLength": 84, + "text": "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.", + "seq": 2 + }, + "timestamp": 1792538415350 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538415535 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_tCVdkwaKaYTxtWrTvKXH7jAY", + "toolName": "lookupWeather", + "agentId": "default", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "messageId": "b8f0af98-3b1a-4087-b910-6a1e60a75c0b", + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "seq": 4 + }, + "timestamp": 1792538416258 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "0dc57c74-6df9-43bf-b248-0696883f327c", + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "agentId": "default", + "role": "tool", + "textLength": 49, + "text": "{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}", + "seq": 5 + }, + "timestamp": 1792538416332 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "9ff0f428-d9ec-4c51-9d6b-935f945a0c64", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0217255e5b6fc195006ad7f7310bb887d089cfc6ac39a00cc9", + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "agentId": "default", + "role": "assistant", + "textLength": 58, + "text": "The weather in Lisbon is sunny with a temperature of 21°C.", + "seq": 6 + }, + "timestamp": 1792538417490 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "9fb80749-50d9-4d5c-9e47-61e6b589cb5e", + "threadId": "5fa049ed-fbcc-4ac9-8e59-bdc843946c86", + "agentId": "default", + "seq": 7 + }, + "timestamp": 1792538417493 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/intelligence/frontend-tool.json b/extension/tests/fixtures/agent-traffic/intelligence/frontend-tool.json new file mode 100644 index 000000000..bff353843 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/intelligence/frontend-tool.json @@ -0,0 +1,577 @@ +{ + "name": "frontend-tool", + "transport": "intelligence", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 58, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"0b7b0974-af2a-43bf-9623-08b6c20efefc\",\"runId\":\"6f0b0608-0733-4ad8-a4d6-20f1388b9bb2\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"7c149759-e2be-478a-96d0-cc22d4ed4437\",\"role\":\"user\",\"content\":\"Call showGreeting with name Ada and mood cheerful, then say done in one word.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 102, + "text": "{\"threadId\":\"0b7b0974-af2a-43bf-9623-08b6c20efefc\",\"runId\":\"6f0b0608-0733-4ad8-a4d6-20f1388b9bb2\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:0b7b0974-af2a-43bf-9623-08b6c20efefc\"}}" + } + ], + "end": { + "at": 102, + "outcome": "complete" + } + }, + { + "at": 1800, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"0b7b0974-af2a-43bf-9623-08b6c20efefc\",\"runId\":\"755bcf5c-5cd6-46f1-a504-93b7676be021\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"7c149759-e2be-478a-96d0-cc22d4ed4437\",\"role\":\"user\",\"content\":\"Call showGreeting with name Ada and mood cheerful, then say done in one word.\"},{\"id\":\"ff4f2049-3af9-43e1-8d60-97db4f04b89f\",\"role\":\"assistant\",\"toolCalls\":[{\"id\":\"call_7Yz4aP65IP3x2lIos86W2CS4\",\"type\":\"function\",\"function\":{\"name\":\"showGreeting\",\"arguments\":\"{\\\"name\\\":\\\"Ada\\\",\\\"mood\\\":\\\"cheerful\\\"}\"},\"metadata\":{\"cpki_event_id\":\"081b1783-bc63-44c8-802d-d4ece75c0d64\",\"cpki_event_seq\":4,\"cpki_ingested\":\"01a15b9d-a6b0-7604-9f0f-ed54a39aff4f\"}}]},{\"id\":\"da1c9a50-5836-4944-8b5a-7a61ed27de3a\",\"role\":\"tool\",\"toolCallId\":\"call_7Yz4aP65IP3x2lIos86W2CS4\",\"content\":\"Shown a greeting card for Ada.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 1836, + "text": "{\"threadId\":\"0b7b0974-af2a-43bf-9623-08b6c20efefc\",\"runId\":\"755bcf5c-5cd6-46f1-a504-93b7676be021\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:0b7b0974-af2a-43bf-9623-08b6c20efefc\"}}" + } + ], + "end": { + "at": 1836, + "outcome": "complete" + } + } + ], + "frames": [ + { + "at": 105, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2" + } + }, + { + "at": 163, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Call showGreeting with name Ada and mood cheerful, then say done in one word.", + "id": "7c149759-e2be-478a-96d0-cc22d4ed4437", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "state": {}, + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "tools": [ + { + "description": "Show a greeting card to the person in the page.", + "name": "showGreeting", + "parameters": { + "properties": { + "mood": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["name", "mood"], + "type": "object" + } + } + ] + }, + "metadata": { + "cpki_event_id": "f2fd6af9-8d25-4ce8-81b6-c23fc129123d", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9d-a0f2-74b5-8f4f-25b3ec7345c1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "run_id": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "type": "RUN_STARTED" + } + }, + { + "at": 168, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "replay_complete" + }, + { + "at": 1552, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "845a9df9-b3a8-4948-b9f1-221a99c3a821", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9d-a697-7a9b-babe-445115fcec24" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "parentMessageId": "ff4f2049-3af9-43e1-8d60-97db4f04b89f", + "project_id": 1, + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "run_id": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "toolCallId": "call_7Yz4aP65IP3x2lIos86W2CS4", + "toolCallName": "showGreeting", + "type": "TOOL_CALL_START" + } + }, + { + "at": 1564, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "delta": "{\"name\":\"Ada\",\"mood\":\"cheerful\"}", + "metadata": { + "cpki_event_id": "7de22aeb-cf4a-4ba3-87b6-b82ca8b3cafc", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9d-a6a3-7dc9-8377-410fbe2678c3" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "run_id": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "toolCallId": "call_7Yz4aP65IP3x2lIos86W2CS4", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 1577, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "081b1783-bc63-44c8-802d-d4ece75c0d64", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9d-a6b0-7604-9f0f-ed54a39aff4f" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "run_id": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "toolCallId": "call_7Yz4aP65IP3x2lIos86W2CS4", + "type": "TOOL_CALL_END" + } + }, + { + "at": 1796, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "a388e398-39b2-423c-a5b1-915efb6ffeaa", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9d-a78b-77c9-8a8f-730037204a13", + "finishReason": "tool-calls" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "run_id": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 244, + "model": "gpt-4.1-mini", + "outputTokens": 38, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 282 + } + ] + } + }, + { + "at": 1798, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "phx_leave" + }, + { + "at": 1838, + "socket": 2, + "direction": "out", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "755bcf5c-5cd6-46f1-a504-93b7676be021" + } + }, + { + "at": 1894, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Shown a greeting card for Ada.", + "id": "da1c9a50-5836-4944-8b5a-7a61ed27de3a", + "role": "tool", + "toolCallId": "call_7Yz4aP65IP3x2lIos86W2CS4" + } + ], + "protocolVersion": "1.0", + "runId": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "state": {}, + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "tools": [ + { + "description": "Show a greeting card to the person in the page.", + "name": "showGreeting", + "parameters": { + "properties": { + "mood": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["name", "mood"], + "type": "object" + } + } + ] + }, + "metadata": { + "cpki_event_id": "54f16fde-2f27-488e-a0b7-0d8a4077d434", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9d-a7b7-7d96-a32b-332b5eff00dc" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "run_id": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "type": "RUN_STARTED" + } + }, + { + "at": 1896, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "replay_complete" + }, + { + "at": 2384, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_0376d6432fdb869a006ad7f735a70c87d0909ab2d36ef5089f", + "metadata": { + "cpki_event_id": "a4d00809-efab-489a-946c-0812bd1e6d75", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9d-a9d5-742f-9a39-8d6233f31486" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "assistant", + "runId": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "run_id": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "type": "TEXT_MESSAGE_START" + } + }, + { + "at": 2386, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "delta": "Done", + "messageId": "msg_0376d6432fdb869a006ad7f735a70c87d0909ab2d36ef5089f", + "metadata": { + "cpki_event_id": "d15fcc20-fa1d-463f-94a1-fc6643c7e998", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9d-a9d5-7a2f-af93-a8f7a07285aa" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "run_id": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 2472, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_0376d6432fdb869a006ad7f735a70c87d0909ab2d36ef5089f", + "metadata": { + "cpki_event_id": "dddf8fef-7c1d-4345-8005-6165d7a83b2a", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9d-aa2d-744f-a8cf-c650a87ef42a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "run_id": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "type": "TEXT_MESSAGE_END" + } + }, + { + "at": 2473, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "59f07f94-14b3-4afa-9142-d2c161282e70", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9d-aa2d-794f-b39e-25136821b062", + "finishReason": "stop" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "run_id": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "thread_id": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 278, + "model": "gpt-4.1-mini", + "outputTokens": 3, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 281 + } + ] + } + }, + { + "at": 2474, + "socket": 2, + "direction": "out", + "joinRef": "3", + "topic": "thread:0b7b0974-af2a-43bf-9623-08b6c20efefc", + "event": "phx_leave" + } + ], + "sink": [ + { + "trajectoryId": "602993c9-8c44-4d18-b215-65d12ba4b96e", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538419346 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "7c149759-e2be-478a-96d0-cc22d4ed4437", + "runId": null, + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "agentId": "default", + "role": "user", + "textLength": 77, + "text": "Call showGreeting with name Ada and mood cheerful, then say done in one word.", + "seq": 2 + }, + "timestamp": 1792538419398 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538419511 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_7Yz4aP65IP3x2lIos86W2CS4", + "toolName": "showGreeting", + "agentId": "default", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "messageId": "ff4f2049-3af9-43e1-8d60-97db4f04b89f", + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "seq": 4 + }, + "timestamp": 1792538420897 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792538421142 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "executing", + "toolCallId": "call_7Yz4aP65IP3x2lIos86W2CS4", + "toolName": "showGreeting", + "agentId": "default", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "messageId": "ff4f2049-3af9-43e1-8d60-97db4f04b89f", + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "seq": 6 + }, + "timestamp": 1792538421143 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "completed", + "toolCallId": "call_7Yz4aP65IP3x2lIos86W2CS4", + "toolName": "showGreeting", + "agentId": "default", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "messageId": "ff4f2049-3af9-43e1-8d60-97db4f04b89f", + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "durationMs": 0, + "outcome": "ok", + "seq": 7 + }, + "timestamp": 1792538421143 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "agentId": "default", + "seq": 8 + }, + "timestamp": 1792538421240 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "602993c9-8c44-4d18-b215-65d12ba4b96e", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0376d6432fdb869a006ad7f735a70c87d0909ab2d36ef5089f", + "runId": "6f0b0608-0733-4ad8-a4d6-20f1388b9bb2", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "agentId": "default", + "role": "assistant", + "textLength": 4, + "text": "Done", + "seq": 9 + }, + "timestamp": 1792538421817 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "755bcf5c-5cd6-46f1-a504-93b7676be021", + "threadId": "0b7b0974-af2a-43bf-9623-08b6c20efefc", + "agentId": "default", + "seq": 10 + }, + "timestamp": 1792538421818 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/intelligence/interrupt.json b/extension/tests/fixtures/agent-traffic/intelligence/interrupt.json new file mode 100644 index 000000000..c1df72a1e --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/intelligence/interrupt.json @@ -0,0 +1,916 @@ +{ + "name": "interrupt", + "transport": "intelligence", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"shopper\"},\"body\":{\"threadId\":\"5a638d2c-f692-4405-b230-b1f80f841a6e\",\"runId\":\"1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"c3271c9a-7846-46ea-be0e-564c3dff1f31\",\"role\":\"user\",\"content\":\"Buy one coffee for 4 dollars. Ask for approval with approvePurchase first.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 95, + "text": "{\"threadId\":\"5a638d2c-f692-4405-b230-b1f80f841a6e\",\"runId\":\"1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:5a638d2c-f692-4405-b230-b1f80f841a6e\"}}" + } + ], + "end": { + "at": 95, + "outcome": "complete" + } + }, + { + "at": 1212, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"shopper\"},\"body\":{\"threadId\":\"5a638d2c-f692-4405-b230-b1f80f841a6e\",\"runId\":\"16606e8c-ce40-4276-820a-816d1b2ce382\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"c3271c9a-7846-46ea-be0e-564c3dff1f31\",\"role\":\"user\",\"content\":\"Buy one coffee for 4 dollars. Ask for approval with approvePurchase first.\"},{\"id\":\"32179a65-c990-450a-9005-2274ddcebb85\",\"role\":\"assistant\",\"toolCalls\":[{\"id\":\"call_tWsSMjCS0ltNdVhztaeiHqLZ\",\"type\":\"function\",\"function\":{\"name\":\"approvePurchase\",\"arguments\":\"{\\\"item\\\":\\\"coffee\\\",\\\"amount\\\":4}\"},\"metadata\":{\"cpki_event_id\":\"643ca3cb-84c2-4a8b-91ef-8cb143f69b4d\",\"cpki_event_seq\":12,\"cpki_ingested\":\"01a15b9e-0d9e-775a-a1f1-f6c5f06156f3\"}}]}],\"resume\":[{\"interruptId\":\"call_tWsSMjCS0ltNdVhztaeiHqLZ\",\"status\":\"resolved\",\"payload\":{\"approved\":true}}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 1245, + "text": "{\"threadId\":\"5a638d2c-f692-4405-b230-b1f80f841a6e\",\"runId\":\"16606e8c-ce40-4276-820a-816d1b2ce382\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:5a638d2c-f692-4405-b230-b1f80f841a6e\"}}" + } + ], + "end": { + "at": 1245, + "outcome": "complete" + } + } + ], + "frames": [ + { + "at": 99, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7" + } + }, + { + "at": 154, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Buy one coffee for 4 dollars. Ask for approval with approvePurchase first.", + "id": "c3271c9a-7846-46ea-be0e-564c3dff1f31", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "state": {}, + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "tools": [] + }, + "metadata": { + "cpki_event_id": "91034bf9-3a31-4eec-9385-5a04a25b6e62", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9e-09e7-7a02-84d7-dbefb9e3c305" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "type": "RUN_STARTED" + } + }, + { + "at": 158, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "replay_complete" + }, + { + "at": 916, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "e2f9b600-0d22-4f1d-a7a9-140d9257322f", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9e-0d15-7715-886f-86e9ed1c2f96" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "parentMessageId": "32179a65-c990-450a-9005-2274ddcebb85", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "toolCallName": "approvePurchase", + "type": "TOOL_CALL_START" + } + }, + { + "at": 918, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "{\"", + "metadata": { + "cpki_event_id": "00411982-69ae-4641-b4fe-c783a070b606", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9e-0d15-7da9-bdf2-70613b39fe07" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 924, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "item", + "metadata": { + "cpki_event_id": "37ee6014-2490-49f3-9227-ad4d788cad72", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9e-0d1d-77e9-afb3-3413b13d99d1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 924, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "\":\"", + "metadata": { + "cpki_event_id": "3f85def5-2e84-4530-ba6c-566b9eb6c37a", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9e-0d1d-78c5-8cce-4711622cb3c1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 924, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "coffee", + "metadata": { + "cpki_event_id": "44f3feb4-4131-40a2-8ab8-41a8b4f570c0", + "cpki_event_seq": 6, + "cpki_ingested": "01a15b9e-0d1d-739b-9a07-6daaf394ee13" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 925, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "\",\"", + "metadata": { + "cpki_event_id": "6e11b17c-eea9-4377-8056-05ec900dcc75", + "cpki_event_seq": 7, + "cpki_ingested": "01a15b9e-0d1d-7660-87da-76755dbb094f" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 925, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "amount", + "metadata": { + "cpki_event_id": "de9b2183-517a-430d-b518-550a1edcfc49", + "cpki_event_seq": 8, + "cpki_ingested": "01a15b9e-0d1d-7f83-852b-62ecf20fddb5" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 931, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "\":", + "metadata": { + "cpki_event_id": "c66bae5d-8cd0-401c-a524-8f0a162ded85", + "cpki_event_seq": 9, + "cpki_ingested": "01a15b9e-0d25-75f8-9826-a02a022783ee" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 932, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "4", + "metadata": { + "cpki_event_id": "3100170c-3d42-495d-ac8e-ef9288d1a45c", + "cpki_event_seq": 10, + "cpki_ingested": "01a15b9e-0d25-7efe-a3b1-f4cbea197b95" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 955, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "}", + "metadata": { + "cpki_event_id": "9f11b02c-327b-47aa-99e8-ee9ca53632ba", + "cpki_event_seq": 11, + "cpki_ingested": "01a15b9e-0d3c-7e81-83dc-44a5222e06b8" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 1053, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "643ca3cb-84c2-4a8b-91ef-8cb143f69b4d", + "cpki_event_seq": 12, + "cpki_ingested": "01a15b9e-0d9e-775a-a1f1-f6c5f06156f3" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "type": "TOOL_CALL_END" + } + }, + { + "at": 1206, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "84a7590c-14c0-4a71-a5ac-6200b6307155", + "cpki_event_seq": 13, + "cpki_ingested": "01a15b9e-0e37-789b-a927-4d2f2712bf37", + "finishReason": "tool-calls" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "outcome": { + "interrupts": [ + { + "id": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "message": "Approve this purchase?", + "reason": "tool_call", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ" + } + ], + "type": "interrupt" + }, + "project_id": 1, + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "run_id": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 230, + "model": "gpt-4.1-mini", + "outputTokens": 20, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 250 + } + ] + } + }, + { + "at": 1209, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "phx_leave" + }, + { + "at": 1246, + "socket": 2, + "direction": "out", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382" + } + }, + { + "at": 1303, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [], + "protocolVersion": "1.0", + "resume": [ + { + "interruptId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "payload": { + "approved": true + }, + "status": "resolved" + } + ], + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "state": {}, + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "tools": [] + }, + "metadata": { + "cpki_event_id": "a52df413-aaf9-4595-a323-40812f3f7d1e", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9e-0e62-7271-9deb-37e7de22f67e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "type": "RUN_STARTED" + } + }, + { + "at": 1304, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "replay_complete" + }, + { + "at": 2150, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "c9402254-ef48-4f7b-8493-7a5c9733e688", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9e-11e7-7472-91fb-35b829f1e594" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "parentMessageId": "c7f87c3e-43dd-4e61-870a-dcd9ca699d83", + "project_id": 1, + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_sacWKIHU1LNOIEWX5KyaYWhS", + "toolCallName": "AGUISendStateDelta", + "type": "TOOL_CALL_START" + } + }, + { + "at": 2163, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "{\"delta\":[{\"op\":\"add\",\"path\":\"/purchases/-\",\"value\":{\"item\":\"coffee\",\"amount\":4}}]}", + "metadata": { + "cpki_event_id": "c94ed052-4a72-4410-a207-337b60d60e67", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9e-11f4-7e2c-8afc-077547114095" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_sacWKIHU1LNOIEWX5KyaYWhS", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2185, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "d66c3e8b-be0d-4bcb-96de-f854a622c7c8", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9e-1209-70ff-b229-2724db4c7936" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_sacWKIHU1LNOIEWX5KyaYWhS", + "type": "TOOL_CALL_END" + } + }, + { + "at": 2185, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "7e22467c-77f0-4252-ac69-a1dfaef8c192", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9e-1209-7d2a-950f-2c5bb636fab4" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "snapshot": {}, + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "type": "STATE_SNAPSHOT" + } + }, + { + "at": 2186, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": [ + { + "op": "add", + "path": "/purchases", + "value": [] + }, + { + "op": "add", + "path": "/purchases/-", + "value": { + "amount": 4, + "item": "coffee" + } + } + ], + "metadata": { + "cpki_event_id": "730f1f80-2404-4dfb-a63e-d86ed571ef03", + "cpki_event_seq": 6, + "cpki_ingested": "01a15b9e-1209-76bb-be4f-01217f7342f6" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "type": "STATE_DELTA" + } + }, + { + "at": 2187, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "content": "{\"success\":true,\"delta\":[{\"op\":\"add\",\"path\":\"/purchases/-\",\"value\":{\"item\":\"coffee\",\"amount\":4}}]}", + "messageId": "4949902f-6356-48e6-9f8e-592902e5dfff", + "metadata": { + "cpki_event_id": "d99e2fa7-443f-47fb-b00a-0ec3e62f8775", + "cpki_event_seq": 7, + "cpki_ingested": "01a15b9e-1209-7449-8c36-2bcd22a56558" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "tool", + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_sacWKIHU1LNOIEWX5KyaYWhS", + "type": "TOOL_CALL_RESULT" + } + }, + { + "at": 2188, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "3f70159f-1a71-45f0-a028-98eeb62f8e7d", + "cpki_event_seq": 8, + "cpki_ingested": "01a15b9e-1209-7876-bcec-04a19a889094" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "parentMessageId": "c7f87c3e-43dd-4e61-870a-dcd9ca699d83", + "project_id": 1, + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_RE8kjrnRtMKebHikN9nCaI5E", + "toolCallName": "approvePurchase", + "type": "TOOL_CALL_START" + } + }, + { + "at": 2188, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "delta": "{\"item\":\"coffee\",\"amount\":4}", + "metadata": { + "cpki_event_id": "2c2f2350-a245-4214-9204-84b4026871b6", + "cpki_event_seq": 9, + "cpki_ingested": "01a15b9e-1209-7db4-9591-b3449e1e1134" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_RE8kjrnRtMKebHikN9nCaI5E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2188, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "41559bab-fe85-4a9f-8d76-1795577d67f5", + "cpki_event_seq": 10, + "cpki_ingested": "01a15b9e-1209-753d-a525-49aef016f958" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "toolCallId": "call_RE8kjrnRtMKebHikN9nCaI5E", + "type": "TOOL_CALL_END" + } + }, + { + "at": 2429, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "6433ddfc-e713-4d3d-884f-06d1e6e130ee", + "cpki_event_seq": 11, + "cpki_ingested": "01a15b9e-12fe-7350-bc2f-767b99fd0e6e", + "finishReason": "tool-calls" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "outcome": { + "interrupts": [ + { + "id": "call_RE8kjrnRtMKebHikN9nCaI5E", + "message": "Approve this purchase?", + "reason": "tool_call", + "toolCallId": "call_RE8kjrnRtMKebHikN9nCaI5E" + } + ], + "type": "interrupt" + }, + "project_id": 1, + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "run_id": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "thread_id": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 261, + "model": "gpt-4.1-mini", + "outputTokens": 74, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 335 + } + ] + } + }, + { + "at": 2430, + "socket": 2, + "direction": "out", + "joinRef": "3", + "topic": "thread:5a638d2c-f692-4405-b230-b1f80f841a6e", + "event": "phx_leave" + } + ], + "sink": [ + { + "trajectoryId": "079ae5e6-e515-4747-a91f-cc12de60759e", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538446220 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "c3271c9a-7846-46ea-be0e-564c3dff1f31", + "runId": null, + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "agentId": "shopper", + "role": "user", + "textLength": 74, + "text": "Buy one coffee for 4 dollars. Ask for approval with approvePurchase first.", + "seq": 2 + }, + "timestamp": 1792538446272 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "agentId": "shopper", + "seq": 3 + }, + "timestamp": 1792538446376 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_tWsSMjCS0ltNdVhztaeiHqLZ", + "toolName": "approvePurchase", + "agentId": "shopper", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "messageId": "32179a65-c990-450a-9005-2274ddcebb85", + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "seq": 4 + }, + "timestamp": 1792538447136 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "agentId": "shopper", + "seq": 5 + }, + "timestamp": 1792538447429 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "agentId": "shopper", + "seq": 6 + }, + "timestamp": 1792538447522 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "079ae5e6-e515-4747-a91f-cc12de60759e", + "events": [ + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_sacWKIHU1LNOIEWX5KyaYWhS", + "toolName": "AGUISendStateDelta", + "agentId": "shopper", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "messageId": "c7f87c3e-43dd-4e61-870a-dcd9ca699d83", + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "seq": 7 + }, + "timestamp": 1792538448369 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "4949902f-6356-48e6-9f8e-592902e5dfff", + "runId": "1bfcd48d-b221-4c95-8f66-b60dfc1ad0e7", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "agentId": "shopper", + "role": "tool", + "textLength": 98, + "text": "{\"success\":true,\"delta\":[{\"op\":\"add\",\"path\":\"/purchases/-\",\"value\":{\"item\":\"coffee\",\"amount\":4}}]}", + "seq": 8 + }, + "timestamp": 1792538448406 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_RE8kjrnRtMKebHikN9nCaI5E", + "toolName": "approvePurchase", + "agentId": "shopper", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "messageId": "c7f87c3e-43dd-4e61-870a-dcd9ca699d83", + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "seq": 9 + }, + "timestamp": 1792538448407 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "16606e8c-ce40-4276-820a-816d1b2ce382", + "threadId": "5a638d2c-f692-4405-b230-b1f80f841a6e", + "agentId": "shopper", + "seq": 10 + }, + "timestamp": 1792538448649 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/intelligence/reasoning.json b/extension/tests/fixtures/agent-traffic/intelligence/reasoning.json new file mode 100644 index 000000000..9ab79d3c4 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/intelligence/reasoning.json @@ -0,0 +1,2189 @@ +{ + "name": "reasoning", + "transport": "intelligence", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 61, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"reasoner\"},\"body\":{\"threadId\":\"ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70\",\"runId\":\"7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"644cb2c1-e6a5-4f88-91bd-26698b014e58\",\"role\":\"user\",\"content\":\"Think it through first: what is 17 multiplied by 23? Call no tools, answer with the number.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 105, + "text": "{\"threadId\":\"ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70\",\"runId\":\"7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70\"}}" + } + ], + "end": { + "at": 105, + "outcome": "complete" + } + } + ], + "frames": [ + { + "at": 108, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6" + } + }, + { + "at": 164, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Think it through first: what is 17 multiplied by 23? Call no tools, answer with the number.", + "id": "644cb2c1-e6a5-4f88-91bd-26698b014e58", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "state": {}, + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "tools": [] + }, + "metadata": { + "cpki_event_id": "c3903d7b-898e-4276-89d3-d9600800d1e3", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9d-dc02-79bd-b1ee-0b68b263412a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "RUN_STARTED" + } + }, + { + "at": 167, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "replay_complete" + }, + { + "at": 616, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "7183a4af-cf76-49cf-8dca-85f0d3363a7d", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9d-ddfb-7f95-82d2-60627e6b79b1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_START" + } + }, + { + "at": 617, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "6844ec1d-d04a-4b9d-8d8c-4ae3e37b5250", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9d-ddfb-7bff-8a5a-0c5d4e7e17e9" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "reasoning", + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_START" + } + }, + { + "at": 1547, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": "**Computing multiplication**\n\nI", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "3f030442-a343-4926-958c-eebe0eae522c", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9d-e19f-7fbf-b398-9936b8d0575d" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1553, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " need", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "3bb2f1d4-1ab4-4908-8b3d-8ae3cfd94ba7", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9d-e1a5-7920-8bb9-cbc2a9ba1f1b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1566, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " to", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "583cc190-342d-4d48-8298-2abc4b58c3ae", + "cpki_event_seq": 6, + "cpki_ingested": "01a15b9d-e1b3-7c8e-b31e-45a9274f4292" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1567, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " think", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "d225aed8-464a-4298-a4e6-5334b614900f", + "cpki_event_seq": 7, + "cpki_ingested": "01a15b9d-e1b3-74c3-b05f-02f7bbc8a887" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1614, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " through", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "8787a39e-2f2d-4bb1-9e73-09ea8d45557f", + "cpki_event_seq": 8, + "cpki_ingested": "01a15b9d-e1e2-7698-8c80-809f523b014b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1615, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " the", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "efad8a20-2893-44e5-9a17-979598b4672b", + "cpki_event_seq": 9, + "cpki_ingested": "01a15b9d-e1e2-7ac8-8970-62dd54a24c2c" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1626, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " user", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "24e64efa-e1ee-475f-86d9-83f9d33748f5", + "cpki_event_seq": 10, + "cpki_ingested": "01a15b9d-e1ee-75e9-ae72-819ce4d48017" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1637, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " request", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "ba309718-1c77-4bf3-85c7-cc855bc7ec87", + "cpki_event_seq": 11, + "cpki_ingested": "01a15b9d-e1f7-7acd-8986-b4f26181e8ea" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1640, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " carefully", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "0c900fe2-5b53-4922-b531-cb33fa0034b8", + "cpki_event_seq": 12, + "cpki_ingested": "01a15b9d-e1fc-73a5-8417-5db7c5ab54db" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1641, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " before", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "e8c64d1d-68dc-4556-822f-39d253b4de86", + "cpki_event_seq": 13, + "cpki_ingested": "01a15b9d-e1fc-771a-8931-6e81367fb540" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1653, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " providing", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "b80ce5b3-fb84-4203-9594-c32dc042678f", + "cpki_event_seq": 14, + "cpki_ingested": "01a15b9d-e208-741e-8b21-4549c97a1789" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1653, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " an", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "8b871fe8-e311-4350-a951-c6cda9c56588", + "cpki_event_seq": 15, + "cpki_ingested": "01a15b9d-e208-794b-a9d4-426f2e1369c2" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1660, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " answer", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "7e7a597f-e625-43ef-8bdf-383f8fe2f0d2", + "cpki_event_seq": 16, + "cpki_ingested": "01a15b9d-e210-712e-925b-0bdb3d2cf7bb" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1661, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ".", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "a89ad5e7-add3-42c7-a768-d70218e31cd6", + "cpki_event_seq": 17, + "cpki_ingested": "01a15b9d-e210-7a31-9ffe-f2ed40f82500" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1677, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " They", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "4d90338e-0dcf-4d7e-a12d-a3bae4a792fa", + "cpki_event_seq": 18, + "cpki_ingested": "01a15b9d-e222-796e-b8d9-65a461a78fbe" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1692, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " want", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "8f25d583-9651-49d8-857a-11bce096e30f", + "cpki_event_seq": 19, + "cpki_ingested": "01a15b9d-e230-7b05-9fb8-3b453f47105f" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1701, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " a", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "634fb523-b1aa-4b6f-9f9f-866b0416850f", + "cpki_event_seq": 20, + "cpki_ingested": "01a15b9d-e238-7917-8b6d-da191238924c" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1702, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " straightforward", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "a78f0c10-3d1f-42de-8063-7f02ea7e9fe3", + "cpki_event_seq": 21, + "cpki_ingested": "01a15b9d-e238-7857-a286-614cc97065d1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1702, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " calculation", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "0e80f4fe-0233-4cc6-be41-b31b7adf07aa", + "cpki_event_seq": 22, + "cpki_ingested": "01a15b9d-e238-7a7b-bd31-76c8f1aa1a26" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1702, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " of", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "89780373-29b5-4238-85f4-85ca234abdc8", + "cpki_event_seq": 23, + "cpki_ingested": "01a15b9d-e238-788e-86f0-636da14f8db8" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1702, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " 17", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "bf31ba04-181e-4287-af7e-057c1aa9fc14", + "cpki_event_seq": 24, + "cpki_ingested": "01a15b9d-e238-7748-b479-93e7f59ddcef" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1702, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " times", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "8aabfe90-4f10-473c-b5fc-b2286384b545", + "cpki_event_seq": 25, + "cpki_ingested": "01a15b9d-e238-77a3-baef-da1e6b568ebf" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1702, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " 23", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "647898ed-1819-4e55-93e1-d9ec0ce59c10", + "cpki_event_seq": 26, + "cpki_ingested": "01a15b9d-e238-7363-ad3b-4c26493870b1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1702, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ".", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "6a9521fc-0f4f-4d92-901b-8bc854b7d430", + "cpki_event_seq": 27, + "cpki_ingested": "01a15b9d-e238-74e9-923a-8819e9b11ec0" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1702, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " I'll", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "4acffb29-2c90-48ae-9a0e-b6bb45e3893c", + "cpki_event_seq": 28, + "cpki_ingested": "01a15b9d-e238-70d8-9651-7f10f14718bf" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1775, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " break", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "3f44ece6-f4cc-4266-ae40-5b9372d5baab", + "cpki_event_seq": 29, + "cpki_ingested": "01a15b9d-e283-7fc6-bd7f-95c70e6d3f4e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1775, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " it", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "0a53809e-eea1-4d7a-a719-9c46792e52d6", + "cpki_event_seq": 30, + "cpki_ingested": "01a15b9d-e283-7a2b-bacc-f2e26f0060ce" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1775, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " down", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "3fb84421-072a-48b3-8094-26810e3ab4be", + "cpki_event_seq": 31, + "cpki_ingested": "01a15b9d-e283-7604-a90e-ddbfb2f7d90e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1780, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ":", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "975b2789-7fb7-4935-ad9e-b218f14cec4b", + "cpki_event_seq": 32, + "cpki_ingested": "01a15b9d-e288-7f03-b77b-ad850c836d4a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1781, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " if", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "b3647821-6e6f-4804-9e00-e1a3f7132cd4", + "cpki_event_seq": 33, + "cpki_ingested": "01a15b9d-e288-71a9-99ef-539f4715633a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1781, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " I", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "d7e914cb-151e-4c86-b270-305e3ef3fa23", + "cpki_event_seq": 34, + "cpki_ingested": "01a15b9d-e288-77c5-afe4-a46f461b4b89" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1790, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " calculate", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "217a08f7-5d27-40da-b8c1-d7938b7f9e3d", + "cpki_event_seq": 35, + "cpki_ingested": "01a15b9d-e292-7a08-9c27-0107dd938d98" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1791, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " 20", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "82d86f0b-9ee9-4f1b-87cd-3917134c21e7", + "cpki_event_seq": 36, + "cpki_ingested": "01a15b9d-e292-7608-85ba-7e63fd0913f3" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1796, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " times", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "666aef8a-d2af-4b1e-ba9d-b3d8815bbf4f", + "cpki_event_seq": 37, + "cpki_ingested": "01a15b9d-e298-72f2-9235-25ea1eb0ee18" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1797, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " 17", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "ebff0c98-29af-402e-ac50-404838bd97df", + "cpki_event_seq": 38, + "cpki_ingested": "01a15b9d-e298-724e-8325-1dc21a19e049" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1797, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "e447513e-013f-4085-afc3-72e0d0f23397", + "cpki_event_seq": 39, + "cpki_ingested": "01a15b9d-e298-71b1-b1b0-21ee8ba1cba0" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1802, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " that", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "1dd96f44-ad6a-473f-aaf6-60993e3ed1fc", + "cpki_event_seq": 40, + "cpki_ingested": "01a15b9d-e29e-71db-be4b-2b41c9a5f098" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1802, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " gives", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "80bb01f4-7c8e-4ba9-9c7e-5e8df288a795", + "cpki_event_seq": 41, + "cpki_ingested": "01a15b9d-e29e-7446-9509-67cdb949b502" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1803, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " me", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "51e97740-942b-4439-b944-293a9fa412ec", + "cpki_event_seq": 42, + "cpki_ingested": "01a15b9d-e29e-7b25-8bcf-b7acb2620343" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1803, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " 340", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "5d9b8626-9dbf-43ad-bbb5-80e501b0b942", + "cpki_event_seq": 43, + "cpki_ingested": "01a15b9d-e29e-7e63-8652-da858c69b5f9" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1803, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ".", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "a639ca3f-76a3-46d4-86fc-8152b6d87ec5", + "cpki_event_seq": 44, + "cpki_ingested": "01a15b9d-e29e-70b9-8334-f37c0e4d075a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1803, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " Then", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "e2561603-3eae-4037-bacb-687be192ebc7", + "cpki_event_seq": 45, + "cpki_ingested": "01a15b9d-e29e-76ec-a4e1-c1545d2c548c" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1803, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " I", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "0cf79ccb-156c-495a-a1fc-deffea30fcc5", + "cpki_event_seq": 46, + "cpki_ingested": "01a15b9d-e29e-76dd-a8f5-5b57026510c1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1813, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " need", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "d5d15665-3c46-4f9e-ab92-5b0e15b35aea", + "cpki_event_seq": 47, + "cpki_ingested": "01a15b9d-e2a8-73d0-8423-1927f21becce" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1821, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " to", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "535e18bc-bc4c-49d4-b322-48a4436afd7b", + "cpki_event_seq": 48, + "cpki_ingested": "01a15b9d-e2b1-7b8c-b94a-791428ea766a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1831, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " compute", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "afdc74ae-c708-46e1-88ad-bde8c1c23fb5", + "cpki_event_seq": 49, + "cpki_ingested": "01a15b9d-e2bb-7e7e-b805-e2da761b6048" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1845, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " 3", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "1a2bf6db-8f33-4d67-8315-ec9cb3722e3f", + "cpki_event_seq": 50, + "cpki_ingested": "01a15b9d-e2c9-7c7b-b8a1-9bdd02e63c4a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1853, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " times", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "06158098-4d70-439e-8286-d28887e03039", + "cpki_event_seq": 51, + "cpki_ingested": "01a15b9d-e2d2-7aae-bfcd-f65e3ffed0ea" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1874, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " 17", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "9c71c4e6-0e0a-47bd-9875-b88b1659d6c3", + "cpki_event_seq": 52, + "cpki_ingested": "01a15b9d-e2e5-7da9-b2a6-437b78374ca0" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1880, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "9120f729-fa44-4f8d-ad28-0d379ea3337e", + "cpki_event_seq": 53, + "cpki_ingested": "01a15b9d-e2ec-74c6-96a0-e7ecdc658686" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1899, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " which", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "7361168c-acf3-4b0f-9a8f-77e44c7b1f9d", + "cpki_event_seq": 54, + "cpki_ingested": "01a15b9d-e2ff-7603-a5fd-a15a63760f34" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1904, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " is", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "3b447829-573d-44eb-9492-6a4dba3ad042", + "cpki_event_seq": 55, + "cpki_ingested": "01a15b9d-e303-77e6-8e4f-d32a18eeda89" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1921, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " 51", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "d311aba2-47fc-415e-a91c-779f5ec1dd78", + "cpki_event_seq": 56, + "cpki_ingested": "01a15b9d-e314-7eb7-9d79-3dbcb7147ed5" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1930, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ".", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "4d8680b0-5e08-4f16-82cb-900ba14a7291", + "cpki_event_seq": 57, + "cpki_ingested": "01a15b9d-e31d-73c9-8e84-59c8c8aa7f86" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1940, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " Adding", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "4150c1f6-21b3-4406-b847-256f0e235f12", + "cpki_event_seq": 58, + "cpki_ingested": "01a15b9d-e328-7645-8443-f427c9e13c46" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1946, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " those", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "144d91ad-54fb-4d2a-98a0-b0cebaf769af", + "cpki_event_seq": 59, + "cpki_ingested": "01a15b9d-e32e-7a06-921f-568a9cadeda9" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1947, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " results", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "606b0784-b839-4da6-8e32-03d5a07bd10f", + "cpki_event_seq": 60, + "cpki_ingested": "01a15b9d-e32e-7582-bf86-1dc65be1a072" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1961, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " together", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "38e31d72-66c0-4fba-99c4-a5b1c409ccda", + "cpki_event_seq": 61, + "cpki_ingested": "01a15b9d-e33d-7c15-b6b9-ef77996af0f2" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1987, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "3fb5fd48-b3d3-426f-bd43-ec6b312764ba", + "cpki_event_seq": 62, + "cpki_ingested": "01a15b9d-e357-7dfc-8765-73a89ea8fbd4" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 1996, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " I", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "4f6dd60c-fead-4f1c-a551-b197f932fe88", + "cpki_event_seq": 63, + "cpki_ingested": "01a15b9d-e361-7069-9057-62e1b6ca3499" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2005, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " arrive", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "31bfd295-775a-45ff-b192-3c324fbe7378", + "cpki_event_seq": 64, + "cpki_ingested": "01a15b9d-e369-7ad0-a30a-c898c4d55546" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2005, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " at", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "5212cf74-dfac-4420-a155-3c088215a19a", + "cpki_event_seq": 65, + "cpki_ingested": "01a15b9d-e369-75c6-87ae-358152773e01" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2033, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " 391", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "b2bd6c0f-55d6-4087-9620-8e9411543a7b", + "cpki_event_seq": 66, + "cpki_ingested": "01a15b9d-e385-7ec4-9c7c-ccf6a12987b6" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2042, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ".", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "d8bdb54e-2e0a-4b6e-999e-63cb662940a2", + "cpki_event_seq": 67, + "cpki_ingested": "01a15b9d-e38e-7a5e-958c-c60f1ba0bedb" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2061, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " So", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "bb9a4221-e75d-4db6-ab52-3facee29145e", + "cpki_event_seq": 68, + "cpki_ingested": "01a15b9d-e3a1-7bd4-bec6-03f7ff5dc8a1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2072, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "8900e119-ecf5-45a2-a870-287df1961935", + "cpki_event_seq": 69, + "cpki_ingested": "01a15b9d-e3ac-76d9-9c1e-f972df3b5c79" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2083, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " I", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "a185bccb-26b0-49e1-a5c6-6f15b25818fe", + "cpki_event_seq": 70, + "cpki_ingested": "01a15b9d-e3b8-7337-83ef-5be079581c1e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2092, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " just", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "0d3ec977-be60-40d1-9cb2-d8d10db51d86", + "cpki_event_seq": 71, + "cpki_ingested": "01a15b9d-e3c0-7df4-95e5-51f0743c943e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2099, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " need", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "9a73b7b5-bca9-4bac-a5bf-e8f04d18896e", + "cpki_event_seq": 72, + "cpki_ingested": "01a15b9d-e3c7-7041-bffd-eb91081e8494" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2109, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " to", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "6311a325-cb11-4ab1-8396-3984692f1e99", + "cpki_event_seq": 73, + "cpki_ingested": "01a15b9d-e3d1-761f-a92f-43f67e450194" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2115, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " give", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "8fbfe97f-3abe-45cc-b204-5d5261aa1416", + "cpki_event_seq": 74, + "cpki_ingested": "01a15b9d-e3d7-755e-9cf4-1b10743d0060" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2125, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " them", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "7891e859-8ce8-4443-8c98-ace9131855ea", + "cpki_event_seq": 75, + "cpki_ingested": "01a15b9d-e3e1-7c86-9009-db35d5fe6d7c" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2135, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " the", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "fcadaedf-8620-436b-9975-74c8a2818c07", + "cpki_event_seq": 76, + "cpki_ingested": "01a15b9d-e3eb-72f5-998b-52482cabb74a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2144, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": " number", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "47edaa71-32a4-4c7d-9e5c-36d0acf17af3", + "cpki_event_seq": 77, + "cpki_ingested": "01a15b9d-e3f4-7c50-a89d-e8413c1b406b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2150, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": ".", + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "6bdc5015-60dd-40df-973e-961dcee56739", + "cpki_event_seq": 78, + "cpki_ingested": "01a15b9d-e3fa-746b-a062-eea7d16df4e8" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_CONTENT" + } + }, + { + "at": 2227, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "220284eb-3b62-4ef1-8afe-1f528e8673f1", + "cpki_event_seq": 79, + "cpki_ingested": "01a15b9d-e447-71d2-9e20-ab958b64af2f" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_MESSAGE_END" + } + }, + { + "at": 2228, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "metadata": { + "cpki_event_id": "2f5ae4da-55ad-4e15-ba7d-15d42b46dd76", + "cpki_event_seq": 80, + "cpki_ingested": "01a15b9d-e447-7080-8c01-6c574d253218" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "REASONING_END" + } + }, + { + "at": 2246, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_07f1526e0392f41d006ad7f744a2ec87d091c390e511ce0504", + "metadata": { + "cpki_event_id": "64175fe7-8dfb-4df3-9367-bb739960a30e", + "cpki_event_seq": 81, + "cpki_ingested": "01a15b9d-e459-7570-b509-251052c4f114" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "assistant", + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "TEXT_MESSAGE_START" + } + }, + { + "at": 2247, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "delta": "391", + "messageId": "msg_07f1526e0392f41d006ad7f744a2ec87d091c390e511ce0504", + "metadata": { + "cpki_event_id": "262ccc17-6e6f-49be-aff4-938733cb2663", + "cpki_event_seq": 82, + "cpki_ingested": "01a15b9d-e459-779e-9b37-f47469bde6b8" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 2452, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_07f1526e0392f41d006ad7f744a2ec87d091c390e511ce0504", + "metadata": { + "cpki_event_id": "80c3c94d-1d09-4982-b901-c945060efefe", + "cpki_event_seq": 83, + "cpki_ingested": "01a15b9d-e50c-7086-a5bd-3cf700a1e40d" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "TEXT_MESSAGE_END" + } + }, + { + "at": 2452, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "e627130b-aadf-47f6-99e9-5cec578e6ea7", + "cpki_event_seq": 84, + "cpki_ingested": "01a15b9d-e50c-7568-bb4f-814152f6fc58", + "finishReason": "stop" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "run_id": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "thread_id": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 226, + "model": "gpt-5-mini", + "outputTokens": 63, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 289 + } + ] + } + }, + { + "at": 2454, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "event": "phx_leave" + } + ], + "sink": [ + { + "trajectoryId": "9f356316-9c0b-499f-a76b-a8db56f06383", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538434465 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "644cb2c1-e6a5-4f88-91bd-26698b014e58", + "runId": null, + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "agentId": "reasoner", + "role": "user", + "textLength": 91, + "text": "Think it through first: what is 17 multiplied by 23? Call no tools, answer with the number.", + "seq": 2 + }, + "timestamp": 1792538434517 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "agentId": "reasoner", + "seq": 3 + }, + "timestamp": 1792538434628 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "9f356316-9c0b-499f-a76b-a8db56f06383", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "rs_07f1526e0392f41d006ad7f74313f087d0a30b0e93780b82d6:0", + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "agentId": "reasoner", + "role": "reasoning", + "textLength": 368, + "text": "**Computing multiplication**\n\nI need to think through the user request carefully before providing an answer. They want a straightforward calculation of 17 times 23. I'll break it down: if I calculate 20 times 17, that gives me 340. Then I need to compute 3 times 17, which is 51. Adding those results together, I arrive at 391. So, I just need to give them the number.", + "seq": 4 + }, + "timestamp": 1792538436689 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_07f1526e0392f41d006ad7f744a2ec87d091c390e511ce0504", + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "agentId": "reasoner", + "role": "assistant", + "textLength": 3, + "text": "391", + "seq": 5 + }, + "timestamp": 1792538436914 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "7f6d7aa1-1b96-4a38-b72b-6c8ffe8cd3a6", + "threadId": "ae6c9cb8-d3d7-4f53-a2ef-9b1e8e477d70", + "agentId": "reasoner", + "seq": 6 + }, + "timestamp": 1792538436916 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/intelligence/reload.json b/extension/tests/fixtures/agent-traffic/intelligence/reload.json new file mode 100644 index 000000000..cfbbf224e --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/intelligence/reload.json @@ -0,0 +1,868 @@ +{ + "name": "reload", + "transport": "intelligence", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 107, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"c7a614d3-30b8-421a-bf04-dabe8aa50112\",\"runId\":\"6bf37379-8515-4028-ad00-b7134924bd19\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"e3455955-980a-48f0-be63-1ea002b7dc66\",\"role\":\"user\",\"content\":\"Name three oceans, separated by commas. Call no tools.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 188, + "text": "{\"threadId\":\"c7a614d3-30b8-421a-bf04-dabe8aa50112\",\"runId\":\"6bf37379-8515-4028-ad00-b7134924bd19\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:c7a614d3-30b8-421a-bf04-dabe8aa50112\"}}" + } + ], + "end": { + "at": 188, + "outcome": "complete" + } + }, + { + "at": 3039, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/connect\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"c7a614d3-30b8-421a-bf04-dabe8aa50112\",\"runId\":\"2e9818b1-a3bb-4cf3-89cf-61cb54eb155b\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[],\"lastSeenEventId\":null}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 3044, + "text": "{\"threadId\":\"c7a614d3-30b8-421a-bf04-dabe8aa50112\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:c7a614d3-30b8-421a-bf04-dabe8aa50112\"}}" + } + ], + "end": { + "at": 3044, + "outcome": "complete" + } + } + ], + "frames": [ + { + "at": 191, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19" + } + }, + { + "at": 253, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Name three oceans, separated by commas. Call no tools.", + "id": "e3455955-980a-48f0-be63-1ea002b7dc66", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "state": {}, + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "tools": [] + }, + "metadata": { + "cpki_event_id": "51c8da48-ac8f-4134-8700-f7c3961190ed", + "cpki_event_seq": 1, + "cpki_ingested": "01a15baf-46ea-793d-b8fe-aa8360739f22" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "RUN_STARTED" + } + }, + { + "at": 256, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "replay_complete" + }, + { + "at": 1018, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "6487cf6b-f834-4a78-b30a-3ba23fd01d93", + "cpki_event_seq": 2, + "cpki_ingested": "01a15baf-4a20-79a5-a779-0e5da7116c4a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "assistant", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_START" + } + }, + { + "at": 1018, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": "Pacific", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "65980a4e-e733-4d65-b7b9-4b512dce3609", + "cpki_event_seq": 3, + "cpki_ingested": "01a15baf-4a20-7d25-9191-8b01bbbd4f51" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1019, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Ocean", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "bf61b156-8813-4105-947b-949a7cd712e1", + "cpki_event_seq": 4, + "cpki_ingested": "01a15baf-4a20-7104-ac9b-f6a0614d3c62" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1051, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "2f9e0245-8bc4-46d9-8b2c-9d8f457804d1", + "cpki_event_seq": 5, + "cpki_ingested": "01a15baf-4a41-7f76-81aa-98515b914e22" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1051, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Atlantic", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "d52983e7-66d9-4bee-9a86-c489ee4e1d42", + "cpki_event_seq": 6, + "cpki_ingested": "01a15baf-4a41-7ea2-8abc-5086dfb2eade" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1051, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Ocean", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "e6e5d57a-805a-4963-bf3b-7c185ee27b99", + "cpki_event_seq": 7, + "cpki_ingested": "01a15baf-4a41-7277-a948-6988f11ed20b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1051, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "ddaf1d47-4425-4aad-8852-609fb3286cf5", + "cpki_event_seq": 8, + "cpki_ingested": "01a15baf-4a41-7a8e-a5a5-ed99d2c42f85" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1095, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Indian", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "3f993ebd-4e88-420e-ad22-632c2ecee792", + "cpki_event_seq": 9, + "cpki_ingested": "01a15baf-4a6d-7784-9d0c-2d785580367d" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1095, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Ocean", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "fb213c88-10e1-49a1-8120-3a5d706e1563", + "cpki_event_seq": 10, + "cpki_ingested": "01a15baf-4a6e-7243-91d1-3268f97cdbf9" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 1426, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "5b9a4de5-2956-419d-b3c1-6e8fd558aeb3", + "cpki_event_seq": 11, + "cpki_ingested": "01a15baf-4bb8-7c01-bb43-356173370ce5" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_END" + } + }, + { + "at": 1426, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "561389bf-a9ff-4a4b-b5ec-a4f2eec92ffd", + "cpki_event_seq": 12, + "cpki_ingested": "01a15baf-4bb8-7e30-9635-159b09f6ce38", + "finishReason": "stop" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 245, + "model": "gpt-4.1-mini", + "outputTokens": 10, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 255 + } + ] + } + }, + { + "at": 1427, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "phx_leave" + }, + { + "at": 3049, + "socket": 2, + "direction": "out", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "phx_join", + "payload": { + "stream_mode": "connect", + "last_seen_event_id": null + } + }, + { + "at": 3061, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Name three oceans, separated by commas. Call no tools.", + "id": "e3455955-980a-48f0-be63-1ea002b7dc66", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "state": {}, + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "tools": [] + }, + "metadata": { + "cpki_event_id": "51c8da48-ac8f-4134-8700-f7c3961190ed", + "cpki_event_seq": 1, + "cpki_ingested": "01a15baf-46ea-793d-b8fe-aa8360739f22" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "RUN_STARTED" + } + }, + { + "at": 3061, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "6487cf6b-f834-4a78-b30a-3ba23fd01d93", + "cpki_event_seq": 2, + "cpki_ingested": "01a15baf-4a20-79a5-a779-0e5da7116c4a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "assistant", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_START" + } + }, + { + "at": 3061, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": "Pacific", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "65980a4e-e733-4d65-b7b9-4b512dce3609", + "cpki_event_seq": 3, + "cpki_ingested": "01a15baf-4a20-7d25-9191-8b01bbbd4f51" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 3061, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Ocean", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "bf61b156-8813-4105-947b-949a7cd712e1", + "cpki_event_seq": 4, + "cpki_ingested": "01a15baf-4a20-7104-ac9b-f6a0614d3c62" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 3061, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "2f9e0245-8bc4-46d9-8b2c-9d8f457804d1", + "cpki_event_seq": 5, + "cpki_ingested": "01a15baf-4a41-7f76-81aa-98515b914e22" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 3061, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Atlantic", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "d52983e7-66d9-4bee-9a86-c489ee4e1d42", + "cpki_event_seq": 6, + "cpki_ingested": "01a15baf-4a41-7ea2-8abc-5086dfb2eade" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 3062, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Ocean", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "e6e5d57a-805a-4963-bf3b-7c185ee27b99", + "cpki_event_seq": 7, + "cpki_ingested": "01a15baf-4a41-7277-a948-6988f11ed20b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 3062, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "ddaf1d47-4425-4aad-8852-609fb3286cf5", + "cpki_event_seq": 8, + "cpki_ingested": "01a15baf-4a41-7a8e-a5a5-ed99d2c42f85" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 3062, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Indian", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "3f993ebd-4e88-420e-ad22-632c2ecee792", + "cpki_event_seq": 9, + "cpki_ingested": "01a15baf-4a6d-7784-9d0c-2d785580367d" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 3062, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "delta": " Ocean", + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "fb213c88-10e1-49a1-8120-3a5d706e1563", + "cpki_event_seq": 10, + "cpki_ingested": "01a15baf-4a6e-7243-91d1-3268f97cdbf9" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 3062, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "metadata": { + "cpki_event_id": "5b9a4de5-2956-419d-b3c1-6e8fd558aeb3", + "cpki_event_seq": 11, + "cpki_ingested": "01a15baf-4bb8-7c01-bb43-356173370ce5" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "TEXT_MESSAGE_END" + } + }, + { + "at": 3062, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "561389bf-a9ff-4a4b-b5ec-a4f2eec92ffd", + "cpki_event_seq": 12, + "cpki_ingested": "01a15baf-4bb8-7e30-9635-159b09f6ce38", + "finishReason": "stop" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "run_id": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "thread_id": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 245, + "model": "gpt-4.1-mini", + "outputTokens": 10, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 255 + } + ] + } + }, + { + "at": 3062, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "replay_complete" + }, + { + "at": 3062, + "socket": 2, + "direction": "in", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "stream_idle" + }, + { + "at": 3063, + "socket": 2, + "direction": "out", + "joinRef": "5", + "topic": "thread:c7a614d3-30b8-421a-bf04-dabe8aa50112", + "event": "phx_leave" + } + ], + "sink": [ + { + "trajectoryId": "fe345290-429d-47f6-a88a-b86fa04ef7da", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792539575853 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "e3455955-980a-48f0-be63-1ea002b7dc66", + "runId": null, + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "agentId": "default", + "role": "user", + "textLength": 54, + "text": "Name three oceans, separated by commas. Call no tools.", + "seq": 2 + }, + "timestamp": 1792539575957 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792539576108 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "agentId": "default", + "role": "assistant", + "textLength": 43, + "text": "Pacific Ocean, Atlantic Ocean, Indian Ocean", + "seq": 4 + }, + "timestamp": 1792539577278 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792539577279 + } + ], + "dropped": 0 + } + ], + "replaySink": [ + { + "trajectoryId": "ed6a0982-24c9-4274-89d0-87bda171a19f", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792539578836 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "agentId": "default", + "seq": 2 + }, + "timestamp": 1792539578913 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0587ec55f2a91fbc006ad7fbb8d8b887d08b91b3a21a2b47c2", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "agentId": "default", + "role": "assistant", + "textLength": 43, + "text": "Pacific Ocean, Atlantic Ocean, Indian Ocean", + "seq": 3 + }, + "timestamp": 1792539578914 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "6bf37379-8515-4028-ad00-b7134924bd19", + "threadId": "c7a614d3-30b8-421a-bf04-dabe8aa50112", + "agentId": "default", + "seq": 4 + }, + "timestamp": 1792539578914 + } + ], + "dropped": 0 + } + ], + "replayFromAt": 2984 +} diff --git a/extension/tests/fixtures/agent-traffic/intelligence/run-error.json b/extension/tests/fixtures/agent-traffic/intelligence/run-error.json new file mode 100644 index 000000000..c3648e0d1 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/intelligence/run-error.json @@ -0,0 +1,184 @@ +{ + "name": "run-error", + "transport": "intelligence", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"broken\"},\"body\":{\"threadId\":\"3a283c98-c95d-47fd-8efd-42069d83dc26\",\"runId\":\"9ed26ec6-e9f5-4010-8a13-f12032d09d14\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"96a00fbd-f7e9-4222-900c-e7048754f84a\",\"role\":\"user\",\"content\":\"Hello?\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 98, + "text": "{\"threadId\":\"3a283c98-c95d-47fd-8efd-42069d83dc26\",\"runId\":\"9ed26ec6-e9f5-4010-8a13-f12032d09d14\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:3a283c98-c95d-47fd-8efd-42069d83dc26\"}}" + } + ], + "end": { + "at": 98, + "outcome": "complete" + } + } + ], + "frames": [ + { + "at": 101, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:3a283c98-c95d-47fd-8efd-42069d83dc26", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "9ed26ec6-e9f5-4010-8a13-f12032d09d14" + } + }, + { + "at": 154, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:3a283c98-c95d-47fd-8efd-42069d83dc26", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Hello?", + "id": "96a00fbd-f7e9-4222-900c-e7048754f84a", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "9ed26ec6-e9f5-4010-8a13-f12032d09d14", + "state": {}, + "threadId": "3a283c98-c95d-47fd-8efd-42069d83dc26", + "tools": [] + }, + "metadata": { + "cpki_event_id": "254b1659-69fd-4595-808f-fed0b0d7c560", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9d-ecd3-70e2-b82c-7534e35128c1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "9ed26ec6-e9f5-4010-8a13-f12032d09d14", + "run_id": "9ed26ec6-e9f5-4010-8a13-f12032d09d14", + "threadId": "3a283c98-c95d-47fd-8efd-42069d83dc26", + "thread_id": "3a283c98-c95d-47fd-8efd-42069d83dc26", + "type": "RUN_STARTED" + } + }, + { + "at": 158, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:3a283c98-c95d-47fd-8efd-42069d83dc26", + "event": "replay_complete" + }, + { + "at": 5590, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:3a283c98-c95d-47fd-8efd-42069d83dc26", + "event": "ag_ui_event", + "payload": { + "message": "The model `gpt-recorder-no-such-model` does not exist or you do not have access to it.", + "metadata": { + "cpki_event_id": "77aaf04e-1923-44e0-9737-54283591a561", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9e-0241-7daf-83f6-039d6ef23db8" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "9ed26ec6-e9f5-4010-8a13-f12032d09d14", + "run_id": "9ed26ec6-e9f5-4010-8a13-f12032d09d14", + "threadId": "3a283c98-c95d-47fd-8efd-42069d83dc26", + "thread_id": "3a283c98-c95d-47fd-8efd-42069d83dc26", + "type": "RUN_ERROR" + } + }, + { + "at": 5591, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:3a283c98-c95d-47fd-8efd-42069d83dc26", + "event": "phx_leave" + } + ], + "sink": [ + { + "trajectoryId": "3222fe6b-78b3-4e7f-aa50-7ae092c554ce", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538438774 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "96a00fbd-f7e9-4222-900c-e7048754f84a", + "runId": null, + "threadId": "3a283c98-c95d-47fd-8efd-42069d83dc26", + "agentId": "broken", + "role": "user", + "textLength": 6, + "text": "Hello?", + "seq": 2 + }, + "timestamp": 1792538438826 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "9ed26ec6-e9f5-4010-8a13-f12032d09d14", + "threadId": "3a283c98-c95d-47fd-8efd-42069d83dc26", + "agentId": "broken", + "seq": 3 + }, + "timestamp": 1792538438930 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "3222fe6b-78b3-4e7f-aa50-7ae092c554ce", + "events": [ + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "error", + "runId": "9ed26ec6-e9f5-4010-8a13-f12032d09d14", + "threadId": "3a283c98-c95d-47fd-8efd-42069d83dc26", + "agentId": "broken", + "seq": 4 + }, + "timestamp": 1792538444365 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/intelligence/state.json b/extension/tests/fixtures/agent-traffic/intelligence/state.json new file mode 100644 index 000000000..0d6b0ae52 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/intelligence/state.json @@ -0,0 +1,1860 @@ +{ + "name": "state", + "transport": "intelligence", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"e190b000-d47e-4b2e-9c40-bd291c72e586\",\"runId\":\"d9a2743a-15f8-47ff-a528-857867b6436b\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{\"todos\":[]},\"messages\":[{\"id\":\"088af6cb-f367-4dd1-89ad-49ccb221ac3f\",\"role\":\"user\",\"content\":\"First call AGUISendStateSnapshot with snapshot {\\\"todos\\\":[\\\"buy milk\\\"]}. Then call AGUISendStateDelta to add \\\"walk dog\\\" at /todos/-. Then call AGUISendStateDelta again to add \\\"post letter\\\" at /todos/-. Then say done.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 99, + "text": "{\"threadId\":\"e190b000-d47e-4b2e-9c40-bd291c72e586\",\"runId\":\"d9a2743a-15f8-47ff-a528-857867b6436b\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:e190b000-d47e-4b2e-9c40-bd291c72e586\"}}" + } + ], + "end": { + "at": 99, + "outcome": "complete" + } + } + ], + "frames": [ + { + "at": 102, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b" + } + }, + { + "at": 158, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "First call AGUISendStateSnapshot with snapshot {\"todos\":[\"buy milk\"]}. Then call AGUISendStateDelta to add \"walk dog\" at /todos/-. Then call AGUISendStateDelta again to add \"post letter\" at /todos/-. Then say done.", + "id": "088af6cb-f367-4dd1-89ad-49ccb221ac3f", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "state": { + "todos": [] + }, + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "tools": [] + }, + "metadata": { + "cpki_event_id": "9822fb21-e4a8-4413-af03-731b1b9ff621", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9d-c201-704d-b18c-5eea682e0935" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "type": "RUN_STARTED" + } + }, + { + "at": 161, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "replay_complete" + }, + { + "at": 897, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "8df96c99-dcf1-454c-b4a9-2fc2dad68302", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9d-c518-771e-91ed-e4ee7d74d476" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "parentMessageId": "bb4ce748-d503-43b4-9e69-a3a594ea1419", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "toolCallName": "AGUISendStateSnapshot", + "type": "TOOL_CALL_START" + } + }, + { + "at": 899, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "{\"", + "metadata": { + "cpki_event_id": "e12f20b1-79ed-44bc-a7bb-567ed08933e2", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9d-c518-710b-908a-b3564574c3b7" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 900, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "snapshot", + "metadata": { + "cpki_event_id": "74664e0c-26a5-48b5-a0dd-a697d6a5fed4", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9d-c518-751e-bfcd-a9498f90196c" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 900, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":{\"", + "metadata": { + "cpki_event_id": "c29ca9b8-4145-4ef6-a62c-8a38eafff15c", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9d-c518-728f-ac72-e3ee846b4dd0" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 901, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "todos", + "metadata": { + "cpki_event_id": "32e71e7e-ca37-4299-87bd-f2606e09653f", + "cpki_event_seq": 6, + "cpki_ingested": "01a15b9d-c518-78ec-9df5-001602456d18" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 901, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":[\"", + "metadata": { + "cpki_event_id": "96cea2f0-da9e-4d6a-b016-37d03ba6519f", + "cpki_event_seq": 7, + "cpki_ingested": "01a15b9d-c518-728b-9b7e-5269aefbe8ad" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 901, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "buy", + "metadata": { + "cpki_event_id": "f037dcd9-957c-4175-b420-f502c4cce951", + "cpki_event_seq": 8, + "cpki_ingested": "01a15b9d-c518-72e4-a11d-f28659ab5d66" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 907, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": " milk", + "metadata": { + "cpki_event_id": "5a7c6da2-8a6a-44cf-9fa6-2c0d68088645", + "cpki_event_seq": 9, + "cpki_ingested": "01a15b9d-c522-7490-ba76-74a048f2af64" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 907, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\"]", + "metadata": { + "cpki_event_id": "5b0cc9cc-8997-422f-a240-84a08e4a45e5", + "cpki_event_seq": 10, + "cpki_ingested": "01a15b9d-c522-769c-ab88-bb0d4cfc7295" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 950, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "}}", + "metadata": { + "cpki_event_id": "a4f97cf1-e374-4e92-9490-f84ee936336c", + "cpki_event_seq": 11, + "cpki_ingested": "01a15b9d-c54d-7421-88e6-c2226a558ae9" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 973, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "7afde53d-bb31-48de-9c7d-1d80410792df", + "cpki_event_seq": 12, + "cpki_ingested": "01a15b9d-c565-7758-bea7-52ce74684059" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_END" + } + }, + { + "at": 974, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "492b2cc5-0578-4d4a-8022-d5d2e3e4d930", + "cpki_event_seq": 13, + "cpki_ingested": "01a15b9d-c565-73e8-a2a0-2d9927208df1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "snapshot": { + "todos": ["buy milk"] + }, + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "type": "STATE_SNAPSHOT" + } + }, + { + "at": 975, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "content": "{\"success\":true,\"snapshot\":{\"todos\":[\"buy milk\"]}}", + "messageId": "7bc999fa-0a7f-4e91-b83a-cd401e66580f", + "metadata": { + "cpki_event_id": "f2bd4ebd-2ab4-4e12-bc38-d29a296b58dc", + "cpki_event_seq": 14, + "cpki_ingested": "01a15b9d-c565-7b60-a35b-4306083a12a9" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "tool", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "type": "TOOL_CALL_RESULT" + } + }, + { + "at": 1929, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "0fc8e471-5c76-4865-83d3-d0ee1e46800f", + "cpki_event_seq": 15, + "cpki_ingested": "01a15b9d-c920-7d28-abd7-fa7faedf812f" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "parentMessageId": "bb4ce748-d503-43b4-9e69-a3a594ea1419", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "toolCallName": "AGUISendStateDelta", + "type": "TOOL_CALL_START" + } + }, + { + "at": 1930, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "{\"", + "metadata": { + "cpki_event_id": "75019bc4-9a2d-4350-a827-79b0421cbd67", + "cpki_event_seq": 16, + "cpki_ingested": "01a15b9d-c920-7a11-b4c8-01b01076445a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 1930, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "delta", + "metadata": { + "cpki_event_id": "40378395-a9d3-4154-8072-f7a2e709dacd", + "cpki_event_seq": 17, + "cpki_ingested": "01a15b9d-c920-7dea-a087-7f3ee5ec29fc" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 1941, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":[", + "metadata": { + "cpki_event_id": "669fa6bd-dcc1-4ef8-9950-90d912b7f030", + "cpki_event_seq": 18, + "cpki_ingested": "01a15b9d-c92c-76ce-8434-b9d5aee34aa6" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 1941, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "{\"", + "metadata": { + "cpki_event_id": "22e44af6-3db4-48a2-8e2e-1ce0944586c2", + "cpki_event_seq": 19, + "cpki_ingested": "01a15b9d-c92c-761a-965c-6392a1a0d3b4" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 1941, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "op", + "metadata": { + "cpki_event_id": "4ecc7cce-379d-45a4-9586-40b563f004f6", + "cpki_event_seq": 20, + "cpki_ingested": "01a15b9d-c92c-7929-b1b3-0f8110ffd67d" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 1941, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":\"", + "metadata": { + "cpki_event_id": "d144a79a-6857-47b7-8582-015ef01f33e8", + "cpki_event_seq": 21, + "cpki_ingested": "01a15b9d-c92c-7dae-aac5-1732a53477fb" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2010, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "add", + "metadata": { + "cpki_event_id": "0557f3ad-95d6-4ea8-9cd8-70f7ec24fe8f", + "cpki_event_seq": 22, + "cpki_ingested": "01a15b9d-c971-7e1f-99bf-bd1cda400e76" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2011, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\",\"", + "metadata": { + "cpki_event_id": "8d24be67-2c0b-4fc2-94ae-e8e0007a5be9", + "cpki_event_seq": 23, + "cpki_ingested": "01a15b9d-c971-76fc-80ee-57b25d63f166" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2019, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "path", + "metadata": { + "cpki_event_id": "d07aeb29-8fb8-40b0-99a0-121ce3b51c75", + "cpki_event_seq": 24, + "cpki_ingested": "01a15b9d-c97a-7544-82b0-17211f9f35b6" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2020, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":", + "metadata": { + "cpki_event_id": "ffbe5ade-de18-458a-a03e-b1fc9c1c43a7", + "cpki_event_seq": 25, + "cpki_ingested": "01a15b9d-c97a-7d15-ab97-117c39cccfe4" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2029, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\"/", + "metadata": { + "cpki_event_id": "51ceb81e-523f-4fa8-acc3-8564d2176a18", + "cpki_event_seq": 26, + "cpki_ingested": "01a15b9d-c984-7f18-a284-de7cce961cec" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2030, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "todos", + "metadata": { + "cpki_event_id": "c2f0539e-ece1-4ed2-b465-9219f8ca736d", + "cpki_event_seq": 27, + "cpki_ingested": "01a15b9d-c984-74eb-8713-01c37f9768ba" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2055, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "/-", + "metadata": { + "cpki_event_id": "f0bc3790-0f1c-4112-9bd7-cf02747f2e21", + "cpki_event_seq": 28, + "cpki_ingested": "01a15b9d-c99e-73d7-9b3f-fb417ec933b8" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2074, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\",\"", + "metadata": { + "cpki_event_id": "b3658809-a93a-4130-bb72-29cce3676a34", + "cpki_event_seq": 29, + "cpki_ingested": "01a15b9d-c9b1-7d5b-a337-8723850201c3" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2085, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "value", + "metadata": { + "cpki_event_id": "896429de-93c2-477d-865c-2eec38106306", + "cpki_event_seq": 30, + "cpki_ingested": "01a15b9d-c9bd-748e-bc4a-166cdcdbb4df" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2086, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":\"", + "metadata": { + "cpki_event_id": "34d9745d-d860-44e6-8465-3e8bbcce1b6f", + "cpki_event_seq": 31, + "cpki_ingested": "01a15b9d-c9bd-74a1-ace5-604ae3696661" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2086, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "walk", + "metadata": { + "cpki_event_id": "72aa555b-bb74-4c38-959d-ad5cb0eb4d6a", + "cpki_event_seq": 32, + "cpki_ingested": "01a15b9d-c9bd-737e-b7ee-be7eb705700d" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2093, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": " dog", + "metadata": { + "cpki_event_id": "0ae758ba-e96c-49b0-8abc-59dbdc0916fb", + "cpki_event_seq": 33, + "cpki_ingested": "01a15b9d-c9c4-7923-93ed-e8c31387a3e8" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2093, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\"}", + "metadata": { + "cpki_event_id": "8d7f3139-7d84-4e96-92cf-8c54c2e30f59", + "cpki_event_seq": 34, + "cpki_ingested": "01a15b9d-c9c4-73e4-afd2-c092deb0daba" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2093, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "]}", + "metadata": { + "cpki_event_id": "90d990fd-0dcf-436c-a2af-c612cf4da67b", + "cpki_event_seq": 35, + "cpki_ingested": "01a15b9d-c9c4-7138-95bc-2d83bf951bad" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2094, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "8c37c282-8f0e-4dfd-879b-21acfe433a7b", + "cpki_event_seq": 36, + "cpki_ingested": "01a15b9d-c9c4-73a5-8674-10a53701c79a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_END" + } + }, + { + "at": 2094, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": [ + { + "op": "add", + "path": "/todos/-", + "value": "walk dog" + } + ], + "metadata": { + "cpki_event_id": "1e938772-9a59-47c6-b3d5-2915a85c865a", + "cpki_event_seq": 37, + "cpki_ingested": "01a15b9d-c9c4-7fa3-9e77-b0a52e3371b2" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "type": "STATE_DELTA" + } + }, + { + "at": 2095, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "content": "{\"success\":true,\"delta\":[{\"op\":\"add\",\"path\":\"/todos/-\",\"value\":\"walk dog\"}]}", + "messageId": "a3f6030c-7014-49d6-a8d4-212689cf4132", + "metadata": { + "cpki_event_id": "921c3caf-f8fa-43ef-b766-485c2656ffc4", + "cpki_event_seq": 38, + "cpki_ingested": "01a15b9d-c9c4-7493-90b4-52fa7ec2c60b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "tool", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "type": "TOOL_CALL_RESULT" + } + }, + { + "at": 2883, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "d4f74e42-622b-40d3-a8ba-9706192ce54d", + "cpki_event_seq": 39, + "cpki_ingested": "01a15b9d-ccd8-74d1-87c8-1dc81a2b3ab5" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "parentMessageId": "bb4ce748-d503-43b4-9e69-a3a594ea1419", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "toolCallName": "AGUISendStateDelta", + "type": "TOOL_CALL_START" + } + }, + { + "at": 2884, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "{\"", + "metadata": { + "cpki_event_id": "d4ede5f9-777c-4992-ad19-4e19ec137d2a", + "cpki_event_seq": 40, + "cpki_ingested": "01a15b9d-ccd8-7773-9646-974bfc115e68" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2884, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "delta", + "metadata": { + "cpki_event_id": "9caf8976-8622-4771-81d7-1f6b910be74e", + "cpki_event_seq": 41, + "cpki_ingested": "01a15b9d-ccd8-7f74-a864-374a2a9f350e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2904, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":[", + "metadata": { + "cpki_event_id": "826ac289-032d-45c2-b6b1-ea7deb012d3f", + "cpki_event_seq": 42, + "cpki_ingested": "01a15b9d-ccf0-7bbd-a2ca-3307ed9fe859" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2905, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "{\"", + "metadata": { + "cpki_event_id": "fa073f53-dc14-4b7e-ba48-401cdb9b2455", + "cpki_event_seq": 43, + "cpki_ingested": "01a15b9d-ccf0-76c7-8497-907a25152c42" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2910, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "op", + "metadata": { + "cpki_event_id": "6f2d05fd-8d6c-4cd6-a307-b819e9525693", + "cpki_event_seq": 44, + "cpki_ingested": "01a15b9d-ccf6-7c14-be81-c18edb6b1323" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2911, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":\"", + "metadata": { + "cpki_event_id": "f993ffba-70aa-42d3-b772-a7fdaf76d9ac", + "cpki_event_seq": 45, + "cpki_ingested": "01a15b9d-ccf6-7863-9847-efeee78e68b1" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2954, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "add", + "metadata": { + "cpki_event_id": "b3197d99-cd89-4b7f-984a-94d1f044f6ae", + "cpki_event_seq": 46, + "cpki_ingested": "01a15b9d-cd1f-7861-9c2c-a4603bf05927" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2954, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\",\"", + "metadata": { + "cpki_event_id": "d6e6cbf3-8fe3-4ad1-9b8b-9664667da5ad", + "cpki_event_seq": 47, + "cpki_ingested": "01a15b9d-cd1f-79b4-9b98-c8b79c3a9868" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2966, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "path", + "metadata": { + "cpki_event_id": "e8ed1380-caf5-4263-abeb-d821c73587e5", + "cpki_event_seq": 48, + "cpki_ingested": "01a15b9d-cd2d-720a-a94b-0a5c3218a2da" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2966, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":", + "metadata": { + "cpki_event_id": "7c7701ba-9078-4381-a445-70b4a890e2fa", + "cpki_event_seq": 49, + "cpki_ingested": "01a15b9d-cd2d-7221-af47-0e2144334b5b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2967, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\"/", + "metadata": { + "cpki_event_id": "ea50d07a-7348-4a77-beea-cf205897ddfd", + "cpki_event_seq": 50, + "cpki_ingested": "01a15b9d-cd2d-7f4e-a9c8-01a886e8fca6" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2979, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "todos", + "metadata": { + "cpki_event_id": "f02e3473-0326-4470-94ab-75afa5b86aa0", + "cpki_event_seq": 51, + "cpki_ingested": "01a15b9d-cd39-72ae-a816-f1fc77b55466" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2979, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "/-", + "metadata": { + "cpki_event_id": "f91136fe-2bde-42d2-8359-3ba860081940", + "cpki_event_seq": 52, + "cpki_ingested": "01a15b9d-cd39-7ca6-846d-805910907cc5" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2984, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\",\"", + "metadata": { + "cpki_event_id": "22ce34e8-b109-4b5c-a8b7-0c04a14bbeb5", + "cpki_event_seq": 53, + "cpki_ingested": "01a15b9d-cd3e-75e7-a7a7-c4d84c792e7b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 2984, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "value", + "metadata": { + "cpki_event_id": "ecff8b16-d709-452d-84ee-44e6dde67242", + "cpki_event_seq": 54, + "cpki_ingested": "01a15b9d-cd3e-7c5c-b1bd-b9b2ae8a1c5c" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 3003, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\":\"", + "metadata": { + "cpki_event_id": "67a973bf-6a0d-4770-be19-6091845c5875", + "cpki_event_seq": 55, + "cpki_ingested": "01a15b9d-cd53-7c38-82c8-2b047715a941" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 3021, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "post", + "metadata": { + "cpki_event_id": "bdeea007-ee3b-4e03-a50c-18f68896cf50", + "cpki_event_seq": 56, + "cpki_ingested": "01a15b9d-cd64-7e2d-9015-c420cc2a98bf" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 3041, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": " letter", + "metadata": { + "cpki_event_id": "cc343169-f19a-4952-9d72-0a2db72fb2cd", + "cpki_event_seq": 57, + "cpki_ingested": "01a15b9d-cd77-7b24-8163-0ca71cbb4283" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 3046, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "\"}", + "metadata": { + "cpki_event_id": "ed3896e4-d080-49f3-b552-6ec4c7e3262b", + "cpki_event_seq": 58, + "cpki_ingested": "01a15b9d-cd7c-7c80-aa5a-ca212b7a7e80" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 3051, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "]}", + "metadata": { + "cpki_event_id": "e41abae6-fe22-44f4-b2fa-f366317f5b2f", + "cpki_event_seq": 59, + "cpki_ingested": "01a15b9d-cd82-73cc-9b5c-170c977483aa" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_ARGS" + } + }, + { + "at": 3058, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "256aa73f-9b06-4919-98fa-f51bd44b29fa", + "cpki_event_seq": 60, + "cpki_ingested": "01a15b9d-cd89-779a-ad15-5a881e6a95b5" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_END" + } + }, + { + "at": 3058, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": [ + { + "op": "add", + "path": "/todos/-", + "value": "post letter" + } + ], + "metadata": { + "cpki_event_id": "cafd4965-8a1d-4178-83d4-18faa60a6989", + "cpki_event_seq": 61, + "cpki_ingested": "01a15b9d-cd89-7474-8384-895cc366a19e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "type": "STATE_DELTA" + } + }, + { + "at": 3059, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "content": "{\"success\":true,\"delta\":[{\"op\":\"add\",\"path\":\"/todos/-\",\"value\":\"post letter\"}]}", + "messageId": "cb76eb57-4b2c-4239-9128-374f8bc636f5", + "metadata": { + "cpki_event_id": "f37441eb-db55-4719-9521-fc5fae48d2d8", + "cpki_event_seq": 62, + "cpki_ingested": "01a15b9d-cd89-7e03-9b3c-9baedd1a971c" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "tool", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "type": "TOOL_CALL_RESULT" + } + }, + { + "at": 4476, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_00c77993a962e514006ad7f740480887d0a0ca49fa90c7c4d7", + "metadata": { + "cpki_event_id": "524a7481-7105-4535-bc76-6b8d5d3193b6", + "cpki_event_seq": 63, + "cpki_ingested": "01a15b9d-d313-7365-a07e-ad645a0b84e4" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "assistant", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "type": "TEXT_MESSAGE_START" + } + }, + { + "at": 4477, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "delta": "done", + "messageId": "msg_00c77993a962e514006ad7f740480887d0a0ca49fa90c7c4d7", + "metadata": { + "cpki_event_id": "d0da0498-8769-46ce-8ba5-0dea8a7df2e7", + "cpki_event_seq": 64, + "cpki_ingested": "01a15b9d-d313-72e0-a55a-72e773bd7abd" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 4720, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_00c77993a962e514006ad7f740480887d0a0ca49fa90c7c4d7", + "metadata": { + "cpki_event_id": "0905fd60-5bef-4208-8df3-64a6cba92ccb", + "cpki_event_seq": 65, + "cpki_ingested": "01a15b9d-d406-7226-83be-19820921bd01" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "type": "TEXT_MESSAGE_END" + } + }, + { + "at": 4721, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "8679af3f-5997-4686-9d3a-cf2dd21c7071", + "cpki_event_seq": 66, + "cpki_ingested": "01a15b9d-d406-7f71-bf98-5d979df7dae2", + "finishReason": "stop" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "run_id": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "thread_id": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 1534, + "model": "gpt-4.1-mini", + "outputTokens": 94, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 1628 + } + ] + } + }, + { + "at": 4723, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:e190b000-d47e-4b2e-9c40-bd291c72e586", + "event": "phx_leave" + } + ], + "sink": [ + { + "trajectoryId": "0528015b-13dc-4b45-a867-89c9909aea22", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538427810 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "088af6cb-f367-4dd1-89ad-49ccb221ac3f", + "runId": null, + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "agentId": "default", + "role": "user", + "textLength": 214, + "text": "First call AGUISendStateSnapshot with snapshot {\"todos\":[\"buy milk\"]}. Then call AGUISendStateDelta to add \"walk dog\" at /todos/-. Then call AGUISendStateDelta again to add \"post letter\" at /todos/-. Then say done.", + "seq": 2 + }, + "timestamp": 1792538427862 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538427970 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_7jlfyGjahjej8leVnWuWkDxm", + "toolName": "AGUISendStateSnapshot", + "agentId": "default", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "messageId": "bb4ce748-d503-43b4-9e69-a3a594ea1419", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "seq": 4 + }, + "timestamp": 1792538428707 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "7bc999fa-0a7f-4e91-b83a-cd401e66580f", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "agentId": "default", + "role": "tool", + "textLength": 50, + "text": "{\"success\":true,\"snapshot\":{\"todos\":[\"buy milk\"]}}", + "seq": 5 + }, + "timestamp": 1792538428784 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_qTDNe2uRkEYbrMXXQtR6qTMx", + "toolName": "AGUISendStateDelta", + "agentId": "default", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "messageId": "bb4ce748-d503-43b4-9e69-a3a594ea1419", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "seq": 6 + }, + "timestamp": 1792538429738 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "0528015b-13dc-4b45-a867-89c9909aea22", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "a3f6030c-7014-49d6-a8d4-212689cf4132", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "agentId": "default", + "role": "tool", + "textLength": 76, + "text": "{\"success\":true,\"delta\":[{\"op\":\"add\",\"path\":\"/todos/-\",\"value\":\"walk dog\"}]}", + "seq": 7 + }, + "timestamp": 1792538429904 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_Rh3uRPIpibQmw2H7LnnqFq8E", + "toolName": "AGUISendStateDelta", + "agentId": "default", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "messageId": "bb4ce748-d503-43b4-9e69-a3a594ea1419", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "seq": 8 + }, + "timestamp": 1792538430692 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "cb76eb57-4b2c-4239-9128-374f8bc636f5", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "agentId": "default", + "role": "tool", + "textLength": 79, + "text": "{\"success\":true,\"delta\":[{\"op\":\"add\",\"path\":\"/todos/-\",\"value\":\"post letter\"}]}", + "seq": 9 + }, + "timestamp": 1792538430867 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "0528015b-13dc-4b45-a867-89c9909aea22", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_00c77993a962e514006ad7f740480887d0a0ca49fa90c7c4d7", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "agentId": "default", + "role": "assistant", + "textLength": 4, + "text": "done", + "seq": 10 + }, + "timestamp": 1792538432529 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "d9a2743a-15f8-47ff-a528-857867b6436b", + "threadId": "e190b000-d47e-4b2e-9c40-bd291c72e586", + "agentId": "default", + "seq": 11 + }, + "timestamp": 1792538432532 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/intelligence/text-answer.json b/extension/tests/fixtures/agent-traffic/intelligence/text-answer.json new file mode 100644 index 000000000..be9a1120e --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/intelligence/text-answer.json @@ -0,0 +1,515 @@ +{ + "name": "text-answer", + "transport": "intelligence", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"86b12d27-49a5-4dc9-b023-2bd5c6412186\",\"runId\":\"d901b771-0984-4a2a-b054-7e41eee47e3d\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"bf38ccd3-73a8-41e9-b606-816c79a260a2\",\"role\":\"user\",\"content\":\"Reply with exactly five words about the sea. Call no tools.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 97, + "text": "{\"threadId\":\"86b12d27-49a5-4dc9-b023-2bd5c6412186\",\"runId\":\"d901b771-0984-4a2a-b054-7e41eee47e3d\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:86b12d27-49a5-4dc9-b023-2bd5c6412186\"}}" + } + ], + "end": { + "at": 97, + "outcome": "complete" + } + } + ], + "frames": [ + { + "at": 101, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d" + } + }, + { + "at": 156, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Reply with exactly five words about the sea. Call no tools.", + "id": "bf38ccd3-73a8-41e9-b606-816c79a260a2", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "state": {}, + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "tools": [] + }, + "metadata": { + "cpki_event_id": "ee519b86-59a4-4f23-b6c5-35d301cf37af", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9d-3e32-7b33-95ac-7a381eba0e79" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "RUN_STARTED" + } + }, + { + "at": 160, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "replay_complete" + }, + { + "at": 648, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "ba1246bb-d787-45df-8c3f-89012cfa6078", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9d-4052-780d-a382-87cd270dd166" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "assistant", + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_START" + } + }, + { + "at": 649, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": "V", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "115386e0-67f9-42c0-bdea-9c072f4ed9d4", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9d-4052-710a-826d-cb46400b7152" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 650, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": "ast", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "86b2b5cb-fba5-4a5f-9f75-e5a26fa66ccb", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9d-4052-7024-ab27-71728466c3e6" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 650, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "7bbee27e-a852-4a57-a198-556f3bf9baa5", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9d-4052-78a5-ab0f-4cf69f23ca04" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 650, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": " deep", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "17d05821-d134-4d55-b1c9-1808f0f85a31", + "cpki_event_seq": 6, + "cpki_ingested": "01a15b9d-4052-730d-9bf3-ed87d42577cb" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 652, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "4206d46a-af9b-45fe-98ed-b1eb4b71cce7", + "cpki_event_seq": 7, + "cpki_ingested": "01a15b9d-4058-7480-9b4a-f095ee61e54a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 664, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": " mysterious", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "bc809c6e-8eb0-44e6-bad9-3c602bbf5850", + "cpki_event_seq": 8, + "cpki_ingested": "01a15b9d-4064-7f50-8b31-0a034553389c" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 665, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "22c4bf32-d485-4154-8da2-35cd8bb27735", + "cpki_event_seq": 9, + "cpki_ingested": "01a15b9d-4064-79b0-b977-78ce6833e929" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 739, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": " calming", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "35d8845c-b6f7-464a-b13f-1541ad3f6fd0", + "cpki_event_seq": 10, + "cpki_ingested": "01a15b9d-40ae-7122-bd0d-0dd55d7fe64e" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 740, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": ",", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "59481cdd-37e7-46ff-90a7-facfcd48c233", + "cpki_event_seq": 11, + "cpki_ingested": "01a15b9d-40ae-7583-a220-14661ca40218" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 745, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": " endless", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "d4099f38-7c03-4b8f-bd3d-cc22d67be9d4", + "cpki_event_seq": 12, + "cpki_ingested": "01a15b9d-40b5-7fd5-8a18-b86f8ad0ac99" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 746, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "delta": ".", + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "e47970a8-21ee-4e2d-99cc-e57f674e11c1", + "cpki_event_seq": 13, + "cpki_ingested": "01a15b9d-40b5-7bd7-899b-b86178752205" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 926, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "metadata": { + "cpki_event_id": "5c784681-fb65-4800-b18e-6fe1e906598b", + "cpki_event_seq": 14, + "cpki_ingested": "01a15b9d-4169-74c3-9177-3441d2e9a1fb" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "TEXT_MESSAGE_END" + } + }, + { + "at": 927, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "02b9d028-bdde-4dcd-8b13-f279dc996c1c", + "cpki_event_seq": 15, + "cpki_ingested": "01a15b9d-4169-71de-b2c5-1f8a91a03ff3", + "finishReason": "stop" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "run_id": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "thread_id": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 211, + "model": "gpt-4.1-mini", + "outputTokens": 13, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 224 + } + ] + } + }, + { + "at": 929, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:86b12d27-49a5-4dc9-b023-2bd5c6412186", + "event": "phx_leave" + } + ], + "sink": [ + { + "trajectoryId": "49b27ff7-90be-459e-89e1-38d7cf51c5ed", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538394070 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "bf38ccd3-73a8-41e9-b606-816c79a260a2", + "runId": null, + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "agentId": "default", + "role": "user", + "textLength": 59, + "text": "Reply with exactly five words about the sea. Call no tools.", + "seq": 2 + }, + "timestamp": 1792538394122 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538394229 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_00f8aa698eeac832006ad7f71ab66487d09097b78150b2cff8", + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "agentId": "default", + "role": "assistant", + "textLength": 41, + "text": "Vast, deep, mysterious, calming, endless.", + "seq": 4 + }, + "timestamp": 1792538394995 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "d901b771-0984-4a2a-b054-7e41eee47e3d", + "threadId": "86b12d27-49a5-4dc9-b023-2bd5c6412186", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792538394998 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/intelligence/two-runs.json b/extension/tests/fixtures/agent-traffic/intelligence/two-runs.json new file mode 100644 index 000000000..0148f4d4c --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/intelligence/two-runs.json @@ -0,0 +1,548 @@ +{ + "name": "two-runs", + "transport": "intelligence", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"6231e72d-6539-481a-9a91-9e25e3672111\",\"runId\":\"3d3db537-abcf-4287-9904-2326e2f0be78\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"4f0d40e5-0046-41da-b714-581857d09139\",\"role\":\"user\",\"content\":\"Name one planet. One word only. Call no tools.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 96, + "text": "{\"threadId\":\"6231e72d-6539-481a-9a91-9e25e3672111\",\"runId\":\"3d3db537-abcf-4287-9904-2326e2f0be78\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:6231e72d-6539-481a-9a91-9e25e3672111\"}}" + } + ], + "end": { + "at": 96, + "outcome": "complete" + } + }, + { + "at": 950, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"6231e72d-6539-481a-9a91-9e25e3672111\",\"runId\":\"c418130f-5e4c-467b-9f06-834545018abb\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"4f0d40e5-0046-41da-b714-581857d09139\",\"role\":\"user\",\"content\":\"Name one planet. One word only. Call no tools.\"},{\"id\":\"msg_07bfedc5a9f7d6e9006ad7f738714887d08dc4798185fb6926\",\"role\":\"assistant\",\"content\":\"Mars\",\"metadata\":{\"cpki_event_id\":\"6700e4f4-5642-43bc-ba40-89ab2e2a3e6a\",\"cpki_event_seq\":4,\"cpki_ingested\":\"01a15b9d-b56a-7ab1-89bd-f74854326321\"}},{\"id\":\"13cd312f-4078-4140-b316-b9904423f2f3\",\"role\":\"user\",\"content\":\"Name another one. One word only. Call no tools.\"}]}}", + "status": 200, + "contentType": "application/json;charset=utf-8", + "chunks": [ + { + "at": 996, + "text": "{\"threadId\":\"6231e72d-6539-481a-9a91-9e25e3672111\",\"runId\":\"c418130f-5e4c-467b-9f06-834545018abb\",\"joinToken\":\"[omitted]\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:6231e72d-6539-481a-9a91-9e25e3672111\"}}" + } + ], + "end": { + "at": 996, + "outcome": "complete" + } + } + ], + "frames": [ + { + "at": 99, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "3d3db537-abcf-4287-9904-2326e2f0be78" + } + }, + { + "at": 154, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Name one planet. One word only. Call no tools.", + "id": "4f0d40e5-0046-41da-b714-581857d09139", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "state": {}, + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "tools": [] + }, + "metadata": { + "cpki_event_id": "a62fba14-7c71-4aa4-8f05-dbf0dca939e6", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9d-b227-7d7a-a5c6-ae06e6c63cd2" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "run_id": "3d3db537-abcf-4287-9904-2326e2f0be78", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "RUN_STARTED" + } + }, + { + "at": 158, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "replay_complete" + }, + { + "at": 792, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_07bfedc5a9f7d6e9006ad7f738714887d08dc4798185fb6926", + "metadata": { + "cpki_event_id": "251cec03-6707-46a0-ad2a-a257c7e365a3", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9d-b4d7-7b51-b5c4-acb1e85532dc" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "assistant", + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "run_id": "3d3db537-abcf-4287-9904-2326e2f0be78", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "TEXT_MESSAGE_START" + } + }, + { + "at": 794, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "delta": "Mars", + "messageId": "msg_07bfedc5a9f7d6e9006ad7f738714887d08dc4798185fb6926", + "metadata": { + "cpki_event_id": "93594c98-a4f5-421a-afd3-e4b1e8f69b2b", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9d-b4d7-7313-8777-ae8a6ed2920b" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "run_id": "3d3db537-abcf-4287-9904-2326e2f0be78", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 940, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_07bfedc5a9f7d6e9006ad7f738714887d08dc4798185fb6926", + "metadata": { + "cpki_event_id": "6700e4f4-5642-43bc-ba40-89ab2e2a3e6a", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9d-b56a-7ab1-89bd-f74854326321" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "run_id": "3d3db537-abcf-4287-9904-2326e2f0be78", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "TEXT_MESSAGE_END" + } + }, + { + "at": 942, + "socket": 1, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "a9727081-9cf8-4842-9a9c-157d2064357a", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9d-b56a-77a5-a4e8-fefe93854783", + "finishReason": "stop" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "run_id": "3d3db537-abcf-4287-9904-2326e2f0be78", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 210, + "model": "gpt-4.1-mini", + "outputTokens": 3, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 213 + } + ] + } + }, + { + "at": 945, + "socket": 1, + "direction": "out", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "phx_leave" + }, + { + "at": 998, + "socket": 2, + "direction": "out", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "phx_join", + "payload": { + "stream_mode": "run", + "run_id": "c418130f-5e4c-467b-9f06-834545018abb" + } + }, + { + "at": 1053, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "input": { + "context": [], + "messages": [ + { + "content": "Name another one. One word only. Call no tools.", + "id": "13cd312f-4078-4140-b316-b9904423f2f3", + "role": "user" + } + ], + "protocolVersion": "1.0", + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "state": {}, + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "tools": [] + }, + "metadata": { + "cpki_event_id": "56663453-d2ec-4e61-ac7d-593eae747835", + "cpki_event_seq": 1, + "cpki_ingested": "01a15b9d-b5aa-7d64-a300-d15408c2e88d" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "protocolVersion": "1.0", + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "run_id": "c418130f-5e4c-467b-9f06-834545018abb", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "RUN_STARTED" + } + }, + { + "at": 1054, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "replay_complete" + }, + { + "at": 2033, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_05271f8f77892f38006ad7f739b82487d0ad182a6185a27b5c", + "metadata": { + "cpki_event_id": "9f4985c8-2dae-4f66-ae9f-20c189c6cc87", + "cpki_event_seq": 2, + "cpki_ingested": "01a15b9d-b9b2-763f-b21c-a840e6c05b9d" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "role": "assistant", + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "run_id": "c418130f-5e4c-467b-9f06-834545018abb", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "TEXT_MESSAGE_START" + } + }, + { + "at": 2034, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "delta": "Ven", + "messageId": "msg_05271f8f77892f38006ad7f739b82487d0ad182a6185a27b5c", + "metadata": { + "cpki_event_id": "4e845d9e-e3b8-4da6-9846-6637e957413a", + "cpki_event_seq": 3, + "cpki_ingested": "01a15b9d-b9b2-78fb-b260-9d31274fe13a" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "run_id": "c418130f-5e4c-467b-9f06-834545018abb", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 2034, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "delta": "us", + "messageId": "msg_05271f8f77892f38006ad7f739b82487d0ad182a6185a27b5c", + "metadata": { + "cpki_event_id": "fbc4f42d-b0db-4244-9c51-6c0fb83a2046", + "cpki_event_seq": 4, + "cpki_ingested": "01a15b9d-b9b2-731e-bc84-4b23b1e5a658" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "run_id": "c418130f-5e4c-467b-9f06-834545018abb", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "TEXT_MESSAGE_CONTENT" + } + }, + { + "at": 2192, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "messageId": "msg_05271f8f77892f38006ad7f739b82487d0ad182a6185a27b5c", + "metadata": { + "cpki_event_id": "ab9d6ae7-6ef9-41eb-972f-1f85784835a6", + "cpki_event_seq": 5, + "cpki_ingested": "01a15b9d-ba50-7d37-97ae-013deab6f8ee" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "run_id": "c418130f-5e4c-467b-9f06-834545018abb", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "TEXT_MESSAGE_END" + } + }, + { + "at": 2192, + "socket": 2, + "direction": "in", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "ag_ui_event", + "payload": { + "metadata": { + "cpki_event_id": "26234b95-3d55-4d88-bd0a-b12ede7fe335", + "cpki_event_seq": 6, + "cpki_ingested": "01a15b9d-ba50-7dfe-97bd-9fc02859195d", + "finishReason": "stop" + }, + "organization_id": "392ec1e3-59dd-4798-a95b-2715d96d2e89", + "project_id": 1, + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "run_id": "c418130f-5e4c-467b-9f06-834545018abb", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "thread_id": "6231e72d-6539-481a-9a91-9e25e3672111", + "type": "RUN_FINISHED", + "usage": [ + { + "cachedInputTokens": 0, + "inputTokens": 231, + "model": "gpt-4.1-mini", + "outputTokens": 4, + "provider": "openai.responses", + "reasoningTokens": 0, + "totalTokens": 235 + } + ] + } + }, + { + "at": 2193, + "socket": 2, + "direction": "out", + "joinRef": "3", + "topic": "thread:6231e72d-6539-481a-9a91-9e25e3672111", + "event": "phx_leave" + } + ], + "sink": [ + { + "trajectoryId": "aff0b1ce-cf84-483a-9b11-463760dd337c", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538423756 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "4f0d40e5-0046-41da-b714-581857d09139", + "runId": null, + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "agentId": "default", + "role": "user", + "textLength": 46, + "text": "Name one planet. One word only. Call no tools.", + "seq": 2 + }, + "timestamp": 1792538423807 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538423912 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_07bfedc5a9f7d6e9006ad7f738714887d08dc4798185fb6926", + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "agentId": "default", + "role": "assistant", + "textLength": 4, + "text": "Mars", + "seq": 4 + }, + "timestamp": 1792538424695 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792538424700 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "13cd312f-4078-4140-b316-b9904423f2f3", + "runId": "3d3db537-abcf-4287-9904-2326e2f0be78", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "agentId": "default", + "role": "user", + "textLength": 47, + "text": "Name another one. One word only. Call no tools.", + "seq": 6 + }, + "timestamp": 1792538424701 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "agentId": "default", + "seq": 7 + }, + "timestamp": 1792538424807 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "aff0b1ce-cf84-483a-9b11-463760dd337c", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_05271f8f77892f38006ad7f739b82487d0ad182a6185a27b5c", + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "agentId": "default", + "role": "assistant", + "textLength": 5, + "text": "Venus", + "seq": 8 + }, + "timestamp": 1792538425946 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "c418130f-5e4c-467b-9f06-834545018abb", + "threadId": "6231e72d-6539-481a-9a91-9e25e3672111", + "agentId": "default", + "seq": 9 + }, + "timestamp": 1792538425947 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/backend-tool.json b/extension/tests/fixtures/agent-traffic/sse/backend-tool.json new file mode 100644 index 000000000..cd0624434 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/backend-tool.json @@ -0,0 +1,199 @@ +{ + "name": "backend-tool", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 52, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"d77c1431-613f-4735-82ec-698e565c972f\",\"runId\":\"1bafd489-293d-4745-9901-606e7193abae\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"42bc7ccb-fda9-42c8-bdad-a3ff3c11a772\",\"role\":\"user\",\"content\":\"What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 54, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"d77c1431-613f-4735-82ec-698e565c972f\",\"runId\":\"1bafd489-293d-4745-9901-606e7193abae\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"d77c1431-613f-4735-82ec-698e565c972f\",\"runId\":\"1bafd489-293d-4745-9901-606e7193abae\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"42bc7ccb-fda9-42c8-bdad-a3ff3c11a772\",\"role\":\"user\",\"content\":\"What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1369, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_x5zUxFHbsYNbObzZhbGIchs6\",\"toolCallName\":\"lookupWeather\",\"parentMessageId\":\"239758bc-73d3-4d65-a056-6448ed338655\"}\n\n" + }, + { + "at": 1370, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_x5zUxFHbsYNbObzZhbGIchs6\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 1373, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_x5zUxFHbsYNbObzZhbGIchs6\",\"delta\":\"city\"}\n\n" + }, + { + "at": 1382, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_x5zUxFHbsYNbObzZhbGIchs6\",\"delta\":\"\\\":\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_x5zUxFHbsYNbObzZhbGIchs6\",\"delta\":\"Lis\"}\n\n" + }, + { + "at": 1395, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_x5zUxFHbsYNbObzZhbGIchs6\",\"delta\":\"bon\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_x5zUxFHbsYNbObzZhbGIchs6\",\"delta\":\"\\\"}\"}\n\n" + }, + { + "at": 1474, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_x5zUxFHbsYNbObzZhbGIchs6\"}\n\ndata: {\"type\":\"TOOL_CALL_RESULT\",\"messageId\":\"3eafed64-3abb-4755-b116-b3722f50ac22\",\"toolCallId\":\"call_x5zUxFHbsYNbObzZhbGIchs6\",\"content\":\"{\\\"city\\\":\\\"Lisbon\\\",\\\"forecast\\\":\\\"sunny\\\",\\\"celsius\\\":21}\",\"role\":\"tool\"}\n\n" + }, + { + "at": 2645, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\"The\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" weather\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" in\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" Lisbon\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" is\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" sunny\"}\n\n" + }, + { + "at": 2700, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" with\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" a\"}\n\n" + }, + { + "at": 2715, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" temperature\"}\n\n" + }, + { + "at": 2718, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" of\"}\n\n" + }, + { + "at": 2720, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" \"}\n\n" + }, + { + "at": 2722, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\"21\"}\n\n" + }, + { + "at": 2725, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" degrees\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\" Celsius\"}\n\n" + }, + { + "at": 2729, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\",\"delta\":\".\"}\n\n" + }, + { + "at": 3042, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"d77c1431-613f-4735-82ec-698e565c972f\",\"runId\":\"1bafd489-293d-4745-9901-606e7193abae\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":472,\"outputTokens\":33,\"totalTokens\":505,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 3043, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "4bb9402d-1d94-4e62-8401-97af3ab6f544", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538294228 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "42bc7ccb-fda9-42c8-bdad-a3ff3c11a772", + "runId": null, + "threadId": "d77c1431-613f-4735-82ec-698e565c972f", + "agentId": "default", + "role": "user", + "textLength": 84, + "text": "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.", + "seq": 2 + }, + "timestamp": 1792538294279 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "1bafd489-293d-4745-9901-606e7193abae", + "threadId": "d77c1431-613f-4735-82ec-698e565c972f", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538294283 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_x5zUxFHbsYNbObzZhbGIchs6", + "toolName": "lookupWeather", + "agentId": "default", + "threadId": "d77c1431-613f-4735-82ec-698e565c972f", + "messageId": "239758bc-73d3-4d65-a056-6448ed338655", + "runId": "1bafd489-293d-4745-9901-606e7193abae", + "seq": 4 + }, + "timestamp": 1792538295598 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "3eafed64-3abb-4755-b116-b3722f50ac22", + "runId": "1bafd489-293d-4745-9901-606e7193abae", + "threadId": "d77c1431-613f-4735-82ec-698e565c972f", + "agentId": "default", + "role": "tool", + "textLength": 49, + "text": "{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}", + "seq": 5 + }, + "timestamp": 1792538295703 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "4bb9402d-1d94-4e62-8401-97af3ab6f544", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0f8746950fbfb18d006ad7f6b8db8c87d082d16524036e8516", + "runId": "1bafd489-293d-4745-9901-606e7193abae", + "threadId": "d77c1431-613f-4735-82ec-698e565c972f", + "agentId": "default", + "role": "assistant", + "textLength": 72, + "text": "The weather in Lisbon is sunny with a temperature of 21 degrees Celsius.", + "seq": 6 + }, + "timestamp": 1792538297271 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "1bafd489-293d-4745-9901-606e7193abae", + "threadId": "d77c1431-613f-4735-82ec-698e565c972f", + "agentId": "default", + "seq": 7 + }, + "timestamp": 1792538297271 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/clicks.json b/extension/tests/fixtures/agent-traffic/sse/clicks.json new file mode 100644 index 000000000..926692d95 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/clicks.json @@ -0,0 +1,640 @@ +{ + "name": "clicks", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"019de807-9be7-4b41-8bc8-dcb36043f40e\",\"runId\":\"3c959610-7f32-4537-9058-71a110747ce2\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"0c9ea7bb-ec1c-4f06-a523-1eebfc10f773\",\"role\":\"user\",\"content\":\"What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 70, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"019de807-9be7-4b41-8bc8-dcb36043f40e\",\"runId\":\"3c959610-7f32-4537-9058-71a110747ce2\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"019de807-9be7-4b41-8bc8-dcb36043f40e\",\"runId\":\"3c959610-7f32-4537-9058-71a110747ce2\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"0c9ea7bb-ec1c-4f06-a523-1eebfc10f773\",\"role\":\"user\",\"content\":\"What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1003, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_WmnBOx8Kcvm4z2nXja4ao2DS\",\"toolCallName\":\"lookupWeather\",\"parentMessageId\":\"4e1b21a8-0a50-4ab9-b18a-da2ee8f5e78f\"}\n\n" + }, + { + "at": 1007, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_WmnBOx8Kcvm4z2nXja4ao2DS\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 1012, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_WmnBOx8Kcvm4z2nXja4ao2DS\",\"delta\":\"city\"}\n\n" + }, + { + "at": 1015, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_WmnBOx8Kcvm4z2nXja4ao2DS\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 1029, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_WmnBOx8Kcvm4z2nXja4ao2DS\",\"delta\":\"Lis\"}\n\n" + }, + { + "at": 1056, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_WmnBOx8Kcvm4z2nXja4ao2DS\",\"delta\":\"bon\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_WmnBOx8Kcvm4z2nXja4ao2DS\",\"delta\":\"\\\"}\"}\n\n" + }, + { + "at": 1116, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_WmnBOx8Kcvm4z2nXja4ao2DS\"}\n\ndata: {\"type\":\"TOOL_CALL_RESULT\",\"messageId\":\"5b6719b0-c349-4a34-a692-9427031aaca2\",\"toolCallId\":\"call_WmnBOx8Kcvm4z2nXja4ao2DS\",\"content\":\"{\\\"city\\\":\\\"Lisbon\\\",\\\"forecast\\\":\\\"sunny\\\",\\\"celsius\\\":21}\",\"role\":\"tool\"}\n\n" + }, + { + "at": 1877, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\"The\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" weather\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" in\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" Lisbon\"}\n\n" + }, + { + "at": 1884, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" is\"}\n\n" + }, + { + "at": 1886, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" sunny\"}\n\n" + }, + { + "at": 1969, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" with\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" a\"}\n\n" + }, + { + "at": 1990, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" temperature\"}\n\n" + }, + { + "at": 1995, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" of\"}\n\n" + }, + { + "at": 1997, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\" \"}\n\n" + }, + { + "at": 1999, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\"21\"}\n\n" + }, + { + "at": 2003, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\"°C\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\",\"delta\":\".\"}\n\n" + }, + { + "at": 2206, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"019de807-9be7-4b41-8bc8-dcb36043f40e\",\"runId\":\"3c959610-7f32-4537-9058-71a110747ce2\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":472,\"outputTokens\":32,\"totalTokens\":504,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 2207, + "outcome": "complete" + } + }, + { + "at": 2220, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"shopper\"},\"body\":{\"threadId\":\"1a20471b-d582-4433-b703-f05c3d0cb289\",\"runId\":\"9b53d353-a7c5-4d74-8bed-7293ed0b5980\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"07551a0e-2307-41d1-98bf-9cb959400eeb\",\"role\":\"user\",\"content\":\"Name one planet. One word only. Call no tools.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 2222, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"1a20471b-d582-4433-b703-f05c3d0cb289\",\"runId\":\"9b53d353-a7c5-4d74-8bed-7293ed0b5980\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"1a20471b-d582-4433-b703-f05c3d0cb289\",\"runId\":\"9b53d353-a7c5-4d74-8bed-7293ed0b5980\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"07551a0e-2307-41d1-98bf-9cb959400eeb\",\"role\":\"user\",\"content\":\"Name one planet. One word only. Call no tools.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 2757, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_085e9365cdf85e87006ad7fb113d9087d0b0ac299741ec49d5\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_085e9365cdf85e87006ad7fb113d9087d0b0ac299741ec49d5\",\"delta\":\"Mars\"}\n\n" + }, + { + "at": 2898, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_085e9365cdf85e87006ad7fb113d9087d0b0ac299741ec49d5\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"1a20471b-d582-4433-b703-f05c3d0cb289\",\"runId\":\"9b53d353-a7c5-4d74-8bed-7293ed0b5980\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":226,\"outputTokens\":3,\"totalTokens\":229,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 2899, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "52b89f7b-c8b6-4d07-ab73-4c991057e2d2", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792539406496 + }, + { + "type": "CUSTOM", + "name": "thread.linked", + "value": { + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "hasMessages": false, + "reason": "open", + "seq": 2 + }, + "timestamp": 1792539406548 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "0c9ea7bb-ec1c-4f06-a523-1eebfc10f773", + "runId": null, + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "role": "user", + "textLength": 84, + "text": "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.", + "seq": 3 + }, + "timestamp": 1792539406549 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "seq": 4 + }, + "timestamp": 1792539406568 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_WmnBOx8Kcvm4z2nXja4ao2DS", + "toolName": "lookupWeather", + "agentId": "default", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "messageId": "4e1b21a8-0a50-4ab9-b18a-da2ee8f5e78f", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "seq": 5 + }, + "timestamp": 1792539407498 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "5b6719b0-c349-4a34-a692-9427031aaca2", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "role": "tool", + "textLength": 49, + "text": "{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}", + "seq": 6 + }, + "timestamp": 1792539407612 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "52b89f7b-c8b6-4d07-ab73-4c991057e2d2", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_03ed833eef8821e8006ad7fb10621887d0af7fd9a36a999804", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "role": "assistant", + "textLength": 58, + "text": "The weather in Lisbon is sunny with a temperature of 21°C.", + "seq": 7 + }, + "timestamp": 1792539408703 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "seq": 8 + }, + "timestamp": 1792539408703 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "p", + "role": null, + "action": null, + "text": "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.", + "attributes": {}, + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "messageId": "0c9ea7bb-ec1c-4f06-a523-1eebfc10f773", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "seq": 9 + }, + "timestamp": 1792539408711 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "button", + "role": null, + "action": null, + "text": "Details", + "attributes": { + "type": "button" + }, + "label": "Details", + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "messageId": "4e1b21a8-0a50-4ab9-b18a-da2ee8f5e78f", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "toolCallId": "call_WmnBOx8Kcvm4z2nXja4ao2DS", + "toolName": "lookupWeather", + "toolStatus": "complete", + "seq": 10 + }, + "timestamp": 1792539408712 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "span", + "role": null, + "action": null, + "text": "lookupWeather", + "attributes": {}, + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "messageId": "4e1b21a8-0a50-4ab9-b18a-da2ee8f5e78f", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "toolCallId": "call_WmnBOx8Kcvm4z2nXja4ao2DS", + "toolName": "lookupWeather", + "toolStatus": "complete", + "seq": 11 + }, + "timestamp": 1792539408713 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "div", + "role": "log", + "action": null, + "text": "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.lookupWeatherDetails{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}The weather in Lisbon is sunny with a temperature of 21°C.", + "attributes": { + "role": "log" + }, + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "seq": 12 + }, + "timestamp": 1792539408713 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "button", + "role": null, + "action": null, + "text": "Other", + "attributes": { + "type": "button" + }, + "label": "Other", + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "seq": 13 + }, + "timestamp": 1792539408713 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "button", + "role": null, + "action": null, + "text": "Settings", + "attributes": { + "id": "elsewhere", + "type": "button" + }, + "label": "Settings", + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "seq": 14 + }, + "timestamp": 1792539408714 + }, + { + "type": "CUSTOM", + "name": "thread.linked", + "value": { + "threadId": "1a20471b-d582-4433-b703-f05c3d0cb289", + "agentId": "shopper", + "hasMessages": false, + "reason": "open", + "seq": 15 + }, + "timestamp": 1792539408714 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "07551a0e-2307-41d1-98bf-9cb959400eeb", + "runId": null, + "threadId": "1a20471b-d582-4433-b703-f05c3d0cb289", + "agentId": "shopper", + "role": "user", + "textLength": 46, + "text": "Name one planet. One word only. Call no tools.", + "seq": 16 + }, + "timestamp": 1792539408714 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "9b53d353-a7c5-4d74-8bed-7293ed0b5980", + "threadId": "1a20471b-d582-4433-b703-f05c3d0cb289", + "agentId": "shopper", + "seq": 17 + }, + "timestamp": 1792539408717 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_085e9365cdf85e87006ad7fb113d9087d0b0ac299741ec49d5", + "runId": "9b53d353-a7c5-4d74-8bed-7293ed0b5980", + "threadId": "1a20471b-d582-4433-b703-f05c3d0cb289", + "agentId": "shopper", + "role": "assistant", + "textLength": 4, + "text": "Mars", + "seq": 18 + }, + "timestamp": 1792539409394 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "9b53d353-a7c5-4d74-8bed-7293ed0b5980", + "threadId": "1a20471b-d582-4433-b703-f05c3d0cb289", + "agentId": "shopper", + "seq": 19 + }, + "timestamp": 1792539409394 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "p", + "role": null, + "action": null, + "text": "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.", + "attributes": {}, + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "messageId": "0c9ea7bb-ec1c-4f06-a523-1eebfc10f773", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "seq": 20 + }, + "timestamp": 1792539409751 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "button", + "role": null, + "action": null, + "text": "Details", + "attributes": { + "type": "button" + }, + "label": "Details", + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "messageId": "4e1b21a8-0a50-4ab9-b18a-da2ee8f5e78f", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "toolCallId": "call_WmnBOx8Kcvm4z2nXja4ao2DS", + "toolName": "lookupWeather", + "toolStatus": "complete", + "seq": 21 + }, + "timestamp": 1792539409751 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "span", + "role": null, + "action": null, + "text": "lookupWeather", + "attributes": {}, + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": "019de807-9be7-4b41-8bc8-dcb36043f40e", + "agentId": "default", + "messageId": "4e1b21a8-0a50-4ab9-b18a-da2ee8f5e78f", + "runId": "3c959610-7f32-4537-9058-71a110747ce2", + "toolCallId": "call_WmnBOx8Kcvm4z2nXja4ao2DS", + "toolName": "lookupWeather", + "toolStatus": "complete", + "seq": 22 + }, + "timestamp": 1792539409751 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "div", + "role": "log", + "action": null, + "text": "What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.lookupWeatherDetails{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}The weather in Lisbon is sunny with a temperature of 21°C.", + "attributes": { + "role": "log" + }, + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": null, + "threadAmbiguity": { + "reason": "multiple-open", + "candidates": [ + "019de807-9be7-4b41-8bc8-dcb36043f40e", + "1a20471b-d582-4433-b703-f05c3d0cb289" + ] + }, + "seq": 23 + }, + "timestamp": 1792539409752 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "button", + "role": null, + "action": null, + "text": "Other", + "attributes": { + "type": "button" + }, + "label": "Other", + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": null, + "threadAmbiguity": { + "reason": "multiple-open", + "candidates": [ + "019de807-9be7-4b41-8bc8-dcb36043f40e", + "1a20471b-d582-4433-b703-f05c3d0cb289" + ] + }, + "seq": 24 + }, + "timestamp": 1792539409752 + }, + { + "type": "CUSTOM", + "name": "click", + "value": { + "target": { + "tag": "button", + "role": null, + "action": null, + "text": "Settings", + "attributes": { + "id": "elsewhere", + "type": "button" + }, + "label": "Settings", + "input": "pointer" + }, + "route": "/", + "url": "http://127.0.0.1:4000/", + "threadId": null, + "threadAmbiguity": { + "reason": "multiple-open", + "candidates": [ + "019de807-9be7-4b41-8bc8-dcb36043f40e", + "1a20471b-d582-4433-b703-f05c3d0cb289" + ] + }, + "seq": 25 + }, + "timestamp": 1792539409755 + } + ], + "dropped": 0 + } + ], + "clicks": [ + { + "at": 2219, + "html": "

What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.

lookupWeather

{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}

The weather in Lisbon is sunny with a temperature of 21°C.

What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.

lookupWeather

{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}

The weather in Lisbon is sunny with a temperature of 21°C.

", + "selectors": [ + "section [data-message-id] p", + "section [data-tool-call-id] button", + "section [data-tool-call-id] span", + "[role=\"log\"] [data-message-id] p", + "[data-message-id=\"not-a-message\"] button", + "#elsewhere" + ] + }, + { + "at": 3260, + "html": "

What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.

lookupWeather

{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}

The weather in Lisbon is sunny with a temperature of 21°C.

Name one planet. One word only. Call no tools.

Mars

What is the weather in Lisbon? Call lookupWeather once, then answer in one sentence.

lookupWeather

{\"city\":\"Lisbon\",\"forecast\":\"sunny\",\"celsius\":21}

The weather in Lisbon is sunny with a temperature of 21°C.

", + "selectors": [ + "section [data-message-id] p", + "section [data-tool-call-id] button", + "section [data-tool-call-id] span", + "[role=\"log\"] [data-message-id] p", + "[data-message-id=\"not-a-message\"] button", + "#elsewhere" + ] + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/frontend-tool-error.json b/extension/tests/fixtures/agent-traffic/sse/frontend-tool-error.json new file mode 100644 index 000000000..ce2423b60 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/frontend-tool-error.json @@ -0,0 +1,237 @@ +{ + "name": "frontend-tool-error", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"c2b2f47c-2b7f-4e95-84f2-eab74d881db8\",\"runId\":\"14d8900f-49b9-42a6-b034-e602100be6e5\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"f580558d-cb0a-4935-a439-d9b98a7f9892\",\"role\":\"user\",\"content\":\"Call showGreeting with name Ada and mood cheerful, then say done in one word.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 72, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"c2b2f47c-2b7f-4e95-84f2-eab74d881db8\",\"runId\":\"14d8900f-49b9-42a6-b034-e602100be6e5\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"c2b2f47c-2b7f-4e95-84f2-eab74d881db8\",\"runId\":\"14d8900f-49b9-42a6-b034-e602100be6e5\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"f580558d-cb0a-4935-a439-d9b98a7f9892\",\"role\":\"user\",\"content\":\"Call showGreeting with name Ada and mood cheerful, then say done in one word.\"}],\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1317, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_LcvTNdQtSmze8wpFHBDFPwSJ\",\"toolCallName\":\"showGreeting\",\"parentMessageId\":\"8345eee7-7315-44e1-aefe-f039af1a7071\"}\n\n" + }, + { + "at": 1322, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_LcvTNdQtSmze8wpFHBDFPwSJ\",\"delta\":\"{\\\"name\\\":\\\"Ada\\\",\\\"mood\\\":\\\"cheerful\\\"}\"}\n\n" + }, + { + "at": 1335, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_LcvTNdQtSmze8wpFHBDFPwSJ\"}\n\n" + }, + { + "at": 1494, + "text": "data: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"tool-calls\"},\"threadId\":\"c2b2f47c-2b7f-4e95-84f2-eab74d881db8\",\"runId\":\"14d8900f-49b9-42a6-b034-e602100be6e5\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":279,\"outputTokens\":38,\"totalTokens\":317,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 1495, + "outcome": "complete" + } + }, + { + "at": 1497, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"c2b2f47c-2b7f-4e95-84f2-eab74d881db8\",\"runId\":\"54aa7613-547f-4369-b03b-a92d1005df22\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"f580558d-cb0a-4935-a439-d9b98a7f9892\",\"role\":\"user\",\"content\":\"Call showGreeting with name Ada and mood cheerful, then say done in one word.\"},{\"id\":\"8345eee7-7315-44e1-aefe-f039af1a7071\",\"role\":\"assistant\",\"toolCalls\":[{\"id\":\"call_LcvTNdQtSmze8wpFHBDFPwSJ\",\"type\":\"function\",\"function\":{\"name\":\"showGreeting\",\"arguments\":\"{\\\"name\\\":\\\"Ada\\\",\\\"mood\\\":\\\"cheerful\\\"}\"}}]},{\"id\":\"2ae1eec0-0ec0-4a42-9a5c-b89c5d9bddcd\",\"role\":\"tool\",\"toolCallId\":\"call_LcvTNdQtSmze8wpFHBDFPwSJ\",\"content\":\"Error: The greeting card could not be shown.\"},{\"id\":\"148ce4af-af2d-4722-a3f0-55fca64463aa\",\"role\":\"user\",\"content\":\"Say ok in one word. Call no tools.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 1501, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"c2b2f47c-2b7f-4e95-84f2-eab74d881db8\",\"runId\":\"54aa7613-547f-4369-b03b-a92d1005df22\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"c2b2f47c-2b7f-4e95-84f2-eab74d881db8\",\"runId\":\"54aa7613-547f-4369-b03b-a92d1005df22\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"8345eee7-7315-44e1-aefe-f039af1a7071\",\"role\":\"assistant\",\"toolCalls\":[{\"id\":\"call_LcvTNdQtSmze8wpFHBDFPwSJ\",\"type\":\"function\",\"function\":{\"name\":\"showGreeting\",\"arguments\":\"{\\\"name\\\":\\\"Ada\\\",\\\"mood\\\":\\\"cheerful\\\"}\"}}]},{\"id\":\"2ae1eec0-0ec0-4a42-9a5c-b89c5d9bddcd\",\"role\":\"tool\",\"content\":\"Error: The greeting card could not be shown.\",\"toolCallId\":\"call_LcvTNdQtSmze8wpFHBDFPwSJ\"},{\"id\":\"148ce4af-af2d-4722-a3f0-55fca64463aa\",\"role\":\"user\",\"content\":\"Say ok in one word. Call no tools.\"}],\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1962, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_0ecb3954271f6fd8006ad8123f7d9087d080c71797898a8e86\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0ecb3954271f6fd8006ad8123f7d9087d080c71797898a8e86\",\"delta\":\"Ok\"}\n\n" + }, + { + "at": 2146, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_0ecb3954271f6fd8006ad8123f7d9087d080c71797898a8e86\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"c2b2f47c-2b7f-4e95-84f2-eab74d881db8\",\"runId\":\"54aa7613-547f-4369-b03b-a92d1005df22\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":330,\"outputTokens\":3,\"totalTokens\":333,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 2146, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "e8905c3a-c7c0-4e7b-84e2-37622e23f94d", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792545341513 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "f580558d-cb0a-4935-a439-d9b98a7f9892", + "runId": null, + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "agentId": "default", + "role": "user", + "textLength": 77, + "text": "Call showGreeting with name Ada and mood cheerful, then say done in one word.", + "seq": 2 + }, + "timestamp": 1792545341565 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "14d8900f-49b9-42a6-b034-e602100be6e5", + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792545341587 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_LcvTNdQtSmze8wpFHBDFPwSJ", + "toolName": "showGreeting", + "agentId": "default", + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "messageId": "8345eee7-7315-44e1-aefe-f039af1a7071", + "runId": "14d8900f-49b9-42a6-b034-e602100be6e5", + "seq": 4 + }, + "timestamp": 1792545342830 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "14d8900f-49b9-42a6-b034-e602100be6e5", + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792545343007 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "executing", + "toolCallId": "call_LcvTNdQtSmze8wpFHBDFPwSJ", + "toolName": "showGreeting", + "agentId": "default", + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "messageId": "8345eee7-7315-44e1-aefe-f039af1a7071", + "runId": "14d8900f-49b9-42a6-b034-e602100be6e5", + "seq": 6 + }, + "timestamp": 1792545343009 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "completed", + "toolCallId": "call_LcvTNdQtSmze8wpFHBDFPwSJ", + "toolName": "showGreeting", + "agentId": "default", + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "messageId": "8345eee7-7315-44e1-aefe-f039af1a7071", + "runId": "14d8900f-49b9-42a6-b034-e602100be6e5", + "durationMs": 0, + "outcome": "error", + "seq": 7 + }, + "timestamp": 1792545343009 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "148ce4af-af2d-4722-a3f0-55fca64463aa", + "runId": "14d8900f-49b9-42a6-b034-e602100be6e5", + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "agentId": "default", + "role": "user", + "textLength": 34, + "text": "Say ok in one word. Call no tools.", + "seq": 8 + }, + "timestamp": 1792545343009 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "54aa7613-547f-4369-b03b-a92d1005df22", + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "agentId": "default", + "seq": 9 + }, + "timestamp": 1792545343014 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "e8905c3a-c7c0-4e7b-84e2-37622e23f94d", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0ecb3954271f6fd8006ad8123f7d9087d080c71797898a8e86", + "runId": "54aa7613-547f-4369-b03b-a92d1005df22", + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "agentId": "default", + "role": "assistant", + "textLength": 2, + "text": "Ok", + "seq": 10 + }, + "timestamp": 1792545343658 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "54aa7613-547f-4369-b03b-a92d1005df22", + "threadId": "c2b2f47c-2b7f-4e95-84f2-eab74d881db8", + "agentId": "default", + "seq": 11 + }, + "timestamp": 1792545343658 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/frontend-tool.json b/extension/tests/fixtures/agent-traffic/sse/frontend-tool.json new file mode 100644 index 000000000..c1e2da7b5 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/frontend-tool.json @@ -0,0 +1,222 @@ +{ + "name": "frontend-tool", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 53, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26\",\"runId\":\"89abb4f6-4d47-426b-85f1-7c2c2168fd49\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"498ac57b-d649-4f9f-98e5-3a1adb7393d7\",\"role\":\"user\",\"content\":\"Call showGreeting with name Ada and mood cheerful, then say done in one word.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 55, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26\",\"runId\":\"89abb4f6-4d47-426b-85f1-7c2c2168fd49\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26\",\"runId\":\"89abb4f6-4d47-426b-85f1-7c2c2168fd49\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"498ac57b-d649-4f9f-98e5-3a1adb7393d7\",\"role\":\"user\",\"content\":\"Call showGreeting with name Ada and mood cheerful, then say done in one word.\"}],\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1070, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_dT7Oa2spQDAON1HCKWBkFUQx\",\"toolCallName\":\"showGreeting\",\"parentMessageId\":\"bf80e13c-f5c3-40dc-9b73-dc2ed4648fc6\"}\n\n" + }, + { + "at": 1088, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_dT7Oa2spQDAON1HCKWBkFUQx\",\"delta\":\"{\\\"name\\\":\\\"Ada\\\",\\\"mood\\\":\\\"cheerful\\\"}\"}\n\n" + }, + { + "at": 1095, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_dT7Oa2spQDAON1HCKWBkFUQx\"}\n\n" + }, + { + "at": 1228, + "text": "data: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"tool-calls\"},\"threadId\":\"ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26\",\"runId\":\"89abb4f6-4d47-426b-85f1-7c2c2168fd49\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":244,\"outputTokens\":38,\"totalTokens\":282,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 1228, + "outcome": "complete" + } + }, + { + "at": 1229, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26\",\"runId\":\"d60b007e-0118-488a-8c7c-46d547636340\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"498ac57b-d649-4f9f-98e5-3a1adb7393d7\",\"role\":\"user\",\"content\":\"Call showGreeting with name Ada and mood cheerful, then say done in one word.\"},{\"id\":\"bf80e13c-f5c3-40dc-9b73-dc2ed4648fc6\",\"role\":\"assistant\",\"toolCalls\":[{\"id\":\"call_dT7Oa2spQDAON1HCKWBkFUQx\",\"type\":\"function\",\"function\":{\"name\":\"showGreeting\",\"arguments\":\"{\\\"name\\\":\\\"Ada\\\",\\\"mood\\\":\\\"cheerful\\\"}\"}}]},{\"id\":\"7f0b70ff-eb5b-45dc-8e11-26296df60301\",\"role\":\"tool\",\"toolCallId\":\"call_dT7Oa2spQDAON1HCKWBkFUQx\",\"content\":\"Shown a greeting card for Ada.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 1231, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26\",\"runId\":\"d60b007e-0118-488a-8c7c-46d547636340\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26\",\"runId\":\"d60b007e-0118-488a-8c7c-46d547636340\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"bf80e13c-f5c3-40dc-9b73-dc2ed4648fc6\",\"role\":\"assistant\",\"toolCalls\":[{\"id\":\"call_dT7Oa2spQDAON1HCKWBkFUQx\",\"type\":\"function\",\"function\":{\"name\":\"showGreeting\",\"arguments\":\"{\\\"name\\\":\\\"Ada\\\",\\\"mood\\\":\\\"cheerful\\\"}\"}}]},{\"id\":\"7f0b70ff-eb5b-45dc-8e11-26296df60301\",\"role\":\"tool\",\"content\":\"Shown a greeting card for Ada.\",\"toolCallId\":\"call_dT7Oa2spQDAON1HCKWBkFUQx\"}],\"tools\":[{\"name\":\"showGreeting\",\"description\":\"Show a greeting card to the person in the page.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"mood\":{\"type\":\"string\"}},\"required\":[\"name\",\"mood\"]}}],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1858, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_0db39211e9cc8a80006ad7f6bb6f0087d09b4ada1df41a5055\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0db39211e9cc8a80006ad7f6bb6f0087d09b4ada1df41a5055\",\"delta\":\"done\"}\n\n" + }, + { + "at": 2019, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_0db39211e9cc8a80006ad7f6bb6f0087d09b4ada1df41a5055\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26\",\"runId\":\"d60b007e-0118-488a-8c7c-46d547636340\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":278,\"outputTokens\":3,\"totalTokens\":281,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 2020, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "4d763ed1-c0db-499a-be49-98489cf8f65a", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538297639 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "498ac57b-d649-4f9f-98e5-3a1adb7393d7", + "runId": null, + "threadId": "ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26", + "agentId": "default", + "role": "user", + "textLength": 77, + "text": "Call showGreeting with name Ada and mood cheerful, then say done in one word.", + "seq": 2 + }, + "timestamp": 1792538297691 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "89abb4f6-4d47-426b-85f1-7c2c2168fd49", + "threadId": "ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538297694 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_dT7Oa2spQDAON1HCKWBkFUQx", + "toolName": "showGreeting", + "agentId": "default", + "threadId": "ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26", + "messageId": "bf80e13c-f5c3-40dc-9b73-dc2ed4648fc6", + "runId": "89abb4f6-4d47-426b-85f1-7c2c2168fd49", + "seq": 4 + }, + "timestamp": 1792538298710 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "89abb4f6-4d47-426b-85f1-7c2c2168fd49", + "threadId": "ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792538298867 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "executing", + "toolCallId": "call_dT7Oa2spQDAON1HCKWBkFUQx", + "toolName": "showGreeting", + "agentId": "default", + "threadId": "ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26", + "messageId": "bf80e13c-f5c3-40dc-9b73-dc2ed4648fc6", + "runId": "89abb4f6-4d47-426b-85f1-7c2c2168fd49", + "seq": 6 + }, + "timestamp": 1792538298867 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "completed", + "toolCallId": "call_dT7Oa2spQDAON1HCKWBkFUQx", + "toolName": "showGreeting", + "agentId": "default", + "threadId": "ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26", + "messageId": "bf80e13c-f5c3-40dc-9b73-dc2ed4648fc6", + "runId": "89abb4f6-4d47-426b-85f1-7c2c2168fd49", + "durationMs": 1, + "outcome": "ok", + "seq": 7 + }, + "timestamp": 1792538298868 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "d60b007e-0118-488a-8c7c-46d547636340", + "threadId": "ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26", + "agentId": "default", + "seq": 8 + }, + "timestamp": 1792538298871 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "4d763ed1-c0db-499a-be49-98489cf8f65a", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0db39211e9cc8a80006ad7f6bb6f0087d09b4ada1df41a5055", + "runId": "89abb4f6-4d47-426b-85f1-7c2c2168fd49", + "threadId": "ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26", + "agentId": "default", + "role": "assistant", + "textLength": 4, + "text": "done", + "seq": 9 + }, + "timestamp": 1792538299659 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "d60b007e-0118-488a-8c7c-46d547636340", + "threadId": "ce9a71ec-6fee-4fe1-a0f6-b42b3a8c5c26", + "agentId": "default", + "seq": 10 + }, + "timestamp": 1792538299659 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/interrupt.json b/extension/tests/fixtures/agent-traffic/sse/interrupt.json new file mode 100644 index 000000000..7de3cc5fa --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/interrupt.json @@ -0,0 +1,308 @@ +{ + "name": "interrupt", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 55, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"shopper\"},\"body\":{\"threadId\":\"0bc2bf7f-bc91-4595-8bd8-9e7aee365732\",\"runId\":\"398a0b9d-f0d5-4122-b4a6-87c866292d53\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"0695f26a-2add-4221-9ff1-e03a4a8636a3\",\"role\":\"user\",\"content\":\"Buy one coffee for 4 dollars. Ask for approval with approvePurchase first.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 68, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"0bc2bf7f-bc91-4595-8bd8-9e7aee365732\",\"runId\":\"398a0b9d-f0d5-4122-b4a6-87c866292d53\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"0bc2bf7f-bc91-4595-8bd8-9e7aee365732\",\"runId\":\"398a0b9d-f0d5-4122-b4a6-87c866292d53\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"0695f26a-2add-4221-9ff1-e03a4a8636a3\",\"role\":\"user\",\"content\":\"Buy one coffee for 4 dollars. Ask for approval with approvePurchase first.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 644, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"toolCallName\":\"approvePurchase\",\"parentMessageId\":\"27958ff2-ead5-40a5-a9d0-4efec6bfda1a\"}\n\n" + }, + { + "at": 645, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"delta\":\"{\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"delta\":\"item\"}\n\n" + }, + { + "at": 651, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 653, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"delta\":\"coffee\"}\n\n" + }, + { + "at": 683, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"delta\":\"\\\",\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"delta\":\"amount\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"delta\":\"\\\":\"}\n\n" + }, + { + "at": 684, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"delta\":\"4\"}\n\n" + }, + { + "at": 721, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"delta\":\"}\"}\n\n" + }, + { + "at": 753, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\"}\n\n" + }, + { + "at": 914, + "text": "data: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"tool-calls\"},\"threadId\":\"0bc2bf7f-bc91-4595-8bd8-9e7aee365732\",\"runId\":\"398a0b9d-f0d5-4122-b4a6-87c866292d53\",\"outcome\":{\"type\":\"interrupt\",\"interrupts\":[{\"id\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"reason\":\"tool_call\",\"message\":\"Approve this purchase?\",\"toolCallId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\"}]},\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":230,\"outputTokens\":20,\"totalTokens\":250,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 915, + "outcome": "complete" + } + }, + { + "at": 917, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"shopper\"},\"body\":{\"threadId\":\"0bc2bf7f-bc91-4595-8bd8-9e7aee365732\",\"runId\":\"e77d6829-4490-4147-b78e-c27ea2ff5de0\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"0695f26a-2add-4221-9ff1-e03a4a8636a3\",\"role\":\"user\",\"content\":\"Buy one coffee for 4 dollars. Ask for approval with approvePurchase first.\"},{\"id\":\"27958ff2-ead5-40a5-a9d0-4efec6bfda1a\",\"role\":\"assistant\",\"toolCalls\":[{\"id\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"type\":\"function\",\"function\":{\"name\":\"approvePurchase\",\"arguments\":\"{\\\"item\\\":\\\"coffee\\\",\\\"amount\\\":4}\"}}]}],\"resume\":[{\"interruptId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"status\":\"resolved\",\"payload\":{\"approved\":true}}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 920, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"0bc2bf7f-bc91-4595-8bd8-9e7aee365732\",\"runId\":\"e77d6829-4490-4147-b78e-c27ea2ff5de0\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"0bc2bf7f-bc91-4595-8bd8-9e7aee365732\",\"runId\":\"e77d6829-4490-4147-b78e-c27ea2ff5de0\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"27958ff2-ead5-40a5-a9d0-4efec6bfda1a\",\"role\":\"assistant\",\"toolCalls\":[{\"id\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"type\":\"function\",\"function\":{\"name\":\"approvePurchase\",\"arguments\":\"{\\\"item\\\":\\\"coffee\\\",\\\"amount\\\":4}\"}}]}],\"tools\":[],\"context\":[],\"forwardedProps\":{},\"resume\":[{\"interruptId\":\"call_PgBiPdnQS2p05EDKRLa6DKm2\",\"status\":\"resolved\",\"payload\":{\"approved\":true}}]}}\n\n" + }, + { + "at": 1506, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\"Purchase\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\" approved\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\".\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\" Buying\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\" one\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\" coffee\"}\n\n" + }, + { + "at": 1569, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\" for\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\" \"}\n\n" + }, + { + "at": 1590, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\"4\"}\n\n" + }, + { + "at": 1592, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\" dollars\"}\n\n" + }, + { + "at": 1607, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\" now\"}\n\n" + }, + { + "at": 1612, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\",\"delta\":\".\"}\n\n" + }, + { + "at": 2004, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\"}\n\ndata: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"toolCallName\":\"AGUISendStateDelta\",\"parentMessageId\":\"msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 2009, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"delta\"}\n\n" + }, + { + "at": 2014, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\":[\"}\n\n" + }, + { + "at": 2020, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 2023, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"op\"}\n\n" + }, + { + "at": 2026, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 2030, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"add\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\",\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"path\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\":\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\"/\"}\n\n" + }, + { + "at": 2032, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"p\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"urchases\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"/-\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\",\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"value\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\":{\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"item\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 2101, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"coffee\"}\n\n" + }, + { + "at": 2104, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\",\\\"\"}\n\n" + }, + { + "at": 2115, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"amount\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"\\\":\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"4\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"}}\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"delta\":\"]}\"}\n\n" + }, + { + "at": 2214, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\"}\n\ndata: {\"type\":\"STATE_SNAPSHOT\",\"snapshot\":{}}\n\ndata: {\"type\":\"STATE_DELTA\",\"delta\":[{\"op\":\"add\",\"path\":\"/purchases\",\"value\":[]},{\"op\":\"add\",\"path\":\"/purchases/-\",\"value\":{\"item\":\"coffee\",\"amount\":4}}]}\n\ndata: {\"type\":\"TOOL_CALL_RESULT\",\"messageId\":\"c8033746-9152-4e47-bf7e-c4aa8b67648d\",\"toolCallId\":\"call_HOadrQiJTvXnyWjvwDlLwllc\",\"content\":\"{\\\"success\\\":true,\\\"delta\\\":[{\\\"op\\\":\\\"add\\\",\\\"path\\\":\\\"/purchases/-\\\",\\\"value\\\":{\\\"item\\\":\\\"coffee\\\",\\\"amount\\\":4}}]}\",\"role\":\"tool\"}\n\n" + }, + { + "at": 2373, + "text": "data: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"tool-calls\"},\"threadId\":\"0bc2bf7f-bc91-4595-8bd8-9e7aee365732\",\"runId\":\"e77d6829-4490-4147-b78e-c27ea2ff5de0\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":263,\"outputTokens\":54,\"totalTokens\":317,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 2373, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "296d5ed2-6144-44ba-ba64-759faabff778", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538324248 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "0695f26a-2add-4221-9ff1-e03a4a8636a3", + "runId": null, + "threadId": "0bc2bf7f-bc91-4595-8bd8-9e7aee365732", + "agentId": "shopper", + "role": "user", + "textLength": 74, + "text": "Buy one coffee for 4 dollars. Ask for approval with approvePurchase first.", + "seq": 2 + }, + "timestamp": 1792538324299 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "398a0b9d-f0d5-4122-b4a6-87c866292d53", + "threadId": "0bc2bf7f-bc91-4595-8bd8-9e7aee365732", + "agentId": "shopper", + "seq": 3 + }, + "timestamp": 1792538324318 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_PgBiPdnQS2p05EDKRLa6DKm2", + "toolName": "approvePurchase", + "agentId": "shopper", + "threadId": "0bc2bf7f-bc91-4595-8bd8-9e7aee365732", + "messageId": "27958ff2-ead5-40a5-a9d0-4efec6bfda1a", + "runId": "398a0b9d-f0d5-4122-b4a6-87c866292d53", + "seq": 4 + }, + "timestamp": 1792538324891 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "398a0b9d-f0d5-4122-b4a6-87c866292d53", + "threadId": "0bc2bf7f-bc91-4595-8bd8-9e7aee365732", + "agentId": "shopper", + "seq": 5 + }, + "timestamp": 1792538325162 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "e77d6829-4490-4147-b78e-c27ea2ff5de0", + "threadId": "0bc2bf7f-bc91-4595-8bd8-9e7aee365732", + "agentId": "shopper", + "seq": 6 + }, + "timestamp": 1792538325167 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "296d5ed2-6144-44ba-ba64-759faabff778", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741", + "runId": "398a0b9d-f0d5-4122-b4a6-87c866292d53", + "threadId": "0bc2bf7f-bc91-4595-8bd8-9e7aee365732", + "agentId": "shopper", + "role": "assistant", + "textLength": 55, + "text": "Purchase approved. Buying one coffee for 4 dollars now.", + "seq": 7 + }, + "timestamp": 1792538326251 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_HOadrQiJTvXnyWjvwDlLwllc", + "toolName": "AGUISendStateDelta", + "agentId": "shopper", + "threadId": "0bc2bf7f-bc91-4595-8bd8-9e7aee365732", + "messageId": "msg_077c312dc66312bd006ad7f6d5baa487d0870dd1616bca7741", + "runId": "e77d6829-4490-4147-b78e-c27ea2ff5de0", + "seq": 8 + }, + "timestamp": 1792538326251 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "c8033746-9152-4e47-bf7e-c4aa8b67648d", + "runId": "398a0b9d-f0d5-4122-b4a6-87c866292d53", + "threadId": "0bc2bf7f-bc91-4595-8bd8-9e7aee365732", + "agentId": "shopper", + "role": "tool", + "textLength": 98, + "text": "{\"success\":true,\"delta\":[{\"op\":\"add\",\"path\":\"/purchases/-\",\"value\":{\"item\":\"coffee\",\"amount\":4}}]}", + "seq": 9 + }, + "timestamp": 1792538326463 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "e77d6829-4490-4147-b78e-c27ea2ff5de0", + "threadId": "0bc2bf7f-bc91-4595-8bd8-9e7aee365732", + "agentId": "shopper", + "seq": 10 + }, + "timestamp": 1792538326620 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/long-message.json b/extension/tests/fixtures/agent-traffic/sse/long-message.json new file mode 100644 index 000000000..8b8a91b25 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/long-message.json @@ -0,0 +1,119 @@ +{ + "name": "long-message", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 57, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"0f95c7ba-3d4f-49cd-ab28-82d161b1c9ac\",\"runId\":\"53bb9d69-a4ef-47e5-9622-f8c26df84f6d\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"a0c4116f-2113-4c72-ad3f-1cd24811b72b\",\"role\":\"user\",\"content\":\"Here is our order log:\\nOrder 1: 1 widgets shipped to warehouse 1.\\nOrder 2: 2 widgets shipped to warehouse 2.\\nOrder 3: 3 widgets shipped to warehouse 3.\\nOrder 4: 4 widgets shipped to warehouse 4.\\nOrder 5: 5 widgets shipped to warehouse 5.\\nOrder 6: 6 widgets shipped to warehouse 1.\\nOrder 7: 7 widgets shipped to warehouse 2.\\nOrder 8: 1 widgets shipped to warehouse 3.\\nOrder 9: 2 widgets shipped to warehouse 4.\\nOrder 10: 3 widgets shipped to warehouse 5.\\nOrder 11: 4 widgets shipped to warehouse 1.\\nOrder 12: 5 widgets shipped to warehouse 2.\\nOrder 13: 6 widgets shipped to warehouse 3.\\nOrder 14: 7 widgets shipped to warehouse 4.\\nOrder 15: 1 widgets shipped to warehouse 5.\\nOrder 16: 2 widgets shipped to warehouse 1.\\nOrder 17: 3 widgets shipped to warehouse 2.\\nOrder 18: 4 widgets shipped to warehouse 3.\\nOrder 19: 5 widgets shipped to warehouse 4.\\nOrder 20: 6 widgets shipped to warehouse 5.\\nOrder 21: 7 widgets shipped to warehouse 1.\\nOrder 22: 1 widgets shipped to warehouse 2.\\nOrder 23: 2 widgets shipped to warehouse 3.\\nOrder 24: 3 widgets shipped to warehouse 4.\\nOrder 25: 4 widgets shipped to warehouse 5.\\nOrder 26: 5 widgets shipped to warehouse 1.\\nOrder 27: 6 widgets shipped to warehouse 2.\\nOrder 28: 7 widgets shipped to warehouse 3.\\nOrder 29: 1 widgets shipped to warehouse 4.\\nOrder 30: 2 widgets shipped to warehouse 5.\\nOrder 31: 3 widgets shipped to warehouse 1.\\nOrder 32: 4 widgets shipped to warehouse 2.\\nOrder 33: 5 widgets shipped to warehouse 3.\\nOrder 34: 6 widgets shipped to warehouse 4.\\nOrder 35: 7 widgets shipped to warehouse 5.\\nOrder 36: 1 widgets shipped to warehouse 1.\\nOrder 37: 2 widgets shipped to warehouse 2.\\nOrder 38: 3 widgets shipped to warehouse 3.\\nOrder 39: 4 widgets shipped to warehouse 4.\\nOrder 40: 5 widgets shipped to warehouse 5.\\nOrder 41: 6 widgets shipped to warehouse 1.\\nOrder 42: 7 widgets shipped to warehouse 2.\\nOrder 43: 1 widgets shipped to warehouse 3.\\nOrder 44: 2 widgets shipped to warehouse 4.\\nOrder 45: 3 widgets shipped to warehouse 5.\\nOrder 46: 4 widgets shipped to warehouse 1.\\nOrder 47: 5 widgets shipped to warehouse 2.\\nOrder 48: 6 widgets shipped to warehouse 3.\\nOrder 49: 7 widgets shipped to warehouse 4.\\nOrder 50: 1 widgets shipped to warehouse 5.\\nOrder 51: 2 widgets shipped to warehouse 1.\\nOrder 52: 3 widgets shipped to warehouse 2.\\nOrder 53: 4 widgets shipped to warehouse 3.\\nOrder 54: 5 widgets shipped to warehouse 4.\\nOrder 55: 6 widgets shipped to warehouse 5.\\nOrder 56: 7 widgets shipped to warehouse 1.\\nOrder 57: 1 widgets shipped to warehouse 2.\\nOrder 58: 2 widgets shipped to warehouse 3.\\nOrder 59: 3 widgets shipped to warehouse 4.\\nOrder 60: 4 widgets shipped to warehouse 5.\\nOrder 61: 5 widgets shipped to warehouse 1.\\nOrder 62: 6 widgets shipped to warehouse 2.\\nOrder 63: 7 widgets shipped to warehouse 3.\\nOrder 64: 1 widgets shipped to warehouse 4.\\nOrder 65: 2 widgets shipped to warehouse 5.\\nOrder 66: 3 widgets shipped to warehouse 1.\\nOrder 67: 4 widgets shipped to warehouse 2.\\nOrder 68: 5 widgets shipped to warehouse 3.\\nOrder 69: 6 widgets shipped to warehouse 4.\\nOrder 70: 7 widgets shipped to warehouse 5.\\nOrder 71: 1 widgets shipped to warehouse 1.\\nOrder 72: 2 widgets shipped to warehouse 2.\\nOrder 73: 3 widgets shipped to warehouse 3.\\nOrder 74: 4 widgets shipped to warehouse 4.\\nOrder 75: 5 widgets shipped to warehouse 5.\\nOrder 76: 6 widgets shipped to warehouse 1.\\nOrder 77: 7 widgets shipped to warehouse 2.\\nOrder 78: 1 widgets shipped to warehouse 3.\\nOrder 79: 2 widgets shipped to warehouse 4.\\nOrder 80: 3 widgets shipped to warehouse 5.\\nOrder 81: 4 widgets shipped to warehouse 1.\\nOrder 82: 5 widgets shipped to warehouse 2.\\nOrder 83: 6 widgets shipped to warehouse 3.\\nOrder 84: 7 widgets shipped to warehouse 4.\\nOrder 85: 1 widgets shipped to warehouse 5.\\nOrder 86: 2 widgets shipped to warehouse 1.\\nOrder 87: 3 widgets shipped to warehouse 2.\\nOrder 88: 4 widgets shipped to warehouse 3.\\nOrder 89: 5 widgets shipped to warehouse 4.\\nOrder 90: 6 widgets shipped to warehouse 5.\\nOrder 91: 7 widgets shipped to warehouse 1.\\nOrder 92: 1 widgets shipped to warehouse 2.\\nOrder 93: 2 widgets shipped to warehouse 3.\\nOrder 94: 3 widgets shipped to warehouse 4.\\nOrder 95: 4 widgets shipped to warehouse 5.\\nOrder 96: 5 widgets shipped to warehouse 1.\\nOrder 97: 6 widgets shipped to warehouse 2.\\nOrder 98: 7 widgets shipped to warehouse 3.\\nOrder 99: 1 widgets shipped to warehouse 4.\\nOrder 100: 2 widgets shipped to warehouse 5.\\nOrder 101: 3 widgets shipped to warehouse 1.\\nOrder 102: 4 widgets shipped to warehouse 2.\\nOrder 103: 5 widgets shipped to warehouse 3.\\nOrder 104: 6 widgets shipped to warehouse 4.\\nOrder 105: 7 widgets shipped to warehouse 5.\\nOrder 106: 1 widgets shipped to warehouse 1.\\nOrder 107: 2 widgets shipped to warehouse 2.\\nOrder 108: 3 widgets shipped to warehouse 3.\\nOrder 109: 4 widgets shipped to warehouse 4.\\nOrder 110: 5 widgets shipped to warehouse 5.\\nOrder 111: 6 widgets shipped to warehouse 1.\\nOrder 112: 7 widgets shipped to warehouse 2.\\nOrder 113: 1 widgets shipped to warehouse 3.\\nOrder 114: 2 widgets shipped to warehouse 4.\\nOrder 115: 3 widgets shipped to warehouse 5.\\nOrder 116: 4 widgets shipped to warehouse 1.\\nOrder 117: 5 widgets shipped to warehouse 2.\\nOrder 118: 6 widgets shipped to warehouse 3.\\nOrder 119: 7 widgets shipped to warehouse 4.\\nOrder 120: 1 widgets shipped to warehouse 5.\\nOrder 121: 2 widgets shipped to warehouse 1.\\nOrder 122: 3 widgets shipped to warehouse 2.\\nOrder 123: 4 widgets shipped to warehouse 3.\\nOrder 124: 5 widgets shipped to warehouse 4.\\nOrder 125: 6 widgets shipped to warehouse 5.\\nOrder 126: 7 widgets shipped to warehouse 1.\\nOrder 127: 1 widgets shipped to warehouse 2.\\nOrder 128: 2 widgets shipped to warehouse 3.\\nOrder 129: 3 widgets shipped to warehouse 4.\\nOrder 130: 4 widgets shipped to warehouse 5.\\nOrder 131: 5 widgets shipped to warehouse 1.\\nOrder 132: 6 widgets shipped to warehouse 2.\\nOrder 133: 7 widgets shipped to warehouse 3.\\nOrder 134: 1 widgets shipped to warehouse 4.\\nOrder 135: 2 widgets shipped to warehouse 5.\\nOrder 136: 3 widgets shipped to warehouse 1.\\nOrder 137: 4 widgets shipped to warehouse 2.\\nOrder 138: 5 widgets shipped to warehouse 3.\\nOrder 139: 6 widgets shipped to warehouse 4.\\nOrder 140: 7 widgets shipped to warehouse 5.\\nOrder 141: 1 widgets shipped to warehouse 1.\\nOrder 142: 2 widgets shipped to warehouse 2.\\nOrder 143: 3 widgets shipped to warehouse 3.\\nOrder 144: 4 widgets shipped to warehouse 4.\\nOrder 145: 5 widgets shipped to warehouse 5.\\nOrder 146: 6 widgets shipped to warehouse 1.\\nOrder 147: 7 widgets shipped to warehouse 2.\\nOrder 148: 1 widgets shipped to warehouse 3.\\nOrder 149: 2 widgets shipped to warehouse 4.\\nOrder 150: 3 widgets shipped to warehouse 5.\\nOrder 151: 4 widgets shipped to warehouse 1.\\nOrder 152: 5 widgets shipped to warehouse 2.\\nOrder 153: 6 widgets shipped to warehouse 3.\\nOrder 154: 7 widgets shipped to warehouse 4.\\nOrder 155: 1 widgets shipped to warehouse 5.\\nOrder 156: 2 widgets shipped to warehouse 1.\\nOrder 157: 3 widgets shipped to warehouse 2.\\nOrder 158: 4 widgets shipped to warehouse 3.\\nOrder 159: 5 widgets shipped to warehouse 4.\\nOrder 160: 6 widgets shipped to warehouse 5.\\nOrder 161: 7 widgets shipped to warehouse 1.\\nOrder 162: 1 widgets shipped to warehouse 2.\\nOrder 163: 2 widgets shipped to warehouse 3.\\nOrder 164: 3 widgets shipped to warehouse 4.\\nOrder 165: 4 widgets shipped to warehouse 5.\\nOrder 166: 5 widgets shipped to warehouse 1.\\nOrder 167: 6 widgets shipped to warehouse 2.\\nOrder 168: 7 widgets shipped to warehouse 3.\\nOrder 169: 1 widgets shipped to warehouse 4.\\nOrder 170: 2 widgets shipped to warehouse 5.\\nOrder 171: 3 widgets shipped to warehouse 1.\\nOrder 172: 4 widgets shipped to warehouse 2.\\nOrder 173: 5 widgets shipped to warehouse 3.\\nOrder 174: 6 widgets shipped to warehouse 4.\\nOrder 175: 7 widgets shipped to warehouse 5.\\nOrder 176: 1 widgets shipped to warehouse 1.\\nOrder 177: 2 widgets shipped to warehouse 2.\\nOrder 178: 3 widgets shipped to warehouse 3.\\nOrder 179: 4 widgets shipped to warehouse 4.\\nOrder 180: 5 widgets shipped to warehouse 5.\\nOrder 181: 6 widgets shipped to warehouse 1.\\nOrder 182: 7 widgets shipped to warehouse 2.\\nOrder 183: 1 widgets shipped to warehouse 3.\\nOrder 184: 2 widgets shipped to warehouse 4.\\nOrder 185: 3 widgets shipped to warehouse 5.\\nOrder 186: 4 widgets shipped to warehouse 1.\\nOrder 187: 5 widgets shipped to warehouse 2.\\nOrder 188: 6 widgets shipped to warehouse 3.\\nOrder 189: 7 widgets shipped to warehouse 4.\\nOrder 190: 1 widgets shipped to warehouse 5.\\nOrder 191: 2 widgets shipped to warehouse 1.\\nOrder 192: 3 widgets shipped to warehouse 2.\\nOrder 193: 4 widgets shipped to warehouse 3.\\nOrder 194: 5 widgets shipped to warehouse 4.\\nOrder 195: 6 widgets shipped to warehouse 5.\\nOrder 196: 7 widgets shipped to warehouse 1.\\nOrder 197: 1 widgets shipped to warehouse 2.\\nOrder 198: 2 widgets shipped to warehouse 3.\\nOrder 199: 3 widgets shipped to warehouse 4.\\nOrder 200: 4 widgets shipped to warehouse 5.\\nOrder 201: 5 widgets shipped to warehouse 1.\\nOrder 202: 6 widgets shipped to warehouse 2.\\nOrder 203: 7 widgets shipped to warehouse 3.\\nOrder 204: 1 widgets shipped to warehouse 4.\\nOrder 205: 2 widgets shipped to warehouse 5.\\nOrder 206: 3 widgets shipped to warehouse 1.\\nOrder 207: 4 widgets shipped to warehouse 2.\\nOrder 208: 5 widgets shipped to warehouse 3.\\nOrder 209: 6 widgets shipped to warehouse 4.\\nOrder 210: 7 widgets shipped to warehouse 5.\\nOrder 211: 1 widgets shipped to warehouse 1.\\nOrder 212: 2 widgets shipped to warehouse 2.\\nOrder 213: 3 widgets shipped to warehouse 3.\\nOrder 214: 4 widgets shipped to warehouse 4.\\nOrder 215: 5 widgets shipped to warehouse 5.\\nOrder 216: 6 widgets shipped to warehouse 1.\\nOrder 217: 7 widgets shipped to warehouse 2.\\nOrder 218: 1 widgets shipped to warehouse 3.\\nOrder 219: 2 widgets shipped to warehouse 4.\\nOrder 220: 3 widgets shipped to warehouse 5.\\nOrder 221: 4 widgets shipped to warehouse 1.\\nOrder 222: 5 widgets shipped to warehouse 2.\\nOrder 223: 6 widgets shipped to warehouse 3.\\nOrder 224: 7 widgets shipped to warehouse 4.\\nOrder 225: 1 widgets shipped to warehouse 5.\\nOrder 226: 2 widgets shipped to warehouse 1.\\nOrder 227: 3 widgets shipped to warehouse 2.\\nOrder 228: 4 widgets shipped to warehouse 3.\\nOrder 229: 5 widgets shipped to warehouse 4.\\nOrder 230: 6 widgets shipped to warehouse 5.\\nOrder 231: 7 widgets shipped to warehouse 1.\\nOrder 232: 1 widgets shipped to warehouse 2.\\nOrder 233: 2 widgets shipped to warehouse 3.\\nOrder 234: 3 widgets shipped to warehouse 4.\\nOrder 235: 4 widgets shipped to warehouse 5.\\nOrder 236: 5 widgets shipped to warehouse 1.\\nOrder 237: 6 widgets shipped to warehouse 2.\\nOrder 238: 7 widgets shipped to warehouse 3.\\nOrder 239: 1 widgets shipped to warehouse 4.\\nOrder 240: 2 widgets shipped to warehouse 5.\\nOrder 241: 3 widgets shipped to warehouse 1.\\nOrder 242: 4 widgets shipped to warehouse 2.\\nOrder 243: 5 widgets shipped to warehouse 3.\\nOrder 244: 6 widgets shipped to warehouse 4.\\nOrder 245: 7 widgets shipped to warehouse 5.\\nOrder 246: 1 widgets shipped to warehouse 1.\\nOrder 247: 2 widgets shipped to warehouse 2.\\nOrder 248: 3 widgets shipped to warehouse 3.\\nOrder 249: 4 widgets shipped to warehouse 4.\\nOrder 250: 5 widgets shipped to warehouse 5.\\nOrder 251: 6 widgets shipped to warehouse 1.\\nOrder 252: 7 widgets shipped to warehouse 2.\\nOrder 253: 1 widgets shipped to warehouse 3.\\nOrder 254: 2 widgets shipped to warehouse 4.\\nOrder 255: 3 widgets shipped to warehouse 5.\\nOrder 256: 4 widgets shipped to warehouse 1.\\nOrder 257: 5 widgets shipped to warehouse 2.\\nOrder 258: 6 widgets shipped to warehouse 3.\\nOrder 259: 7 widgets shipped to warehouse 4.\\nOrder 260: 1 widgets shipped to warehouse 5.\\nOrder 261: 2 widgets shipped to warehouse 1.\\nOrder 262: 3 widgets shipped to warehouse 2.\\nOrder 263: 4 widgets shipped to warehouse 3.\\nOrder 264: 5 widgets shipped to warehouse 4.\\nOrder 265: 6 widgets shipped to warehouse 5.\\nOrder 266: 7 widgets shipped to warehouse 1.\\nOrder 267: 1 widgets shipped to warehouse 2.\\nOrder 268: 2 widgets shipped to warehouse 3.\\nOrder 269: 3 widgets shipped to warehouse 4.\\nOrder 270: 4 widgets shipped to warehouse 5.\\nOrder 271: 5 widgets shipped to warehouse 1.\\nOrder 272: 6 widgets shipped to warehouse 2.\\nOrder 273: 7 widgets shipped to warehouse 3.\\nOrder 274: 1 widgets shipped to warehouse 4.\\nOrder 275: 2 widgets shipped to warehouse 5.\\nOrder 276: 3 widgets shipped to warehouse 1.\\nOrder 277: 4 widgets shipped to warehouse 2.\\nOrder 278: 5 widgets shipped to warehouse 3.\\nOrder 279: 6 widgets shipped to warehouse 4.\\nOrder 280: 7 widgets shipped to warehouse 5.\\nOrder 281: 1 widgets shipped to warehouse 1.\\nOrder 282: 2 widgets shipped to warehouse 2.\\nOrder 283: 3 widgets shipped to warehouse 3.\\nOrder 284: 4 widgets shipped to warehouse 4.\\nOrder 285: 5 widgets shipped to warehouse 5.\\nOrder 286: 6 widgets shipped to warehouse 1.\\nOrder 287: 7 widgets shipped to warehouse 2.\\nOrder 288: 1 widgets shipped to warehouse 3.\\nOrder 289: 2 widgets shipped to warehouse 4.\\nOrder 290: 3 widgets shipped to warehouse 5.\\nOrder 291: 4 widgets shipped to warehouse 1.\\nOrder 292: 5 widgets shipped to warehouse 2.\\nOrder 293: 6 widgets shipped to warehouse 3.\\nOrder 294: 7 widgets shipped to warehouse 4.\\nOrder 295: 1 widgets shipped to warehouse 5.\\nOrder 296: 2 widgets shipped to warehouse 1.\\nOrder 297: 3 widgets shipped to warehouse 2.\\nOrder 298: 4 widgets shipped to warehouse 3.\\nOrder 299: 5 widgets shipped to warehouse 4.\\nOrder 300: 6 widgets shipped to warehouse 5.\\nOrder 301: 7 widgets shipped to warehouse 1.\\nOrder 302: 1 widgets shipped to warehouse 2.\\nOrder 303: 2 widgets shipped to warehouse 3.\\nOrder 304: 3 widgets shipped to warehouse 4.\\nOrder 305: 4 widgets shipped to warehouse 5.\\nOrder 306: 5 widgets shipped to warehouse 1.\\nOrder 307: 6 widgets shipped to warehouse 2.\\nOrder 308: 7 widgets shipped to warehouse 3.\\nOrder 309: 1 widgets shipped to warehouse 4.\\nOrder 310: 2 widgets shipped to warehouse 5.\\nOrder 311: 3 widgets shipped to warehouse 1.\\nOrder 312: 4 widgets shipped to warehouse 2.\\nOrder 313: 5 widgets shipped to warehouse 3.\\nOrder 314: 6 widgets shipped to warehouse 4.\\nOrder 315: 7 widgets shipped to warehouse 5.\\nOrder 316: 1 widgets shipped to warehouse 1.\\nOrder 317: 2 widgets shipped to warehouse 2.\\nOrder 318: 3 widgets shipped to warehouse 3.\\nOrder 319: 4 widgets shipped to warehouse 4.\\nOrder 320: 5 widgets shipped to warehouse 5.\\nOrder 321: 6 widgets shipped to warehouse 1.\\nOrder 322: 7 widgets shipped to warehouse 2.\\nOrder 323: 1 widgets shipped to warehouse 3.\\nOrder 324: 2 widgets shipped to warehouse 4.\\nOrder 325: 3 widgets shipped to warehouse 5.\\nOrder 326: 4 widgets shipped to warehouse 1.\\nOrder 327: 5 widgets shipped to warehouse 2.\\nOrder 328: 6 widgets shipped to warehouse 3.\\nOrder 329: 7 widgets shipped to warehouse 4.\\nOrder 330: 1 widgets shipped to warehouse 5.\\nOrder 331: 2 widgets shipped to warehouse 1.\\nOrder 332: 3 widgets shipped to warehouse 2.\\nOrder 333: 4 widgets shipped to warehouse 3.\\nOrder 334: 5 widgets shipped to warehouse 4.\\nOrder 335: 6 widgets shipped to warehouse 5.\\nOrder 336: 7 widgets shipped to warehouse 1.\\nOrder 337: 1 widgets shipped to warehouse 2.\\nOrder 338: 2 widgets shipped to warehouse 3.\\nOrder 339: 3 widgets shipped to warehouse 4.\\nOrder 340: 4 widgets shipped to warehouse 5.\\nOrder 341: 5 widgets shipped to warehouse 1.\\nOrder 342: 6 widgets shipped to warehouse 2.\\nOrder 343: 7 widgets shipped to warehouse 3.\\nOrder 344: 1 widgets shipped to warehouse 4.\\nOrder 345: 2 widgets shipped to warehouse 5.\\nOrder 346: 3 widgets shipped to warehouse 1.\\nOrder 347: 4 widgets shipped to warehouse 2.\\nOrder 348: 5 widgets shipped to warehouse 3.\\nOrder 349: 6 widgets shipped to warehouse 4.\\nOrder 350: 7 widgets shipped to warehouse 5.\\nOrder 351: 1 widgets shipped to warehouse 1.\\nOrder 352: 2 widgets shipped to warehouse 2.\\nOrder 353: 3 widgets shipped to warehouse 3.\\nOrder 354: 4 widgets shipped to warehouse 4.\\nOrder 355: 5 widgets shipped to warehouse 5.\\nOrder 356: 6 widgets shipped to warehouse 1.\\nOrder 357: 7 widgets shipped to warehouse 2.\\nOrder 358: 1 widgets shipped to warehouse 3.\\nOrder 359: 2 widgets shipped to warehouse 4.\\nOrder 360: 3 widgets shipped to warehouse 5.\\nOrder 361: 4 widgets shipped to warehouse 1.\\nOrder 362: 5 widgets shipped to warehouse 2.\\nOrder 363: 6 widgets shipped to warehouse 3.\\nOrder 364: 7 widgets shipped to warehouse 4.\\nOrder 365: 1 widgets shipped to warehouse 5.\\nOrder 366: 2 widgets shipped to warehouse 1.\\nOrder 367: 3 widgets shipped to warehouse 2.\\nOrder 368: 4 widgets shipped to warehouse 3.\\nOrder 369: 5 widgets shipped to warehouse 4.\\nOrder 370: 6 widgets shipped to warehouse 5.\\nOrder 371: 7 widgets shipped to warehouse 1.\\nOrder 372: 1 widgets shipped to warehouse 2.\\nOrder 373: 2 widgets shipped to warehouse 3.\\nOrder 374: 3 widgets shipped to warehouse 4.\\nOrder 375: 4 widgets shipped to warehouse 5.\\nOrder 376: 5 widgets shipped to warehouse 1.\\nOrder 377: 6 widgets shipped to warehouse 2.\\nOrder 378: 7 widgets shipped to warehouse 3.\\nOrder 379: 1 widgets shipped to warehouse 4.\\nOrder 380: 2 widgets shipped to warehouse 5.\\nOrder 381: 3 widgets shipped to warehouse 1.\\nOrder 382: 4 widgets shipped to warehouse 2.\\nOrder 383: 5 widgets shipped to warehouse 3.\\nOrder 384: 6 widgets shipped to warehouse 4.\\nOrder 385: 7 widgets shipped to warehouse 5.\\nOrder 386: 1 widgets shipped to warehouse 1.\\nOrder 387: 2 widgets shipped to warehouse 2.\\nOrder 388: 3 widgets shipped to warehouse 3.\\nOrder 389: 4 widgets shipped to warehouse 4.\\nOrder 390: 5 widgets shipped to warehouse 5.\\nOrder 391: 6 widgets shipped to warehouse 1.\\nOrder 392: 7 widgets shipped to warehouse 2.\\nOrder 393: 1 widgets shipped to warehouse 3.\\nOrder 394: 2 widgets shipped to warehouse 4.\\nOrder 395: 3 widgets shipped to warehouse 5.\\nOrder 396: 4 widgets shipped to warehouse 1.\\nOrder 397: 5 widgets shipped to warehouse 2.\\nOrder 398: 6 widgets shipped to warehouse 3.\\nOrder 399: 7 widgets shipped to warehouse 4.\\nOrder 400: 1 widgets shipped to warehouse 5.\\nOrder 401: 2 widgets shipped to warehouse 1.\\nOrder 402: 3 widgets shipped to warehouse 2.\\nOrder 403: 4 widgets shipped to warehouse 3.\\nOrder 404: 5 widgets shipped to warehouse 4.\\nOrder 405: 6 widgets shipped to warehouse 5.\\nOrder 406: 7 widgets shipped to warehouse 1.\\nOrder 407: 1 widgets shipped to warehouse 2.\\nOrder 408: 2 widgets shipped to warehouse 3.\\nOrder 409: 3 widgets shipped to warehouse 4.\\nOrder 410: 4 widgets shipped to warehouse 5.\\nOrder 411: 5 widgets shipped to warehouse 1.\\nOrder 412: 6 widgets shipped to warehouse 2.\\nOrder 413: 7 widgets shipped to warehouse 3.\\nOrder 414: 1 widgets shipped to warehouse 4.\\nOrder 415: 2 widgets shipped to warehouse 5.\\nOrder 416: 3 widgets shipped to warehouse 1.\\nOrder 417: 4 widgets shipped to warehouse 2.\\nOrder 418: 5 widgets shipped to warehouse 3.\\nOrder 419: 6 widgets shipped to warehouse 4.\\nOrder 420: 7 widgets shipped to warehouse 5.\\nOrder 421: 1 widgets shipped to warehouse 1.\\nOrder 422: 2 widgets shipped to warehouse 2.\\nOrder 423: 3 widgets shipped to warehouse 3.\\nOrder 424: 4 widgets shipped to warehouse 4.\\nOrder 425: 5 widgets shipped to warehouse 5.\\nOrder 426: 6 widgets shipped to warehouse 1.\\nOrder 427: 7 widgets shipped to warehouse 2.\\nOrder 428: 1 widgets shipped to warehouse 3.\\nOrder 429: 2 widgets shipped to warehouse 4.\\nOrder 430: 3 widgets shipped to warehouse 5.\\nOrder 431: 4 widgets shipped to warehouse 1.\\nOrder 432: 5 widgets shipped to warehouse 2.\\nOrder 433: 6 widgets shipped to warehouse 3.\\nOrder 434: 7 widgets shipped to warehouse 4.\\nOrder 435: 1 widgets shipped to warehouse 5.\\nOrder 436: 2 widgets shipped to warehouse 1.\\nOrder 437: 3 widgets shipped to warehouse 2.\\nOrder 438: 4 widgets shipped to warehouse 3.\\nOrder 439: 5 widgets shipped to warehouse 4.\\nOrder 440: 6 widgets shipped to warehouse 5.\\nOrder 441: 7 widgets shipped to warehouse 1.\\nOrder 442: 1 widgets shipped to warehouse 2.\\nOrder 443: 2 widgets shipped to warehouse 3.\\nOrder 444: 3 widgets shipped to warehouse 4.\\nOrder 445: 4 widgets shipped to warehouse 5.\\nOrder 446: 5 widgets shipped to warehouse 1.\\nOrder 447: 6 widgets shipped to warehouse 2.\\nOrder 448: 7 widgets shipped to warehouse 3.\\nOrder 449: 1 widgets shipped to warehouse 4.\\nOrder 450: 2 widgets shipped to warehouse 5.\\nOrder 451: 3 widgets shipped to warehouse 1.\\nOrder 452: 4 widgets shipped to warehouse 2.\\nOrder 453: 5 widgets shipped to warehouse 3.\\nOrder 454: 6 widgets shipped to warehouse 4.\\nOrder 455: 7 widgets shipped to warehouse 5.\\nOrder 456: 1 widgets shipped to warehouse 1.\\nOrder 457: 2 widgets shipped to warehouse 2.\\nOrder 458: 3 widgets shipped to warehouse 3.\\nOrder 459: 4 widgets shipped to warehouse 4.\\nOrder 460: 5 widgets shipped to warehouse 5.\\nOrder 461: 6 widgets shipped to warehouse 1.\\nOrder 462: 7 widgets shipped to warehouse 2.\\nOrder 463: 1 widgets shipped to warehouse 3.\\nOrder 464: 2 widgets shipped to warehouse 4.\\nOrder 465: 3 widgets shipped to warehouse 5.\\nOrder 466: 4 widgets shipped to warehouse 1.\\nOrder 467: 5 widgets shipped to warehouse 2.\\nOrder 468: 6 widgets shipped to warehouse 3.\\nOrder 469: 7 widgets shipped to warehouse 4.\\nOrder 470: 1 widgets shipped to warehouse 5.\\nOrder 471: 2 widgets shipped to warehouse 1.\\nOrder 472: 3 widgets shipped to warehouse 2.\\nOrder 473: 4 widgets shipped to warehouse 3.\\nOrder 474: 5 widgets shipped to warehouse 4.\\nOrder 475: 6 widgets shipped to warehouse 5.\\nOrder 476: 7 widgets shipped to warehouse 1.\\nOrder 477: 1 widgets shipped to warehouse 2.\\nOrder 478: 2 widgets shipped to warehouse 3.\\nOrder 479: 3 widgets shipped to warehouse 4.\\nOrder 480: 4 widgets shipped to warehouse 5.\\nOrder 481: 5 widgets shipped to warehouse 1.\\nOrder 482: 6 widgets shipped to warehouse 2.\\nOrder 483: 7 widgets shipped to warehouse 3.\\nOrder 484: 1 widgets shipped to warehouse 4.\\nOrder 485: 2 widgets shipped to warehouse 5.\\nOrder 486: 3 widgets shipped to warehouse 1.\\nOrder 487: 4 widgets shipped to warehouse 2.\\nOrder 488: 5 widgets shipped to warehouse 3.\\nOrder 489: 6 widgets shipped to warehouse 4.\\nOrder 490: 7 widgets shipped to warehouse 5.\\nOrder 491: 1 widgets shipped to warehouse 1.\\nOrder 492: 2 widgets shipped to warehouse 2.\\nOrder 493: 3 widgets shipped to warehouse 3.\\nOrder 494: 4 widgets shipped to warehouse 4.\\nOrder 495: 5 widgets shipped to warehouse 5.\\nOrder 496: 6 widgets shipped to warehouse 1.\\nOrder 497: 7 widgets shipped to warehouse 2.\\nOrder 498: 1 widgets shipped to warehouse 3.\\nOrder 499: 2 widgets shipped to warehouse 4.\\nOrder 500: 3 widgets shipped to warehouse 5.\\nOrder 501: 4 widgets shipped to warehouse 1.\\nOrder 502: 5 widgets shipped to warehouse 2.\\nOrder 503: 6 widgets shipped to warehouse 3.\\nOrder 504: 7 widgets shipped to warehouse 4.\\nOrder 505: 1 widgets shipped to warehouse 5.\\nOrder 506: 2 widgets shipped to warehouse 1.\\nOrder 507: 3 widgets shipped to warehouse 2.\\nOrder 508: 4 widgets shipped to warehouse 3.\\nOrder 509: 5 widgets shipped to warehouse 4.\\nOrder 510: 6 widgets shipped to warehouse 5.\\nOrder 511: 7 widgets shipped to warehouse 1.\\nOrder 512: 1 widgets shipped to warehouse 2.\\nOrder 513: 2 widgets shipped to warehouse 3.\\nOrder 514: 3 widgets shipped to warehouse 4.\\nOrder 515: 4 widgets shipped to warehouse 5.\\nOrder 516: 5 widgets shipped to warehouse 1.\\nOrder 517: 6 widgets shipped to warehouse 2.\\nOrder 518: 7 widgets shipped to warehouse 3.\\nOrder 519: 1 widgets shipped to warehouse 4.\\nOrder 520: 2 widgets shipped to warehouse 5.\\nOrder 521: 3 widgets shipped to warehouse 1.\\nOrder 522: 4 widgets shipped to warehouse 2.\\nOrder 523: 5 widgets shipped to warehouse 3.\\nOrder 524: 6 widgets shipped to warehouse 4.\\nOrder 525: 7 widgets shipped to warehouse 5.\\nOrder 526: 1 widgets shipped to warehouse 1.\\nOrder 527: 2 widgets shipped to warehouse 2.\\nOrder 528: 3 widgets shipped to warehouse 3.\\nOrder 529: 4 widgets shipped to warehouse 4.\\nOrder 530: 5 widgets shipped to warehouse 5.\\nOrder 531: 6 widgets shipped to warehouse 1.\\nOrder 532: 7 widgets shipped to warehouse 2.\\nOrder 533: 1 widgets shipped to warehouse 3.\\nOrder 534: 2 widgets shipped to warehouse 4.\\nOrder 535: 3 widgets shipped to warehouse 5.\\nOrder 536: 4 widgets shipped to warehouse 1.\\nOrder 537: 5 widgets shipped to warehouse 2.\\nOrder 538: 6 widgets shipped to warehouse 3.\\nOrder 539: 7 widgets shipped to warehouse 4.\\nOrder 540: 1 widgets shipped to warehouse 5.\\nOrder 541: 2 widgets shipped to warehouse 1.\\nOrder 542: 3 widgets shipped to warehouse 2.\\nOrder 543: 4 widgets shipped to warehouse 3.\\nOrder 544: 5 widgets shipped to warehouse 4.\\nOrder 545: 6 widgets shipped to warehouse 5.\\nOrder 546: 7 widgets shipped to warehouse 1.\\nOrder 547: 1 widgets shipped to warehouse 2.\\nOrder 548: 2 widgets shipped to warehouse 3.\\nOrder 549: 3 widgets shipped to warehouse 4.\\nOrder 550: 4 widgets shipped to warehouse 5.\\nOrder 551: 5 widgets shipped to warehouse 1.\\nOrder 552: 6 widgets shipped to warehouse 2.\\nOrder 553: 7 widgets shipped to warehouse 3.\\nOrder 554: 1 widgets shipped to warehouse 4.\\nOrder 555: 2 widgets shipped to warehouse 5.\\nOrder 556: 3 widgets shipped to warehouse 1.\\nOrder 557: 4 widgets shipped to warehouse 2.\\nOrder 558: 5 widgets shipped to warehouse 3.\\nOrder 559: 6 widgets shipped to warehouse 4.\\nOrder 560: 7 widgets shipped to warehouse 5.\\nOrder 561: 1 widgets shipped to warehouse 1.\\nOrder 562: 2 widgets shipped to warehouse 2.\\nOrder 563: 3 widgets shipped to warehouse 3.\\nOrder 564: 4 widgets shipped to warehouse 4.\\nOrder 565: 5 widgets shipped to warehouse 5.\\nOrder 566: 6 widgets shipped to warehouse 1.\\nOrder 567: 7 widgets shipped to warehouse 2.\\nOrder 568: 1 widgets shipped to warehouse 3.\\nOrder 569: 2 widgets shipped to warehouse 4.\\nOrder 570: 3 widgets shipped to warehouse 5.\\nOrder 571: 4 widgets shipped to warehouse 1.\\nOrder 572: 5 widgets shipped to warehouse 2.\\nOrder 573: 6 widgets shipped to warehouse 3.\\nOrder 574: 7 widgets shipped to warehouse 4.\\nOrder 575: 1 widgets shipped to warehouse 5.\\nOrder 576: 2 widgets shipped to warehouse 1.\\nOrder 577: 3 widgets shipped to warehouse 2.\\nOrder 578: 4 widgets shipped to warehouse 3.\\nOrder 579: 5 widgets shipped to warehouse 4.\\nOrder 580: 6 widgets shipped to warehouse 5.\\nOrder 581: 7 widgets shipped to warehouse 1.\\nOrder 582: 1 widgets shipped to warehouse 2.\\nOrder 583: 2 widgets shipped to warehouse 3.\\nOrder 584: 3 widgets shipped to warehouse 4.\\nOrder 585: 4 widgets shipped to warehouse 5.\\nOrder 586: 5 widgets shipped to warehouse 1.\\nOrder 587: 6 widgets shipped to warehouse 2.\\nOrder 588: 7 widgets shipped to warehouse 3.\\nOrder 589: 1 widgets shipped to warehouse 4.\\nOrder 590: 2 widgets shipped to warehouse 5.\\nOrder 591: 3 widgets shipped to warehouse 1.\\nOrder 592: 4 widgets shipped to warehouse 2.\\nOrder 593: 5 widgets shipped to warehouse 3.\\nOrder 594: 6 widgets shipped to warehouse 4.\\nOrder 595: 7 widgets shipped to warehouse 5.\\nOrder 596: 1 widgets shipped to warehouse 1.\\nOrder 597: 2 widgets shipped to warehouse 2.\\nOrder 598: 3 widgets shipped to warehouse 3.\\nOrder 599: 4 widgets shipped to warehouse 4.\\nOrder 600: 5 widgets shipped to warehouse 5.\\nIn one short sentence, how many orders are there? Call no tools.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 66, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"0f95c7ba-3d4f-49cd-ab28-82d161b1c9ac\",\"runId\":\"53bb9d69-a4ef-47e5-9622-f8c26df84f6d\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"0f95c7ba-3d4f-49cd-ab28-82d161b1c9ac\",\"runId\":\"53bb9d69-a4ef-47e5-9622-f8c26df84f6d\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"a0c4116f-2113-4c72-ad3f-1cd24811b72b\",\"role\":\"user\",\"content\":\"Here is our order log:\\nOrder 1: 1 widgets shipped to warehouse 1.\\nOrder 2: 2 widgets shipped to warehouse 2.\\nOrder 3: 3 widgets shipped to warehouse 3.\\nOrder 4: 4 widgets shipped to warehouse 4.\\nOrder 5: 5 widgets shipped to warehouse 5.\\nOrder 6: 6 widgets shipped to warehouse 1.\\nOrder 7: 7 widgets shipped to warehouse 2.\\nOrder 8: 1 widgets shipped to warehouse 3.\\nOrder 9: 2 widgets shipped to warehouse 4.\\nOrder 10: 3 widgets shipped to warehouse 5.\\nOrder 11: 4 widgets shipped to warehouse 1.\\nOrder 12: 5 widgets shipped to warehouse 2.\\nOrder 13: 6 widgets shipped to warehouse 3.\\nOrder 14: 7 widgets shipped to warehouse 4.\\nOrder 15: 1 widgets shipped to warehouse 5.\\nOrder 16: 2 widgets shipped to warehouse 1.\\nOrder 17: 3 widgets shipped to warehouse 2.\\nOrder 18: 4 widgets shipped to warehouse 3.\\nOrder 19: 5 widgets shipped to warehouse 4.\\nOrder 20: 6 widgets shipped to warehouse 5.\\nOrder 21: 7 widgets shipped to warehouse 1.\\nOrder 22: 1 widgets shipped to warehouse 2.\\nOrder 23: 2 widgets shipped to warehouse 3.\\nOrder 24: 3 widgets shipped to warehouse 4.\\nOrder 25: 4 widgets shipped to warehouse 5.\\nOrder 26: 5 widgets shipped to warehouse 1.\\nOrder 27: 6 widgets shipped to warehouse 2.\\nOrder 28: 7 widgets shipped to warehouse 3.\\nOrder 29: 1 widgets shipped to warehouse 4.\\nOrder 30: 2 widgets shipped to warehouse 5.\\nOrder 31: 3 widgets shipped to warehouse 1.\\nOrder 32: 4 widgets shipped to warehouse 2.\\nOrder 33: 5 widgets shipped to warehouse 3.\\nOrder 34: 6 widgets shipped to warehouse 4.\\nOrder 35: 7 widgets shipped to warehouse 5.\\nOrder 36: 1 widgets shipped to warehouse 1.\\nOrder 37: 2 widgets shipped to warehouse 2.\\nOrder 38: 3 widgets shipped to warehouse 3.\\nOrder 39: 4 widgets shipped to warehouse 4.\\nOrder 40: 5 widgets shipped to warehouse 5.\\nOrder 41: 6 widgets shipped to warehouse 1.\\nOrder 42: 7 widgets shipped to warehouse 2.\\nOrder 43: 1 widgets shipped to warehouse 3.\\nOrder 44: 2 widgets shipped to warehouse 4.\\nOrder 45: 3 widgets shipped to warehouse 5.\\nOrder 46: 4 widgets shipped to warehouse 1.\\nOrder 47: 5 widgets shipped to warehouse 2.\\nOrder 48: 6 widgets shipped to warehouse 3.\\nOrder 49: 7 widgets shipped to warehouse 4.\\nOrder 50: 1 widgets shipped to warehouse 5.\\nOrder 51: 2 widgets shipped to warehouse 1.\\nOrder 52: 3 widgets shipped to warehouse 2.\\nOrder 53: 4 widgets shipped to warehouse 3.\\nOrder 54: 5 widgets shipped to warehouse 4.\\nOrder 55: 6 widgets shipped to warehouse 5.\\nOrder 56: 7 widgets shipped to warehouse 1.\\nOrder 57: 1 widgets shipped to warehouse 2.\\nOrder 58: 2 widgets shipped to warehouse 3.\\nOrder 59: 3 widgets shipped to warehouse 4.\\nOrder 60: 4 widgets shipped to warehouse 5.\\nOrder 61: 5 widgets shipped to warehouse 1.\\nOrder 62: 6 widgets shipped to warehouse 2.\\nOrder 63: 7 widgets shipped to warehouse 3.\\nOrder 64: 1 widgets shipped to warehouse 4.\\nOrder 65: 2 widgets shipped to warehouse 5.\\nOrder 66: 3 widgets shipped to warehouse 1.\\nOrder 67: 4 widgets shipped to warehouse 2.\\nOrder 68: 5 widgets shipped to warehouse 3.\\nOrder 69: 6 widgets shipped to warehouse 4.\\nOrder 70: 7 widgets shipped to warehouse 5.\\nOrder 71: 1 widgets shipped to warehouse 1.\\nOrder 72: 2 widgets shipped to warehouse 2.\\nOrder 73: 3 widgets shipped to warehouse 3.\\nOrder 74: 4 widgets shipped to warehouse 4.\\nOrder 75: 5 widgets shipped to warehouse 5.\\nOrder 76: 6 widgets shipped to warehouse 1.\\nOrder 77: 7 widgets shipped to warehouse 2.\\nOrder 78: 1 widgets shipped to warehouse 3.\\nOrder 79: 2 widgets shipped to warehouse 4.\\nOrder 80: 3 widgets shipped to warehouse 5.\\nOrder 81: 4 widgets shipped to warehouse 1.\\nOrder 82: 5 widgets shipped to warehouse 2.\\nOrder 83: 6 widgets shipped to warehouse 3.\\nOrder 84: 7 widgets shipped to warehouse 4.\\nOrder 85: 1 widgets shipped to warehouse 5.\\nOrder 86: 2 widgets shipped to warehouse 1.\\nOrder 87: 3 widgets shipped to warehouse 2.\\nOrder 88: 4 widgets shipped to warehouse 3.\\nOrder 89: 5 widgets shipped to warehouse 4.\\nOrder 90: 6 widgets shipped to warehouse 5.\\nOrder 91: 7 widgets shipped to warehouse 1.\\nOrder 92: 1 widgets shipped to warehouse 2.\\nOrder 93: 2 widgets shipped to warehouse 3.\\nOrder 94: 3 widgets shipped to warehouse 4.\\nOrder 95: 4 widgets shipped to warehouse 5.\\nOrder 96: 5 widgets shipped to warehouse 1.\\nOrder 97: 6 widgets shipped to warehouse 2.\\nOrder 98: 7 widgets shipped to warehouse 3.\\nOrder 99: 1 widgets shipped to warehouse 4.\\nOrder 100: 2 widgets shipped to warehouse 5.\\nOrder 101: 3 widgets shipped to warehouse 1.\\nOrder 102: 4 widgets shipped to warehouse 2.\\nOrder 103: 5 widgets shipped to warehouse 3.\\nOrder 104: 6 widgets shipped to warehouse 4.\\nOrder 105: 7 widgets shipped to warehouse 5.\\nOrder 106: 1 widgets shipped to warehouse 1.\\nOrder 107: 2 widgets shipped to warehouse 2.\\nOrder 108: 3 widgets shipped to warehouse 3.\\nOrder 109: 4 widgets shipped to warehouse 4.\\nOrder 110: 5 widgets shipped to warehouse 5.\\nOrder 111: 6 widgets shipped to warehouse 1.\\nOrder 112: 7 widgets shipped to warehouse 2.\\nOrder 113: 1 widgets shipped to warehouse 3.\\nOrder 114: 2 widgets shipped to warehouse 4.\\nOrder 115: 3 widgets shipped to warehouse 5.\\nOrder 116: 4 widgets shipped to warehouse 1.\\nOrder 117: 5 widgets shipped to warehouse 2.\\nOrder 118: 6 widgets shipped to warehouse 3.\\nOrder 119: 7 widgets shipped to warehouse 4.\\nOrder 120: 1 widgets shipped to warehouse 5.\\nOrder 121: 2 widgets shipped to warehouse 1.\\nOrder 122: 3 widgets shipped to warehouse 2.\\nOrder 123: 4 widgets shipped to warehouse 3.\\nOrder 124: 5 widgets shipped to warehouse 4.\\nOrder 125: 6 widgets shipped to warehouse 5.\\nOrder 126: 7 widgets shipped to warehouse 1.\\nOrder 127: 1 widgets shipped to warehouse 2.\\nOrder 128: 2 widgets shipped to warehouse 3.\\nOrder 129: 3 widgets shipped to warehouse 4.\\nOrder 130: 4 widgets shipped to warehouse 5.\\nOrder 131: 5 widgets shipped to warehouse 1.\\nOrder 132: 6 widgets shipped to warehouse 2.\\nOrder 133: 7 widgets shipped to warehouse 3.\\nOrder 134: 1 widgets shipped to warehouse 4.\\nOrder 135: 2 widgets shipped to warehouse 5.\\nOrder 136: 3 widgets shipped to warehouse 1.\\nOrder 137: 4 widgets shipped to warehouse 2.\\nOrder 138: 5 widgets shipped to warehouse 3.\\nOrder 139: 6 widgets shipped to warehouse 4.\\nOrder 140: 7 widgets shipped to warehouse 5.\\nOrder 141: 1 widgets shipped to warehouse 1.\\nOrder 142: 2 widgets shipped to warehouse 2.\\nOrder 143: 3 widgets shipped to warehouse 3.\\nOrder 144: 4 widgets shipped to warehouse 4.\\nOrder 145: 5 widgets shipped to warehouse 5.\\nOrder 146: 6 widgets shipped to warehouse 1.\\nOrder 147: 7 widgets shipped to warehouse 2.\\nOrder 148: 1 widgets shipped to warehouse 3.\\nOrder 149: 2 widgets shipped to warehouse 4.\\nOrder 150: 3 widgets shipped to warehouse 5.\\nOrder 151: 4 widgets shipped to warehouse 1.\\nOrder 152: 5 widgets shipped to warehouse 2.\\nOrder 153: 6 widgets shipped to warehouse 3.\\nOrder 154: 7 widgets shipped to warehouse 4.\\nOrder 155: 1 widgets shipped to warehouse 5.\\nOrder 156: 2 widgets shipped to warehouse 1.\\nOrder 157: 3 widgets shipped to warehouse 2.\\nOrder 158: 4 widgets shipped to warehouse 3.\\nOrder 159: 5 widgets shipped to warehouse 4.\\nOrder 160: 6 widgets shipped to warehouse 5.\\nOrder 161: 7 widgets shipped to warehouse 1.\\nOrder 162: 1 widgets shipped to warehouse 2.\\nOrder 163: 2 widgets shipped to warehouse 3.\\nOrder 164: 3 widgets shipped to warehouse 4.\\nOrder 165: 4 widgets shipped to warehouse 5.\\nOrder 166: 5 widgets shipped to warehouse 1.\\nOrder 167: 6 widgets shipped to warehouse 2.\\nOrder 168: 7 widgets shipped to warehouse 3.\\nOrder 169: 1 widgets shipped to warehouse 4.\\nOrder 170: 2 widgets shipped to warehouse 5.\\nOrder 171: 3 widgets shipped to warehouse 1.\\nOrder 172: 4 widgets shipped to warehouse 2.\\nOrder 173: 5 widgets shipped to warehouse 3.\\nOrder 174: 6 widgets shipped to warehouse 4.\\nOrder 175: 7 widgets shipped to warehouse 5.\\nOrder 176: 1 widgets shipped to warehouse 1.\\nOrder 177: 2 widgets shipped to warehouse 2.\\nOrder 178: 3 widgets shipped to warehouse 3.\\nOrder 179: 4 widgets shipped to warehouse 4.\\nOrder 180: 5 widgets shipped to warehouse 5.\\nOrder 181: 6 widgets shipped to warehouse 1.\\nOrder 182: 7 widgets shipped to warehouse 2.\\nOrder 183: 1 widgets shipped to warehouse 3.\\nOrder 184: 2 widgets shipped to warehouse 4.\\nOrder 185: 3 widgets shipped to warehouse 5.\\nOrder 186: 4 widgets shipped to warehouse 1.\\nOrder 187: 5 widgets shipped to warehouse 2.\\nOrder 188: 6 widgets shipped to warehouse 3.\\nOrder 189: 7 widgets shipped to warehouse 4.\\nOrder 190: 1 widgets shipped to warehouse 5.\\nOrder 191: 2 widgets shipped to warehouse 1.\\nOrder 192: 3 widgets shipped to warehouse 2.\\nOrder 193: 4 widgets shipped to warehouse 3.\\nOrder 194: 5 widgets shipped to warehouse 4.\\nOrder 195: 6 widgets shipped to warehouse 5.\\nOrder 196: 7 widgets shipped to warehouse 1.\\nOrder 197: 1 widgets shipped to warehouse 2.\\nOrder 198: 2 widgets shipped to warehouse 3.\\nOrder 199: 3 widgets shipped to warehouse 4.\\nOrder 200: 4 widgets shipped to warehouse 5.\\nOrder 201: 5 widgets shipped to warehouse 1.\\nOrder 202: 6 widgets shipped to warehouse 2.\\nOrder 203: 7 widgets shipped to warehouse 3.\\nOrder 204: 1 widgets shipped to warehouse 4.\\nOrder 205: 2 widgets shipped to warehouse 5.\\nOrder 206: 3 widgets shipped to warehouse 1.\\nOrder 207: 4 widgets shipped to warehouse 2.\\nOrder 208: 5 widgets shipped to warehouse 3.\\nOrder 209: 6 widgets shipped to warehouse 4.\\nOrder 210: 7 widgets shipped to warehouse 5.\\nOrder 211: 1 widgets shipped to warehouse 1.\\nOrder 212: 2 widgets shipped to warehouse 2.\\nOrder 213: 3 widgets shipped to warehouse 3.\\nOrder 214: 4 widgets shipped to warehouse 4.\\nOrder 215: 5 widgets shipped to warehouse 5.\\nOrder 216: 6 widgets shipped to warehouse 1.\\nOrder 217: 7 widgets shipped to warehouse 2.\\nOrder 218: 1 widgets shipped to warehouse 3.\\nOrder 219: 2 widgets shipped to warehouse 4.\\nOrder 220: 3 widgets shipped to warehouse 5.\\nOrder 221: 4 widgets shipped to warehouse 1.\\nOrder 222: 5 widgets shipped to warehouse 2.\\nOrder 223: 6 widgets shipped to warehouse 3.\\nOrder 224: 7 widgets shipped to warehouse 4.\\nOrder 225: 1 widgets shipped to warehouse 5.\\nOrder 226: 2 widgets shipped to warehouse 1.\\nOrder 227: 3 widgets shipped to warehouse 2.\\nOrder 228: 4 widgets shipped to warehouse 3.\\nOrder 229: 5 widgets shipped to warehouse 4.\\nOrder 230: 6 widgets shipped to warehouse 5.\\nOrder 231: 7 widgets shipped to warehouse 1.\\nOrder 232: 1 widgets shipped to warehouse 2.\\nOrder 233: 2 widgets shipped to warehouse 3.\\nOrder 234: 3 widgets shipped to warehouse 4.\\nOrder 235: 4 widgets shipped to warehouse 5.\\nOrder 236: 5 widgets shipped to warehouse 1.\\nOrder 237: 6 widgets shipped to warehouse 2.\\nOrder 238: 7 widgets shipped to warehouse 3.\\nOrder 239: 1 widgets shipped to warehouse 4.\\nOrder 240: 2 widgets shipped to warehouse 5.\\nOrder 241: 3 widgets shipped to warehouse 1.\\nOrder 242: 4 widgets shipped to warehouse 2.\\nOrder 243: 5 widgets shipped to warehouse 3.\\nOrder 244: 6 widgets shipped to warehouse 4.\\nOrder 245: 7 widgets shipped to warehouse 5.\\nOrder 246: 1 widgets shipped to warehouse 1.\\nOrder 247: 2 widgets shipped to warehouse 2.\\nOrder 248: 3 widgets shipped to warehouse 3.\\nOrder 249: 4 widgets shipped to warehouse 4.\\nOrder 250: 5 widgets shipped to warehouse 5.\\nOrder 251: 6 widgets shipped to warehouse 1.\\nOrder 252: 7 widgets shipped to warehouse 2.\\nOrder 253: 1 widgets shipped to warehouse 3.\\nOrder 254: 2 widgets shipped to warehouse 4.\\nOrder 255: 3 widgets shipped to warehouse 5.\\nOrder 256: 4 widgets shipped to warehouse 1.\\nOrder 257: 5 widgets shipped to warehouse 2.\\nOrder 258: 6 widgets shipped to warehouse 3.\\nOrder 259: 7 widgets shipped to warehouse 4.\\nOrder 260: 1 widgets shipped to warehouse 5.\\nOrder 261: 2 widgets shipped to warehouse 1.\\nOrder 262: 3 widgets shipped to warehouse 2.\\nOrder 263: 4 widgets shipped to warehouse 3.\\nOrder 264: 5 widgets shipped to warehouse 4.\\nOrder 265: 6 widgets shipped to warehouse 5.\\nOrder 266: 7 widgets shipped to warehouse 1.\\nOrder 267: 1 widgets shipped to warehouse 2.\\nOrder 268: 2 widgets shipped to warehouse 3.\\nOrder 269: 3 widgets shipped to warehouse 4.\\nOrder 270: 4 widgets shipped to warehouse 5.\\nOrder 271: 5 widgets shipped to warehouse 1.\\nOrder 272: 6 widgets shipped to warehouse 2.\\nOrder 273: 7 widgets shipped to warehouse 3.\\nOrder 274: 1 widgets shipped to warehouse 4.\\nOrder 275: 2 widgets shipped to warehouse 5.\\nOrder 276: 3 widgets shipped to warehouse 1.\\nOrder 277: 4 widgets shipped to warehouse 2.\\nOrder 278: 5 widgets shipped to warehouse 3.\\nOrder 279: 6 widgets shipped to warehouse 4.\\nOrder 280: 7 widgets shipped to warehouse 5.\\nOrder 281: 1 widgets shipped to warehouse 1.\\nOrder 282: 2 widgets shipped to warehouse 2.\\nOrder 283: 3 widgets shipped to warehouse 3.\\nOrder 284: 4 widgets shipped to warehouse 4.\\nOrder 285: 5 widgets shipped to warehouse 5.\\nOrder 286: 6 widgets shipped to warehouse 1.\\nOrder 287: 7 widgets shipped to warehouse 2.\\nOrder 288: 1 widgets shipped to warehouse 3.\\nOrder 289: 2 widgets shipped to warehouse 4.\\nOrder 290: 3 widgets shipped to warehouse 5.\\nOrder 291: 4 widgets shipped to warehouse 1.\\nOrder 292: 5 widgets shipped to warehouse 2.\\nOrder 293: 6 widgets shipped to warehouse 3.\\nOrder 294: 7 widgets shipped to warehouse 4.\\nOrder 295: 1 widgets shipped to warehouse 5.\\nOrder 296: 2 widgets shipped to warehouse 1.\\nOrder 297: 3 widgets shipped to warehouse 2.\\nOrder 298: 4 widgets shipped to warehouse 3.\\nOrder 299: 5 widgets shipped to warehouse 4.\\nOrder 300: 6 widgets shipped to warehouse 5.\\nOrder 301: 7 widgets shipped to warehouse 1.\\nOrder 302: 1 widgets shipped to warehouse 2.\\nOrder 303: 2 widgets shipped to warehouse 3.\\nOrder 304: 3 widgets shipped to warehouse 4.\\nOrder 305: 4 widgets shipped to warehouse 5.\\nOrder 306: 5 widgets shipped to warehouse 1.\\nOrder 307: 6 widgets shipped to warehouse 2.\\nOrder 308: 7 widgets shipped to warehouse 3.\\nOrder 309: 1 widgets shipped to warehouse 4.\\nOrder 310: 2 widgets shipped to warehouse 5.\\nOrder 311: 3 widgets shipped to warehouse 1.\\nOrder 312: 4 widgets shipped to warehouse 2.\\nOrder 313: 5 widgets shipped to warehouse 3.\\nOrder 314: 6 widgets shipped to warehouse 4.\\nOrder 315: 7 widgets shipped to warehouse 5.\\nOrder 316: 1 widgets shipped to warehouse 1.\\nOrder 317: 2 widgets shipped to warehouse 2.\\nOrder 318: 3 widgets shipped to warehouse 3.\\nOrder 319: 4 widgets shipped to warehouse 4.\\nOrder 320: 5 widgets shipped to warehouse 5.\\nOrder 321: 6 widgets shipped to warehouse 1.\\nOrder 322: 7 widgets shipped to warehouse 2.\\nOrder 323: 1 widgets shipped to warehouse 3.\\nOrder 324: 2 widgets shipped to warehouse 4.\\nOrder 325: 3 widgets shipped to warehouse 5.\\nOrder 326: 4 widgets shipped to warehouse 1.\\nOrder 327: 5 widgets shipped to warehouse 2.\\nOrder 328: 6 widgets shipped to warehouse 3.\\nOrder 329: 7 widgets shipped to warehouse 4.\\nOrder 330: 1 widgets shipped to warehouse 5.\\nOrder 331: 2 widgets shipped to warehouse 1.\\nOrder 332: 3 widgets shipped to warehouse 2.\\nOrder 333: 4 widgets shipped to warehouse 3.\\nOrder 334: 5 widgets shipped to warehouse 4.\\nOrder 335: 6 widgets shipped to warehouse 5.\\nOrder 336: 7 widgets shipped to warehouse 1.\\nOrder 337: 1 widgets shipped to warehouse 2.\\nOrder 338: 2 widgets shipped to warehouse 3.\\nOrder 339: 3 widgets shipped to warehouse 4.\\nOrder 340: 4 widgets shipped to warehouse 5.\\nOrder 341: 5 widgets shipped to warehouse 1.\\nOrder 342: 6 widgets shipped to warehouse 2.\\nOrder 343: 7 widgets shipped to warehouse 3.\\nOrder 344: 1 widgets shipped to warehouse 4.\\nOrder 345: 2 widgets shipped to warehouse 5.\\nOrder 346: 3 widgets shipped to warehouse 1.\\nOrder 347: 4 widgets shipped to warehouse 2.\\nOrder 348: 5 widgets shipped to warehouse 3.\\nOrder 349: 6 widgets shipped to warehouse 4.\\nOrder 350: 7 widgets shipped to warehouse 5.\\nOrder 351: 1 widgets shipped to warehouse 1.\\nOrder 352: 2 widgets shipped to warehouse 2.\\nOrder 353: 3 widgets shipped to warehouse 3.\\nOrder 354: 4 widgets shipped to warehouse 4.\\nOrder 355: 5 widgets shipped to warehouse 5.\\nOrder 356: 6 widgets shipped to warehouse 1.\\nOrder 357: 7 widgets shipped to warehouse 2.\\nOrder 358: 1 widgets shipped to warehouse 3.\\nOrder 359: 2 widgets shipped to warehouse 4.\\nOrder 360: 3 widgets shipped to warehouse 5.\\nOrder 361: 4 widgets shipped to warehouse 1.\\nOrder 362: 5 widgets shipped to warehouse 2.\\nOrder 363: 6 widgets shipped to warehouse 3.\\nOrder 364: 7 widgets shipped to warehouse 4.\\nOrder 365: 1 widgets shipped to warehouse 5.\\nOrder 366: 2 widgets shipped to warehouse 1.\\nOrder 367: 3 widgets shipped to warehouse 2.\\nOrder 368: 4 widgets shipped to warehouse 3.\\nOrder 369: 5 widgets shipped to warehouse 4.\\nOrder 370: 6 widgets shipped to warehouse 5.\\nOrder 371: 7 widgets shipped to warehouse 1.\\nOrder 372: 1 widgets shipped to warehouse 2.\\nOrder 373: 2 widgets shipped to warehouse 3.\\nOrder 374: 3 widgets shipped to warehouse 4.\\nOrder 375: 4 widgets shipped to warehouse 5.\\nOrder 376: 5 widgets shipped to warehouse 1.\\nOrder 377: 6 widgets shipped to warehouse 2.\\nOrder 378: 7 widgets shipped to warehouse 3.\\nOrder 379: 1 widgets shipped to warehouse 4.\\nOrder 380: 2 widgets shipped to warehouse 5.\\nOrder 381: 3 widgets shipped to warehouse 1.\\nOrder 382: 4 widgets shipped to warehouse 2.\\nOrder 383: 5 widgets shipped to warehouse 3.\\nOrder 384: 6 widgets shipped to warehouse 4.\\nOrder 385: 7 widgets shipped to warehouse 5.\\nOrder 386: 1 widgets shipped to warehouse 1.\\nOrder 387: 2 widgets shipped to warehouse 2.\\nOrder 388: 3 widgets shipped to warehouse 3.\\nOrder 389: 4 widgets shipped to warehouse 4.\\nOrder 390: 5 widgets shipped to warehouse 5.\\nOrder 391: 6 widgets shipped to warehouse 1.\\nOrder 392: 7 widgets shipped to warehouse 2.\\nOrder 393: 1 widgets shipped to warehouse 3.\\nOrder 394: 2 widgets shipped to warehouse 4.\\nOrder 395: 3 widgets shipped to warehouse 5.\\nOrder 396: 4 widgets shipped to warehouse 1.\\nOrder 397: 5 widgets shipped to warehouse 2.\\nOrder 398: 6 widgets shipped to warehouse 3.\\nOrder 399: 7 widgets shipped to warehouse 4.\\nOrder 400: 1 widgets shipped to warehouse 5.\\nOrder 401: 2 widgets shipped to warehouse 1.\\nOrder 402: 3 widgets shipped to warehouse 2.\\nOrder 403: 4 widgets shipped to warehouse 3.\\nOrder 404: 5 widgets shipped to warehouse 4.\\nOrder 405: 6 widgets shipped to warehouse 5.\\nOrder 406: 7 widgets shipped to warehouse 1.\\nOrder 407: 1 widgets shipped to warehouse 2.\\nOrder 408: 2 widgets shipped to warehouse 3.\\nOrder 409: 3 widgets shipped to warehouse 4.\\nOrder 410: 4 widgets shipped to warehouse 5.\\nOrder 411: 5 widgets shipped to warehouse 1.\\nOrder 412: 6 widgets shipped to warehouse 2.\\nOrder 413: 7 widgets shipped to warehouse 3.\\nOrder 414: 1 widgets shipped to warehouse 4.\\nOrder 415: 2 widgets shipped to warehouse 5.\\nOrder 416: 3 widgets shipped to warehouse 1.\\nOrder 417: 4 widgets shipped to warehouse 2.\\nOrder 418: 5 widgets shipped to warehouse 3.\\nOrder 419: 6 widgets shipped to warehouse 4.\\nOrder 420: 7 widgets shipped to warehouse 5.\\nOrder 421: 1 widgets shipped to warehouse 1.\\nOrder 422: 2 widgets shipped to warehouse 2.\\nOrder 423: 3 widgets shipped to warehouse 3.\\nOrder 424: 4 widgets shipped to warehouse 4.\\nOrder 425: 5 widgets shipped to warehouse 5.\\nOrder 426: 6 widgets shipped to warehouse 1.\\nOrder 427: 7 widgets shipped to warehouse 2.\\nOrder 428: 1 widgets shipped to warehouse 3.\\nOrder 429: 2 widgets shipped to warehouse 4.\\nOrder 430: 3 widgets shipped to warehouse 5.\\nOrder 431: 4 widgets shipped to warehouse 1.\\nOrder 432: 5 widgets shipped to warehouse 2.\\nOrder 433: 6 widgets shipped to warehouse 3.\\nOrder 434: 7 widgets shipped to warehouse 4.\\nOrder 435: 1 widgets shipped to warehouse 5.\\nOrder 436: 2 widgets shipped to warehouse 1.\\nOrder 437: 3 widgets shipped to warehouse 2.\\nOrder 438: 4 widgets shipped to warehouse 3.\\nOrder 439: 5 widgets shipped to warehouse 4.\\nOrder 440: 6 widgets shipped to warehouse 5.\\nOrder 441: 7 widgets shipped to warehouse 1.\\nOrder 442: 1 widgets shipped to warehouse 2.\\nOrder 443: 2 widgets shipped to warehouse 3.\\nOrder 444: 3 widgets shipped to warehouse 4.\\nOrder 445: 4 widgets shipped to warehouse 5.\\nOrder 446: 5 widgets shipped to warehouse 1.\\nOrder 447: 6 widgets shipped to warehouse 2.\\nOrder 448: 7 widgets shipped to warehouse 3.\\nOrder 449: 1 widgets shipped to warehouse 4.\\nOrder 450: 2 widgets shipped to warehouse 5.\\nOrder 451: 3 widgets shipped to warehouse 1.\\nOrder 452: 4 widgets shipped to warehouse 2.\\nOrder 453: 5 widgets shipped to warehouse 3.\\nOrder 454: 6 widgets shipped to warehouse 4.\\nOrder 455: 7 widgets shipped to warehouse 5.\\nOrder 456: 1 widgets shipped to warehouse 1.\\nOrder 457: 2 widgets shipped to warehouse 2.\\nOrder 458: 3 widgets shipped to warehouse 3.\\nOrder 459: 4 widgets shipped to warehouse 4.\\nOrder 460: 5 widgets shipped to warehouse 5.\\nOrder 461: 6 widgets shipped to warehouse 1.\\nOrder 462: 7 widgets shipped to warehouse 2.\\nOrder 463: 1 widgets shipped to warehouse 3.\\nOrder 464: 2 widgets shipped to warehouse 4.\\nOrder 465: 3 widgets shipped to warehouse 5.\\nOrder 466: 4 widgets shipped to warehouse 1.\\nOrder 467: 5 widgets shipped to warehouse 2.\\nOrder 468: 6 widgets shipped to warehouse 3.\\nOrder 469: 7 widgets shipped to warehouse 4.\\nOrder 470: 1 widgets shipped to warehouse 5.\\nOrder 471: 2 widgets shipped to warehouse 1.\\nOrder 472: 3 widgets shipped to warehouse 2.\\nOrder 473: 4 widgets shipped to warehouse 3.\\nOrder 474: 5 widgets shipped to warehouse 4.\\nOrder 475: 6 widgets shipped to warehouse 5.\\nOrder 476: 7 widgets shipped to warehouse 1.\\nOrder 477: 1 widgets shipped to warehouse 2.\\nOrder 478: 2 widgets shipped to warehouse 3.\\nOrder 479: 3 widgets shipped to warehouse 4.\\nOrder 480: 4 widgets shipped to warehouse 5.\\nOrder 481: 5 widgets shipped to warehouse 1.\\nOrder 482: 6 widgets shipped to warehouse 2.\\nOrder 483: 7 widgets shipped to warehouse 3.\\nOrder 484: 1 widgets shipped to warehouse 4.\\nOrder 485: 2 widgets shipped to warehouse 5.\\nOrder 486: 3 widgets shipped to warehouse 1.\\nOrder 487: 4 widgets shipped to warehouse 2.\\nOrder 488: 5 widgets shipped to warehouse 3.\\nOrder 489: 6 widgets shipped to warehouse 4.\\nOrder 490: 7 widgets shipped to warehouse 5.\\nOrder 491: 1 widgets shipped to warehouse 1.\\nOrder 492: 2 widgets shipped to warehouse 2.\\nOrder 493: 3 widgets shipped to warehouse 3.\\nOrder 494: 4 widgets shipped to warehouse 4.\\nOrder 495: 5 widgets shipped to warehouse 5.\\nOrder 496: 6 widgets shipped to warehouse 1.\\nOrder 497: 7 widgets shipped to warehouse 2.\\nOrder 498: 1 widgets shipped to warehouse 3.\\nOrder 499: 2 widgets shipped to warehouse 4.\\nOrder 500: 3 widgets shipped to warehouse 5.\\nOrder 501: 4 widgets shipped to warehouse 1.\\nOrder 502: 5 widgets shipped to warehouse 2.\\nOrder 503: 6 widgets shipped to warehouse 3.\\nOrder 504: 7 widgets shipped to warehouse 4.\\nOrder 505: 1 widgets shipped to warehouse 5.\\nOrder 506: 2 widgets shipped to warehouse 1.\\nOrder 507: 3 widgets shipped to warehouse 2.\\nOrder 508: 4 widgets shipped to warehouse 3.\\nOrder 509: 5 widgets shipped to warehouse 4.\\nOrder 510: 6 widgets shipped to warehouse 5.\\nOrder 511: 7 widgets shipped to warehouse 1.\\nOrder 512: 1 widgets shipped to warehouse 2.\\nOrder 513: 2 widgets shipped to warehouse 3.\\nOrder 514: 3 widgets shipped to warehouse 4.\\nOrder 515: 4 widgets shipped to warehouse 5.\\nOrder 516: 5 widgets shipped to warehouse 1.\\nOrder 517: 6 widgets shipped to warehouse 2.\\nOrder 518: 7 widgets shipped to warehouse 3.\\nOrder 519: 1 widgets shipped to warehouse 4.\\nOrder 520: 2 widgets shipped to warehouse 5.\\nOrder 521: 3 widgets shipped to warehouse 1.\\nOrder 522: 4 widgets shipped to warehouse 2.\\nOrder 523: 5 widgets shipped to warehouse 3.\\nOrder 524: 6 widgets shipped to warehouse 4.\\nOrder 525: 7 widgets shipped to warehouse 5.\\nOrder 526: 1 widgets shipped to warehouse 1.\\nOrder 527: 2 widgets shipped to warehouse 2.\\nOrder 528: 3 widgets shipped to warehouse 3.\\nOrder 529: 4 widgets shipped to warehouse 4.\\nOrder 530: 5 widgets shipped to warehouse 5.\\nOrder 531: 6 widgets shipped to warehouse 1.\\nOrder 532: 7 widgets shipped to warehouse 2.\\nOrder 533: 1 widgets shipped to warehouse 3.\\nOrder 534: 2 widgets shipped to warehouse 4.\\nOrder 535: 3 widgets shipped to warehouse 5.\\nOrder 536: 4 widgets shipped to warehouse 1.\\nOrder 537: 5 widgets shipped to warehouse 2.\\nOrder 538: 6 widgets shipped to warehouse 3.\\nOrder 539: 7 widgets shipped to warehouse 4.\\nOrder 540: 1 widgets shipped to warehouse 5.\\nOrder 541: 2 widgets shipped to warehouse 1.\\nOrder 542: 3 widgets shipped to warehouse 2.\\nOrder 543: 4 widgets shipped to warehouse 3.\\nOrder 544: 5 widgets shipped to warehouse 4.\\nOrder 545: 6 widgets shipped to warehouse 5.\\nOrder 546: 7 widgets shipped to warehouse 1.\\nOrder 547: 1 widgets shipped to warehouse 2.\\nOrder 548: 2 widgets shipped to warehouse 3.\\nOrder 549: 3 widgets shipped to warehouse 4.\\nOrder 550: 4 widgets shipped to warehouse 5.\\nOrder 551: 5 widgets shipped to warehouse 1.\\nOrder 552: 6 widgets shipped to warehouse 2.\\nOrder 553: 7 widgets shipped to warehouse 3.\\nOrder 554: 1 widgets shipped to warehouse 4.\\nOrder 555: 2 widgets shipped to warehouse 5.\\nOrder 556: 3 widgets shipped to warehouse 1.\\nOrder 557: 4 widgets shipped to warehouse 2.\\nOrder 558: 5 widgets shipped to warehouse 3.\\nOrder 559: 6 widgets shipped to warehouse 4.\\nOrder 560: 7 widgets shipped to warehouse 5.\\nOrder 561: 1 widgets shipped to warehouse 1.\\nOrder 562: 2 widgets shipped to warehouse 2.\\nOrder 563: 3 widgets shipped to warehouse 3.\\nOrder 564: 4 widgets shipped to warehouse 4.\\nOrder 565: 5 widgets shipped to warehouse 5.\\nOrder 566: 6 widgets shipped to warehouse 1.\\nOrder 567: 7 widgets shipped to warehouse 2.\\nOrder 568: 1 widgets shipped to warehouse 3.\\nOrder 569: 2 widgets shipped to warehouse 4.\\nOrder 570: 3 widgets shipped to warehouse 5.\\nOrder 571: 4 widgets shipped to warehouse 1.\\nOrder 572: 5 widgets shipped to warehouse 2.\\nOrder 573: 6 widgets shipped to warehouse 3.\\nOrder 574: 7 widgets shipped to warehouse 4.\\nOrder 575: 1 widgets shipped to warehouse 5.\\nOrder 576: 2 widgets shipped to warehouse 1.\\nOrder 577: 3 widgets shipped to warehouse 2.\\nOrder 578: 4 widgets shipped to warehouse 3.\\nOrder 579: 5 widgets shipped to warehouse 4.\\nOrder 580: 6 widgets shipped to warehouse 5.\\nOrder 581: 7 widgets shipped to warehouse 1.\\nOrder 582: 1 widgets shipped to warehouse 2.\\nOrder 583: 2 widgets shipped to warehouse 3.\\nOrder 584: 3 widgets shipped to warehouse 4.\\nOrder 585: 4 widgets shipped to warehouse 5.\\nOrder 586: 5 widgets shipped to warehouse 1.\\nOrder 587: 6 widgets shipped to warehouse 2.\\nOrder 588: 7 widgets shipped to warehouse 3.\\nOrder 589: 1 widgets shipped to warehouse 4.\\nOrder 590: 2 widgets shipped to warehouse 5.\\nOrder 591: 3 widgets shipped to warehouse 1.\\nOrder 592: 4 widgets shipped to warehouse 2.\\nOrder 593: 5 widgets shipped to warehouse 3.\\nOrder 594: 6 widgets shipped to warehouse 4.\\nOrder 595: 7 widgets shipped to warehouse 5.\\nOrder 596: 1 widgets shipped to warehouse 1.\\nOrder 597: 2 widgets shipped to warehouse 2.\\nOrder 598: 3 widgets shipped to warehouse 3.\\nOrder 599: 4 widgets shipped to warehouse 4.\\nOrder 600: 5 widgets shipped to warehouse 5.\\nIn one short sentence, how many orders are there? Call no tools.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1054, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\",\"delta\":\"There\"}\n\n" + }, + { + "at": 1089, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\",\"delta\":\" are\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\",\"delta\":\" \"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\",\"delta\":\"600\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\",\"delta\":\" orders\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\",\"delta\":\" in\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\",\"delta\":\" total\"}\n\n" + }, + { + "at": 1091, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\",\"delta\":\".\"}\n\n" + }, + { + "at": 1292, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"0f95c7ba-3d4f-49cd-ab28-82d161b1c9ac\",\"runId\":\"53bb9d69-a4ef-47e5-9622-f8c26df84f6d\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":8054,\"outputTokens\":10,\"totalTokens\":8064,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 1294, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "d26d059b-db55-409b-b277-86ac99d0f23d", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792539659486 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "a0c4116f-2113-4c72-ad3f-1cd24811b72b", + "runId": null, + "threadId": "0f95c7ba-3d4f-49cd-ab28-82d161b1c9ac", + "agentId": "default", + "role": "user", + "textLength": 26979, + "text": "Here is our order log:\nOrder 1: 1 widgets shipped to warehouse 1.\nOrder 2: 2 widgets shipped to warehouse 2.\nOrder 3: 3 widgets shipped to warehouse 3.\nOrder 4: 4 widgets shipped to warehouse 4.\nOrder 5: 5 widgets shipped to warehouse 5.\nOrder 6: 6 widgets shipped to warehouse 1.\nOrder 7: 7 widgets shipped to warehouse 2.\nOrder 8: 1 widgets shipped to warehouse 3.\nOrder 9: 2 widgets shipped to warehouse 4.\nOrder 10: 3 widgets shipped to warehouse 5.\nOrder 11: 4 widgets shipped to warehouse 1.\nOrder 12: 5 widgets shipped to warehouse 2.\nOrder 13: 6 widgets shipped to warehouse 3.\nOrder 14: 7 widgets shipped to warehouse 4.\nOrder 15: 1 widgets shipped to warehouse 5.\nOrder 16: 2 widgets shipped to warehouse 1.\nOrder 17: 3 widgets shipped to warehouse 2.\nOrder 18: 4 widgets shipped to warehouse 3.\nOrder 19: 5 widgets shipped to warehouse 4.\nOrder 20: 6 widgets shipped to warehouse 5.\nOrder 21: 7 widgets shipped to warehouse 1.\nOrder 22: 1 widgets shipped to warehouse 2.\nOrder 23: 2 widgets shipped to warehouse 3.\nOrder 24: 3 widgets shipped to warehouse 4.\nOrder 25: 4 widgets shipped to warehouse 5.\nOrder 26: 5 widgets shipped to warehouse 1.\nOrder 27: 6 widgets shipped to warehouse 2.\nOrder 28: 7 widgets shipped to warehouse 3.\nOrder 29: 1 widgets shipped to warehouse 4.\nOrder 30: 2 widgets shipped to warehouse 5.\nOrder 31: 3 widgets shipped to warehouse 1.\nOrder 32: 4 widgets shipped to warehouse 2.\nOrder 33: 5 widgets shipped to warehouse 3.\nOrder 34: 6 widgets shipped to warehouse 4.\nOrder 35: 7 widgets shipped to warehouse 5.\nOrder 36: 1 widgets shipped to warehouse 1.\nOrder 37: 2 widgets shipped to warehouse 2.\nOrder 38: 3 widgets shipped to warehouse 3.\nOrder 39: 4 widgets shipped to warehouse 4.\nOrder 40: 5 widgets shipped to warehouse 5.\nOrder 41: 6 widgets shipped to warehouse 1.\nOrder 42: 7 widgets shipped to warehouse 2.\nOrder 43: 1 widgets shipped to warehouse 3.\nOrder 44: 2 widgets shipped to warehouse 4.\nOrder 45: 3 widgets shipped to warehouse 5.\nOrder 46: 4 widgets shipped to warehouse 1.\nOrder 47: 5 widgets shipped to warehouse 2.\nOrder 48: 6 widgets shipped to warehouse 3.\nOrder 49: 7 widgets shipped to warehouse 4.\nOrder 50: 1 widgets shipped to warehouse 5.\nOrder 51: 2 widgets shipped to warehouse 1.\nOrder 52: 3 widgets shipped to warehouse 2.\nOrder 53: 4 widgets shipped to warehouse 3.\nOrder 54: 5 widgets shipped to warehouse 4.\nOrder 55: 6 widgets shipped to warehouse 5.\nOrder 56: 7 widgets shipped to warehouse 1.\nOrder 57: 1 widgets shipped to warehouse 2.\nOrder 58: 2 widgets shipped to warehouse 3.\nOrder 59: 3 widgets shipped to warehouse 4.\nOrder 60: 4 widgets shipped to warehouse 5.\nOrder 61: 5 widgets shipped to warehouse 1.\nOrder 62: 6 widgets shipped to warehouse 2.\nOrder 63: 7 widgets shipped to warehouse 3.\nOrder 64: 1 widgets shipped to warehouse 4.\nOrder 65: 2 widgets shipped to warehouse 5.\nOrder 66: 3 widgets shipped to warehouse 1.\nOrder 67: 4 widgets shipped to warehouse 2.\nOrder 68: 5 widgets shipped to warehouse 3.\nOrder 69: 6 widgets shipped to warehouse 4.\nOrder 70: 7 widgets shipped to warehouse 5.\nOrder 71: 1 widgets shipped to warehouse 1.\nOrder 72: 2 widgets shipped to warehouse 2.\nOrder 73: 3 widgets shipped to warehouse 3.\nOrder 74: 4 widgets shipped to warehouse 4.\nOrder 75: 5 widgets shipped to warehouse 5.\nOrder 76: 6 widgets shipped to warehouse 1.\nOrder 77: 7 widgets shipped to warehouse 2.\nOrder 78: 1 widgets shipped to warehouse 3.\nOrder 79: 2 widgets shipped to warehouse 4.\nOrder 80: 3 widgets shipped to warehouse 5.\nOrder 81: 4 widgets shipped to warehouse 1.\nOrder 82: 5 widgets shipped to warehouse 2.\nOrder 83: 6 widgets shipped to warehouse 3.\nOrder 84: 7 widgets shipped to warehouse 4.\nOrder 85: 1 widgets shipped to warehouse 5.\nOrder 86: 2 widgets shipped to warehouse 1.\nOrder 87: 3 widgets shipped to warehouse 2.\nOrder 88: 4 widgets shipped to warehouse 3.\nOrder 89: 5 widgets shipped to warehouse 4.\nOrder 90: 6 widgets shipped to warehouse 5.\nOrder 91: 7 widgets shipped to warehouse 1.\nOrder 92: 1 widgets shipped to warehouse 2.\nOrder 93: 2 widgets shipped to warehouse 3.\nOrder 94: 3 widgets shipped to warehouse 4.\nOrder 95: 4 widgets shipped to warehouse 5.\nOrder 96: 5 widgets shipped to warehouse 1.\nOrder 97: 6 widgets shipped to warehouse 2.\nOrder 98: 7 widgets shipped to warehouse 3.\nOrder 99: 1 widgets shipped to warehouse 4.\nOrder 100: 2 widgets shipped to warehouse 5.\nOrder 101: 3 widgets shipped to warehouse 1.\nOrder 102: 4 widgets shipped to warehouse 2.\nOrder 103: 5 widgets shipped to warehouse 3.\nOrder 104: 6 widgets shipped to warehouse 4.\nOrder 105: 7 widgets shipped to warehouse 5.\nOrder 106: 1 widgets shipped to warehouse 1.\nOrder 107: 2 widgets shipped to warehouse 2.\nOrder 108: 3 widgets shipped to warehouse 3.\nOrder 109: 4 widgets shipped to warehouse 4.\nOrder 110: 5 widgets shipped to warehouse 5.\nOrder 111: 6 widgets shipped to warehouse 1.\nOrder 112: 7 widgets shipped to warehouse 2.\nOrder 113: 1 widgets shipped to warehouse 3.\nOrder 114: 2 widgets shipped to warehouse 4.\nOrder 115: 3 widgets shipped to warehouse 5.\nOrder 116: 4 widgets shipped to warehouse 1.\nOrder 117: 5 widgets shipped to warehouse 2.\nOrder 118: 6 widgets shipped to warehouse 3.\nOrder 119: 7 widgets shipped to warehouse 4.\nOrder 120: 1 widgets shipped to warehouse 5.\nOrder 121: 2 widgets shipped to warehouse 1.\nOrder 122: 3 widgets shipped to warehouse 2.\nOrder 123: 4 widgets shipped to warehouse 3.\nOrder 124: 5 widgets shipped to warehouse 4.\nOrder 125: 6 widgets shipped to warehouse 5.\nOrder 126: 7 widgets shipped to warehouse 1.\nOrder 127: 1 widgets shipped to warehouse 2.\nOrder 128: 2 widgets shipped to warehouse 3.\nOrder 129: 3 widgets shipped to warehouse 4.\nOrder 130: 4 widgets shipped to warehouse 5.\nOrder 131: 5 widgets shipped to warehouse 1.\nOrder 132: 6 widgets shipped to warehouse 2.\nOrder 133: 7 widgets shipped to warehouse 3.\nOrder 134: 1 widgets shipped to warehouse 4.\nOrder 135: 2 widgets shipped to warehouse 5.\nOrder 136: 3 widgets shipped to warehouse 1.\nOrder 137: 4 widgets shipped to warehouse 2.\nOrder 138: 5 widgets shipped to warehouse 3.\nOrder 139: 6 widgets shipped to warehouse 4.\nOrder 140: 7 widgets shipped to warehouse 5.\nOrder 141: 1 widgets shipped to warehouse 1.\nOrder 142: 2 widgets shipped to warehouse 2.\nOrder 143: 3 widgets shipped to warehouse 3.\nOrder 144: 4 widgets shipped to warehouse 4.\nOrder 145: 5 widgets shipped to warehouse 5.\nOrder 146: 6 widgets shipped to warehouse 1.\nOrder 147: 7 widgets shipped to warehouse 2.\nOrder 148: 1 widgets shipped to warehouse 3.\nOrder 149: 2 widgets shipped to warehouse 4.\nOrder 150: 3 widgets shipped to warehouse 5.\nOrder 151: 4 widgets shipped to warehouse 1.\nOrder 152: 5 widgets shipped to warehouse 2.\nOrder 153: 6 widgets shipped to warehouse 3.\nOrder 154: 7 widgets shipped to warehouse 4.\nOrder 155: 1 widgets shipped to warehouse 5.\nOrder 156: 2 widgets shipped to warehouse 1.\nOrder 157: 3 widgets shipped to warehouse 2.\nOrder 158: 4 widgets shipped to warehouse 3.\nOrder 159: 5 widgets shipped to warehouse 4.\nOrder 160: 6 widgets shipped to warehouse 5.\nOrder 161: 7 widgets shipped to warehouse 1.\nOrder 162: 1 widgets shipped to warehouse 2.\nOrder 163: 2 widgets shipped to warehouse 3.\nOrder 164: 3 widgets shipped to warehouse 4.\nOrder 165: 4 widgets shipped to warehouse 5.\nOrder 166: 5 widgets shipped to warehouse 1.\nOrder 167: 6 widgets shipped to warehouse 2.\nOrder 168: 7 widgets shipped to warehouse 3.\nOrder 169: 1 widgets shipped to warehouse 4.\nOrder 170: 2 widgets shipped to warehouse 5.\nOrder 171: 3 widgets shipped to warehouse 1.\nOrder 172: 4 widgets shipped to warehouse 2.\nOrder 173: 5 widgets shipped to warehouse 3.\nOrder 174: 6 widgets shipped to warehouse 4.\nOrder 175: 7 widgets shipped to warehouse 5.\nOrder 176: 1 widgets shipped to warehouse 1.\nOrder 177: 2 widgets shipped to warehouse 2.\nOrder 178: 3 widgets shipped to warehouse 3.\nOrder 179: 4 widgets shipped to warehouse 4.\nOrder 180: 5 widgets shipped to warehouse 5.\nOrder 181: 6 widgets shipped to warehouse 1.\nOrder 182: 7 widgets shipped to warehouse 2.\nOrder 183: 1 widgets shipped to warehouse 3.\nOrder 184: 2 widgets shipped to warehouse 4.\nOrder 185: 3 widgets shipped to warehouse 5.\nOrder 186: 4 widgets shipped to warehouse 1.\nOrder 187: 5 widgets shipped to warehouse 2.\nOrder 188: 6 widgets shipped to warehouse 3.\nOrder 189: 7 widgets shipped to warehouse 4.\nOrder 190: 1 widgets shipped to warehouse 5.\nOrder 191: 2 widgets shipped to warehouse 1.\nOrder 192: 3 widgets shipped to warehouse 2.\nOrder 193: 4 widgets shipped to warehouse 3.\nOrder 194: 5 widgets shipped to warehouse 4.\nOrder 195: 6 widgets shipped to warehouse 5.\nOrder 196: 7 widgets shipped to warehouse 1.\nOrder 197: 1 widgets shipped to warehouse 2.\nOrder 198: 2 widgets shipped to warehouse 3.\nOrder 199: 3 widgets shipped to warehouse 4.\nOrder 200: 4 widgets shipped to warehouse 5.\nOrder 201: 5 widgets shipped to warehouse 1.\nOrder 202: 6 widgets shipped to warehouse 2.\nOrder 203: 7 widgets shipped to warehouse 3.\nOrder 204: 1 widgets shipped to warehouse 4.\nOrder 205: 2 widgets shipped to warehouse 5.\nOrder 206: 3 widgets shipped to warehouse 1.\nOrder 207: 4 widgets shipped to warehouse 2.\nOrder 208: 5 widgets shipped to warehouse 3.\nOrder 209: 6 widgets shipped to warehouse 4.\nOrder 210: 7 widgets shipped to warehouse 5.\nOrder 211: 1 widgets shipped to warehouse 1.\nOrder 212: 2 widgets shipped to warehouse 2.\nOrder 213: 3 widgets shipped to warehouse 3.\nOrder 214: 4 widgets shipped to warehouse 4.\nOrder 215: 5 widgets shipped to warehouse 5.\nOrder 216: 6 widgets shipped to warehouse 1.\nOrder 217: 7 widgets shipped to warehouse 2.\nOrder 218: 1 widgets shipped to warehouse 3.\nOrder 219: 2 widgets shipped to warehouse 4.\nOrder 220: 3 widgets shipped to warehouse 5.\nOrder 221: 4 widgets shipped to warehouse 1.\nOrder 222: 5 widgets shipped to warehouse 2.\nOrder 223: 6 widgets shipped to warehouse 3.\nOrder 224: 7 widgets shipped to warehouse 4.\nOrder 225: 1 widgets shipped to warehouse 5.\nOrder 226: 2 widgets shipped to warehouse 1.\nOrder 227: 3 widgets shipped to warehouse 2.\nOrder 228: 4 widgets shipped to warehouse 3.\nOrder 229: 5 widgets shipped to warehouse 4.\nOrder 230: 6 widgets shipped to warehouse 5.\nOrder 231: 7 widgets shipped to warehouse 1.\nOrder 232: 1 widgets shipped to warehouse 2.\nOrder 233: 2 widgets shipped to warehouse 3.\nOrder 234: 3 widgets shipped to warehouse 4.\nOrder 235: 4 widgets shipped to warehouse 5.\nOrder 236: 5 widgets shipped to warehouse 1.\nOrder 237: 6 widgets shipped to warehouse 2.\nOrder 238: 7 widgets shipped to warehouse 3.\nOrder 239: 1 widgets shipped to warehouse 4.\nOrder 240: 2 widgets shipped to warehouse 5.\nOrder 241: 3 widgets shipped to warehouse 1.\nOrder 242: 4 widgets shipped to warehouse 2.\nOrder 243: 5 widgets shipped to warehouse 3.\nOrder 244: 6 widgets shipped to warehouse 4.\nOrder 245: 7 widgets shipped to warehouse 5.\nOrder 246: 1 widgets shipped to warehouse 1.\nOrder 247: 2 widgets shipped to warehouse 2.\nOrder 248: 3 widgets shipped to warehouse 3.\nOrder 249: 4 widgets shipped to warehouse 4.\nOrder 250: 5 widgets shipped to warehouse 5.\nOrder 251: 6 widgets shipped to warehouse 1.\nOrder 252: 7 widgets shipped to warehouse 2.\nOrder 253: 1 widgets shipped to warehouse 3.\nOrder 254: 2 widgets shipped to warehouse 4.\nOrder 255: 3 widgets shipped to warehouse 5.\nOrder 256: 4 widgets shipped to warehouse 1.\nOrder 257: 5 widgets shipped to warehouse 2.\nOrder 258: 6 widgets shipped to warehouse 3.\nOrder 259: 7 widgets shipped to warehouse 4.\nOrder 260: 1 widgets shipped to warehouse 5.\nOrder 261: 2 widgets shipped to warehouse 1.\nOrder 262: 3 widgets shipped to warehouse 2.\nOrder 263: 4 widgets shipped to warehouse 3.\nOrder 264: 5 widgets shipped to warehouse 4.\nOrder 265: 6 widgets shipped to warehouse 5.\nOrder 266: 7 widgets shipped to warehouse 1.\nOrder 267: 1 widgets shipped to warehouse 2.\nOrder 268: 2 widgets shipped to warehouse 3.\nOrder 269: 3 widgets shipped to warehouse 4.\nOrder 270: 4 widgets shipped to warehouse 5.\nOrder 271: 5 widgets shipped to warehouse 1.\nOrder 272: 6 widgets shipped to warehouse 2.\nOrder 273: 7 widgets shipped to warehouse 3.\nOrder 274: 1 widgets shipped to warehouse 4.\nOrder 275: 2 widgets shipped to warehouse 5.\nOrder 276: 3 widgets shipped to warehouse 1.\nOrder 277: 4 widgets shipped to warehouse 2.\nOrder 278: 5 widgets shipped to warehouse 3.\nOrder 279: 6 widgets shipped to warehouse 4.\nOrder 280: 7 widgets shipped to warehouse 5.\nOrder 281: 1 widgets shipped to warehouse 1.\nOrder 282: 2 widgets shipped to warehouse 2.\nOrder 283: 3 widgets shipped to warehouse 3.\nOrder 284: 4 widgets shipped to warehouse 4.\nOrder 285: 5 widgets shipped to warehouse 5.\nOrder 286: 6 widgets shipped to warehouse 1.\nOrder 287: 7 widgets shipped to warehouse 2.\nOrder 288: 1 widgets shipped to warehouse 3.\nOrder 289: 2 widgets shipped to warehouse 4.\nOrder 290: 3 widgets shipped to warehouse 5.\nOrder 291: 4 widgets shipped to warehouse 1.\nOrder 292: 5 widgets shipped to warehouse 2.\nOrder 293: 6 widgets shipped to warehouse 3.\nOrder 294: 7 widgets shipped to warehouse 4.\nOrder 295: 1 widgets shipped to warehouse 5.\nOrder 296: 2 widgets shipped to warehouse 1.\nOrder 297: 3 widgets shipped to warehouse 2.\nOrder 298: 4 widgets shipped to warehouse 3.\nOrder 299: 5 widgets shipped to warehouse 4.\nOrder 300: 6 widgets shipped to warehouse 5.\nOrder 301: 7 widgets shipped to warehouse 1.\nOrder 302: 1 widgets shipped to warehouse 2.\nOrder 303: 2 widgets shipped to warehouse 3.\nOrder 304: 3 widgets shipped to warehouse 4.\nOrder 305: 4 widgets shipped to warehouse 5.\nOrder 306: 5 widgets shipped to warehouse 1.\nOrder 307: 6 widgets shipped to warehouse 2.\nOrder 308: 7 widgets shipped to warehouse 3.\nOrder 309: 1 widgets shipped to warehouse 4.\nOrder 310: 2 widgets shipped to warehouse 5.\nOrder 311: 3 widgets shipped to warehouse 1.\nOrder 312: 4 widgets shipped to warehouse 2.\nOrder 313: 5 widgets shipped to warehouse 3.\nOrder 314: 6 widgets shipped to warehouse 4.\nOrder 315: 7 widgets shipped to warehouse 5.\nOrder 316: 1 widgets shipped to warehouse 1.\nOrder 317: 2 widgets shipped to warehouse 2.\nOrder 318: 3 widgets shipped to warehouse 3.\nOrder 319: 4 widgets shipped to warehouse 4.\nOrder 320: 5 widgets shipped to warehouse 5.\nOrder 321: 6 widgets shipped to warehouse 1.\nOrder 322: 7 widgets shipped to warehouse 2.\nOrder 323: 1 widgets shipped to warehouse 3.\nOrder 324: 2 widgets shipped to warehouse 4.\nOrder 325: 3 widgets shipped to warehouse 5.\nOrder 326: 4 widgets shipped to warehouse 1.\nOrder 327: 5 widgets shipped to warehouse 2.\nOrder 328: 6 widgets shipped to warehouse 3.\nOrder 329: 7 widgets shipped to warehouse 4.\nOrder 330: 1 widgets shipped to warehouse 5.\nOrder 331: 2 widgets shipped to warehouse 1.\nOrder 332: 3 widgets shipped to warehouse 2.\nOrder 333: 4 widgets shipped to warehouse 3.\nOrder 334: 5 widgets shipped to warehouse 4.\nOrder 335: 6 widgets shipped to warehouse 5.\nOrder 336: 7 widgets shipped to warehouse 1.\nOrder 337: 1 widgets shipped to warehouse 2.\nOrder 338: 2 widgets shipped to warehouse 3.\nOrder 339: 3 widgets shipped to warehouse 4.\nOrder 340: 4 widgets shipped to warehouse 5.\nOrder 341: 5 widgets shipped to warehouse 1.\nOrder 342: 6 widgets shipped to warehouse 2.\nOrder 343: 7 widgets shipped to warehouse 3.\nOrder 344: 1 widgets shipped to warehouse 4.\nOrder 345: 2 widgets shipped to warehouse 5.\nOrder 346: 3 widgets shipped to warehouse 1.\nOrder 347: 4 widgets shipped to warehouse 2.\nOrder 348: 5 widgets shipped to warehouse 3.\nOrder 349: 6 widgets shipped to warehouse 4.\nOrder 350: 7 widgets shipped to warehouse 5.\nOrder 351: 1 widgets shipped to warehouse 1.\nOrder 352: 2 widgets shipped to warehouse 2.\nOrder 353: 3 widgets shipped to warehouse 3.\nOrder 354: 4 widgets shipped to warehouse 4.\nOrder 355: 5 widgets shipped to warehouse 5.\nOrder 356: 6 widgets shipped to warehouse 1.\nOrder 357: 7 widgets shipped to warehouse 2.\nOrder 358: 1 widgets shipped to warehouse 3.\nOrder 359: 2 widgets shipped to warehouse 4.\nOrder 360: 3 widgets shipped to warehouse 5.\nOrder 361: 4 widgets shipped to warehouse 1.\nOrder 362: 5 widgets shipped to warehouse 2.\nOrder 363: 6 widgets shipped to warehouse 3.\nOrder 364: 7 widgets shipped to warehouse 4.\nOrder 365: 1 widgets shipped to warehouse 5.\nOrder 366: 2 widgets shipped to warehouse 1.\nOrder 367: 3 widgets shipped to warehouse 2.\nOrder 368: 4 widgets shipped to warehouse 3.\nOrder 369: 5 widgets shipped to warehouse 4.\nOrder 370: 6 widgets shipped to warehouse 5.\nOrder 371: 7 widgets shipped to warehouse 1.\nOrder 372: 1 widgets shipped to warehouse 2.\nOrder 373: 2 widgets shipped to warehouse 3.\nOrder 374: 3 widgets shipped to warehouse 4.\nOrder 375: 4 widgets shipped to warehouse 5.\nOrder 376: 5 widgets shipped to warehouse 1.\nOrder 377: 6 widgets shipped to warehouse 2.\nOrder 378: 7 widgets shipped to warehouse 3.\nOrder 379: 1 widgets shipped to warehouse 4.\nOrder 380: 2 widgets shipped to warehouse 5.\nOrder 381: 3 widgets shipped to warehouse 1.\nOrder 382: 4 widgets shipped to warehouse 2.\nOrder 383: 5 widgets shipped to warehouse 3.\nOrder 384: 6 widgets shipped to warehouse 4.\nOrder 385: 7 widgets shipped to warehouse 5.\nOrder 386: 1 widgets shipped to warehouse 1.\nOrder 387: 2 widgets shipped to warehouse 2.\nOrder 388: 3 widgets shipped to warehouse 3.\nOrder 389: 4 widgets shipped to warehouse 4.\nOrder 390: 5 widgets shipped to warehouse 5.\nOrder 391: 6 widgets shipped to warehouse 1.\nOrder 392: 7 widgets shipped to warehouse 2.\nOrder 393: 1 widgets shipped to warehouse 3.\nOrder 394: 2 widgets shipped to warehouse 4.\nOrder 395: 3 widgets shipped to warehouse 5.\nOrder 396: 4 widgets shipped to warehouse 1.\nOrder 397: 5 widgets shipped to warehouse 2.\nOrder 398: 6 widgets shipped to warehouse 3.\nOrder 399: 7 widgets shipped to warehouse 4.\nOrder 400: 1 widgets shipped to warehouse 5.\nOrder 401: 2 widgets shipped to warehouse 1.\nOrder 402: 3 widgets shipped to warehouse 2.\nOrder 403: 4 widgets shipped to warehouse 3.\nOrder 404: 5 widgets shipped to warehouse 4.\nOrder 405: 6 widgets shipped to warehouse 5.\nOrder 406: 7 widgets shipped to warehouse 1.\nOrder 407: 1 widgets shipped to warehouse 2.\nOrder 408: 2 widgets shipped to warehouse 3.\nOrder 409: 3 widgets shipped to warehouse 4.\nOrder 410: 4 widgets shipped to warehouse 5.\nOrder 411: 5 widgets shipped to warehouse 1.\nOrder 412: 6 widgets shipped to warehouse 2.\nOrder 413: 7 widgets shipped to warehouse 3.\nOrder 414: 1 widgets shipped to warehouse 4.\nOrder 415: 2 widgets shipped to warehouse 5.\nOrder 416: 3 widgets shipped to warehouse 1.\nOrder 417: 4 widgets shipped to warehouse 2.\nOrder 418: 5 widgets shipped to warehouse 3.\nOrder 419: 6 widgets shipped to warehouse 4.\nOrder 420: 7 widgets shipped to warehouse 5.\nOrder 421: 1 widgets shipped to warehouse 1.\nOrder 422: 2 widgets shipped to warehouse 2.\nOrder 423: 3 widgets shipped to warehouse 3.\nOrder 424: 4 widgets shipped to warehouse 4.\nOrder 425: 5 widgets shipped to warehouse 5.\nOrder 426: 6 widgets shipped to warehouse 1.\nOrder 427: 7 widgets shipped to warehouse 2.\nOrder 428: 1 widgets shipped to warehouse 3.\nOrder 429: 2 widgets shipped to warehouse 4.\nOrder 430: 3 widgets shipped to warehouse 5.\nOrder 431: 4 widgets shipped to warehouse 1.\nOrder 432: 5 widgets shipped to warehouse 2.\nOrder 433: 6 widgets shipped to warehouse 3.\nOrder 434: 7 widgets shipped to warehouse 4.\nOrder 435: 1 widgets shipped to warehouse 5.\nOrder 436: 2 widgets shipped to warehouse 1.\nOrder 437: 3 widgets shipped to warehouse 2.\nOrder 438: 4 widgets shipped to warehouse 3.\nOrder 439: 5 widgets shipped to warehouse 4.\nOrder 440: 6 widgets shipped to warehouse 5.\nOrder 441: 7 widgets shipped to warehouse 1.\nOrder 442: 1 widgets shipped to warehouse 2.\nOrder 443: 2 widgets shipped to warehouse 3.\nOrder 444: 3 widgets shipped to warehouse 4.\nOrder 445: 4 widgets shipped to warehouse 5.\nOrder 446: 5 widgets shipped to warehouse 1.\nOrder 447: 6 widgets shipped to warehouse 2.\nOrder 448: 7 widgets shipped to warehouse 3.\nOrder 449: 1 widgets shipped to warehouse 4.\nOrder 450: 2 widgets shipped to warehouse 5.\nOrder 451: 3 widgets shipped to warehouse 1.\nOrder 452: 4 widgets shipped to warehouse 2.\nOrder 453: 5 widgets shipped to warehouse 3.\nOrder 454: 6 widgets shipped to warehouse 4.\nOrder 455: 7 widgets shipped to warehouse 5.\nOrder 456: 1 widgets shipped to warehouse 1.\nOrder 457: 2 widgets shipped to warehouse 2.\nOrder 458: 3 widgets shipped to warehouse 3.\nOrder 459: 4 widgets shipped to warehouse 4.\nOrder 460: 5 widgets shipped to warehouse 5.\nOrder 461: 6 widgets shipped to warehouse 1.\nOrder 462: 7 widgets shipped to warehouse 2.\nOrder 463: 1 widgets shipped to warehouse 3.\nOrder 464: 2 widgets shipped to warehouse 4.\nOrder 465: 3 widgets shipped to warehouse 5.\nOrder 466: 4 widgets shipped to warehouse 1.\nOrder 467: 5 widgets shipped to warehouse 2.\nOrder 468: 6 widgets shipped to warehouse 3.\nOrder 469: 7 widgets shipped to warehouse 4.\nOrder 470: 1 widgets shipped to warehouse 5.\nOrder 471: 2 widgets shipped to warehouse 1.\nOrder 472: 3 widgets shipped to warehouse 2.\nOrder 473: 4 widgets shipped to warehouse 3.\nOrder 474: 5 widgets shipped to warehouse 4.\nOrder 475: 6 widgets shipped to warehouse 5.\nOrder 476: 7 widgets shipped to warehouse 1.\nOrder 477: 1 widgets shipped to warehouse 2.\nOrder 478: 2 widgets shipped to warehouse 3.\nOrder 479: 3 widgets shipped to warehouse 4.\nOrder 480: 4 widgets shipped to warehouse 5.\nOrder 481: 5 widgets shipped to warehouse 1.\nOrder 482: 6 widgets shipped to warehouse 2.\nOrder 483: 7 widgets shipped to warehouse 3.\nOrder 484: 1 widgets shipped to warehouse 4.\nOrder 485: 2 widgets shipped to warehouse 5.\nOrder 486: 3 widgets shipped to warehouse 1.\nOrder 487: 4 widgets shipped to warehouse 2.\nOrder 488: 5 widgets shipped to warehouse 3.\nOrder 489: 6 widgets shipped to warehouse 4.\nOrder 490: 7 widgets shipped to warehouse 5.\nOrder 491: 1 widgets shipped to warehouse 1.\nOrder 492: 2 widgets shipped to warehouse 2.\nOrder 493: 3 widgets shipped to warehouse 3.\nOrder 494: 4 widgets shipped to warehouse 4.\nOrder 495: 5 widgets shipped to warehouse 5.\nOrder 496: 6 widgets shipped to warehouse 1.\nOrder 497: 7 widgets shipped to warehouse 2.\nOrder 498: 1 widgets shipped to warehouse 3.\nOrder 499: 2 widgets shipped to warehouse 4.\nOrder 500: 3 widgets shipped to warehouse 5.\nOrder 501: 4 widgets shipped to warehouse 1.\nOrder 502: 5 widgets shipped to warehouse 2.\nOrder 503: 6 widgets shipped to warehouse 3.\nOrder 504: 7 widgets shipped to warehouse 4.\nOrder 505: 1 widgets shipped to warehouse 5.\nOrder 506: 2 widgets shipped to warehouse 1.\nOrder 507: 3 widgets shipped to warehouse 2.\nOrder 508: 4 widgets shipped to warehouse 3.\nOrder 509: 5 widgets shipped to warehouse 4.\nOrder 510: 6 widgets shipped to warehouse 5.\nOrder 511: 7 widgets shipped to warehouse 1.\nOrder 512: 1 widgets shipped to warehouse 2.\nOrder 513: 2 widgets shipped to warehouse 3.\nOrder 514: 3 widgets shipped to warehouse 4.\nOrder 515: 4 widgets shipped to warehouse 5.\nOrder 516: 5 widgets shipped to warehouse 1.\nOrder 517: 6 widgets shipped to warehouse 2.\nOrder 518: 7 widgets shipped to warehouse 3.\nOrder 519: 1 widgets shipped to warehouse 4.\nOrder 520: 2 widgets shipped to warehouse 5.\nOrder 521: 3 widgets shipped to warehouse 1.\nOrder 522: 4 widgets shipped to warehouse 2.\nOrder 523: 5 widgets shipped to warehouse 3.\nOrder 524: 6 widgets shipped to warehouse 4.\nOrder 525: 7 widgets shipped to warehouse 5.\nOrder 526: 1 widgets shipped to warehouse 1.\nOrder 527: 2 widgets shipped to warehouse 2.\nOrder 528: 3 widgets shipped to warehouse 3.\nOrder 529: 4 widgets shipped to warehouse 4.\nOrder 530: 5 widgets shipped to warehouse 5.\nOrder 531: 6 widgets shipped to warehouse 1.\nOrder 532: 7 widgets shipped to warehouse 2.\nOrder 533: 1 widgets shipped to warehouse 3.\nOrder 534: 2 widgets shipped to warehouse 4.\nOrder 535: 3 widgets shipped to warehouse 5.\nOrder 536: 4 widgets shipped to warehouse 1.\nOrder 537: 5 widgets shipped to warehouse 2.\nOrder 538: 6 widgets shipped to warehouse 3.\nOrder 539: 7 widgets shipped to warehouse 4.\nOrder 540: 1 widgets shipped to warehouse 5.\nOrder 541: 2 widgets shipped to warehouse 1.\nOrder 542: 3 widgets shipped to warehouse 2.\nOrder 543: 4 widgets shipped to warehouse 3.\nOrder 544: 5 widgets shipped to warehouse 4.\nOrder 545: 6 widgets shipped to warehouse 5.\nOrder 546: 7 widgets shipped to warehouse 1.\nOrder 547: 1 widgets shipped to warehouse 2.\nOrder 548: 2 widgets shipped to warehouse 3.\nOrder 549: 3 widgets shipped to warehouse 4.\nOrder 550: 4 widgets shipped to warehouse 5.\nOrder 551: 5 widgets shipped to warehouse 1.\nOrder 552: 6 widgets shipped to warehouse 2.\nOrder 553: 7 widgets shipped to warehouse 3.\nOrder 554: 1 widgets shipped to warehouse 4.\nOrder 555: 2 widgets shipped to warehouse 5.\nOrder 556: 3 widgets shipped to warehouse 1.\nOrder 557: 4 widgets shipped to warehouse 2.\nOrder 558: 5 widgets shipped to warehouse 3.\nOrder 559: 6 widgets shipped to warehouse 4.\nOrder 560: 7 widgets shipped to warehouse 5.\nOrder 561: 1 widgets shipped to warehouse 1.\nOrder 562: 2 widgets shipped to warehouse 2.\nOrder 563: 3 widgets shipped to warehouse 3.\nOrder 564: 4 widgets shipped to warehouse 4.\nOrder 565: 5 widgets shipped to warehouse 5.\nOrder 566: 6 widgets shipped to warehouse 1.\nOrder 567: 7 widgets shipped to warehouse 2.\nOrder 568: 1 widgets shipped to warehouse 3.\nOrder 569: 2 widgets shipped to warehouse 4.\nOrder 570: 3 widgets shipped to warehouse 5.\nOrder 571: 4 widgets shipped to warehouse 1.\nOrder 572: 5 widgets shipped to warehouse 2.\nOrder 573: 6 widgets shipped to warehouse 3.\nOrder 574: 7 widgets shipped to warehouse 4.\nOrder 575: 1 widgets shipped to warehouse 5.\nOrder 576: 2 widgets shipped to warehouse 1.\nOrder 577: 3 widgets shipped to warehouse 2.\nOrder 578: 4 widgets shipped to warehouse 3.\nOrder 579: 5 widgets shipped to warehouse 4.\nOrder 580: 6 widgets shipped to warehouse 5.\nOrder 581: 7 widgets shipped to warehouse 1.\nOrder 582: 1 widgets shipped to warehouse 2.\nOrder 583: 2 widgets shipped to warehouse 3.\nOrder 584: 3 widgets shipped to warehouse 4.\nOrder 585: 4 widgets shipped to warehouse 5.\nOrder 586: 5 widgets shipped to warehouse 1.\nOrder 587: 6 widgets shipped to warehouse 2.\nOrder 588: 7 widgets shipped to warehouse 3.\nOrder 589: 1 widgets shipped to warehouse 4.\nOrder 590: 2 widgets shipped to warehouse 5.\nOrder 591: 3 widgets shipped to warehouse 1.\nOrder 592: 4 widgets shipped to warehouse 2.\nOrder 593: 5 widgets shipped to warehouse 3.\nOrder 594: 6 widgets shipped to warehouse 4.\nOrder 595: 7 widgets shipped to warehouse 5.\nOrder 596: 1 widgets shipped to warehouse 1.\nOrder 597: 2 widgets shipped to warehouse 2.\nOrder 598: 3 widgets shipped to warehouse 3.\nOrder 599: 4 widgets shipped to warehouse 4.\nOrder 600: 5 widgets shipped to warehouse 5.\nIn one short sentence, how many orders are there? Call no tools.", + "seq": 2 + }, + "timestamp": 1792539659539 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "53bb9d69-a4ef-47e5-9622-f8c26df84f6d", + "threadId": "0f95c7ba-3d4f-49cd-ab28-82d161b1c9ac", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792539659554 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0f3e2cb93aefaff8006ad7fc0c848487d084ee12eef4862c48", + "runId": "53bb9d69-a4ef-47e5-9622-f8c26df84f6d", + "threadId": "0f95c7ba-3d4f-49cd-ab28-82d161b1c9ac", + "agentId": "default", + "role": "assistant", + "textLength": 30, + "text": "There are 600 orders in total.", + "seq": 4 + }, + "timestamp": 1792539660779 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "53bb9d69-a4ef-47e5-9622-f8c26df84f6d", + "threadId": "0f95c7ba-3d4f-49cd-ab28-82d161b1c9ac", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792539660779 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/reasoning.json b/extension/tests/fixtures/agent-traffic/sse/reasoning.json new file mode 100644 index 000000000..9b44deae6 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/reasoning.json @@ -0,0 +1,438 @@ +{ + "name": "reasoning", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 57, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"reasoner\"},\"body\":{\"threadId\":\"4d582330-f33d-462f-ac79-56afc0616a92\",\"runId\":\"510a0708-f417-4611-a7f5-78c85434091c\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"0e884135-208a-4bb0-8ba4-8725b7c95024\",\"role\":\"user\",\"content\":\"Think it through step by step first: a bat and a ball cost 1.10 dollars in total, and the bat costs 1.00 dollar more than the ball. How much is the ball? Call no tools, answer with the amount only.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 72, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"4d582330-f33d-462f-ac79-56afc0616a92\",\"runId\":\"510a0708-f417-4611-a7f5-78c85434091c\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"4d582330-f33d-462f-ac79-56afc0616a92\",\"runId\":\"510a0708-f417-4611-a7f5-78c85434091c\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"0e884135-208a-4bb0-8ba4-8725b7c95024\",\"role\":\"user\",\"content\":\"Think it through step by step first: a bat and a ball cost 1.10 dollars in total, and the bat costs 1.00 dollar more than the ball. How much is the ball? Call no tools, answer with the amount only.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 869, + "text": "data: {\"type\":\"REASONING_START\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\"}\n\ndata: {\"type\":\"REASONING_MESSAGE_START\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"role\":\"reasoning\"}\n\n" + }, + { + "at": 3354, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\"**Clarifying output format**\\n\\nI\"}\n\n" + }, + { + "at": 3358, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" realize\"}\n\ndata: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" I\"}\n\ndata: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" can\"}\n\n" + }, + { + "at": 3359, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" safely\"}\n\n" + }, + { + "at": 3362, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" provide\"}\n\n" + }, + { + "at": 3367, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" the\"}\n\n" + }, + { + "at": 3372, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" amount\"}\n\n" + }, + { + "at": 3377, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" of\"}\n\n" + }, + { + "at": 3385, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" \\\"\"}\n\n" + }, + { + "at": 3392, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\"0\"}\n\n" + }, + { + "at": 3396, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\".\"}\n\n" + }, + { + "at": 3403, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\"05\"}\n\n" + }, + { + "at": 3424, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" dollars\"}\n\n" + }, + { + "at": 3428, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\",\\\"\"}\n\n" + }, + { + "at": 3437, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" but\"}\n\n" + }, + { + "at": 3441, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" it\"}\n\n" + }, + { + "at": 3447, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" seems\"}\n\n" + }, + { + "at": 3453, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" the\"}\n\n" + }, + { + "at": 3458, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" instruction\"}\n\n" + }, + { + "at": 3463, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" is\"}\n\n" + }, + { + "at": 3469, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" to\"}\n\n" + }, + { + "at": 3476, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" just\"}\n\n" + }, + { + "at": 3478, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" give\"}\n\n" + }, + { + "at": 3485, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" the\"}\n\n" + }, + { + "at": 3491, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" amount\"}\n\n" + }, + { + "at": 3497, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\".\"}\n\n" + }, + { + "at": 3499, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" So\"}\n\n" + }, + { + "at": 3505, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\",\"}\n\n" + }, + { + "at": 3520, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" I'll\"}\n\n" + }, + { + "at": 3523, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" output\"}\n\n" + }, + { + "at": 3529, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" it\"}\n\n" + }, + { + "at": 3538, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" as\"}\n\n" + }, + { + "at": 3545, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" \\\"$\"}\n\n" + }, + { + "at": 3556, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\"0\"}\n\n" + }, + { + "at": 3563, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\".\"}\n\n" + }, + { + "at": 3568, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\"05\"}\n\ndata: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\"\\\"\"}\n\n" + }, + { + "at": 3576, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" without\"}\n\n" + }, + { + "at": 3582, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" any\"}\n\n" + }, + { + "at": 3589, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" additional\"}\n\n" + }, + { + "at": 3593, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" text\"}\n\n" + }, + { + "at": 3599, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\".\"}\n\n" + }, + { + "at": 3604, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" That\"}\n\n" + }, + { + "at": 3609, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" keeps\"}\n\n" + }, + { + "at": 3613, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" it\"}\n\n" + }, + { + "at": 3622, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" straightforward\"}\n\n" + }, + { + "at": 3626, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" and\"}\n\n" + }, + { + "at": 3635, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" clear\"}\n\n" + }, + { + "at": 3637, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\".\"}\n\n" + }, + { + "at": 3644, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" I\"}\n\n" + }, + { + "at": 3649, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" want\"}\n\n" + }, + { + "at": 3653, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" to\"}\n\n" + }, + { + "at": 3664, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" make\"}\n\n" + }, + { + "at": 3678, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" sure\"}\n\n" + }, + { + "at": 3681, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" I\"}\n\n" + }, + { + "at": 3686, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" follow\"}\n\n" + }, + { + "at": 3691, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" the\"}\n\n" + }, + { + "at": 3696, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" guidelines\"}\n\n" + }, + { + "at": 3702, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" correctly\"}\n\n" + }, + { + "at": 3709, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\",\"}\n\n" + }, + { + "at": 3713, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" so\"}\n\n" + }, + { + "at": 3718, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" no\"}\n\n" + }, + { + "at": 3724, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" extra\"}\n\n" + }, + { + "at": 3727, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" information\"}\n\n" + }, + { + "at": 3732, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" will\"}\n\n" + }, + { + "at": 3743, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" be\"}\n\n" + }, + { + "at": 3750, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" confusing\"}\n\n" + }, + { + "at": 3761, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" for\"}\n\n" + }, + { + "at": 3765, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" anyone\"}\n\n" + }, + { + "at": 3773, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\".\"}\n\n" + }, + { + "at": 3776, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" Let's\"}\n\n" + }, + { + "at": 3784, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" keep\"}\n\n" + }, + { + "at": 3789, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" it\"}\n\n" + }, + { + "at": 3795, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\" simple\"}\n\n" + }, + { + "at": 3801, + "text": "data: {\"type\":\"REASONING_MESSAGE_CONTENT\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\",\"delta\":\"!\"}\n\n" + }, + { + "at": 3856, + "text": "data: {\"type\":\"REASONING_MESSAGE_END\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\"}\n\ndata: {\"type\":\"REASONING_END\",\"messageId\":\"rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0\"}\n\n" + }, + { + "at": 3924, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_019fac63cb8539ae006ad7fa48f75887d08f524a687721ddc0\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_019fac63cb8539ae006ad7fa48f75887d08f524a687721ddc0\",\"delta\":\"$\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_019fac63cb8539ae006ad7fa48f75887d08f524a687721ddc0\",\"delta\":\"0\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_019fac63cb8539ae006ad7fa48f75887d08f524a687721ddc0\",\"delta\":\".\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_019fac63cb8539ae006ad7fa48f75887d08f524a687721ddc0\",\"delta\":\"05\"}\n\n" + }, + { + "at": 4258, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_019fac63cb8539ae006ad7fa48f75887d08f524a687721ddc0\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"4d582330-f33d-462f-ac79-56afc0616a92\",\"runId\":\"510a0708-f417-4611-a7f5-78c85434091c\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-5-mini\",\"inputTokens\":255,\"outputTokens\":253,\"totalTokens\":508,\"reasoningTokens\":192,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 4259, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "f5dd9e94-25ea-4912-a975-581a916c6009", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792539205122 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "0e884135-208a-4bb0-8ba4-8725b7c95024", + "runId": null, + "threadId": "4d582330-f33d-462f-ac79-56afc0616a92", + "agentId": "reasoner", + "role": "user", + "textLength": 197, + "text": "Think it through step by step first: a bat and a ball cost 1.10 dollars in total, and the bat costs 1.00 dollar more than the ball. How much is the ball? Call no tools, answer with the amount only.", + "seq": 2 + }, + "timestamp": 1792539205174 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "510a0708-f417-4611-a7f5-78c85434091c", + "threadId": "4d582330-f33d-462f-ac79-56afc0616a92", + "agentId": "reasoner", + "seq": 3 + }, + "timestamp": 1792539205195 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "f5dd9e94-25ea-4912-a975-581a916c6009", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "rs_019fac63cb8539ae006ad7fa46022087d085440850211c6b7c:0", + "runId": "510a0708-f417-4611-a7f5-78c85434091c", + "threadId": "4d582330-f33d-462f-ac79-56afc0616a92", + "agentId": "reasoner", + "role": "reasoning", + "textLength": 378, + "text": "**Clarifying output format**\n\nI realize I can safely provide the amount of \"0.05 dollars,\" but it seems the instruction is to just give the amount. So, I'll output it as \"$0.05\" without any additional text. That keeps it straightforward and clear. I want to make sure I follow the guidelines correctly, so no extra information will be confusing for anyone. Let's keep it simple!", + "seq": 4 + }, + "timestamp": 1792539208976 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "f5dd9e94-25ea-4912-a975-581a916c6009", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_019fac63cb8539ae006ad7fa48f75887d08f524a687721ddc0", + "runId": "510a0708-f417-4611-a7f5-78c85434091c", + "threadId": "4d582330-f33d-462f-ac79-56afc0616a92", + "agentId": "reasoner", + "role": "assistant", + "textLength": 5, + "text": "$0.05", + "seq": 5 + }, + "timestamp": 1792539209379 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "510a0708-f417-4611-a7f5-78c85434091c", + "threadId": "4d582330-f33d-462f-ac79-56afc0616a92", + "agentId": "reasoner", + "seq": 6 + }, + "timestamp": 1792539209380 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/reload.json b/extension/tests/fixtures/agent-traffic/sse/reload.json new file mode 100644 index 000000000..f0c6b292d --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/reload.json @@ -0,0 +1,197 @@ +{ + "name": "reload", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 57, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"aeed5ddd-d219-4fde-975b-535e13bd7e86\",\"runId\":\"bb9f07ba-97e9-45d8-86bd-dea18346e6e2\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"c03206e6-2853-42cf-9197-bfbd51f655d7\",\"role\":\"user\",\"content\":\"Name three oceans, separated by commas. Call no tools.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 70, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"aeed5ddd-d219-4fde-975b-535e13bd7e86\",\"runId\":\"bb9f07ba-97e9-45d8-86bd-dea18346e6e2\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"aeed5ddd-d219-4fde-975b-535e13bd7e86\",\"runId\":\"bb9f07ba-97e9-45d8-86bd-dea18346e6e2\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"c03206e6-2853-42cf-9197-bfbd51f655d7\",\"role\":\"user\",\"content\":\"Name three oceans, separated by commas. Call no tools.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 882, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"delta\":\"Atlantic\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"delta\":\" Ocean\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"delta\":\",\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"delta\":\" Pacific\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"delta\":\" Ocean\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"delta\":\",\"}\n\n" + }, + { + "at": 887, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"delta\":\" Indian\"}\n\n" + }, + { + "at": 900, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"delta\":\" Ocean\"}\n\n" + }, + { + "at": 1102, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"aeed5ddd-d219-4fde-975b-535e13bd7e86\",\"runId\":\"bb9f07ba-97e9-45d8-86bd-dea18346e6e2\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":245,\"outputTokens\":10,\"totalTokens\":255,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 1104, + "outcome": "complete" + } + }, + { + "at": 1513, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/connect\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"aeed5ddd-d219-4fde-975b-535e13bd7e86\",\"runId\":\"f6a2fab3-a212-49e7-a451-8c54c198f6ef\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 1515, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"aeed5ddd-d219-4fde-975b-535e13bd7e86\",\"runId\":\"bb9f07ba-97e9-45d8-86bd-dea18346e6e2\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"aeed5ddd-d219-4fde-975b-535e13bd7e86\",\"runId\":\"bb9f07ba-97e9-45d8-86bd-dea18346e6e2\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"c03206e6-2853-42cf-9197-bfbd51f655d7\",\"role\":\"user\",\"content\":\"Name three oceans, separated by commas. Call no tools.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\ndata: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\",\"delta\":\"Atlantic Ocean, Pacific Ocean, Indian Ocean\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"aeed5ddd-d219-4fde-975b-535e13bd7e86\",\"runId\":\"bb9f07ba-97e9-45d8-86bd-dea18346e6e2\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":245,\"outputTokens\":10,\"totalTokens\":255,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 1515, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "7573bb83-3c25-4278-819a-198e328da343", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792539582324 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "c03206e6-2853-42cf-9197-bfbd51f655d7", + "runId": null, + "threadId": "aeed5ddd-d219-4fde-975b-535e13bd7e86", + "agentId": "default", + "role": "user", + "textLength": 54, + "text": "Name three oceans, separated by commas. Call no tools.", + "seq": 2 + }, + "timestamp": 1792539582377 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "bb9f07ba-97e9-45d8-86bd-dea18346e6e2", + "threadId": "aeed5ddd-d219-4fde-975b-535e13bd7e86", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792539582395 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb", + "runId": "bb9f07ba-97e9-45d8-86bd-dea18346e6e2", + "threadId": "aeed5ddd-d219-4fde-975b-535e13bd7e86", + "agentId": "default", + "role": "assistant", + "textLength": 43, + "text": "Atlantic Ocean, Pacific Ocean, Indian Ocean", + "seq": 4 + }, + "timestamp": 1792539583427 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "bb9f07ba-97e9-45d8-86bd-dea18346e6e2", + "threadId": "aeed5ddd-d219-4fde-975b-535e13bd7e86", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792539583427 + } + ], + "dropped": 0 + } + ], + "replaySink": [ + { + "trajectoryId": "1a6e7157-0729-4bcb-a134-9db080eb4861", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792539583781 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "bb9f07ba-97e9-45d8-86bd-dea18346e6e2", + "threadId": "aeed5ddd-d219-4fde-975b-535e13bd7e86", + "agentId": "default", + "seq": 2 + }, + "timestamp": 1792539583839 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_089cc6583f7283ee006ad7fbbf2dd887d0b93998261a0c9bbb", + "runId": "bb9f07ba-97e9-45d8-86bd-dea18346e6e2", + "threadId": "aeed5ddd-d219-4fde-975b-535e13bd7e86", + "agentId": "default", + "role": "assistant", + "textLength": 43, + "text": "Atlantic Ocean, Pacific Ocean, Indian Ocean", + "seq": 3 + }, + "timestamp": 1792539583839 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "bb9f07ba-97e9-45d8-86bd-dea18346e6e2", + "threadId": "aeed5ddd-d219-4fde-975b-535e13bd7e86", + "agentId": "default", + "seq": 4 + }, + "timestamp": 1792539583839 + } + ], + "dropped": 0 + } + ], + "replayFromAt": 1458 +} diff --git a/extension/tests/fixtures/agent-traffic/sse/run-error.json b/extension/tests/fixtures/agent-traffic/sse/run-error.json new file mode 100644 index 000000000..238604fac --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/run-error.json @@ -0,0 +1,98 @@ +{ + "name": "run-error", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"broken\"},\"body\":{\"threadId\":\"c79b6da3-7e7a-4994-9d72-78aa2a6713ce\",\"runId\":\"646d881a-a791-4256-a9fc-ed13a26d8fb0\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"866566bb-9f73-4750-a65a-efbd9c045d20\",\"role\":\"user\",\"content\":\"Hello?\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 70, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"c79b6da3-7e7a-4994-9d72-78aa2a6713ce\",\"runId\":\"646d881a-a791-4256-a9fc-ed13a26d8fb0\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"c79b6da3-7e7a-4994-9d72-78aa2a6713ce\",\"runId\":\"646d881a-a791-4256-a9fc-ed13a26d8fb0\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"866566bb-9f73-4750-a65a-efbd9c045d20\",\"role\":\"user\",\"content\":\"Hello?\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 5424, + "text": "data: {\"type\":\"RUN_ERROR\",\"message\":\"The model `gpt-recorder-no-such-model` does not exist or you do not have access to it.\"}\n\n" + } + ], + "end": { + "at": 5425, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "8b9eaf7e-b5f6-482c-96a9-7a1843aa5085", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538318179 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "866566bb-9f73-4750-a65a-efbd9c045d20", + "runId": null, + "threadId": "c79b6da3-7e7a-4994-9d72-78aa2a6713ce", + "agentId": "broken", + "role": "user", + "textLength": 6, + "text": "Hello?", + "seq": 2 + }, + "timestamp": 1792538318231 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "646d881a-a791-4256-a9fc-ed13a26d8fb0", + "threadId": "c79b6da3-7e7a-4994-9d72-78aa2a6713ce", + "agentId": "broken", + "seq": 3 + }, + "timestamp": 1792538318250 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "8b9eaf7e-b5f6-482c-96a9-7a1843aa5085", + "events": [ + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "error", + "runId": "646d881a-a791-4256-a9fc-ed13a26d8fb0", + "threadId": "c79b6da3-7e7a-4994-9d72-78aa2a6713ce", + "agentId": "broken", + "seq": 4 + }, + "timestamp": 1792538323603 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/secrets.json b/extension/tests/fixtures/agent-traffic/sse/secrets.json new file mode 100644 index 000000000..b9ba3ece1 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/secrets.json @@ -0,0 +1,219 @@ +{ + "name": "secrets", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"82645013-9161-45f6-8bff-5d7e30fd0fc4\",\"runId\":\"5826bd74-12fd-4b8e-9ef4-f31ad206763d\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"f36af2cf-684a-46a7-95ef-2a28ea4214b6\",\"role\":\"user\",\"content\":\"This is a test system with test data. Pay invoice INV-7 with card 4111 1111 1111 1111 by calling payInvoice once. My account password is Tr0ub4dor-horse-77, do not repeat it. Then say paid.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 69, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"82645013-9161-45f6-8bff-5d7e30fd0fc4\",\"runId\":\"5826bd74-12fd-4b8e-9ef4-f31ad206763d\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"82645013-9161-45f6-8bff-5d7e30fd0fc4\",\"runId\":\"5826bd74-12fd-4b8e-9ef4-f31ad206763d\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"f36af2cf-684a-46a7-95ef-2a28ea4214b6\",\"role\":\"user\",\"content\":\"This is a test system with test data. Pay invoice INV-7 with card 4111 1111 1111 1111 by calling payInvoice once. My account password is Tr0ub4dor-horse-77, do not repeat it. Then say paid.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 836, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"toolCallName\":\"payInvoice\",\"parentMessageId\":\"0ef94db8-c870-4044-8a29-85d1e8f76f6e\"}\n\n" + }, + { + "at": 842, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 844, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"invoice\"}\n\n" + }, + { + "at": 847, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"\\\":\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"INV\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"-\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"7\"}\n\n" + }, + { + "at": 866, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"\\\",\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"card\"}\n\n" + }, + { + "at": 876, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"Number\"}\n\n" + }, + { + "at": 882, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"\\\":\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"411\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"1\"}\n\n" + }, + { + "at": 901, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\" \"}\n\n" + }, + { + "at": 905, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"111\"}\n\n" + }, + { + "at": 907, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"1\"}\n\n" + }, + { + "at": 920, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\" \"}\n\n" + }, + { + "at": 947, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"111\"}\n\n" + }, + { + "at": 954, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"1\"}\n\n" + }, + { + "at": 968, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\" \"}\n\n" + }, + { + "at": 989, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"111\"}\n\n" + }, + { + "at": 999, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"1\"}\n\n" + }, + { + "at": 1014, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"delta\":\"\\\"}\"}\n\n" + }, + { + "at": 1020, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\"}\n\ndata: {\"type\":\"TOOL_CALL_RESULT\",\"messageId\":\"fa97138b-fe2d-4d9e-a8ad-b2f24644dd64\",\"toolCallId\":\"call_N4YWH28VgwrGCSoKTCnZJvsP\",\"content\":\"{\\\"invoice\\\":\\\"INV-7\\\",\\\"paid\\\":true}\",\"role\":\"tool\"}\n\n" + }, + { + "at": 1833, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_03116d86950b465c006ad7fb48b4a887d0a16a80824345f5e0\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03116d86950b465c006ad7fb48b4a887d0a16a80824345f5e0\",\"delta\":\"Paid\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_03116d86950b465c006ad7fb48b4a887d0a16a80824345f5e0\",\"delta\":\".\"}\n\n" + }, + { + "at": 2058, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_03116d86950b465c006ad7fb48b4a887d0a16a80824345f5e0\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"82645013-9161-45f6-8bff-5d7e30fd0fc4\",\"runId\":\"5826bd74-12fd-4b8e-9ef4-f31ad206763d\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":631,\"outputTokens\":36,\"totalTokens\":667,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 2060, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "1e0aac1e-07e3-4c71-ab09-c0f301fa84d5", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792539462930 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "f36af2cf-684a-46a7-95ef-2a28ea4214b6", + "runId": null, + "threadId": "82645013-9161-45f6-8bff-5d7e30fd0fc4", + "agentId": "default", + "role": "user", + "textLength": 189, + "text": "This is a test system with test data. Pay invoice INV-7 with card 4111 1111 1111 1111 by calling payInvoice once. My account password is Tr0ub4dor-horse-77, do not repeat it. Then say paid.", + "seq": 2 + }, + "timestamp": 1792539462983 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "5826bd74-12fd-4b8e-9ef4-f31ad206763d", + "threadId": "82645013-9161-45f6-8bff-5d7e30fd0fc4", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792539463000 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_N4YWH28VgwrGCSoKTCnZJvsP", + "toolName": "payInvoice", + "agentId": "default", + "threadId": "82645013-9161-45f6-8bff-5d7e30fd0fc4", + "messageId": "0ef94db8-c870-4044-8a29-85d1e8f76f6e", + "runId": "5826bd74-12fd-4b8e-9ef4-f31ad206763d", + "seq": 4 + }, + "timestamp": 1792539463766 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "fa97138b-fe2d-4d9e-a8ad-b2f24644dd64", + "runId": "5826bd74-12fd-4b8e-9ef4-f31ad206763d", + "threadId": "82645013-9161-45f6-8bff-5d7e30fd0fc4", + "agentId": "default", + "role": "tool", + "textLength": 31, + "text": "{\"invoice\":\"INV-7\",\"paid\":true}", + "seq": 5 + }, + "timestamp": 1792539463950 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "1e0aac1e-07e3-4c71-ab09-c0f301fa84d5", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_03116d86950b465c006ad7fb48b4a887d0a16a80824345f5e0", + "runId": "5826bd74-12fd-4b8e-9ef4-f31ad206763d", + "threadId": "82645013-9161-45f6-8bff-5d7e30fd0fc4", + "agentId": "default", + "role": "assistant", + "textLength": 5, + "text": "Paid.", + "seq": 6 + }, + "timestamp": 1792539464989 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "5826bd74-12fd-4b8e-9ef4-f31ad206763d", + "threadId": "82645013-9161-45f6-8bff-5d7e30fd0fc4", + "agentId": "default", + "seq": 7 + }, + "timestamp": 1792539464989 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/state.json b/extension/tests/fixtures/agent-traffic/sse/state.json new file mode 100644 index 000000000..39dcbc2b1 --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/state.json @@ -0,0 +1,409 @@ +{ + "name": "state", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 52, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"621cef1b-dcff-4a2a-8f7a-fb9055485424\",\"runId\":\"c21a685d-dc58-4103-9fce-0d5ab192538d\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{\"todos\":[]},\"messages\":[{\"id\":\"fd15a94c-5e48-4b70-b049-22d3cf135cdb\",\"role\":\"user\",\"content\":\"First call AGUISendStateSnapshot with snapshot {\\\"todos\\\":[\\\"buy milk\\\"]}. Then call AGUISendStateDelta to add \\\"walk dog\\\" at /todos/-. Then call AGUISendStateDelta again to add \\\"post letter\\\" at /todos/-. Then say done.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 56, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"621cef1b-dcff-4a2a-8f7a-fb9055485424\",\"runId\":\"c21a685d-dc58-4103-9fce-0d5ab192538d\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"621cef1b-dcff-4a2a-8f7a-fb9055485424\",\"runId\":\"c21a685d-dc58-4103-9fce-0d5ab192538d\",\"protocolVersion\":\"1.0\",\"state\":{\"todos\":[]},\"messages\":[{\"id\":\"fd15a94c-5e48-4b70-b049-22d3cf135cdb\",\"role\":\"user\",\"content\":\"First call AGUISendStateSnapshot with snapshot {\\\"todos\\\":[\\\"buy milk\\\"]}. Then call AGUISendStateDelta to add \\\"walk dog\\\" at /todos/-. Then call AGUISendStateDelta again to add \\\"post letter\\\" at /todos/-. Then say done.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1200, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"toolCallName\":\"AGUISendStateSnapshot\",\"parentMessageId\":\"5c7e748c-ce03-45b4-80ee-715dda4fff7b\"}\n\n" + }, + { + "at": 1202, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 1208, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"delta\":\"snapshot\"}\n\n" + }, + { + "at": 1221, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"delta\":\"\\\":{\\\"\"}\n\n" + }, + { + "at": 1239, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"delta\":\"todos\"}\n\n" + }, + { + "at": 1255, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"delta\":\"\\\":[\\\"\"}\n\n" + }, + { + "at": 1270, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"delta\":\"buy\"}\n\n" + }, + { + "at": 1287, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"delta\":\" milk\"}\n\n" + }, + { + "at": 1292, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"delta\":\"\\\"]\"}\n\n" + }, + { + "at": 1315, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"delta\":\"}}\"}\n\n" + }, + { + "at": 1324, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\"}\n\ndata: {\"type\":\"STATE_SNAPSHOT\",\"snapshot\":{\"todos\":[\"buy milk\"]}}\n\ndata: {\"type\":\"TOOL_CALL_RESULT\",\"messageId\":\"42dd4cac-c219-45cb-9d4a-164fa260926e\",\"toolCallId\":\"call_SC47ltT7qKnGd2TdOn28bZeK\",\"content\":\"{\\\"success\\\":true,\\\"snapshot\\\":{\\\"todos\\\":[\\\"buy milk\\\"]}}\",\"role\":\"tool\"}\n\n" + }, + { + "at": 2176, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"toolCallName\":\"AGUISendStateDelta\",\"parentMessageId\":\"5c7e748c-ce03-45b4-80ee-715dda4fff7b\"}\n\n" + }, + { + "at": 2181, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 2185, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"delta\"}\n\n" + }, + { + "at": 2191, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"\\\":[\"}\n\n" + }, + { + "at": 2196, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 2202, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"op\"}\n\n" + }, + { + "at": 2204, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 2205, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"add\"}\n\n" + }, + { + "at": 2211, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"\\\",\\\"\"}\n\n" + }, + { + "at": 2214, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"path\"}\n\n" + }, + { + "at": 2218, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"\\\":\"}\n\n" + }, + { + "at": 2222, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"\\\"/\"}\n\n" + }, + { + "at": 2225, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"todos\"}\n\n" + }, + { + "at": 2227, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"/-\"}\n\n" + }, + { + "at": 2236, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"\\\",\\\"\"}\n\n" + }, + { + "at": 2255, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"value\"}\n\n" + }, + { + "at": 2265, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 2267, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"walk\"}\n\n" + }, + { + "at": 2316, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\" dog\"}\n\n" + }, + { + "at": 2324, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"\\\"}\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"delta\":\"]}\"}\n\ndata: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\"}\n\ndata: {\"type\":\"STATE_DELTA\",\"delta\":[{\"op\":\"add\",\"path\":\"/todos/-\",\"value\":\"walk dog\"}]}\n\ndata: {\"type\":\"TOOL_CALL_RESULT\",\"messageId\":\"c0838ac8-8682-4ffc-a76f-439d2f2621cc\",\"toolCallId\":\"call_cq3PWX7m2YRuMjUdzTs1nPU6\",\"content\":\"{\\\"success\\\":true,\\\"delta\\\":[{\\\"op\\\":\\\"add\\\",\\\"path\\\":\\\"/todos/-\\\",\\\"value\\\":\\\"walk dog\\\"}]}\",\"role\":\"tool\"}\n\n" + }, + { + "at": 3291, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"toolCallName\":\"AGUISendStateDelta\",\"parentMessageId\":\"5c7e748c-ce03-45b4-80ee-715dda4fff7b\"}\n\n" + }, + { + "at": 3293, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 3301, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"delta\"}\n\n" + }, + { + "at": 3304, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"\\\":[\"}\n\n" + }, + { + "at": 3306, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 3310, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"op\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"\\\":\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"add\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"\\\",\\\"\"}\n\n" + }, + { + "at": 3311, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"path\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"\\\":\"}\n\n" + }, + { + "at": 3325, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"\\\"/\"}\n\n" + }, + { + "at": 3331, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"todos\"}\n\n" + }, + { + "at": 3337, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"/-\"}\n\n" + }, + { + "at": 3342, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"\\\",\\\"\"}\n\n" + }, + { + "at": 3349, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"value\"}\n\n" + }, + { + "at": 3356, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 3378, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"post\"}\n\n" + }, + { + "at": 3394, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\" letter\"}\n\n" + }, + { + "at": 3412, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"\\\"}\"}\n\n" + }, + { + "at": 3418, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"delta\":\"]}\"}\n\n" + }, + { + "at": 3424, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\"}\n\ndata: {\"type\":\"STATE_DELTA\",\"delta\":[{\"op\":\"add\",\"path\":\"/todos/-\",\"value\":\"post letter\"}]}\n\ndata: {\"type\":\"TOOL_CALL_RESULT\",\"messageId\":\"26b53ec7-f205-4a2c-83f7-74650c151a98\",\"toolCallId\":\"call_1M0m1RoZzzTaKWJjASBPMCdk\",\"content\":\"{\\\"success\\\":true,\\\"delta\\\":[{\\\"op\\\":\\\"add\\\",\\\"path\\\":\\\"/todos/-\\\",\\\"value\\\":\\\"post letter\\\"}]}\",\"role\":\"tool\"}\n\n" + }, + { + "at": 4183, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_0ce7f44ea88074c1006ad7f6c2637887d081eab6e615287c9c\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0ce7f44ea88074c1006ad7f6c2637887d081eab6e615287c9c\",\"delta\":\"done\"}\n\n" + }, + { + "at": 4314, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_0ce7f44ea88074c1006ad7f6c2637887d081eab6e615287c9c\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"621cef1b-dcff-4a2a-8f7a-fb9055485424\",\"runId\":\"c21a685d-dc58-4103-9fce-0d5ab192538d\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":1534,\"outputTokens\":94,\"totalTokens\":1628,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 4315, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "068f9d8d-ac3c-40f9-b7d1-a9a012abe246", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538302266 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "fd15a94c-5e48-4b70-b049-22d3cf135cdb", + "runId": null, + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "agentId": "default", + "role": "user", + "textLength": 214, + "text": "First call AGUISendStateSnapshot with snapshot {\"todos\":[\"buy milk\"]}. Then call AGUISendStateDelta to add \"walk dog\" at /todos/-. Then call AGUISendStateDelta again to add \"post letter\" at /todos/-. Then say done.", + "seq": 2 + }, + "timestamp": 1792538302318 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "c21a685d-dc58-4103-9fce-0d5ab192538d", + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538302322 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_SC47ltT7qKnGd2TdOn28bZeK", + "toolName": "AGUISendStateSnapshot", + "agentId": "default", + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "messageId": "5c7e748c-ce03-45b4-80ee-715dda4fff7b", + "runId": "c21a685d-dc58-4103-9fce-0d5ab192538d", + "seq": 4 + }, + "timestamp": 1792538303466 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "42dd4cac-c219-45cb-9d4a-164fa260926e", + "runId": "c21a685d-dc58-4103-9fce-0d5ab192538d", + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "agentId": "default", + "role": "tool", + "textLength": 50, + "text": "{\"success\":true,\"snapshot\":{\"todos\":[\"buy milk\"]}}", + "seq": 5 + }, + "timestamp": 1792538303591 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "068f9d8d-ac3c-40f9-b7d1-a9a012abe246", + "events": [ + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_cq3PWX7m2YRuMjUdzTs1nPU6", + "toolName": "AGUISendStateDelta", + "agentId": "default", + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "messageId": "5c7e748c-ce03-45b4-80ee-715dda4fff7b", + "runId": "c21a685d-dc58-4103-9fce-0d5ab192538d", + "seq": 6 + }, + "timestamp": 1792538304442 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "c0838ac8-8682-4ffc-a76f-439d2f2621cc", + "runId": "c21a685d-dc58-4103-9fce-0d5ab192538d", + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "agentId": "default", + "role": "tool", + "textLength": 76, + "text": "{\"success\":true,\"delta\":[{\"op\":\"add\",\"path\":\"/todos/-\",\"value\":\"walk dog\"}]}", + "seq": 7 + }, + "timestamp": 1792538304592 + }, + { + "type": "CUSTOM", + "name": "tool.call", + "value": { + "phase": "started", + "toolCallId": "call_1M0m1RoZzzTaKWJjASBPMCdk", + "toolName": "AGUISendStateDelta", + "agentId": "default", + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "messageId": "5c7e748c-ce03-45b4-80ee-715dda4fff7b", + "runId": "c21a685d-dc58-4103-9fce-0d5ab192538d", + "seq": 8 + }, + "timestamp": 1792538305557 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "26b53ec7-f205-4a2c-83f7-74650c151a98", + "runId": "c21a685d-dc58-4103-9fce-0d5ab192538d", + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "agentId": "default", + "role": "tool", + "textLength": 79, + "text": "{\"success\":true,\"delta\":[{\"op\":\"add\",\"path\":\"/todos/-\",\"value\":\"post letter\"}]}", + "seq": 9 + }, + "timestamp": 1792538305690 + } + ], + "dropped": 0 + }, + { + "trajectoryId": "068f9d8d-ac3c-40f9-b7d1-a9a012abe246", + "events": [ + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0ce7f44ea88074c1006ad7f6c2637887d081eab6e615287c9c", + "runId": "c21a685d-dc58-4103-9fce-0d5ab192538d", + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "agentId": "default", + "role": "assistant", + "textLength": 4, + "text": "done", + "seq": 10 + }, + "timestamp": 1792538306581 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "c21a685d-dc58-4103-9fce-0d5ab192538d", + "threadId": "621cef1b-dcff-4a2a-8f7a-fb9055485424", + "agentId": "default", + "seq": 11 + }, + "timestamp": 1792538306581 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/suggestions.json b/extension/tests/fixtures/agent-traffic/sse/suggestions.json new file mode 100644 index 000000000..42c0de9ed --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/suggestions.json @@ -0,0 +1,261 @@ +{ + "name": "suggestions", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"6ab2b425-570a-4fb3-80f6-183d23e1682a\",\"runId\":\"6780558d-6a23-44ab-ad27-d3c170ed373c\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"1b04fc84-05c4-41a5-868b-a89b178317a7\",\"role\":\"user\",\"content\":\"Name one river. One word only. Call no tools.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 69, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"6ab2b425-570a-4fb3-80f6-183d23e1682a\",\"runId\":\"6780558d-6a23-44ab-ad27-d3c170ed373c\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"6ab2b425-570a-4fb3-80f6-183d23e1682a\",\"runId\":\"6780558d-6a23-44ab-ad27-d3c170ed373c\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"1b04fc84-05c4-41a5-868b-a89b178317a7\",\"role\":\"user\",\"content\":\"Name one river. One word only. Call no tools.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1030, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_08e9c22f8bed4639006ad7fbd33a9887d0bb6610db4f70a3fb\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_08e9c22f8bed4639006ad7fbd33a9887d0bb6610db4f70a3fb\",\"delta\":\"N\"}\n\n" + }, + { + "at": 1032, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_08e9c22f8bed4639006ad7fbd33a9887d0bb6610db4f70a3fb\",\"delta\":\"ile\"}\n\n" + }, + { + "at": 1231, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_08e9c22f8bed4639006ad7fbd33a9887d0bb6610db4f70a3fb\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"6ab2b425-570a-4fb3-80f6-183d23e1682a\",\"runId\":\"6780558d-6a23-44ab-ad27-d3c170ed373c\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":245,\"outputTokens\":4,\"totalTokens\":249,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 1232, + "outcome": "complete" + } + }, + { + "at": 1234, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"6fb02984-d354-4fa2-9793-866430dd5d6d\",\"runId\":\"c056cbf5-28e9-4425-aa67-d1c8a16ad762\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"copilotkitSuggest\",\"description\":\"Suggest what the user could say next\",\"parameters\":{\"type\":\"object\",\"properties\":{\"suggestions\":{\"type\":\"array\",\"description\":\"List of suggestions shown to the user as buttons.\",\"items\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\",\"description\":\"The title of the suggestion. This is shown as a button and should be short.\"},\"message\":{\"type\":\"string\",\"description\":\"The message to send when the suggestion is clicked. This should be a clear, complete sentence and will be sent as an instruction to the AI.\"}},\"required\":[\"title\",\"message\"]}}},\"required\":[\"suggestions\"]}}],\"context\":[],\"forwardedProps\":{\"toolChoice\":{\"type\":\"function\",\"function\":{\"name\":\"copilotkitSuggest\"}}},\"state\":{},\"messages\":[{\"id\":\"1b04fc84-05c4-41a5-868b-a89b178317a7\",\"role\":\"user\",\"content\":\"Name one river. One word only. Call no tools.\"},{\"id\":\"msg_08e9c22f8bed4639006ad7fbd33a9887d0bb6610db4f70a3fb\",\"role\":\"assistant\",\"content\":\"Nile\"},{\"id\":\"6fb02984-d354-4fa2-9793-866430dd5d6d\",\"role\":\"user\",\"content\":\"Suggest what the user could say next. Provide clear, highly relevant suggestions by calling the `copilotkitSuggest` tool.\\nProvide at least 1 and at most 2 suggestions.\\nThe user has the following tools available: [].\\n Suggest what the person could ask next.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 1236, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"6fb02984-d354-4fa2-9793-866430dd5d6d\",\"runId\":\"c056cbf5-28e9-4425-aa67-d1c8a16ad762\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"6fb02984-d354-4fa2-9793-866430dd5d6d\",\"runId\":\"c056cbf5-28e9-4425-aa67-d1c8a16ad762\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"1b04fc84-05c4-41a5-868b-a89b178317a7\",\"role\":\"user\",\"content\":\"Name one river. One word only. Call no tools.\"},{\"id\":\"msg_08e9c22f8bed4639006ad7fbd33a9887d0bb6610db4f70a3fb\",\"role\":\"assistant\",\"content\":\"Nile\"},{\"id\":\"6fb02984-d354-4fa2-9793-866430dd5d6d\",\"role\":\"user\",\"content\":\"Suggest what the user could say next. Provide clear, highly relevant suggestions by calling the `copilotkitSuggest` tool.\\nProvide at least 1 and at most 2 suggestions.\\nThe user has the following tools available: [].\\n Suggest what the person could ask next.\"}],\"tools\":[{\"name\":\"copilotkitSuggest\",\"description\":\"Suggest what the user could say next\",\"parameters\":{\"type\":\"object\",\"properties\":{\"suggestions\":{\"type\":\"array\",\"description\":\"List of suggestions shown to the user as buttons.\",\"items\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\",\"description\":\"The title of the suggestion. This is shown as a button and should be short.\"},\"message\":{\"type\":\"string\",\"description\":\"The message to send when the suggestion is clicked. This should be a clear, complete sentence and will be sent as an instruction to the AI.\"}},\"required\":[\"title\",\"message\"]}}},\"required\":[\"suggestions\"]}}],\"context\":[],\"forwardedProps\":{\"toolChoice\":{\"type\":\"function\",\"function\":{\"name\":\"copilotkitSuggest\"}}}}}\n\n" + }, + { + "at": 1730, + "text": "data: {\"type\":\"TOOL_CALL_START\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"toolCallName\":\"copilotkitSuggest\",\"parentMessageId\":\"00193767-dc03-412f-bb02-994b29896f9a\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 1733, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"suggest\"}\n\n" + }, + { + "at": 1736, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"ions\"}\n\n" + }, + { + "at": 1740, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"\\\":[\"}\n\n" + }, + { + "at": 1746, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"{\\\"\"}\n\n" + }, + { + "at": 1765, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"title\"}\n\n" + }, + { + "at": 1767, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 1771, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"Name\"}\n\n" + }, + { + "at": 1774, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" a\"}\n\n" + }, + { + "at": 1775, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" mountain\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\".\\\",\\\"\"}\n\n" + }, + { + "at": 1792, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"message\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 1800, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"Name\"}\n\n" + }, + { + "at": 1803, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" one\"}\n\n" + }, + { + "at": 1809, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" mountain\"}\n\n" + }, + { + "at": 1814, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\".\"}\n\n" + }, + { + "at": 1818, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" One\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" word\"}\n\n" + }, + { + "at": 1828, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" only\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\".\\\"\"}\n\n" + }, + { + "at": 1849, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"},{\\\"\"}\n\n" + }, + { + "at": 1852, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"title\"}\n\n" + }, + { + "at": 1855, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 1860, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"Name\"}\n\n" + }, + { + "at": 1864, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" a\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" country\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\".\\\",\\\"\"}\n\n" + }, + { + "at": 1877, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"message\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"\\\":\\\"\"}\n\n" + }, + { + "at": 1883, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"Name\"}\n\n" + }, + { + "at": 1887, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" one\"}\n\n" + }, + { + "at": 1895, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" country\"}\n\n" + }, + { + "at": 1898, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\".\"}\n\n" + }, + { + "at": 1903, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" One\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" word\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\" only\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\".\\\"\"}\n\n" + }, + { + "at": 1919, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"}\"}\n\n" + }, + { + "at": 1931, + "text": "data: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\",\"delta\":\"]}\"}\n\n" + }, + { + "at": 1976, + "text": "data: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_vzQelEvBOezalGA74Nx5cB17\"}\n\n" + }, + { + "at": 2211, + "text": "data: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"tool-calls\"},\"threadId\":\"6fb02984-d354-4fa2-9793-866430dd5d6d\",\"runId\":\"c056cbf5-28e9-4425-aa67-d1c8a16ad762\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":403,\"outputTokens\":53,\"totalTokens\":456,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 2212, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "bb9d25db-0c67-4a21-96e7-80bd8e86150e", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792539602276 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "1b04fc84-05c4-41a5-868b-a89b178317a7", + "runId": null, + "threadId": "6ab2b425-570a-4fb3-80f6-183d23e1682a", + "agentId": "default", + "role": "user", + "textLength": 45, + "text": "Name one river. One word only. Call no tools.", + "seq": 2 + }, + "timestamp": 1792539602328 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "6780558d-6a23-44ab-ad27-d3c170ed373c", + "threadId": "6ab2b425-570a-4fb3-80f6-183d23e1682a", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792539602346 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_08e9c22f8bed4639006ad7fbd33a9887d0bb6610db4f70a3fb", + "runId": "6780558d-6a23-44ab-ad27-d3c170ed373c", + "threadId": "6ab2b425-570a-4fb3-80f6-183d23e1682a", + "agentId": "default", + "role": "assistant", + "textLength": 4, + "text": "Nile", + "seq": 4 + }, + "timestamp": 1792539603507 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "6780558d-6a23-44ab-ad27-d3c170ed373c", + "threadId": "6ab2b425-570a-4fb3-80f6-183d23e1682a", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792539603507 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/text-answer.json b/extension/tests/fixtures/agent-traffic/sse/text-answer.json new file mode 100644 index 000000000..b50f1a96d --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/text-answer.json @@ -0,0 +1,131 @@ +{ + "name": "text-answer", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 56, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"7832e216-ead6-41f5-acda-ccfea87c636c\",\"runId\":\"a231ee27-37a3-4b37-80ff-c5152bcec8fd\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"ac2e1424-b82b-4a52-a912-92757ad25d25\",\"role\":\"user\",\"content\":\"Reply with exactly five words about the sea. Call no tools.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 68, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"7832e216-ead6-41f5-acda-ccfea87c636c\",\"runId\":\"a231ee27-37a3-4b37-80ff-c5152bcec8fd\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"7832e216-ead6-41f5-acda-ccfea87c636c\",\"runId\":\"a231ee27-37a3-4b37-80ff-c5152bcec8fd\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"ac2e1424-b82b-4a52-a912-92757ad25d25\",\"role\":\"user\",\"content\":\"Reply with exactly five words about the sea. Call no tools.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1187, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\"V\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\"ast\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\",\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\" deep\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\",\"}\n\n" + }, + { + "at": 1188, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\" mysterious\"}\n\n" + }, + { + "at": 1195, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\",\"}\n\n" + }, + { + "at": 1197, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\" blue\"}\n\n" + }, + { + "at": 1204, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\",\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\" calming\"}\n\n" + }, + { + "at": 1215, + "text": "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\",\"delta\":\".\"}\n\n" + }, + { + "at": 1428, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"7832e216-ead6-41f5-acda-ccfea87c636c\",\"runId\":\"a231ee27-37a3-4b37-80ff-c5152bcec8fd\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":211,\"outputTokens\":13,\"totalTokens\":224,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 1430, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "145ba127-4a61-4eae-baf9-9e53c0f68dfd", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538292417 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "ac2e1424-b82b-4a52-a912-92757ad25d25", + "runId": null, + "threadId": "7832e216-ead6-41f5-acda-ccfea87c636c", + "agentId": "default", + "role": "user", + "textLength": 59, + "text": "Reply with exactly five words about the sea. Call no tools.", + "seq": 2 + }, + "timestamp": 1792538292469 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "a231ee27-37a3-4b37-80ff-c5152bcec8fd", + "threadId": "7832e216-ead6-41f5-acda-ccfea87c636c", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538292487 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_0fd6567cb44ca5c9006ad7f6b5965c87d0adfc8023bd737db2", + "runId": "a231ee27-37a3-4b37-80ff-c5152bcec8fd", + "threadId": "7832e216-ead6-41f5-acda-ccfea87c636c", + "agentId": "default", + "role": "assistant", + "textLength": 38, + "text": "Vast, deep, mysterious, blue, calming.", + "seq": 4 + }, + "timestamp": 1792538293846 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "a231ee27-37a3-4b37-80ff-c5152bcec8fd", + "threadId": "7832e216-ead6-41f5-acda-ccfea87c636c", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792538293846 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agent-traffic/sse/two-runs.json b/extension/tests/fixtures/agent-traffic/sse/two-runs.json new file mode 100644 index 000000000..19cc6b0ec --- /dev/null +++ b/extension/tests/fixtures/agent-traffic/sse/two-runs.json @@ -0,0 +1,191 @@ +{ + "name": "two-runs", + "transport": "sse", + "recordedWith": { + "copilotkit": "c21eeda029", + "@ag-ui/client": "1.0.2" + }, + "exchanges": [ + { + "at": 57, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"e7b6c023-7ca7-44e5-9fe1-130bca93f838\",\"runId\":\"fb3c9d5b-3612-4754-9574-19bdedd46d01\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"beabf54a-850c-42e2-ad1b-7b985c45765a\",\"role\":\"user\",\"content\":\"Name one planet. One word only. Call no tools.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 59, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"e7b6c023-7ca7-44e5-9fe1-130bca93f838\",\"runId\":\"fb3c9d5b-3612-4754-9574-19bdedd46d01\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"e7b6c023-7ca7-44e5-9fe1-130bca93f838\",\"runId\":\"fb3c9d5b-3612-4754-9574-19bdedd46d01\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"beabf54a-850c-42e2-ad1b-7b985c45765a\",\"role\":\"user\",\"content\":\"Name one planet. One word only. Call no tools.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 584, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_04c9b51f991a75b7006ad7f6bc923c87d0ac29d7c4971803f9\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_04c9b51f991a75b7006ad7f6bc923c87d0ac29d7c4971803f9\",\"delta\":\"Mars\"}\n\n" + }, + { + "at": 743, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_04c9b51f991a75b7006ad7f6bc923c87d0ac29d7c4971803f9\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"e7b6c023-7ca7-44e5-9fe1-130bca93f838\",\"runId\":\"fb3c9d5b-3612-4754-9574-19bdedd46d01\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":210,\"outputTokens\":3,\"totalTokens\":213,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 744, + "outcome": "complete" + } + }, + { + "at": 745, + "url": "http://127.0.0.1:4000/api/copilotkit", + "method": "POST", + "body": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"e7b6c023-7ca7-44e5-9fe1-130bca93f838\",\"runId\":\"9f9149e4-5e08-420d-84ee-cfbce74b2be5\",\"protocolVersion\":\"1.0\",\"tools\":[],\"context\":[],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"beabf54a-850c-42e2-ad1b-7b985c45765a\",\"role\":\"user\",\"content\":\"Name one planet. One word only. Call no tools.\"},{\"id\":\"msg_04c9b51f991a75b7006ad7f6bc923c87d0ac29d7c4971803f9\",\"role\":\"assistant\",\"content\":\"Mars\"},{\"id\":\"08487985-14ef-4007-9e37-8648762134dd\",\"role\":\"user\",\"content\":\"Name another one. One word only. Call no tools.\"}]}}", + "status": 200, + "contentType": "text/event-stream", + "chunks": [ + { + "at": 749, + "text": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"e7b6c023-7ca7-44e5-9fe1-130bca93f838\",\"runId\":\"9f9149e4-5e08-420d-84ee-cfbce74b2be5\",\"protocolVersion\":\"1.0\",\"input\":{\"threadId\":\"e7b6c023-7ca7-44e5-9fe1-130bca93f838\",\"runId\":\"9f9149e4-5e08-420d-84ee-cfbce74b2be5\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"08487985-14ef-4007-9e37-8648762134dd\",\"role\":\"user\",\"content\":\"Name another one. One word only. Call no tools.\"}],\"tools\":[],\"context\":[],\"forwardedProps\":{}}}\n\n" + }, + { + "at": 1723, + "text": "data: {\"type\":\"TEXT_MESSAGE_START\",\"messageId\":\"msg_004758eb733e6e10006ad7f6bdb18887d0957d2f697384cba7\",\"role\":\"assistant\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_004758eb733e6e10006ad7f6bdb18887d0957d2f697384cba7\",\"delta\":\"Ven\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"msg_004758eb733e6e10006ad7f6bdb18887d0957d2f697384cba7\",\"delta\":\"us\"}\n\n" + }, + { + "at": 1862, + "text": "data: {\"type\":\"TEXT_MESSAGE_END\",\"messageId\":\"msg_004758eb733e6e10006ad7f6bdb18887d0957d2f697384cba7\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"metadata\":{\"finishReason\":\"stop\"},\"threadId\":\"e7b6c023-7ca7-44e5-9fe1-130bca93f838\",\"runId\":\"9f9149e4-5e08-420d-84ee-cfbce74b2be5\",\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-4.1-mini\",\"inputTokens\":231,\"outputTokens\":4,\"totalTokens\":235,\"reasoningTokens\":0,\"cachedInputTokens\":0}]}\n\n" + } + ], + "end": { + "at": 1863, + "outcome": "complete" + } + } + ], + "frames": [], + "sink": [ + { + "trajectoryId": "e1d20228-fd46-47c6-9e00-90168d31d27c", + "events": [ + { + "type": "CUSTOM", + "name": "page", + "value": { + "route": "/", + "url": "http://127.0.0.1:4000/", + "title": "", + "referrer": "", + "seq": 1 + }, + "timestamp": 1792538300037 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "beabf54a-850c-42e2-ad1b-7b985c45765a", + "runId": null, + "threadId": "e7b6c023-7ca7-44e5-9fe1-130bca93f838", + "agentId": "default", + "role": "user", + "textLength": 46, + "text": "Name one planet. One word only. Call no tools.", + "seq": 2 + }, + "timestamp": 1792538300088 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "fb3c9d5b-3612-4754-9574-19bdedd46d01", + "threadId": "e7b6c023-7ca7-44e5-9fe1-130bca93f838", + "agentId": "default", + "seq": 3 + }, + "timestamp": 1792538300096 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_04c9b51f991a75b7006ad7f6bc923c87d0ac29d7c4971803f9", + "runId": "fb3c9d5b-3612-4754-9574-19bdedd46d01", + "threadId": "e7b6c023-7ca7-44e5-9fe1-130bca93f838", + "agentId": "default", + "role": "assistant", + "textLength": 4, + "text": "Mars", + "seq": 4 + }, + "timestamp": 1792538300781 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "fb3c9d5b-3612-4754-9574-19bdedd46d01", + "threadId": "e7b6c023-7ca7-44e5-9fe1-130bca93f838", + "agentId": "default", + "seq": 5 + }, + "timestamp": 1792538300781 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "08487985-14ef-4007-9e37-8648762134dd", + "runId": "fb3c9d5b-3612-4754-9574-19bdedd46d01", + "threadId": "e7b6c023-7ca7-44e5-9fe1-130bca93f838", + "agentId": "default", + "role": "user", + "textLength": 47, + "text": "Name another one. One word only. Call no tools.", + "seq": 6 + }, + "timestamp": 1792538300781 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "started", + "runId": "9f9149e4-5e08-420d-84ee-cfbce74b2be5", + "threadId": "e7b6c023-7ca7-44e5-9fe1-130bca93f838", + "agentId": "default", + "seq": 7 + }, + "timestamp": 1792538300785 + }, + { + "type": "CUSTOM", + "name": "agent.message", + "value": { + "messageId": "msg_004758eb733e6e10006ad7f6bdb18887d0957d2f697384cba7", + "runId": "9f9149e4-5e08-420d-84ee-cfbce74b2be5", + "threadId": "e7b6c023-7ca7-44e5-9fe1-130bca93f838", + "agentId": "default", + "role": "assistant", + "textLength": 5, + "text": "Venus", + "seq": 8 + }, + "timestamp": 1792538301899 + }, + { + "type": "CUSTOM", + "name": "agent.run", + "value": { + "phase": "finished", + "runId": "9f9149e4-5e08-420d-84ee-cfbce74b2be5", + "threadId": "e7b6c023-7ca7-44e5-9fe1-130bca93f838", + "agentId": "default", + "seq": 9 + }, + "timestamp": 1792538301900 + } + ], + "dropped": 0 + } + ] +} diff --git a/extension/tests/fixtures/agui/generic-proto-ws-ticker.json b/extension/tests/fixtures/agui/generic-proto-ws-ticker.json new file mode 100644 index 000000000..56ee0d072 --- /dev/null +++ b/extension/tests/fixtures/agui/generic-proto-ws-ticker.json @@ -0,0 +1,3790 @@ +{ + "description": "CopilotKit main's BuiltInAgent on OpenAI gpt-5.2 served the standard AG-UI way (@ag-ui/encoder 1.0.2 Accept negotiation) at /rig/awp: an @ag-ui/client 1.0.2 HttpAgent asking for application/vnd.ag-ui.event+proto, then one asking for SSE; the same agent over a plain WebSocket sending each event as a JSON text frame; then a non-AG-UI EventSource (CRLF, a comment, event, id and retry fields, two-line data). Real Chrome over CDP.", + "browser": "Chrome/156.0.8078.47", + "entries": [ + { + "kind": "mark", + "t": 24.064, + "wall": 1792538832903, + "name": "navigate", + "data": { "url": "http://127.0.0.1:7631/rig/page" } + }, + { + "kind": "event", + "t": 25.607, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "CC639E774C1017352E058AA4E237E804", + "loaderId": "CC639E774C1017352E058AA4E237E804", + "documentURL": "http://127.0.0.1:7631/rig/page", + "request": { + "url": "http://127.0.0.1:7631/rig/page", + "method": "GET", + "headers": { + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"macOS\"" + } + }, + "timestamp": 1786794.461056, + "wallTime": 1792538832.905029, + "type": "Document", + "frameId": "E16845A14AFEB2A6A172A1AAF8A7F432", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 27.107, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "CC639E774C1017352E058AA4E237E804", + "loaderId": "CC639E774C1017352E058AA4E237E804", + "timestamp": 1786794.462755, + "type": "Document", + "response": { + "url": "http://127.0.0.1:7631/rig/page", + "status": 200, + "statusText": "OK", + "headers": { + "Content-Length": "533052", + "Content-Type": "text/html", + "Date": "Tue, 20 Oct 2026 23:27:12 GMT" + }, + "mimeType": "text/html" + }, + "hasExtraInfo": true, + "frameId": "E16845A14AFEB2A6A172A1AAF8A7F432" + } + }, + { + "kind": "event", + "t": 31.081, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "CC639E774C1017352E058AA4E237E804", + "timestamp": 1786794.464435, + "dataLength": 533052, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 44.757, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "CC639E774C1017352E058AA4E237E804", + "timestamp": 1786794.464281, + "encodedDataLength": 533157 + } + }, + { + "kind": "mark", + "t": 1299.495, + "wall": 1792538834179, + "name": "click", + "data": { + "name": "proto", + "selector": "#proto", + "text": "Ask over protobuf" + } + }, + { + "kind": "event", + "t": 1304.467, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "72164.3", + "loaderId": "CC639E774C1017352E058AA4E237E804", + "documentURL": "http://127.0.0.1:7631/rig/page", + "request": { + "url": "http://127.0.0.1:7631/rig/awp", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7631/rig/page", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Accept": "application/vnd.ag-ui.event+proto", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"threadId\":\"cf3d80dc-cdd8-495a-b569-65565f179c30\",\"runId\":\"8852a83e-8cbf-4854-b70e-794b83aa8616\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"3ff843bc-bd47-4c65-ac83-f22dc3057f29\",\"role\":\"user\",\"content\":\"Should we approve deal-2 at its amount?\"}],\"tools\":[],\"context\":[{\"description\":\"The deal the user is looking at\",\"value\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"}],\"forwardedProps\":{}}", + "hasPostData": true + }, + "timestamp": 1786795.738735, + "wallTime": 1792538834.183168, + "type": "Fetch", + "frameId": "E16845A14AFEB2A6A172A1AAF8A7F432", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 1320.829, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "72164.3", + "loaderId": "CC639E774C1017352E058AA4E237E804", + "timestamp": 1786795.756018, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7631/rig/awp", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + "Date": "Tue, 20 Oct 2026 23:27:13 GMT", + "Content-Type": "application/vnd.ag-ui.event+proto" + }, + "mimeType": "application/vnd.ag-ui.event+proto" + }, + "hasExtraInfo": true, + "frameId": "E16845A14AFEB2A6A172A1AAF8A7F432" + } + }, + { + "kind": "command", + "t": 1320.835, + "session": "page", + "method": "Network.streamResourceContent", + "params": { "requestId": "72164.3" }, + "tEnd": 1322.401, + "result": { + "bufferedData": "AAAAV2JVCgIICxIkY2YzZDgwZGMtY2RkOC00OTVhLWI1NjktNjU1NjVmMTc5YzMwGiQ4ODUyYTgzZS04Y2JmLTQ4NTQtYjcwZS03OTRiODNhYTg2MTYyAzEuMA==" + } + }, + { + "kind": "event", + "t": 1322.381, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786795.75654, + "dataLength": 91, + "encodedDataLength": 97 + } + }, + { + "kind": "event", + "t": 2314.728, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786796.748467, + "dataLength": 147, + "encodedDataLength": 153, + "data": "AAAAQMoBPQoCCBgaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAAAABL0gFICgIIGRo3cnNfMDVkYWRhNDU2ZjRlY2E2MjAwNmFkN2Y4ZDMzNDE4ODdkMDlkNTI5MzUyZDE4NmEwNTQ6MCIJcmVhc29uaW5n" + } + }, + { + "kind": "event", + "t": 2953.872, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.388833, + "dataLength": 175, + "encodedDataLength": 181, + "data": "AAAAYNoBXQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiHioqTG9va2luZyB1cCBkZWFsIGhpc3RvcnkqKgoKSQAAAEfaAUQKAggaGjdyc18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkMzM0MTg4N2QwOWQ1MjkzNTJkMTg2YTA1NDowIgUgd2FudA==" + } + }, + { + "kind": "event", + "t": 3020.477, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.455661, + "dataLength": 73, + "encodedDataLength": 79, + "data": "AAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyB0bw==" + } + }, + { + "kind": "event", + "t": 3022.702, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.458134, + "dataLength": 76, + "encodedDataLength": 82, + "data": "AAAASNoBRQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBiBzdGFydA==" + } + }, + { + "kind": "event", + "t": 3025.487, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.460936, + "dataLength": 73, + "encodedDataLength": 79, + "data": "AAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyBieQ==" + } + }, + { + "kind": "event", + "t": 3027.429, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.462929, + "dataLength": 76, + "encodedDataLength": 82, + "data": "AAAASNoBRQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBiB1c2luZw==" + } + }, + { + "kind": "event", + "t": 3028.478, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.463957, + "dataLength": 74, + "encodedDataLength": 80, + "data": "AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBCB0aGU=" + } + }, + { + "kind": "event", + "t": 3032.028, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.467467, + "dataLength": 148, + "encodedDataLength": 154, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSB0b29sAAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyB0bw==" + } + }, + { + "kind": "event", + "t": 3033.961, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.469554, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSBsb29r" + } + }, + { + "kind": "event", + "t": 3034.83, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.470471, + "dataLength": 73, + "encodedDataLength": 79, + "data": "AAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyB1cA==" + } + }, + { + "kind": "event", + "t": 3038.475, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.474114, + "dataLength": 74, + "encodedDataLength": 80, + "data": "AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBCB0aGU=" + } + }, + { + "kind": "event", + "t": 3062.779, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.498079, + "dataLength": 524, + "encodedDataLength": 537, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSBkZWFsAAAAStoBRwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiCCBoaXN0b3J5AAAAQ9oBQAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAS4AAABF2gFCCgIIGho3cnNfMDVkYWRhNDU2ZjRlY2E2MjAwNmFkN2Y4ZDMzNDE4ODdkMDlkNTI5MzUyZDE4NmEwNTQ6MCIDIEl0AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBOKAmXMAAABM2gFJCgIIGho3cnNfMDVkYWRhNDU2ZjRlY2E2MjAwNmFkN2Y4ZDMzNDE4ODdkMDlkNTI5MzUyZDE4NmEwNTQ6MCIKIGltcG9ydGFudAAAAEXaAUIKAggaGjdyc18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkMzM0MTg4N2QwOWQ1MjkzNTJkMTg2YTA1NDowIgMgdG8=" + } + }, + { + "kind": "event", + "t": 3077.896, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.513421, + "dataLength": 74, + "encodedDataLength": 80, + "data": "AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBCBnZXQ=" + } + }, + { + "kind": "event", + "t": 3089.798, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.52532, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSB0aGF0" + } + }, + { + "kind": "event", + "t": 3098.178, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.533692, + "dataLength": 82, + "encodedDataLength": 88, + "data": "AAAATtoBSwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiDCBpbmZvcm1hdGlvbg==" + } + }, + { + "kind": "event", + "t": 3100.639, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.53608, + "dataLength": 223, + "encodedDataLength": 229, + "data": "AAAASNoBRQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBiBmaXJzdAAAAEPaAUAKAggaGjdyc18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkMzM0MTg4N2QwOWQ1MjkzNTJkMTg2YTA1NDowIgEuAAAASNoBRQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBiBBZnRlcg==" + } + }, + { + "kind": "event", + "t": 3134.683, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.568024, + "dataLength": 80, + "encodedDataLength": 86, + "data": "AAAATNoBSQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiCiBnYXRoZXJpbmc=" + } + }, + { + "kind": "event", + "t": 3134.688, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.569871, + "dataLength": 74, + "encodedDataLength": 80, + "data": "AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBCB0aGU=" + } + }, + { + "kind": "event", + "t": 3136.744, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.572292, + "dataLength": 78, + "encodedDataLength": 84, + "data": "AAAAStoBRwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiCCBkZXRhaWxz" + } + }, + { + "kind": "event", + "t": 3139.777, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.575357, + "dataLength": 71, + "encodedDataLength": 77, + "data": "AAAAQ9oBQAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiASw=" + } + }, + { + "kind": "event", + "t": 3143.323, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.578737, + "dataLength": 147, + "encodedDataLength": 153, + "data": "AAAARNoBQQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAiBJAAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBeKAmWxs" + } + }, + { + "kind": "event", + "t": 3145.905, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.581519, + "dataLength": 77, + "encodedDataLength": 83, + "data": "AAAASdoBRgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiByBmb2xsb3c=" + } + }, + { + "kind": "event", + "t": 3152.816, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.588409, + "dataLength": 148, + "encodedDataLength": 154, + "data": "AAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyB1cAAAAEfaAUQKAggaGjdyc18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkMzM0MTg4N2QwOWQ1MjkzNTJkMTg2YTA1NDowIgUgd2l0aA==" + } + }, + { + "kind": "event", + "t": 3154.951, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.59055, + "dataLength": 230, + "encodedDataLength": 236, + "data": "AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBCB0d28AAABI2gFFCgIIGho3cnNfMDVkYWRhNDU2ZjRlY2E2MjAwNmFkN2Y4ZDMzNDE4ODdkMDlkNTI5MzUyZDE4NmEwNTQ6MCIGIHNob3J0AAAATNoBSQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiCiBzZW50ZW5jZXM=" + } + }, + { + "kind": "event", + "t": 3157.101, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.592583, + "dataLength": 460, + "encodedDataLength": 473, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSB0aGF0AAAAStoBRwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiCCBwcm92aWRlAAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyBteQAAAFHaAU4KAggaGjdyc18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkMzM0MTg4N2QwOWQ1MjkzNTJkMTg2YTA1NDowIg8gcmVjb21tZW5kYXRpb24AAABI2gFFCgIIGho3cnNfMDVkYWRhNDU2ZjRlY2E2MjAwNmFkN2Y4ZDMzNDE4ODdkMDlkNTI5MzUyZDE4NmEwNTQ6MCIGIGJhc2VkAAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyBvbg==" + } + }, + { + "kind": "event", + "t": 3160.498, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.596165, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSB3aGF0" + } + }, + { + "kind": "event", + "t": 3188.673, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.624255, + "dataLength": 72, + "encodedDataLength": 78, + "data": "AAAARNoBQQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAiBJ" + } + }, + { + "kind": "event", + "t": 3198.646, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.634225, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSBmaW5k" + } + }, + { + "kind": "event", + "t": 3200.952, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.636482, + "dataLength": 220, + "encodedDataLength": 226, + "data": "AAAAQ9oBQAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAS4AAABF2gFCCgIIGho3cnNfMDVkYWRhNDU2ZjRlY2E2MjAwNmFkN2Y4ZDMzNDE4ODdkMDlkNTI5MzUyZDE4NmEwNTQ6MCIDIEl0AAAASNoBRQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBiBzZWVtcw==" + } + }, + { + "kind": "event", + "t": 3203.341, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.638971, + "dataLength": 86, + "encodedDataLength": 92, + "data": "AAAAUtoBTwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiECBzdHJhaWdodGZvcndhcmQ=" + } + }, + { + "kind": "event", + "t": 3206.751, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.642328, + "dataLength": 71, + "encodedDataLength": 77, + "data": "AAAAQ9oBQAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiASw=" + } + }, + { + "kind": "event", + "t": 3210.895, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.646485, + "dataLength": 74, + "encodedDataLength": 80, + "data": "AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBCBidXQ=" + } + }, + { + "kind": "event", + "t": 3218.482, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.654097, + "dataLength": 72, + "encodedDataLength": 78, + "data": "AAAARNoBQQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAiBJ" + } + }, + { + "kind": "event", + "t": 3224.413, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.660015, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSB3YW50" + } + }, + { + "kind": "event", + "t": 3238.59, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.674142, + "dataLength": 73, + "encodedDataLength": 79, + "data": "AAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyB0bw==" + } + }, + { + "kind": "event", + "t": 3245.496, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.681065, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSBtYWtl" + } + }, + { + "kind": "event", + "t": 3248.527, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.68403, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSBzdXJl" + } + }, + { + "kind": "event", + "t": 3260.067, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.69558, + "dataLength": 72, + "encodedDataLength": 78, + "data": "AAAARNoBQQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAiBJ" + } + }, + { + "kind": "event", + "t": 3262.081, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.697723, + "dataLength": 74, + "encodedDataLength": 80, + "data": "AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBOKAmW0=" + } + }, + { + "kind": "event", + "t": 3280.615, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.716231, + "dataLength": 79, + "encodedDataLength": 85, + "data": "AAAAS9oBSAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiCSB0aG9yb3VnaA==" + } + }, + { + "kind": "event", + "t": 3288.737, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.724357, + "dataLength": 71, + "encodedDataLength": 77, + "data": "AAAAQ9oBQAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiASw=" + } + }, + { + "kind": "event", + "t": 3295.189, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.730839, + "dataLength": 73, + "encodedDataLength": 79, + "data": "AAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyBzbw==" + } + }, + { + "kind": "event", + "t": 3302.903, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.738569, + "dataLength": 72, + "encodedDataLength": 78, + "data": "AAAARNoBQQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAiBJ" + } + }, + { + "kind": "event", + "t": 3308.62, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.74429, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBeKAmWxs" + } + }, + { + "kind": "event", + "t": 3312.047, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.747686, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSBzdGF5" + } + }, + { + "kind": "event", + "t": 3316.888, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.752508, + "dataLength": 78, + "encodedDataLength": 84, + "data": "AAAAStoBRwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiCCBmb2N1c2Vk" + } + }, + { + "kind": "event", + "t": 3324.125, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.759742, + "dataLength": 73, + "encodedDataLength": 79, + "data": "AAAARdoBQgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAyBvbg==" + } + }, + { + "kind": "event", + "t": 3328.466, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.764084, + "dataLength": 78, + "encodedDataLength": 84, + "data": "AAAAStoBRwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiCCBnZXR0aW5n" + } + }, + { + "kind": "event", + "t": 3333.634, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.769275, + "dataLength": 74, + "encodedDataLength": 80, + "data": "AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBCB0aGU=" + } + }, + { + "kind": "event", + "t": 3339.288, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.774904, + "dataLength": 76, + "encodedDataLength": 82, + "data": "AAAASNoBRQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBiByaWdodA==" + } + }, + { + "kind": "event", + "t": 3345.955, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.781497, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSBkYXRh" + } + }, + { + "kind": "event", + "t": 3357.541, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.79309, + "dataLength": 75, + "encodedDataLength": 81, + "data": "AAAAR9oBRAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBSBmcm9t" + } + }, + { + "kind": "event", + "t": 3363.006, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.798602, + "dataLength": 74, + "encodedDataLength": 80, + "data": "AAAARtoBQwoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBCB0aGU=" + } + }, + { + "kind": "event", + "t": 3379.799, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.815259, + "dataLength": 77, + "encodedDataLength": 83, + "data": "AAAASdoBRgoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiByBsb29rdXA=" + } + }, + { + "kind": "event", + "t": 3384.223, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.819768, + "dataLength": 71, + "encodedDataLength": 77, + "data": "AAAAQ9oBQAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiAS4=" + } + }, + { + "kind": "event", + "t": 3390.735, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.826309, + "dataLength": 76, + "encodedDataLength": 82, + "data": "AAAASNoBRQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBiBMZXQncw==" + } + }, + { + "kind": "event", + "t": 3405.946, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.84144, + "dataLength": 76, + "encodedDataLength": 82, + "data": "AAAASNoBRQoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiBiBiZWdpbg==" + } + }, + { + "kind": "event", + "t": 3409.479, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.84498, + "dataLength": 71, + "encodedDataLength": 77, + "data": "AAAAQ9oBQAoCCBoaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAiASE=" + } + }, + { + "kind": "event", + "t": 3446.124, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.880301, + "dataLength": 136, + "encodedDataLength": 142, + "data": "AAAAQOIBPQoCCBsaN3JzXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQzMzQxODg3ZDA5ZDUyOTM1MmQxODZhMDU0OjAAAABA8gE9CgIIHRo3cnNfMDVkYWRhNDU2ZjRlY2E2MjAwNmFkN2Y4ZDMzNDE4ODdkMDlkNTI5MzUyZDE4NmEwNTQ6MA==" + } + }, + { + "kind": "event", + "t": 3447.335, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.882231, + "dataLength": 98, + "encodedDataLength": 104, + "data": "AAAAXiJcCgIIAxIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYaEWxvb2t1cERlYWxIaXN0b3J5IiQwMTFkNmZhZi1jNTMyLTRhNzMtOTBkNy1hMTU2NTgwYTAyNzc=" + } + }, + { + "kind": "event", + "t": 3448.581, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.883724, + "dataLength": 45, + "encodedDataLength": 51, + "data": "AAAAKSonCgIIBBIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYaAnsi" + } + }, + { + "kind": "event", + "t": 3461.331, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.896644, + "dataLength": 47, + "encodedDataLength": 53, + "data": "AAAAKyopCgIIBBIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYaBGRlYWw=" + } + }, + { + "kind": "event", + "t": 3495.236, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.930001, + "dataLength": 45, + "encodedDataLength": 51, + "data": "AAAAKSonCgIIBBIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYaAklk" + } + }, + { + "kind": "event", + "t": 3498.782, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.934121, + "dataLength": 46, + "encodedDataLength": 52, + "data": "AAAAKiooCgIIBBIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYaAyI6Ig==" + } + }, + { + "kind": "event", + "t": 3508.993, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.944178, + "dataLength": 47, + "encodedDataLength": 53, + "data": "AAAAKyopCgIIBBIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYaBGRlYWw=" + } + }, + { + "kind": "event", + "t": 3527.923, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.96324, + "dataLength": 44, + "encodedDataLength": 50, + "data": "AAAAKComCgIIBBIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYaAS0=" + } + }, + { + "kind": "event", + "t": 3531.051, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.966522, + "dataLength": 44, + "encodedDataLength": 50, + "data": "AAAAKComCgIIBBIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYaATI=" + } + }, + { + "kind": "event", + "t": 3533.741, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.96901, + "dataLength": 45, + "encodedDataLength": 51, + "data": "AAAAKSonCgIIBBIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYaAiJ9" + } + }, + { + "kind": "event", + "t": 3553.086, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786797.987461, + "dataLength": 324, + "encodedDataLength": 331, + "data": "AAAAJTIjCgIIBRIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYAAAEXsgGTAgoCCBUaJGI3ZWU2ZmEzLWM1YmQtNGI0Yy1iZGZlLTY2YTUyNzY5MmY1MCIdY2FsbF9RSXJoNVNrUmlzOXV0SGNpYnVIaW5vRGYqwQF7ImRlYWxJZCI6ImRlYWwtMiIsIm5hbWUiOiJHbG9iZXggZXhwYW5zaW9uIiwiYW1vdW50Ijo0ODAwMCwic3RhZ2UiOiJyZXZpZXciLCJwYXN0RGlzY291bnRzIjpbIjEwJSBvbiByZW5ld2FsIGluIDIwMjUiXSwicGF5bWVudFJlY29yZCI6InBhaWQgb24gdGltZSBmb3IgMyB5ZWFycyIsImFwcHJvdmFscyI6WyJzYWxlcyBtYW5hZ2VyIl19MgR0b29s" + } + }, + { + "kind": "event", + "t": 4591.527, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.026514, + "dataLength": 83, + "encodedDataLength": 89, + "data": "AAAAT4oBTAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgNZZXM=" + } + }, + { + "kind": "event", + "t": 4594.636, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.030116, + "dataLength": 170, + "encodedDataLength": 176, + "data": "AAAAT4oBTAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgPigJQAAABTigFQCgIIExI2bXNnXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQ1N2IxNDg3ZDA4ZmYzYjZhMWYwYTA5N2E0Gglhc3Npc3RhbnQiB2FwcHJvdmU=" + } + }, + { + "kind": "event", + "t": 4622.558, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.058018, + "dataLength": 85, + "encodedDataLength": 91, + "data": "AAAAUYoBTgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgUgZGVhbA==" + } + }, + { + "kind": "event", + "t": 4663.333, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.098773, + "dataLength": 81, + "encodedDataLength": 87, + "data": "AAAATYoBSgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgEt" + } + }, + { + "kind": "event", + "t": 4666.695, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.10224, + "dataLength": 81, + "encodedDataLength": 87, + "data": "AAAATYoBSgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgEy" + } + }, + { + "kind": "event", + "t": 4667.811, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.10331, + "dataLength": 83, + "encodedDataLength": 89, + "data": "AAAAT4oBTAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgMgYXQ=" + } + }, + { + "kind": "event", + "t": 4671.112, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.10657, + "dataLength": 164, + "encodedDataLength": 170, + "data": "AAAATooBSwoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgIgJAAAAE6KAUsKAggTEjZtc2dfMDVkYWRhNDU2ZjRlY2E2MjAwNmFkN2Y4ZDU3YjE0ODdkMDhmZjNiNmExZjBhMDk3YTQaCWFzc2lzdGFudCICNDg=" + } + }, + { + "kind": "event", + "t": 4694.635, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.130012, + "dataLength": 81, + "encodedDataLength": 87, + "data": "AAAATYoBSgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgEs" + } + }, + { + "kind": "event", + "t": 4699.458, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.134957, + "dataLength": 164, + "encodedDataLength": 170, + "data": "AAAAT4oBTAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgMwMDAAAABNigFKCgIIExI2bXNnXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQ1N2IxNDg3ZDA4ZmYzYjZhMWYwYTA5N2E0Gglhc3Npc3RhbnQiASw=" + } + }, + { + "kind": "event", + "t": 4745.433, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.180908, + "dataLength": 86, + "encodedDataLength": 92, + "data": "AAAAUooBTwoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgYgZ2l2ZW4=" + } + }, + { + "kind": "event", + "t": 4747.442, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.18306, + "dataLength": 84, + "encodedDataLength": 90, + "data": "AAAAUIoBTQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgQgdGhl" + } + }, + { + "kind": "event", + "t": 4815.013, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.250445, + "dataLength": 89, + "encodedDataLength": 95, + "data": "AAAAVYoBUgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgkgY3VzdG9tZXI=" + } + }, + { + "kind": "event", + "t": 4830.545, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.266056, + "dataLength": 84, + "encodedDataLength": 90, + "data": "AAAAUIoBTQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgTigJlz" + } + }, + { + "kind": "event", + "t": 4832.581, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.268216, + "dataLength": 83, + "encodedDataLength": 89, + "data": "AAAAT4oBTAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgMgb24=" + } + }, + { + "kind": "event", + "t": 4833.588, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.269233, + "dataLength": 85, + "encodedDataLength": 91, + "data": "AAAAUYoBTgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgUtdGltZQ==" + } + }, + { + "kind": "event", + "t": 4836.627, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.27227, + "dataLength": 88, + "encodedDataLength": 94, + "data": "AAAAVIoBUQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgggcGF5bWVudA==" + } + }, + { + "kind": "event", + "t": 4856.378, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.291753, + "dataLength": 87, + "encodedDataLength": 93, + "data": "AAAAU4oBUAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgcgcmVjb3Jk" + } + }, + { + "kind": "event", + "t": 4858.837, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.294287, + "dataLength": 84, + "encodedDataLength": 90, + "data": "AAAAUIoBTQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgQgZm9y" + } + }, + { + "kind": "event", + "t": 4859.833, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.29536, + "dataLength": 81, + "encodedDataLength": 87, + "data": "AAAATYoBSgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgEg" + } + }, + { + "kind": "event", + "t": 4868.247, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.30372, + "dataLength": 167, + "encodedDataLength": 173, + "data": "AAAATYoBSgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgEzAAAAUooBTwoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgYgeWVhcnM=" + } + }, + { + "kind": "event", + "t": 4888.128, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.323579, + "dataLength": 84, + "encodedDataLength": 90, + "data": "AAAAUIoBTQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgQgYW5k" + } + }, + { + "kind": "event", + "t": 4899.698, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.335094, + "dataLength": 85, + "encodedDataLength": 91, + "data": "AAAAUYoBTgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgUgb25seQ==" + } + }, + { + "kind": "event", + "t": 4921.904, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.357356, + "dataLength": 82, + "encodedDataLength": 88, + "data": "AAAATooBSwoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgIgYQ==" + } + }, + { + "kind": "event", + "t": 4962.282, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.397631, + "dataLength": 330, + "encodedDataLength": 337, + "data": "AAAAUooBTwoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgYgcHJpb3IAAABNigFKCgIIExI2bXNnXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQ1N2IxNDg3ZDA4ZmYzYjZhMWYwYTA5N2E0Gglhc3Npc3RhbnQiASAAAABOigFLCgIIExI2bXNnXzA1ZGFkYTQ1NmY0ZWNhNjIwMDZhZDdmOGQ1N2IxNDg3ZDA4ZmYzYjZhMWYwYTA5N2E0Gglhc3Npc3RhbnQiAjEwAAAATYoBSgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgEl" + } + }, + { + "kind": "event", + "t": 5004.479, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.439923, + "dataLength": 88, + "encodedDataLength": 94, + "data": "AAAAVIoBUQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgggcmVuZXdhbA==" + } + }, + { + "kind": "event", + "t": 5018.287, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.453744, + "dataLength": 89, + "encodedDataLength": 95, + "data": "AAAAVYoBUgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgkgZGlzY291bnQ=" + } + }, + { + "kind": "event", + "t": 5020.393, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.455877, + "dataLength": 81, + "encodedDataLength": 87, + "data": "AAAATYoBSgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgEu" + } + }, + { + "kind": "event", + "t": 5050.123, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.485533, + "dataLength": 83, + "encodedDataLength": 89, + "data": "AAAAT4oBTAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgMgSXQ=" + } + }, + { + "kind": "event", + "t": 5083.389, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.518832, + "dataLength": 88, + "encodedDataLength": 94, + "data": "AAAAVIoBUQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgggYWxyZWFkeQ==" + } + }, + { + "kind": "event", + "t": 5095.194, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.530675, + "dataLength": 84, + "encodedDataLength": 90, + "data": "AAAAUIoBTQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgQgaGFz" + } + }, + { + "kind": "event", + "t": 5125.099, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.560542, + "dataLength": 86, + "encodedDataLength": 92, + "data": "AAAAUooBTwoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgYgc2FsZXM=" + } + }, + { + "kind": "event", + "t": 5137.974, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.573453, + "dataLength": 88, + "encodedDataLength": 94, + "data": "AAAAVIoBUQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgggbWFuYWdlcg==" + } + }, + { + "kind": "event", + "t": 5141.636, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.577188, + "dataLength": 89, + "encodedDataLength": 95, + "data": "AAAAVYoBUgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgkgYXBwcm92YWw=" + } + }, + { + "kind": "event", + "t": 5145.352, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.580858, + "dataLength": 164, + "encodedDataLength": 170, + "data": "AAAATYoBSgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgEsAAAAT4oBTAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgMgc28=" + } + }, + { + "kind": "event", + "t": 5158.879, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.594351, + "dataLength": 83, + "encodedDataLength": 89, + "data": "AAAAT4oBTAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgMgaXQ=" + } + }, + { + "kind": "event", + "t": 5193.775, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.629221, + "dataLength": 86, + "encodedDataLength": 92, + "data": "AAAAUooBTwoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgYgbG9va3M=" + } + }, + { + "kind": "event", + "t": 5228.481, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.663936, + "dataLength": 84, + "encodedDataLength": 90, + "data": "AAAAUIoBTQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgQgbG93" + } + }, + { + "kind": "event", + "t": 5238.517, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.67396, + "dataLength": 85, + "encodedDataLength": 91, + "data": "AAAAUYoBTgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgUgcmlzaw==" + } + }, + { + "kind": "event", + "t": 5243.314, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.678788, + "dataLength": 83, + "encodedDataLength": 89, + "data": "AAAAT4oBTAoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgMgdG8=" + } + }, + { + "kind": "event", + "t": 5245.156, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.680705, + "dataLength": 88, + "encodedDataLength": 94, + "data": "AAAAVIoBUQoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgggcHJvY2VlZA==" + } + }, + { + "kind": "event", + "t": 5265.251, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.700724, + "dataLength": 81, + "encodedDataLength": 87, + "data": "AAAATYoBSgoCCBMSNm1zZ18wNWRhZGE0NTZmNGVjYTYyMDA2YWQ3ZjhkNTdiMTQ4N2QwOGZmM2I2YTFmMGEwOTdhNBoJYXNzaXN0YW50IgEu" + } + }, + { + "kind": "event", + "t": 5412.723, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.846984, + "dataLength": 154, + "encodedDataLength": 165, + "data": "AAAAlmqTAQocCAwiGAoWCgxmaW5pc2hSZWFzb24SBhoEc3RvcBIkY2YzZDgwZGMtY2RkOC00OTVhLWI1NjktNjU1NjVmMTc5YzMwGiQ4ODUyYTgzZS04Y2JmLTQ4NTQtYjcwZS03OTRiODNhYTg2MTY6JwoQb3BlbmFpLnJlc3BvbnNlcxIHZ3B0LTUuMhj1BCBhKNYFMBQ4AA==" + } + }, + { + "kind": "event", + "t": 5412.733, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "72164.3", + "timestamp": 1786799.846926, + "encodedDataLength": 12153 + } + }, + { + "kind": "mark", + "t": 7581.391, + "wall": 1792538840461, + "name": "click", + "data": { "name": "sse", "selector": "#sse", "text": "Ask over SSE" } + }, + { + "kind": "event", + "t": 7583.171, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "72164.4", + "loaderId": "CC639E774C1017352E058AA4E237E804", + "documentURL": "http://127.0.0.1:7631/rig/page", + "request": { + "url": "http://127.0.0.1:7631/rig/awp", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7631/rig/page", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Accept": "text/event-stream", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"threadId\":\"038a7627-dc11-44b3-acfa-e532b9ec85c5\",\"runId\":\"3e143559-7adb-4483-a767-1099de922f90\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"bb994ec7-225a-4712-b3e4-f7850a4a91be\",\"role\":\"user\",\"content\":\"Should we approve deal-2 at its amount?\"}],\"tools\":[],\"context\":[{\"description\":\"The deal the user is looking at\",\"value\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"}],\"forwardedProps\":{}}", + "hasPostData": true + }, + "timestamp": 1786802.018359, + "wallTime": 1792538840.462401, + "type": "Fetch", + "frameId": "E16845A14AFEB2A6A172A1AAF8A7F432", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 7593.847, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "72164.4", + "loaderId": "CC639E774C1017352E058AA4E237E804", + "timestamp": 1786802.028601, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7631/rig/awp", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + "Date": "Tue, 20 Oct 2026 23:27:19 GMT", + "Content-Type": "text/event-stream" + }, + "mimeType": "text/event-stream" + }, + "hasExtraInfo": true, + "frameId": "E16845A14AFEB2A6A172A1AAF8A7F432" + } + }, + { + "kind": "command", + "t": 7593.865, + "session": "page", + "method": "Network.streamResourceContent", + "params": { "requestId": "72164.4" }, + "tEnd": 7594.322, + "result": { + "bufferedData": "ZGF0YTogeyJ0eXBlIjoiUlVOX1NUQVJURUQiLCJ0aHJlYWRJZCI6IjAzOGE3NjI3LWRjMTEtNDRiMy1hY2ZhLWU1MzJiOWVjODVjNSIsInJ1bklkIjoiM2UxNDM1NTktN2FkYi00NDgzLWE3NjctMTA5OWRlOTIyZjkwIiwicHJvdG9jb2xWZXJzaW9uIjoiMS4wIn0KCg==" + } + }, + { + "kind": "event", + "t": 7594.021, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786802.029071, + "dataLength": 151, + "encodedDataLength": 157 + } + }, + { + "kind": "event", + "t": 8598.097, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.033414, + "dataLength": 235, + "encodedDataLength": 241, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX1NUQVJUIiwibWVzc2FnZUlkIjoicnNfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZDk4MDc4ODdkMDhiNGZhZWZkOTlhNDMxMjU6MCJ9CgpkYXRhOiB7InR5cGUiOiJSRUFTT05JTkdfTUVTU0FHRV9TVEFSVCIsIm1lc3NhZ2VJZCI6InJzXzBjNWNkZWYzMDMwMzhjNjQwMDZhZDdmOGQ5ODA3ODg3ZDA4YjRmYWVmZDk5YTQzMTI1OjAiLCJyb2xlIjoicmVhc29uaW5nIn0KCg==" + } + }, + { + "kind": "event", + "t": 9294.179, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.729557, + "dataLength": 212, + "encodedDataLength": 218, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfRU5EIiwibWVzc2FnZUlkIjoicnNfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZDk4MDc4ODdkMDhiNGZhZWZkOTlhNDMxMjU6MCJ9CgpkYXRhOiB7InR5cGUiOiJSRUFTT05JTkdfRU5EIiwibWVzc2FnZUlkIjoicnNfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZDk4MDc4ODdkMDhiNGZhZWZkOTlhNDMxMjU6MCJ9Cgo=" + } + }, + { + "kind": "event", + "t": 9301.244, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.736725, + "dataLength": 171, + "encodedDataLength": 177, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX1NUQVJUIiwicGFyZW50TWVzc2FnZUlkIjoiMTcxODA2NTMtMmJmYi00OGUxLWJmMGEtOGM0NjBkNWZiNDg4IiwidG9vbENhbGxJZCI6ImNhbGxfMnhpU0V1Y0tlcldRRmxYWjhFUHA4TFdKIiwidG9vbENhbGxOYW1lIjoibG9va3VwRGVhbEhpc3RvcnkifQoK" + } + }, + { + "kind": "event", + "t": 9319.377, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.75463, + "dataLength": 185, + "encodedDataLength": 191, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF8yeGlTRXVjS2VyV1FGbFhaOEVQcDhMV0oiLCJkZWx0YSI6IntcIiJ9CgpkYXRhOiB7InR5cGUiOiJUT09MX0NBTExfQVJHUyIsInRvb2xDYWxsSWQiOiJjYWxsXzJ4aVNFdWNLZXJXUUZsWFo4RVBwOExXSiIsImRlbHRhIjoiZGVhbCJ9Cgo=" + } + }, + { + "kind": "event", + "t": 9324.877, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.760369, + "dataLength": 91, + "encodedDataLength": 97, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF8yeGlTRXVjS2VyV1FGbFhaOEVQcDhMV0oiLCJkZWx0YSI6IklkIn0KCg==" + } + }, + { + "kind": "event", + "t": 9336.606, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.772005, + "dataLength": 94, + "encodedDataLength": 100, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF8yeGlTRXVjS2VyV1FGbFhaOEVQcDhMV0oiLCJkZWx0YSI6IlwiOlwiIn0KCg==" + } + }, + { + "kind": "event", + "t": 9372.79, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.808035, + "dataLength": 93, + "encodedDataLength": 99, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF8yeGlTRXVjS2VyV1FGbFhaOEVQcDhMV0oiLCJkZWx0YSI6ImRlYWwifQoK" + } + }, + { + "kind": "event", + "t": 9398.044, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.833415, + "dataLength": 90, + "encodedDataLength": 96, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF8yeGlTRXVjS2VyV1FGbFhaOEVQcDhMV0oiLCJkZWx0YSI6Ii0ifQoK" + } + }, + { + "kind": "event", + "t": 9409.869, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.845321, + "dataLength": 90, + "encodedDataLength": 96, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF8yeGlTRXVjS2VyV1FGbFhaOEVQcDhMV0oiLCJkZWx0YSI6IjIifQoK" + } + }, + { + "kind": "event", + "t": 9425.347, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.860726, + "dataLength": 92, + "encodedDataLength": 98, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF8yeGlTRXVjS2VyV1FGbFhaOEVQcDhMV0oiLCJkZWx0YSI6IlwifSJ9Cgo=" + } + }, + { + "kind": "event", + "t": 9452.608, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786803.887884, + "dataLength": 454, + "encodedDataLength": 461, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0VORCIsInRvb2xDYWxsSWQiOiJjYWxsXzJ4aVNFdWNLZXJXUUZsWFo4RVBwOExXSiJ9CgpkYXRhOiB7InR5cGUiOiJUT09MX0NBTExfUkVTVUxUIiwicm9sZSI6InRvb2wiLCJtZXNzYWdlSWQiOiJhOTA0Y2FhZS1mYmUyLTQzNDMtYTQwYi0zODdhYWU2YTFhYTMiLCJ0b29sQ2FsbElkIjoiY2FsbF8yeGlTRXVjS2VyV1FGbFhaOEVQcDhMV0oiLCJjb250ZW50Ijoie1wiZGVhbElkXCI6XCJkZWFsLTJcIixcIm5hbWVcIjpcIkdsb2JleCBleHBhbnNpb25cIixcImFtb3VudFwiOjQ4MDAwLFwic3RhZ2VcIjpcInJldmlld1wiLFwicGFzdERpc2NvdW50c1wiOltcIjEwJSBvbiByZW5ld2FsIGluIDIwMjVcIl0sXCJwYXltZW50UmVjb3JkXCI6XCJwYWlkIG9uIHRpbWUgZm9yIDMgeWVhcnNcIixcImFwcHJvdmFsc1wiOltcInNhbGVzIG1hbmFnZXJcIl19In0KCg==" + } + }, + { + "kind": "event", + "t": 10493.074, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786804.928364, + "dataLength": 421, + "encodedDataLength": 434, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiWWVzIn0KCmRhdGE6IHsidHlwZSI6IlRFWFRfTUVTU0FHRV9DSFVOSyIsInJvbGUiOiJhc3Npc3RhbnQiLCJtZXNzYWdlSWQiOiJtc2dfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZGI1OWE0ODdkMDlhM2U5NWNlNjY3MzVjNTUiLCJkZWx0YSI6IuKAlCJ9CgpkYXRhOiB7InR5cGUiOiJURVhUX01FU1NBR0VfQ0hVTksiLCJyb2xlIjoiYXNzaXN0YW50IiwibWVzc2FnZUlkIjoibXNnXzBjNWNkZWYzMDMwMzhjNjQwMDZhZDdmOGRiNTlhNDg3ZDA5YTNlOTVjZTY2NzM1YzU1IiwiZGVsdGEiOiJhcHByb3ZlIn0KCg==" + } + }, + { + "kind": "event", + "t": 10509.026, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786804.944276, + "dataLength": 141, + "encodedDataLength": 147, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIGRlYWwifQoK" + } + }, + { + "kind": "event", + "t": 10547.4, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786804.982936, + "dataLength": 137, + "encodedDataLength": 143, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiLSJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10552.113, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786804.987537, + "dataLength": 276, + "encodedDataLength": 283, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiMiJ9CgpkYXRhOiB7InR5cGUiOiJURVhUX01FU1NBR0VfQ0hVTksiLCJyb2xlIjoiYXNzaXN0YW50IiwibWVzc2FnZUlkIjoibXNnXzBjNWNkZWYzMDMwMzhjNjQwMDZhZDdmOGRiNTlhNDg3ZDA5YTNlOTVjZTY2NzM1YzU1IiwiZGVsdGEiOiIgYXQifQoK" + } + }, + { + "kind": "event", + "t": 10553.679, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786804.98926, + "dataLength": 276, + "encodedDataLength": 283, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiICQifQoKZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiNDgifQoK" + } + }, + { + "kind": "event", + "t": 10579.558, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.014946, + "dataLength": 137, + "encodedDataLength": 143, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiLCJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10621.117, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.056283, + "dataLength": 281, + "encodedDataLength": 288, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiMDAwIn0KCmRhdGE6IHsidHlwZSI6IlRFWFRfTUVTU0FHRV9DSFVOSyIsInJvbGUiOiJhc3Npc3RhbnQiLCJtZXNzYWdlSWQiOiJtc2dfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZGI1OWE0ODdkMDlhM2U5NWNlNjY3MzVjNTUiLCJkZWx0YSI6IiBnaXZlbiJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10673.993, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.108446, + "dataLength": 141, + "encodedDataLength": 147, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIEdsb2IifQoK" + } + }, + { + "kind": "event", + "t": 10724.776, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.159549, + "dataLength": 138, + "encodedDataLength": 144, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiZXgifQoK" + } + }, + { + "kind": "event", + "t": 10736.716, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.171597, + "dataLength": 140, + "encodedDataLength": 146, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoi4oCZcyJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10770.667, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.205963, + "dataLength": 139, + "encodedDataLength": 145, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIG9uIn0KCg==" + } + }, + { + "kind": "event", + "t": 10783.738, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.219113, + "dataLength": 141, + "encodedDataLength": 147, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiLXRpbWUifQoK" + } + }, + { + "kind": "event", + "t": 10786.528, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.220896, + "dataLength": 287, + "encodedDataLength": 294, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHBheW1lbnQifQoKZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHJlY29yZCJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10810.745, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.246034, + "dataLength": 140, + "encodedDataLength": 146, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIGZvciJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10811.893, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.2467, + "dataLength": 137, + "encodedDataLength": 143, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiICJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10819.989, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.255535, + "dataLength": 137, + "encodedDataLength": 143, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiMyJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10823.036, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.258349, + "dataLength": 282, + "encodedDataLength": 289, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHllYXJzIn0KCmRhdGE6IHsidHlwZSI6IlRFWFRfTUVTU0FHRV9DSFVOSyIsInJvbGUiOiJhc3Npc3RhbnQiLCJtZXNzYWdlSWQiOiJtc2dfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZGI1OWE0ODdkMDlhM2U5NWNlNjY3MzVjNTUiLCJkZWx0YSI6IiBhbmQifQoK" + } + }, + { + "kind": "event", + "t": 10847.551, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.282, + "dataLength": 141, + "encodedDataLength": 147, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIG9ubHkifQoK" + } + }, + { + "kind": "event", + "t": 10856.465, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.289967, + "dataLength": 138, + "encodedDataLength": 144, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIGEifQoK" + } + }, + { + "kind": "event", + "t": 10859.793, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.292595, + "dataLength": 554, + "encodedDataLength": 568, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHByaW9yIn0KCmRhdGE6IHsidHlwZSI6IlRFWFRfTUVTU0FHRV9DSFVOSyIsInJvbGUiOiJhc3Npc3RhbnQiLCJtZXNzYWdlSWQiOiJtc2dfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZGI1OWE0ODdkMDlhM2U5NWNlNjY3MzVjNTUiLCJkZWx0YSI6IiAifQoKZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiMTAifQoKZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiJSJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10891.751, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.326851, + "dataLength": 144, + "encodedDataLength": 150, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHJlbmV3YWwifQoK" + } + }, + { + "kind": "event", + "t": 10895.659, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.33068, + "dataLength": 282, + "encodedDataLength": 289, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIGRpc2NvdW50In0KCmRhdGE6IHsidHlwZSI6IlRFWFRfTUVTU0FHRV9DSFVOSyIsInJvbGUiOiJhc3Npc3RhbnQiLCJtZXNzYWdlSWQiOiJtc2dfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZGI1OWE0ODdkMDlhM2U5NWNlNjY3MzVjNTUiLCJkZWx0YSI6Ii4ifQoK" + } + }, + { + "kind": "event", + "t": 10916.346, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.350666, + "dataLength": 139, + "encodedDataLength": 145, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIEl0In0KCg==" + } + }, + { + "kind": "event", + "t": 10949.227, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.384308, + "dataLength": 144, + "encodedDataLength": 150, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIGFscmVhZHkifQoK" + } + }, + { + "kind": "event", + "t": 10961.953, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.39738, + "dataLength": 140, + "encodedDataLength": 146, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIGhhcyJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10968.082, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.403547, + "dataLength": 431, + "encodedDataLength": 444, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHNhbGVzIn0KCmRhdGE6IHsidHlwZSI6IlRFWFRfTUVTU0FHRV9DSFVOSyIsInJvbGUiOiJhc3Npc3RhbnQiLCJtZXNzYWdlSWQiOiJtc2dfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZGI1OWE0ODdkMDlhM2U5NWNlNjY3MzVjNTUiLCJkZWx0YSI6IiBtYW5hZ2VyIn0KCmRhdGE6IHsidHlwZSI6IlRFWFRfTUVTU0FHRV9DSFVOSyIsInJvbGUiOiJhc3Npc3RhbnQiLCJtZXNzYWdlSWQiOiJtc2dfMGM1Y2RlZjMwMzAzOGM2NDAwNmFkN2Y4ZGI1OWE0ODdkMDlhM2U5NWNlNjY3MzVjNTUiLCJkZWx0YSI6IiBhcHByb3ZhbCJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10985.454, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.42049, + "dataLength": 137, + "encodedDataLength": 143, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiLCJ9Cgo=" + } + }, + { + "kind": "event", + "t": 10997.384, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.431922, + "dataLength": 139, + "encodedDataLength": 145, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHNvIn0KCg==" + } + }, + { + "kind": "event", + "t": 10999.446, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.434753, + "dataLength": 139, + "encodedDataLength": 145, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIGl0In0KCg==" + } + }, + { + "kind": "event", + "t": 11003.225, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.437814, + "dataLength": 142, + "encodedDataLength": 148, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIGxvb2tzIn0KCg==" + } + }, + { + "kind": "event", + "t": 11037.679, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.472814, + "dataLength": 140, + "encodedDataLength": 146, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIGxvdyJ9Cgo=" + } + }, + { + "kind": "event", + "t": 11047.439, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.48255, + "dataLength": 141, + "encodedDataLength": 147, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHJpc2sifQoK" + } + }, + { + "kind": "event", + "t": 11049.162, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.484554, + "dataLength": 139, + "encodedDataLength": 145, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHRvIn0KCg==" + } + }, + { + "kind": "event", + "t": 11070.193, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.504891, + "dataLength": 144, + "encodedDataLength": 150, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiIHByb2NlZWQifQoK" + } + }, + { + "kind": "event", + "t": 11095.859, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.531195, + "dataLength": 137, + "encodedDataLength": 143, + "data": "ZGF0YTogeyJ0eXBlIjoiVEVYVF9NRVNTQUdFX0NIVU5LIiwicm9sZSI6ImFzc2lzdGFudCIsIm1lc3NhZ2VJZCI6Im1zZ18wYzVjZGVmMzAzMDM4YzY0MDA2YWQ3ZjhkYjU5YTQ4N2QwOWEzZTk1Y2U2NjczNWM1NSIsImRlbHRhIjoiLiJ9Cgo=" + } + }, + { + "kind": "event", + "t": 11307.763, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.741919, + "dataLength": 320, + "encodedDataLength": 332, + "data": "ZGF0YTogeyJ0eXBlIjoiUlVOX0ZJTklTSEVEIiwidGhyZWFkSWQiOiIwMzhhNzYyNy1kYzExLTQ0YjMtYWNmYS1lNTMyYjllYzg1YzUiLCJydW5JZCI6IjNlMTQzNTU5LTdhZGItNDQ4My1hNzY3LTEwOTlkZTkyMmY5MCIsIm1ldGFkYXRhIjp7ImZpbmlzaFJlYXNvbiI6InN0b3AifSwidXNhZ2UiOlt7InByb3ZpZGVyIjoib3BlbmFpLnJlc3BvbnNlcyIsIm1vZGVsIjoiZ3B0LTUuMiIsImlucHV0VG9rZW5zIjo2MjMsIm91dHB1dFRva2VucyI6OTAsInRvdGFsVG9rZW5zIjo3MTMsInJlYXNvbmluZ1Rva2VucyI6MTQsImNhY2hlZElucHV0VG9rZW5zIjowfV19Cgo=" + } + }, + { + "kind": "event", + "t": 11307.773, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "72164.4", + "timestamp": 1786805.741869, + "encodedDataLength": 9465 + } + }, + { + "kind": "mark", + "t": 13362.741, + "wall": 1792538846242, + "name": "click", + "data": { + "name": "ws", + "selector": "#ws", + "text": "Ask over a WebSocket" + } + }, + { + "kind": "event", + "t": 13366.773, + "session": "page", + "method": "Network.webSocketCreated", + "params": { "requestId": "72164.5", "url": "ws://127.0.0.1:7631/rig/ws" } + }, + { + "kind": "event", + "t": 13383.976, + "session": "page", + "method": "Network.webSocketWillSendHandshakeRequest", + "params": { + "requestId": "72164.5", + "timestamp": 1786807.819197, + "wallTime": 1792538846.263223, + "request": { + "headers": { + "Upgrade": "websocket", + "Origin": "http://127.0.0.1:7631", + "Cache-Control": "no-cache", + "Accept-Language": "en-US,en;q=0.9", + "Pragma": "no-cache", + "Connection": "Upgrade", + "Sec-WebSocket-Key": "RtRQz4Vnrji6Rqjf0IA0Zg==", + "Accept-Encoding": "gzip, deflate, br, zstd", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Sec-WebSocket-Version": "13", + "Host": "127.0.0.1:7631", + "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits" + } + } + } + }, + { + "kind": "event", + "t": 13383.999, + "session": "page", + "method": "Network.webSocketHandshakeResponseReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786807.819231, + "response": { + "status": 101, + "statusText": "Switching Protocols", + "headers": { + "Upgrade": "websocket", + "Date": "Tue, 20 Oct 2026 23:27:25 GMT", + "Sec-WebSocket-Accept": "ld8e3z8ewZ9jB9E3P1+uKcHlsRM=", + "Connection": "Upgrade" + } + } + } + }, + { + "kind": "event", + "t": 13384.015, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "72164.5", + "timestamp": 1786807.819348, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"threadId\":\"51cca0ba-7d7e-4beb-b01d-d3bfc7c9a5e1\",\"runId\":\"2aca4e42-a8f7-4daf-bf13-f0459c560581\",\"messages\":[{\"id\":\"b94c785e-c554-4f55-ba91-322ea9736762\",\"role\":\"user\",\"content\":\"Should we approve deal-2 at its amount?\"}],\"state\":{},\"tools\":[],\"context\":[{\"description\":\"The deal the user is looking at\",\"value\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"}],\"forwardedProps\":{}}" + } + } + }, + { + "kind": "event", + "t": 13387.929, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786807.823489, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"RUN_STARTED\",\"threadId\":\"51cca0ba-7d7e-4beb-b01d-d3bfc7c9a5e1\",\"runId\":\"2aca4e42-a8f7-4daf-bf13-f0459c560581\",\"protocolVersion\":\"1.0\"}" + } + } + }, + { + "kind": "event", + "t": 14152.656, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786808.587961, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"REASONING_START\",\"messageId\":\"rs_01c4ceadc005f720006ad7f8df0ca887d08c63ace50c7e5ae8:0\"}" + } + } + }, + { + "kind": "event", + "t": 14152.715, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786808.588028, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"REASONING_MESSAGE_START\",\"messageId\":\"rs_01c4ceadc005f720006ad7f8df0ca887d08c63ace50c7e5ae8:0\",\"role\":\"reasoning\"}" + } + } + }, + { + "kind": "event", + "t": 15081.458, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.516271, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"REASONING_MESSAGE_END\",\"messageId\":\"rs_01c4ceadc005f720006ad7f8df0ca887d08c63ace50c7e5ae8:0\"}" + } + } + }, + { + "kind": "event", + "t": 15081.473, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.516337, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"REASONING_END\",\"messageId\":\"rs_01c4ceadc005f720006ad7f8df0ca887d08c63ace50c7e5ae8:0\"}" + } + } + }, + { + "kind": "event", + "t": 15082.379, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.517809, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_START\",\"parentMessageId\":\"7c2f3ab0-ffd7-4071-a0ee-2d0232eb22d9\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"toolCallName\":\"lookupDealHistory\"}" + } + } + }, + { + "kind": "event", + "t": 15086.676, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.522005, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"delta\":\"{\\\"\"}" + } + } + }, + { + "kind": "event", + "t": 15088.994, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.524559, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"delta\":\"deal\"}" + } + } + }, + { + "kind": "event", + "t": 15093.068, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.528587, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"delta\":\"Id\"}" + } + } + }, + { + "kind": "event", + "t": 15100.85, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.536517, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"delta\":\"\\\":\\\"\"}" + } + } + }, + { + "kind": "event", + "t": 15125.402, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.560487, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"delta\":\"deal\"}" + } + } + }, + { + "kind": "event", + "t": 15145.02, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.579729, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"delta\":\"-\"}" + } + } + }, + { + "kind": "event", + "t": 15150.54, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.585783, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"delta\":\"2\"}" + } + } + }, + { + "kind": "event", + "t": 15152.566, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.588009, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"delta\":\"\\\"}\"}" + } + } + }, + { + "kind": "event", + "t": 15152.701, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.58827, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\"}" + } + } + }, + { + "kind": "event", + "t": 15152.709, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786809.58829, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TOOL_CALL_RESULT\",\"role\":\"tool\",\"messageId\":\"8f6182bc-b391-490b-a473-9eff2fc0df55\",\"toolCallId\":\"call_ApsPUxUC3XprMFwS6KyFi5kb\",\"content\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"name\\\":\\\"Globex expansion\\\",\\\"amount\\\":48000,\\\"stage\\\":\\\"review\\\",\\\"pastDiscounts\\\":[\\\"10% on renewal in 2025\\\"],\\\"paymentRecord\\\":\\\"paid on time for 3 years\\\",\\\"approvals\\\":[\\\"sales manager\\\"]}\"}" + } + } + }, + { + "kind": "event", + "t": 16024.631, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.459976, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"Deal\"}" + } + } + }, + { + "kind": "event", + "t": 16058.793, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.493849, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"-\"}" + } + } + }, + { + "kind": "event", + "t": 16058.99, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.493966, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"2\"}" + } + } + }, + { + "kind": "event", + "t": 16059.001, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.494339, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" (\"}" + } + } + }, + { + "kind": "event", + "t": 16090.154, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.524445, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"Glob\"}" + } + } + }, + { + "kind": "event", + "t": 16093.596, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.52913, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"ex\"}" + } + } + }, + { + "kind": "event", + "t": 16095.127, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.530732, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" expansion\"}" + } + } + }, + { + "kind": "event", + "t": 16095.135, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.530775, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\")\"}" + } + } + }, + { + "kind": "event", + "t": 16095.137, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.53079, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" looks\"}" + } + } + }, + { + "kind": "event", + "t": 16131.162, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.565697, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" safe\"}" + } + } + }, + { + "kind": "event", + "t": 16163.963, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.599584, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" to\"}" + } + } + }, + { + "kind": "event", + "t": 16165.921, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.601598, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" approve\"}" + } + } + }, + { + "kind": "event", + "t": 16169.713, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.60525, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" at\"}" + } + } + }, + { + "kind": "event", + "t": 16199.507, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.634981, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" $\"}" + } + } + }, + { + "kind": "event", + "t": 16210.499, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.646104, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"48\"}" + } + } + }, + { + "kind": "event", + "t": 16236.394, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.671899, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\",\"}" + } + } + }, + { + "kind": "event", + "t": 16246.604, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.68131, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"000\"}" + } + } + }, + { + "kind": "event", + "t": 16273.394, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.708991, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" given\"}" + } + } + }, + { + "kind": "event", + "t": 16308.298, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.743442, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" the\"}" + } + } + }, + { + "kind": "event", + "t": 16348.365, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.7828, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" customer\"}" + } + } + }, + { + "kind": "event", + "t": 16355.412, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.790687, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" has\"}" + } + } + }, + { + "kind": "event", + "t": 16359.01, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.794192, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" paid\"}" + } + } + }, + { + "kind": "event", + "t": 16364.875, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.800542, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" on\"}" + } + } + }, + { + "kind": "event", + "t": 16364.887, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.800587, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" time\"}" + } + } + }, + { + "kind": "event", + "t": 16380.869, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.816062, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" for\"}" + } + } + }, + { + "kind": "event", + "t": 16382.768, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.818208, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" \"}" + } + } + }, + { + "kind": "event", + "t": 16395.788, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.831071, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"3\"}" + } + } + }, + { + "kind": "event", + "t": 16395.794, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.831118, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" years\"}" + } + } + }, + { + "kind": "event", + "t": 16395.796, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.831134, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" and\"}" + } + } + }, + { + "kind": "event", + "t": 16421.929, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.857392, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" only\"}" + } + } + }, + { + "kind": "event", + "t": 16428.455, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.863376, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" a\"}" + } + } + }, + { + "kind": "event", + "t": 16433.495, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.86895, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" prior\"}" + } + } + }, + { + "kind": "event", + "t": 16435.269, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.870201, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" \"}" + } + } + }, + { + "kind": "event", + "t": 16435.283, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.87024, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"10\"}" + } + } + }, + { + "kind": "event", + "t": 16435.285, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.870258, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"%\"}" + } + } + }, + { + "kind": "event", + "t": 16458.103, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.893588, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" renewal\"}" + } + } + }, + { + "kind": "event", + "t": 16460.318, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.895716, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" discount\"}" + } + } + }, + { + "kind": "event", + "t": 16460.325, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.895783, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" in\"}" + } + } + }, + { + "kind": "event", + "t": 16460.328, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.895804, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" \"}" + } + } + }, + { + "kind": "event", + "t": 16493.002, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.928112, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"202\"}" + } + } + }, + { + "kind": "event", + "t": 16504.171, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.939602, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"5\"}" + } + } + }, + { + "kind": "event", + "t": 16506.969, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.942495, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\".\"}" + } + } + }, + { + "kind": "event", + "t": 16525.925, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786810.96157, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" It\"}" + } + } + }, + { + "kind": "event", + "t": 16564.61, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.000078, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" already\"}" + } + } + }, + { + "kind": "event", + "t": 16577.254, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.012555, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" has\"}" + } + } + }, + { + "kind": "event", + "t": 16608.655, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.043725, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" sales\"}" + } + } + }, + { + "kind": "event", + "t": 16621.464, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.056937, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" manager\"}" + } + } + }, + { + "kind": "event", + "t": 16624.115, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.05953, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" approval\"}" + } + } + }, + { + "kind": "event", + "t": 16652.785, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.087934, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\",\"}" + } + } + }, + { + "kind": "event", + "t": 16662.602, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.098037, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" so\"}" + } + } + }, + { + "kind": "event", + "t": 16688.612, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.123935, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" I\"}" + } + } + }, + { + "kind": "event", + "t": 16699.097, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.134548, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" recommend\"}" + } + } + }, + { + "kind": "event", + "t": 16742.008, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.176837, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" approving\"}" + } + } + }, + { + "kind": "event", + "t": 16757.072, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.191362, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" unless\"}" + } + } + }, + { + "kind": "event", + "t": 16757.079, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.191418, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" finance\"}" + } + } + }, + { + "kind": "event", + "t": 16790.26, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.224379, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\"/legal\"}" + } + } + }, + { + "kind": "event", + "t": 16827.113, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.260059, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" flags\"}" + } + } + }, + { + "kind": "event", + "t": 16832.199, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.267827, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" something\"}" + } + } + }, + { + "kind": "event", + "t": 16861.591, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.295437, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" outside\"}" + } + } + }, + { + "kind": "event", + "t": 16873.653, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.309292, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" this\"}" + } + } + }, + { + "kind": "event", + "t": 16900.328, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.334347, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\" history\"}" + } + } + }, + { + "kind": "event", + "t": 16905.581, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.341106, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_01c4ceadc005f720006ad7f8e0eb8487d080fb1a2c6387eab2\",\"delta\":\".\"}" + } + } + }, + { + "kind": "event", + "t": 17148.538, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "72164.5", + "timestamp": 1786811.58384, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"type\":\"RUN_FINISHED\",\"threadId\":\"51cca0ba-7d7e-4beb-b01d-d3bfc7c9a5e1\",\"runId\":\"2aca4e42-a8f7-4daf-bf13-f0459c560581\",\"metadata\":{\"finishReason\":\"stop\"},\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-5.2\",\"inputTokens\":627,\"outputTokens\":108,\"totalTokens\":735,\"reasoningTokens\":18,\"cachedInputTokens\":0}]}" + } + } + }, + { + "kind": "event", + "t": 17148.884, + "session": "page", + "method": "Network.webSocketClosed", + "params": { "requestId": "72164.5", "timestamp": 1786811.58433 } + }, + { + "kind": "mark", + "t": 19167.851, + "wall": 1792538852047, + "name": "click", + "data": { + "name": "ticker", + "selector": "#ticker", + "text": "Watch prices" + } + }, + { + "kind": "event", + "t": 19176.426, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "72164.6", + "loaderId": "CC639E774C1017352E058AA4E237E804", + "documentURL": "http://127.0.0.1:7631/rig/page", + "request": { + "url": "http://127.0.0.1:7631/rig/ticker", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7631/rig/page", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Accept": "text/event-stream", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1786813.611303, + "wallTime": 1792538852.055368, + "type": "EventSource", + "frameId": "E16845A14AFEB2A6A172A1AAF8A7F432", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 19181.172, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "72164.6", + "loaderId": "CC639E774C1017352E058AA4E237E804", + "timestamp": 1786813.616444, + "type": "EventSource", + "response": { + "url": "http://127.0.0.1:7631/rig/ticker", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + "Date": "Tue, 20 Oct 2026 23:27:31 GMT", + "Content-Type": "text/event-stream" + }, + "mimeType": "text/event-stream" + }, + "hasExtraInfo": true, + "frameId": "E16845A14AFEB2A6A172A1AAF8A7F432" + } + }, + { + "kind": "command", + "t": 19181.201, + "session": "page", + "method": "Network.streamResourceContent", + "params": { "requestId": "72164.6" }, + "tEnd": 19181.429, + "result": { + "bufferedData": "OiBwcmljZXMNCnJldHJ5OiA1MDAwDQoNCmV2ZW50OiBwcmljZQ0KaWQ6IDENCmRhdGE6IHsic3ltYm9sIjoiR0xCWCIsDQpkYXRhOiAicHJpY2UiOjEwMX0NCg0K" + } + }, + { + "kind": "event", + "t": 19181.233, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.6", + "timestamp": 1786813.616468, + "dataLength": 93, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 19444.991, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.6", + "timestamp": 1786813.877994, + "dataLength": 68, + "encodedDataLength": 99, + "data": "ZXZlbnQ6IHByaWNlDQppZDogMg0KZGF0YTogeyJzeW1ib2wiOiJHTEJYIiwNCmRhdGE6ICJwcmljZSI6MTAyfQ0KDQo=" + } + }, + { + "kind": "event", + "t": 19695.856, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.6", + "timestamp": 1786814.129873, + "dataLength": 68, + "encodedDataLength": 74, + "data": "ZXZlbnQ6IHByaWNlDQppZDogMw0KZGF0YTogeyJzeW1ib2wiOiJHTEJYIiwNCmRhdGE6ICJwcmljZSI6MTAzfQ0KDQo=" + } + }, + { + "kind": "event", + "t": 19945.496, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.6", + "timestamp": 1786814.380914, + "dataLength": 68, + "encodedDataLength": 74, + "data": "ZXZlbnQ6IHByaWNlDQppZDogNA0KZGF0YTogeyJzeW1ib2wiOiJHTEJYIiwNCmRhdGE6ICJwcmljZSI6MTA0fQ0KDQo=" + } + }, + { + "kind": "event", + "t": 20196.313, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.6", + "timestamp": 1786814.631559, + "dataLength": 68, + "encodedDataLength": 74, + "data": "ZXZlbnQ6IHByaWNlDQppZDogNQ0KZGF0YTogeyJzeW1ib2wiOiJHTEJYIiwNCmRhdGE6ICJwcmljZSI6MTA1fQ0KDQo=" + } + }, + { + "kind": "event", + "t": 20462.177, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.6", + "timestamp": 1786814.889005, + "dataLength": 68, + "encodedDataLength": 74, + "data": "ZXZlbnQ6IHByaWNlDQppZDogNg0KZGF0YTogeyJzeW1ib2wiOiJHTEJYIiwNCmRhdGE6ICJwcmljZSI6MTA2fQ0KDQo=" + } + }, + { + "kind": "event", + "t": 20462.188, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "72164.6", + "timestamp": 1786814.889557, + "dataLength": 0, + "encodedDataLength": 79 + } + }, + { + "kind": "event", + "t": 20462.194, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "72164.6", + "timestamp": 1786814.888987, + "encodedDataLength": 616 + } + } + ] +} diff --git a/extension/tests/fixtures/agui/intelligence-websocket.json b/extension/tests/fixtures/agui/intelligence-websocket.json new file mode 100644 index 000000000..fde392acb --- /dev/null +++ b/extension/tests/fixtures/agui/intelligence-websocket.json @@ -0,0 +1,6527 @@ +{ + "description": "CopilotKit main (c21eeda029) v2 react demo, learning deal page through a local CopilotKit Intelligence (runtime in Intelligence mode: run POST answers join credentials, AG-UI arrives as Phoenix V2 ag_ui_event frames on thread:), real BuiltInAgent on OpenAI gpt-5.2, real Chrome over CDP: Send, then Approve.", + "browser": "Chrome/156.0.8078.47", + "entries": [ + { + "kind": "mark", + "t": 17.452, + "wall": 1792538644126, + "name": "navigate", + "data": { "url": "http://127.0.0.1:7621/learning/deals/deal-2" } + }, + { + "kind": "event", + "t": 18.924, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/learning/deals/deal-2", + "method": "GET", + "headers": { + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"macOS\"" + } + }, + "timestamp": 1786605.680894, + "wallTime": 1792538644.127591, + "type": "Document", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 95.435, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.757379, + "type": "Document", + "response": { + "url": "http://127.0.0.1:7621/learning/deals/deal-2", + "status": 200, + "statusText": "OK", + "headers": { + "Cache-Control": "no-store, must-revalidate", + "Connection": "keep-alive", + "Content-Encoding": "gzip", + "Content-Type": "text/html; charset=utf-8", + "Date": "Tue, 20 Oct 2026 23:24:04 GMT", + "Keep-Alive": "timeout=5", + "Transfer-Encoding": "chunked", + "Vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding", + "X-Powered-By": "Next.js", + "link": "; rel=preload; as=\"font\"; crossorigin=\"\"; type=\"font/woff2\", ; rel=preload; as=\"font\"; crossorigin=\"\"; type=\"font/woff2\"" + }, + "mimeType": "text/html" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 100.586, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.76182, + "dataLength": 11022, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 101.149, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.762516, + "dataLength": 8763, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 102.152, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.763798, + "dataLength": 4634, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 102.153, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.763878, + "dataLength": 2360, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 102.997, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.764955, + "dataLength": 2160, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 104.171, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.766039, + "dataLength": 1391, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 105.343, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.766698, + "dataLength": 1092, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 105.884, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.7679, + "dataLength": 50, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 107.61, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.76953, + "dataLength": 6195, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 109.071, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.771048, + "dataLength": 3662, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 120.617, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.771395, + "encodedDataLength": 12193 + } + }, + { + "kind": "event", + "t": 264.689, + "session": "page", + "method": "Network.webSocketCreated", + "params": { + "requestId": "68328.107", + "url": "ws://127.0.0.1:7621/_next/webpack-hmr" + } + }, + { + "kind": "event", + "t": 265.082, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.110", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/learning-events", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"trajectoryId\":\"542cc753-7048-4fb6-8049-e8d96ecad7b4\",\"events\":[{\"type\":\"CUSTOM\",\"name\":\"page\",\"value\":{\"route\":\"/learning/deals/deal-2\",\"url\":\"http://127.0.0.1:7621/learning/deals/deal-2\",\"title\":\"Create Next App\",\"referrer\":\"\",\"seq\":1},\"timestamp\":1792538644368},{\"type\":\"CUSTOM\",\"name\":\"thread.linked\",\"value\":{\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"hasMessages\":false,\"reason\":\"start\",\"seq\":2},\"timestamp\":1792538644368}],\"dropped\":0}", + "hasPostData": true + }, + "timestamp": 1786605.924041, + "wallTime": 1792538644.371146, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 265.158, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.111", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"info\"}", + "hasPostData": true + }, + "timestamp": 1786605.926414, + "wallTime": 1792538644.373072, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 265.178, + "session": "page", + "method": "Network.webSocketWillSendHandshakeRequest", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.926678, + "wallTime": 1792538644.373317, + "request": { + "headers": { + "Upgrade": "websocket", + "Origin": "http://127.0.0.1:7621", + "Cache-Control": "no-cache", + "Accept-Language": "en-US,en;q=0.9", + "Pragma": "no-cache", + "Connection": "Upgrade", + "Sec-WebSocket-Key": "BPF3rFeOszAsClfIyo26iQ==", + "Accept-Encoding": "gzip, deflate, br, zstd", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Sec-WebSocket-Version": "13", + "Host": "127.0.0.1:7621", + "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits" + } + } + } + }, + { + "kind": "event", + "t": 276.128, + "session": "page", + "method": "Network.webSocketHandshakeResponseReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.937222, + "response": { + "status": 101, + "statusText": "Switching Protocols", + "headers": { + "Upgrade": "websocket", + "Sec-WebSocket-Accept": "PkK5go7dyj2v819orPswlna9FPs=", + "Connection": "Upgrade" + } + } + } + }, + { + "kind": "event", + "t": 278.137, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.120", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/__nextjs_original-stack-frames", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "text/plain;charset=UTF-8", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"frames\":[{\"file\":\"https://react.dev/link/hydration-mismatch\",\"line1\":null,\"column1\":null,\"methodName\":\"\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":2892,\"column1\":56,\"methodName\":\"throwOnHydrationMismatch\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":2946,\"column1\":23,\"methodName\":\"prepareToHydrateHostInstance\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":6330,\"column1\":60,\"methodName\":\"completeWork\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":886,\"column1\":74,\"methodName\":\"runWithFiberInDEV\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":8363,\"column1\":23,\"methodName\":\"completeUnitOfWork\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":8300,\"column1\":28,\"methodName\":\"performUnitOfWork\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":8294,\"column1\":58,\"methodName\":\"workLoopConcurrentByScheduler\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":8276,\"column1\":71,\"methodName\":\"renderRootConcurrent\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":7908,\"column1\":176,\"methodName\":\"performWorkOnRoot\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\",\"line1\":8882,\"column1\":9,\"methodName\":\"performWorkOnRootViaSchedulerTask\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_8dc561c9._.js\",\"line1\":2601,\"column1\":64,\"methodName\":\"MessagePort.performWorkUntilDeadline\",\"arguments\":[]},{\"file\":\"\",\"line1\":null,\"column1\":null,\"methodName\":\"code\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/_568cbc5a._.js\",\"line1\":316,\"column1\":438,\"methodName\":\"DealView\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/_568cbc5a._.js\",\"line1\":128,\"column1\":428,\"methodName\":\"DealPage\",\"arguments\":[]},{\"file\":\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_a36fb2aa._.js\",\"line1\":2639,\"column1\":50,\"methodName\":\"ClientPageRoot\",\"arguments\":[]}],\"isServer\":false,\"isEdgeServer\":false,\"isAppDirectory\":true}", + "hasPostData": true + }, + "timestamp": 1786605.938413, + "wallTime": 1792538644.38603, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 278.956, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.940772, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_a0ff3932._.js\"}" + } + } + }, + { + "kind": "event", + "t": 278.961, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.940794, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_src_app_favicon_ico_mjs_282d3dd3._.js\"}" + } + } + }, + { + "kind": "event", + "t": 278.963, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.940799, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_src_app_layout_tsx_b60fa84a._.js\"}" + } + } + }, + { + "kind": "event", + "t": 278.964, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.940804, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_src_app_learning_layout_tsx_54925341._.js\"}" + } + } + }, + { + "kind": "event", + "t": 278.966, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.940808, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/59a95_next_dist_client_components_builtin_global-error_54925341.js\"}" + } + } + }, + { + "kind": "event", + "t": 279.015, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.940812, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_src_app_learning_deals_[id]_page_tsx_5c3fbae3._.js\"}" + } + } + }, + { + "kind": "event", + "t": 279.018, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.940816, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/[turbopack]_browser_dev_hmr-client_hmr-client_ts_2dcac3f6._.js\"}" + } + } + }, + { + "kind": "event", + "t": 279.03, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.940864, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"isrManifest\",\"data\":{\"/learning/deals/deal-2\":true,\"/learning/deals/deal-1\":true}}" + } + } + }, + { + "kind": "event", + "t": 279.308, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941245, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"turbopack-connected\",\"data\":{\"sessionId\":3398307161546938}}" + } + } + }, + { + "kind": "event", + "t": 279.31, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941282, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_a0ff3932._.js\",\"headers\":null}" + } + } + }, + { + "kind": "event", + "t": 279.311, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941291, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_src_app_favicon_ico_mjs_282d3dd3._.js\",\"headers\":null}" + } + } + }, + { + "kind": "event", + "t": 279.312, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941297, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_src_app_layout_tsx_b60fa84a._.js\",\"headers\":null}" + } + } + }, + { + "kind": "event", + "t": 279.322, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941301, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_src_app_learning_layout_tsx_54925341._.js\",\"headers\":null}" + } + } + }, + { + "kind": "event", + "t": 279.324, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941304, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/59a95_next_dist_client_components_builtin_global-error_54925341.js\",\"headers\":null}" + } + } + }, + { + "kind": "event", + "t": 279.325, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941306, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/examples_v2_react_demo_src_app_learning_deals_[id]_page_tsx_5c3fbae3._.js\",\"headers\":null}" + } + } + }, + { + "kind": "event", + "t": 279.326, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941308, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/[turbopack]_browser_dev_hmr-client_hmr-client_ts_2dcac3f6._.js\",\"headers\":null}" + } + } + }, + { + "kind": "event", + "t": 279.329, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941329, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"sync\",\"errors\":[],\"warnings\":[],\"hash\":\"\",\"versionInfo\":{\"staleness\":\"stale-major\",\"expected\":\"16.4.2\",\"installed\":\"15.5.24\"},\"debug\":{},\"devIndicator\":{\"disabledUntil\":0},\"devToolsConfig\":{}}" + } + } + }, + { + "kind": "event", + "t": 279.33, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.941363, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 300.977, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786605.962809, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/packages_web-inspector_dist_index_mjs_985f41af._.js\"}" + } + } + }, + { + "kind": "event", + "t": 321.883, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.122", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "https://cdn.copilotkit.ai/notifications/v1.json?framework=react&sdkVersion=1.79.1", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1786605.980322, + "wallTime": 1792538644.426964, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 321.889, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.123", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "https://cdn.copilotkit.ai/inspector-hud/v1.json", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1786605.983617, + "wallTime": 1792538644.430256, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 330.141, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.122", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786605.991915, + "type": "Fetch", + "response": { + "url": "https://cdn.copilotkit.ai/notifications/v1.json?framework=react&sdkVersion=1.79.1", + "status": 200, + "statusText": "", + "headers": { + "access-control-expose-headers": "*", + "content-encoding": "br", + "x-amz-version-id": "LRhw8lMei1VM_0O9kRbha_LErga0kp7n", + "age": "30", + "etag": "W/\"6d06faed4323b12c1d1dad2cb02e8338\"", + "x-cache": "Hit from cloudfront", + "x-amz-cf-id": "DD_6TLygvm8L2kKQ7ypSjnnHzaMM-4jr7DYJc1-wrUuHl2DMdPfIxg==", + "date": "Tue, 20 Oct 2026 23:24:04 GMT", + "content-type": "application/json", + "vary": "Accept-Encoding", + "last-modified": "Mon, 19 Oct 2026 17:16:43 GMT", + "cache-control": "public,max-age=300", + "via": "1.1 b54238be18861f9bb1272bf1cb10e040.cloudfront.net (CloudFront)", + "access-control-allow-origin": "*", + "x-amz-cf-pop": "SEA900-P9", + "server": "AmazonS3", + "x-amz-server-side-encryption": "AES256" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 330.147, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.122", + "timestamp": 1786605.9921, + "dataLength": 5548, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 330.158, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.122", + "timestamp": 1786605.991868, + "encodedDataLength": 401 + } + }, + { + "kind": "command", + "t": 330.167, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.122" }, + "tEnd": 336.708, + "result": { + "body": "{\n \"schemaVersion\": 1,\n \"notifications\": [\n {\n \"id\": \"16f7d877-49e3-41c3-9ca6-f951d3d8ba80\",\n \"title\": \"The Channels SDK is live, plus CopilotKit for Angular!\",\n \"body\": \"## Now live: the Channels SDK\\n\\nThe **Channels SDK** lets you build native, stateful experiences for any agent across Slack, Microsoft Teams, and more — without the production headaches. Slack and Teams are generally available today, with Discord and WhatsApp coming soon.\\n\\nWith the Channels SDK, you can:\\n\\n- **Write once, render native everywhere** — author shared Channels JSX a single time and it renders as native Block Kit in Slack and Adaptive Cards in Microsoft Teams, so one agent architecture expands to new platforms without a rebuild\\n- **Act in context** — investigate an issue, call a tool, or move work forward with the conversation still in view\\n- **Make approvals native** — route decisions through Block Kit and Adaptive Cards instead of sending people off to another dashboard\\n\\nWorks with LangGraph, CrewAI, Microsoft Agent Framework, or any custom AG-UI agent.\\n\\n[Try a live agent in our Community Slack →](https://go.copilotkit.ai/TryChannels)\\n\\n[Read the Channels SDK launch post →](https://go.copilotkit.ai/Blog/ChannelsSDK)\\n\\n---\\n\\n## New: CopilotKit for Angular\\n\\nBring any AG-UI agent into your Angular app with **`@copilotkit/angular`**, the Angular bindings for CopilotKit — built on native Angular patterns and signals.\\n\\nCopilotKit for Angular includes:\\n\\n- **Drop-in chat UI** — add `` with compose, suggestions, and attachments already wired up\\n- **Generative UI and A2UI** — agent-generated component trees render directly in the chat stream through registered tools\\n- **Shared agent state** — expose trusted Angular signals to the agent with `injectAgentStore()` and register narrow frontend mutations\\n\\n[Explore the Angular resources →](https://go.copilotkit.ai/angular-resources-page)\\n\\n[Open the Angular docs →](https://go.copilotkit.ai/angular-docs)\\n\\n---\\n\\n## Also new\\n\\nCopilotKit v1.67 also includes:\\n\\n- A **reorganized Web Inspector** — navigation is now grouped into Threads, Agents, and Learning, and the Threads sidebar gains a usage footer\\n- A **bounded in-memory agent runner**, so the default runner no longer grows until the process runs out of memory\\n- **`fullHeightChildren` on `CopilotSidebar`**, an opt-in prop that lets sidebar content fill the viewport\\n\\n[Read the v1.67 release notes →](https://go.copilotkit.ai/copilotkit-release-notes)\",\n \"publishedAt\": \"2026-08-13T13:00:00.000Z\",\n \"audiences\": [\n {}\n ],\n \"priority\": \"Normal\"\n },\n {\n \"id\": \"2e930b26-c970-484a-b340-a2cb28f20224\",\n \"title\": \"AG-UI 1.0 is here, plus OpenBot and OpenMuse!\",\n \"body\": \"## AG-UI 1.0 is here\\n\\nAG-UI, the protocol that connects agents to user-facing apps, has reached **1.0**. The spec is also published as a JSON Schema, and every SDK is generated from it, so TypeScript, Python, and .NET all speak exactly the same protocol.\\n\\nWhat's new in 1.0:\\n\\n- **Subagents**: new events show which agent did what when work is delegated, so your UI can give each subagent its own card while it works\\n- **Human-in-the-loop interrupts**: a run can pause to ask the user something, and the next run picks up right where it left off\\n- **Multimodal tool results**: tools can return images, audio, video, and documents, not just text\\n- **Metadata** for sending custom data from your agent to the frontend\\n- **Token usage** on every run, so you can show what each run cost, subagents included\\n- **Reasoning and activity events** for \\\"Thinking...\\\" panels and live progress widgets\\n- **Backwards compatible**: your existing agents and apps keep working\\n\\n[Read the developer’s guide to AG-UI →](https://go.copilotkit.ai/dev-guide-to-ag-ui)\\n\\n[Open the AG-UI docs →](https://docs.ag-ui.com)\\n\\n---\\n\\n## New: OpenBot\\n\\n**Open-source AI coworkers your company can actually own.** Each coworker gets a computer of its own: a real browser, its own files, and only the tools you grant. Every action is decided before it happens and recorded after.\\n\\nBring any AG-UI agent, watch it work on its own screen, and take the wheel when it reaches something it shouldn't do alone.\\n\\n[Explore OpenBot →](https://go.copilotkit.ai/openbot)\\n\\n[Star it on GitHub →](https://github.com/CopilotKit/OpenBot)\\n\\n---\\n\\n## New: OpenMuse\\n\\n**A personal agent with a browser, terminal, files, and work that keeps going.** Ask for an outcome, follow the plan, review its actions, and come back to the result.\\n\\nBuilt with CopilotKit React Native, so it runs on iOS, Android, and the web from one codebase. Clone it and make it yours.\\n\\n[Explore OpenMuse →](https://go.copilotkit.ai/openmuse)\\n\\n[Star it on GitHub →](https://github.com/CopilotKit/openmuse)\\n\\n---\\n\\n## Also new\\n\\nRecent CopilotKit releases (v1.69 to v1.75) also include:\\n\\n- A **redesigned Inspector** with a new Home view, runtime health at a glance, and an isolated **agent Playground** where you can fork a saved Thread without touching the original\\n- **`transformMessages` on `CopilotChatMessageView`** to reshape messages before they render\\n- **More resilient SSE streaming**, including reconnects that keep their place\\n\\n[Read the release notes →](https://go.copilotkit.ai/copilotkit-release-notes)\",\n \"publishedAt\": \"2026-09-30T13:00:00.000Z\",\n \"audiences\": [\n {}\n ],\n \"priority\": \"Normal\"\n }\n ]\n}\n", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 349.01, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.123", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.010773, + "type": "Fetch", + "response": { + "url": "https://cdn.copilotkit.ai/inspector-hud/v1.json", + "status": 200, + "statusText": "", + "headers": { + "access-control-expose-headers": "*", + "x-amz-version-id": "YX5GK3rmcq6Hnxm2SBrs1KTYxHCMDE2Y", + "age": "286", + "etag": "\"5ac302b728e0b10230a5788ca452f0eb\"", + "x-cache": "Hit from cloudfront", + "x-amz-cf-id": "GphEPfBaDbdIND56vjuHEzPkYV6lZWi_xXPibAF2a9wdrJwu_medtA==", + "date": "Tue, 20 Oct 2026 23:24:04 GMT", + "content-type": "application/json", + "vary": "Accept-Encoding", + "last-modified": "Mon, 19 Oct 2026 17:17:10 GMT", + "cache-control": "public,max-age=300", + "via": "1.1 b54238be18861f9bb1272bf1cb10e040.cloudfront.net (CloudFront)", + "accept-ranges": "bytes", + "access-control-allow-origin": "*", + "content-length": "40", + "x-amz-cf-pop": "SEA900-P9", + "server": "AmazonS3", + "x-amz-server-side-encryption": "AES256" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 349.03, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.123", + "timestamp": 1786606.010942, + "dataLength": 40, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 349.032, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.123", + "timestamp": 1786606.010689, + "encodedDataLength": 401 + } + }, + { + "kind": "command", + "t": 349.047, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.123" }, + "tEnd": 349.269, + "result": { + "body": "{\n \"schemaVersion\": 1,\n \"rules\": []\n}\n", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 480.274, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.120", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.141838, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/__nextjs_original-stack-frames", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Content-Encoding": "gzip", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:04 GMT", + "Content-Type": "application/json", + "Vary": "Accept-Encoding", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 480.826, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.120", + "timestamp": 1786606.142294, + "dataLength": 6391, + "encodedDataLength": 1137 + } + }, + { + "kind": "event", + "t": 480.828, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.120", + "timestamp": 1786606.141889, + "encodedDataLength": 1352 + } + }, + { + "kind": "command", + "t": 480.837, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.120" }, + "tEnd": 481.206, + "result": { + "body": "[{\"status\":\"rejected\",\"reason\":\"[Error: Unknown url scheme 'https'\\n\\nDebug info:\\n- Execution of TaskId { id: 2147484182 } transient failed\\n- Execution of project_trace_source_operation failed\\n- Execution of *SourceMap::new_from_rope_cached failed\\n- Execution of get_source_map_rope failed\\n- Unknown url scheme 'https'] {\\n code: 'GenericFailure'\\n}\"},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":4505,\"column1\":11,\"ignored\":true,\"methodName\":\"throwOnHydrationMismatch\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":4601,\"column1\":21,\"ignored\":true,\"methodName\":\"prepareToHydrateHostInstance\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":11634,\"column1\":15,\"ignored\":true,\"methodName\":\"completeWork\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":871,\"column1\":30,\"ignored\":true,\"methodName\":\"runWithFiberInDEV\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":15862,\"column1\":19,\"ignored\":true,\"methodName\":\"completeUnitOfWork\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":15743,\"column1\":11,\"ignored\":true,\"methodName\":\"performUnitOfWork\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":15720,\"column1\":9,\"ignored\":true,\"methodName\":\"workLoopConcurrentByScheduler\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":15695,\"column1\":15,\"ignored\":true,\"methodName\":\"renderRootConcurrent\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":14989,\"column1\":13,\"ignored\":true,\"methodName\":\"performWorkOnRoot\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\",\"line1\":16815,\"column1\":7,\"ignored\":true,\"methodName\":\"performWorkOnRootViaSchedulerTask\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/scheduler/cjs/scheduler.development.js\",\"line1\":45,\"column1\":48,\"ignored\":true,\"methodName\":\"MessagePort.performWorkUntilDeadline\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"\",\"line1\":null,\"column1\":null,\"ignored\":false,\"methodName\":\"code\"},\"originalCodeFrame\":null}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"src/app/learning/deals/[id]/page.tsx\",\"line1\":129,\"column1\":24,\"ignored\":false,\"methodName\":\"DealView\"},\"originalCodeFrame\":\" 127 | \\n 128 |

\\n> 129 | Open thread: {config?.threadId}\\n | ^\\n 130 |

\\n 131 | \\n 132 |
\"}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"src/app/learning/deals/[id]/page.tsx\",\"line1\":27,\"column1\":7,\"ignored\":false,\"methodName\":\"DealPage\"},\"originalCodeFrame\":\" 25 | return (\\n 26 | \\n> 27 | \\n | ^\\n 28 | \\n 29 | );\\n 30 | }\"}},{\"status\":\"fulfilled\",\"value\":{\"originalStackFrame\":{\"arguments\":[],\"file\":\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/src/client/components/client-page.tsx\",\"line1\":60,\"column1\":12,\"ignored\":true,\"methodName\":\"ClientPageRoot\"},\"originalCodeFrame\":null}}]", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 519.431, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.181099, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"20\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 519.438, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.181236, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 526.359, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.110", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.188247, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/learning-events", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:04 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 526.364, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.110", + "timestamp": 1786606.188368, + "dataLength": 14, + "encodedDataLength": 24 + } + }, + { + "kind": "event", + "t": 576.071, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.111", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.237966, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:04 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 576.245, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.111", + "timestamp": 1786606.23809, + "dataLength": 1337, + "encodedDataLength": 1349 + } + }, + { + "kind": "event", + "t": 576.248, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.111", + "timestamp": 1786606.237903, + "encodedDataLength": 1631 + } + }, + { + "kind": "command", + "t": 576.256, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.111" }, + "tEnd": 580.475, + "result": { + "body": "{\"version\":\"1.79.1\",\"agents\":{\"default\":{\"name\":\"default\",\"description\":\"\",\"className\":\"BuiltInAgent\",\"capabilities\":{\"tools\":{\"supported\":true,\"clientProvided\":true},\"transport\":{\"streaming\":true},\"humanInTheLoop\":{\"interrupts\":true}}}},\"audioFileTranscriptionEnabled\":false,\"mode\":\"intelligence\",\"threadEndpoints\":{\"list\":false,\"inspect\":false,\"mutations\":false,\"realtimeMetadata\":false},\"singleRoute\":{\"resourceOperations\":true,\"threadEndpoints\":{\"list\":true,\"inspect\":true,\"mutations\":true,\"realtimeMetadata\":true}},\"suggestions\":true,\"intelligence\":{\"wsUrl\":\"ws://127.0.0.1:7253/client\"},\"inspectorMetadata\":true,\"a2uiEnabled\":false,\"openGenerativeUIEnabled\":false,\"licenseStatus\":\"valid\",\"runtimeEntitlements\":{\"status\":\"ready\",\"entitlement\":{\"active\":true,\"source\":\"selfHostedDeploymentLicense\",\"features\":{\"sdk.angular\":true,\"deployment_via_helm_chart\":true,\"analytics\":true,\"self_learning\":true,\"trajectories\":true,\"msteams\":true,\"memory\":true,\"managed_channels\":true,\"managed_channels.slack\":true,\"managed_channels.teams\":true},\"limits\":{\"threads.retention_hours\":0,\"threads.max_count\":0,\"multimodal_storage_gb\":0,\"managed_channels.max_channels\":0,\"managed_channels.ingress_retry_minutes\":60,\"managed_channels.max_lifetime_turns\":0},\"planCode\":\"enterprise\",\"entitlementSource\":\"enterprise_override\"}},\"telemetryDisabled\":false}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 580.416, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.136", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "https://telemetry.copilotkit.ai/ingest", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/", + "X-CopilotKit-Telemetry-Id": "49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36" + }, + "postData": "{\"event\":\"oss.inspector.whats_new_signal_viewed\",\"properties\":{\"banner_id\":\"2e930b26-c970-484a-b340-a2cb28f20224\",\"notification_id\":\"2e930b26-c970-484a-b340-a2cb28f20224\",\"surface\":\"launcher\",\"presentation\":\"animated\",\"package_name\":\"@copilotkit/web-inspector\",\"package_version\":\"1.79.1\",\"inspector_distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\",\"distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\"},\"package\":{\"name\":\"@copilotkit/web-inspector\",\"version\":\"1.79.1\"},\"ts\":1792538644}", + "hasPostData": true + }, + "timestamp": 1786606.240779, + "wallTime": 1792538644.687376, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 580.447, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.137", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"resource/request\",\"params\":{\"path\":\"/threads?agentId=default\",\"httpMethod\":\"GET\"}}", + "hasPostData": true + }, + "timestamp": 1786606.241751, + "wallTime": 1792538644.68833, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 580.452, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.138", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"inspector/metadata\"}", + "hasPostData": true + }, + "timestamp": 1786606.242266, + "wallTime": 1792538644.688853, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 583.351, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.110", + "timestamp": 1786606.188086, + "encodedDataLength": 274 + } + }, + { + "kind": "command", + "t": 583.359, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.110" }, + "tEnd": 583.706, + "result": { "body": "{\"inserted\":2}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 583.443, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.245366, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 613.322, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.27514, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"21\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 613.329, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.275216, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 613.547, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.275535, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 784.054, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.136", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.445892, + "type": "Fetch", + "response": { + "url": "https://telemetry.copilotkit.ai/ingest", + "status": 202, + "statusText": "", + "headers": { + "access-control-allow-headers": "Content-Type, X-CopilotKit-Telemetry-Id, Authorization", + "apigw-requestid": "FkOzVhgDoAMEVEQ=", + "access-control-allow-origin": "*", + "content-length": "11", + "date": "Tue, 20 Oct 2026 23:24:04 GMT", + "content-type": "application/json", + "access-control-allow-methods": "POST, OPTIONS" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 786.016, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.136", + "timestamp": 1786606.447974, + "dataLength": 11, + "encodedDataLength": 20 + } + }, + { + "kind": "event", + "t": 825.096, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.139", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "https://telemetry.copilotkit.ai/ingest", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/", + "X-CopilotKit-Telemetry-Id": "49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36" + }, + "postData": "{\"event\":\"oss.inspector.hud_viewed\",\"properties\":{\"trigger\":\"intro\",\"package_name\":\"@copilotkit/web-inspector\",\"package_version\":\"1.79.1\",\"inspector_distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\",\"distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\"},\"package\":{\"name\":\"@copilotkit/web-inspector\",\"version\":\"1.79.1\"},\"ts\":1792538644}", + "hasPostData": true + }, + "timestamp": 1786606.486561, + "wallTime": 1792538644.933124, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 825.128, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.140", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "https://telemetry.copilotkit.ai/ingest", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/", + "X-CopilotKit-Telemetry-Id": "49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36" + }, + "postData": "{\"event\":\"oss.inspector.hud_notification_viewed\",\"properties\":{\"banner_id\":\"2e930b26-c970-484a-b340-a2cb28f20224\",\"notification_id\":\"2e930b26-c970-484a-b340-a2cb28f20224\",\"trigger\":\"intro\",\"package_name\":\"@copilotkit/web-inspector\",\"package_version\":\"1.79.1\",\"inspector_distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\",\"distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\"},\"package\":{\"name\":\"@copilotkit/web-inspector\",\"version\":\"1.79.1\"},\"ts\":1792538644}", + "hasPostData": true + }, + "timestamp": 1786606.486791, + "wallTime": 1792538644.933323, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 825.136, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.141", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "https://telemetry.copilotkit.ai/ingest", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/", + "X-CopilotKit-Telemetry-Id": "49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36" + }, + "postData": "{\"event\":\"oss.inspector.hud_feature_toggle_viewed\",\"properties\":{\"feature\":\"learning\",\"trigger\":\"intro\",\"package_name\":\"@copilotkit/web-inspector\",\"package_version\":\"1.79.1\",\"inspector_distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\",\"distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\"},\"package\":{\"name\":\"@copilotkit/web-inspector\",\"version\":\"1.79.1\"},\"ts\":1792538644}", + "hasPostData": true + }, + "timestamp": 1786606.486902, + "wallTime": 1792538644.933433, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 825.15, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.142", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "https://telemetry.copilotkit.ai/ingest", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/", + "X-CopilotKit-Telemetry-Id": "49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36" + }, + "postData": "{\"event\":\"oss.inspector.hud_hide_viewed\",\"properties\":{\"trigger\":\"intro\",\"package_name\":\"@copilotkit/web-inspector\",\"package_version\":\"1.79.1\",\"inspector_distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\",\"distinct_id\":\"49d1af7d-aa5f-43a1-9f56-dddcdebbb3fe\"},\"package\":{\"name\":\"@copilotkit/web-inspector\",\"version\":\"1.79.1\"},\"ts\":1792538644}", + "hasPostData": true + }, + "timestamp": 1786606.486986, + "wallTime": 1792538644.933516, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 832.063, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.137", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.4939, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:04 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 832.087, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.137", + "timestamp": 1786606.494061, + "dataLength": 499, + "encodedDataLength": 511 + } + }, + { + "kind": "event", + "t": 832.088, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.137", + "timestamp": 1786606.49322, + "encodedDataLength": 793 + } + }, + { + "kind": "command", + "t": 832.094, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.137" }, + "tEnd": 833.131, + "result": { + "body": "{\"threads\":[{\"id\":\"b516d408-26eb-4fd6-9fc9-8a070c87efff\",\"organizationId\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"projectId\":1,\"agentId\":\"default\",\"endUserId\":\"agui-e2e-demo-user\",\"createdById\":\"1_agui-e2e-demo-user\",\"learningContainerIds\":[],\"learningContainerId\":null,\"learningContainerAssignedAt\":null,\"name\":\"Hello in Five Words\",\"archived\":false,\"createdAt\":\"2026-10-20T23:23:47.571Z\",\"updatedAt\":\"2026-10-20T23:23:49.062Z\"}],\"nextCursor\":null,\"joinCode\":\"e05d7a88-dd5e-43ef-9136-bc98ce68d93d\"}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 833.258, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.143", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"resource/request\",\"params\":{\"path\":\"/threads/subscribe\",\"httpMethod\":\"POST\"},\"body\":{}}", + "hasPostData": true + }, + "timestamp": 1786606.495154, + "wallTime": 1792538644.941687, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 835.073, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.136", + "timestamp": 1786606.447909, + "encodedDataLength": 233 + } + }, + { + "kind": "command", + "t": 835.082, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.136" }, + "tEnd": 835.245, + "result": { "body": "{\"ok\":true}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 871.108, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.138", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.532978, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "cache-control": "no-store, private", + "Connection": "keep-alive", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:04 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 871.129, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.138", + "timestamp": 1786606.533104, + "dataLength": 244, + "encodedDataLength": 255 + } + }, + { + "kind": "event", + "t": 871.131, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.138", + "timestamp": 1786606.532917, + "encodedDataLength": 571 + } + }, + { + "kind": "command", + "t": 871.138, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.138" }, + "tEnd": 871.873, + "result": { + "body": "{\"schemaVersion\":1,\"identity\":{\"organizationName\":\"Recorder E2E\",\"projectName\":\"Recorder E2E\"},\"plan\":{\"code\":\"enterprise\",\"label\":\"Enterprise\"},\"license\":{\"state\":\"valid\"},\"usage\":{\"used\":11,\"limit\":{\"kind\":\"unlimited\"},\"expiringSoonCount\":0}}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 878.333, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.539971, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"22\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 878.339, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.540033, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 878.579, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.540586, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 934.358, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.140", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.596215, + "type": "Fetch", + "response": { + "url": "https://telemetry.copilotkit.ai/ingest", + "status": 202, + "statusText": "", + "headers": { + "access-control-allow-methods": "POST, OPTIONS", + "apigw-requestid": "FkOzXhWAIAMEblA=", + "access-control-allow-origin": "*", + "content-length": "11", + "date": "Tue, 20 Oct 2026 23:24:05 GMT", + "content-type": "application/json", + "access-control-allow-headers": "Content-Type, X-CopilotKit-Telemetry-Id, Authorization" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 936.283, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.140", + "timestamp": 1786606.598252, + "dataLength": 11, + "encodedDataLength": 20 + } + }, + { + "kind": "event", + "t": 936.48, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.141", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.598419, + "type": "Fetch", + "response": { + "url": "https://telemetry.copilotkit.ai/ingest", + "status": 202, + "statusText": "", + "headers": { + "access-control-allow-headers": "Content-Type, X-CopilotKit-Telemetry-Id, Authorization", + "apigw-requestid": "FkOzXh3PoAMEb1w=", + "access-control-allow-origin": "*", + "content-length": "11", + "date": "Tue, 20 Oct 2026 23:24:05 GMT", + "content-type": "application/json", + "access-control-allow-methods": "POST, OPTIONS" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 936.483, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.141", + "timestamp": 1786606.598491, + "dataLength": 11, + "encodedDataLength": 29 + } + }, + { + "kind": "event", + "t": 939.501, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.142", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.6014, + "type": "Fetch", + "response": { + "url": "https://telemetry.copilotkit.ai/ingest", + "status": 202, + "statusText": "", + "headers": { + "access-control-allow-headers": "Content-Type, X-CopilotKit-Telemetry-Id, Authorization", + "apigw-requestid": "FkOzXjO5oAMEbbg=", + "access-control-allow-origin": "*", + "content-length": "11", + "date": "Tue, 20 Oct 2026 23:24:05 GMT", + "content-type": "application/json", + "access-control-allow-methods": "POST, OPTIONS" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 939.505, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.142", + "timestamp": 1786606.601502, + "dataLength": 11, + "encodedDataLength": 29 + } + }, + { + "kind": "event", + "t": 941.546, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.139", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.603464, + "type": "Fetch", + "response": { + "url": "https://telemetry.copilotkit.ai/ingest", + "status": 202, + "statusText": "", + "headers": { + "access-control-allow-methods": "POST, OPTIONS", + "apigw-requestid": "FkOzXjePoAMEbFg=", + "access-control-allow-origin": "*", + "content-length": "11", + "date": "Tue, 20 Oct 2026 23:24:05 GMT", + "content-type": "application/json", + "access-control-allow-headers": "Content-Type, X-CopilotKit-Telemetry-Id, Authorization" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 941.551, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.139", + "timestamp": 1786606.60355, + "dataLength": 11, + "encodedDataLength": 29 + } + }, + { + "kind": "event", + "t": 985.261, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.140", + "timestamp": 1786606.598202, + "encodedDataLength": 233 + } + }, + { + "kind": "command", + "t": 985.273, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.140" }, + "tEnd": 985.487, + "result": { "body": "{\"ok\":true}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 986.58, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.141", + "timestamp": 1786606.598356, + "encodedDataLength": 232 + } + }, + { + "kind": "command", + "t": 986.589, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.141" }, + "tEnd": 986.781, + "result": { "body": "{\"ok\":true}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 990.301, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.142", + "timestamp": 1786606.60132, + "encodedDataLength": 233 + } + }, + { + "kind": "command", + "t": 990.312, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.142" }, + "tEnd": 990.503, + "result": { "body": "{\"ok\":true}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 991.745, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.139", + "timestamp": 1786606.6034, + "encodedDataLength": 233 + } + }, + { + "kind": "command", + "t": 991.753, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.139" }, + "tEnd": 991.954, + "result": { "body": "{\"ok\":true}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 1066.175, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.143", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786606.728035, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:05 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 1066.222, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.143", + "timestamp": 1786606.728194, + "dataLength": 52, + "encodedDataLength": 63 + } + }, + { + "kind": "event", + "t": 1066.226, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.143", + "timestamp": 1786606.727975, + "encodedDataLength": 345 + } + }, + { + "kind": "command", + "t": 1066.235, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.143" }, + "tEnd": 1068.167, + "result": { + "body": "{\"joinToken\":\"join-token-1\"}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 1068.153, + "session": "page", + "method": "Network.webSocketCreated", + "params": { + "requestId": "68328.144", + "url": "ws://127.0.0.1:7253/client/websocket?join_token=join-token-1&vsn=2.0.0" + } + }, + { + "kind": "event", + "t": 1068.199, + "session": "page", + "method": "Network.webSocketWillSendHandshakeRequest", + "params": { + "requestId": "68328.144", + "timestamp": 1786606.730012, + "wallTime": 1792538645.17648, + "request": { + "headers": { + "Upgrade": "websocket", + "Origin": "http://127.0.0.1:7621", + "Cache-Control": "no-cache", + "Accept-Language": "en-US,en;q=0.9", + "Pragma": "no-cache", + "Connection": "Upgrade", + "Sec-WebSocket-Key": "Si8U0kwv1X6lEdSMPSiKrA==", + "Accept-Encoding": "gzip, deflate, br, zstd", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Sec-WebSocket-Version": "13", + "Host": "127.0.0.1:7253", + "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits" + } + } + } + }, + { + "kind": "event", + "t": 1070.405, + "session": "page", + "method": "Network.webSocketHandshakeResponseReceived", + "params": { + "requestId": "68328.144", + "timestamp": 1786606.732107, + "response": { + "status": 101, + "statusText": "Switching Protocols", + "headers": { + "upgrade": "websocket", + "cache-control": "max-age=0, private, must-revalidate", + "date": "Tue, 20 Oct 2026 23:24:04 GMT", + "sec-websocket-accept": "QLRJNm4FQvG6hq9+bGv+oNFTbLs=", + "connection": "Upgrade" + } + } + } + }, + { + "kind": "event", + "t": 1070.412, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.144", + "timestamp": 1786606.732203, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "[\"5\",\"5\",\"user_meta:e05d7a88-dd5e-43ef-9136-bc98ce68d93d\",\"phx_join\",{}]" + } + } + }, + { + "kind": "event", + "t": 1071.271, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.144", + "timestamp": 1786606.733034, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"5\",\"5\",\"user_meta:e05d7a88-dd5e-43ef-9136-bc98ce68d93d\",\"phx_reply\",{\"status\":\"ok\",\"response\":{}}]" + } + } + }, + { + "kind": "event", + "t": 1121.288, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.783089, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"23\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 1121.296, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.78317, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 1133.603, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.145", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"inspector/metadata\"}", + "hasPostData": true + }, + "timestamp": 1786606.795374, + "wallTime": 1792538645.241853, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 1135.302, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.797192, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 1184.27, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.846042, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"24\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 1184.279, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.846138, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 1184.458, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786606.846429, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 1376.136, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.145", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786607.037967, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "cache-control": "no-store, private", + "Connection": "keep-alive", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:05 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 1376.148, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.145", + "timestamp": 1786607.038094, + "dataLength": 244, + "encodedDataLength": 255 + } + }, + { + "kind": "event", + "t": 1376.15, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.145", + "timestamp": 1786607.037914, + "encodedDataLength": 571 + } + }, + { + "kind": "command", + "t": 1376.157, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.145" }, + "tEnd": 1376.405, + "result": { + "body": "{\"schemaVersion\":1,\"identity\":{\"organizationName\":\"Recorder E2E\",\"projectName\":\"Recorder E2E\"},\"plan\":{\"code\":\"enterprise\",\"label\":\"Enterprise\"},\"license\":{\"state\":\"valid\"},\"usage\":{\"used\":11,\"limit\":{\"kind\":\"unlimited\"},\"expiringSoonCount\":0}}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 1411.196, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786607.073008, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"25\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 1411.206, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786607.07308, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 2266.097, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.146", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/learning-events", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"trajectoryId\":\"542cc753-7048-4fb6-8049-e8d96ecad7b4\",\"events\":[{\"type\":\"CUSTOM\",\"name\":\"page\",\"value\":{\"route\":\"/learning/deals/deal-2\",\"url\":\"http://127.0.0.1:7621/learning/deals/deal-2\",\"title\":\"Create Next App\",\"referrer\":\"\",\"seq\":3},\"timestamp\":1792538644372},{\"type\":\"CUSTOM\",\"name\":\"thread.linked\",\"value\":{\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"hasMessages\":false,\"reason\":\"start\",\"seq\":4},\"timestamp\":1792538644372},{\"type\":\"CUSTOM\",\"name\":\"network\",\"value\":{\"transport\":\"fetch\",\"method\":\"POST\",\"url\":\"http://127.0.0.1:7621/__nextjs_original-stack-frames\",\"origin\":\"http://127.0.0.1:7621\",\"route\":\"/__nextjs_original-stack-frames\",\"status\":200,\"durationMs\":205,\"completedAt\":1792538644588,\"outcome\":\"ok\",\"request\":{\"headers\":{},\"body\":{\"status\":\"complete\",\"text\":\"{\\\"frames\\\":[{\\\"file\\\":\\\"https://react.dev/link/hydration-mismatch\\\",\\\"line1\\\":null,\\\"column1\\\":null,\\\"methodName\\\":\\\"\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":2892,\\\"column1\\\":56,\\\"methodName\\\":\\\"throwOnHydrationMismatch\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":2946,\\\"column1\\\":23,\\\"methodName\\\":\\\"prepareToHydrateHostInstance\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":6330,\\\"column1\\\":60,\\\"methodName\\\":\\\"completeWork\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":886,\\\"column1\\\":74,\\\"methodName\\\":\\\"runWithFiberInDEV\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":8363,\\\"column1\\\":23,\\\"methodName\\\":\\\"completeUnitOfWork\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":8300,\\\"column1\\\":28,\\\"methodName\\\":\\\"performUnitOfWork\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":8294,\\\"column1\\\":58,\\\"methodName\\\":\\\"workLoopConcurrentByScheduler\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":8276,\\\"column1\\\":71,\\\"methodName\\\":\\\"renderRootConcurrent\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":7908,\\\"column1\\\":176,\\\"methodName\\\":\\\"performWorkOnRoot\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_react-dom_8e760a3b._.js\\\",\\\"line1\\\":8882,\\\"column1\\\":9,\\\"methodName\\\":\\\"performWorkOnRootViaSchedulerTask\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_compiled_8dc561c9._.js\\\",\\\"line1\\\":2601,\\\"column1\\\":64,\\\"methodName\\\":\\\"MessagePort.performWorkUntilDeadline\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"\\\",\\\"line1\\\":null,\\\"column1\\\":null,\\\"methodName\\\":\\\"code\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/_568cbc5a._.js\\\",\\\"line1\\\":316,\\\"column1\\\":438,\\\"methodName\\\":\\\"DealView\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/_568cbc5a._.js\\\",\\\"line1\\\":128,\\\"column1\\\":428,\\\"methodName\\\":\\\"DealPage\\\",\\\"arguments\\\":[]},{\\\"file\\\":\\\"file:///Users/dmckay/dev/cpk/cpk-worktrees/copilotkit-main-agui/examples/v2/react/demo/.next-intel/static/chunks/59a95_next_dist_a36fb2aa._.js\\\",\\\"line1\\\":2639,\\\"column1\\\":50,\\\"methodName\\\":\\\"ClientPageRoot\\\",\\\"arguments\\\":[]}],\\\"isServer\\\":false,\\\"isEdgeServer\\\":false,\\\"isAppDirectory\\\":true}\",\"encoding\":\"utf-8\"}},\"response\":{\"url\":\"http://127.0.0.1:7621/__nextjs_original-stack-frames\",\"headers\":{\"connection\":\"keep-alive\",\"content-encoding\":\"gzip\",\"content-type\":\"application/json\",\"date\":\"Tue, 20 Oct 2026 23:24:04 GMT\",\"keep-alive\":\"timeout=5\",\"transfer-encoding\":\"chunked\",\"vary\":\"Accept-Encoding\"},\"body\":{\"status\":\"truncated\",\"text\":\"[{\\\"status\\\":\\\"rejected\\\",\\\"reason\\\":\\\"[Error: Unknown url scheme 'https'\\\\n\\\\nDebug info:\\\\n- Execution of TaskId { id: 2147484182 } transient failed\\\\n- Execution of project_trace_source_operation failed\\\\n- Execution of *SourceMap::new_from_rope_cached failed\\\\n- Execution of get_source_map_rope failed\\\\n- Unknown url scheme 'https'] {\\\\n code: 'GenericFailure'\\\\n}\\\"},{\\\"status\\\":\\\"fulfilled\\\",\\\"value\\\":{\\\"originalStackFrame\\\":{\\\"arguments\\\":[],\\\"file\\\":\\\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\\\",\\\"line1\\\":4505,\\\"column1\\\":11,\\\"ignored\\\":true,\\\"methodName\\\":\\\"throwOnHydrationMismatch\\\"},\\\"originalCodeFrame\\\":null}},{\\\"status\\\":\\\"fulfilled\\\",\\\"value\\\":{\\\"originalStackFrame\\\":{\\\"arguments\\\":[],\\\"file\\\":\\\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\\\",\\\"line1\\\":4601,\\\"column1\\\":21,\\\"ignored\\\":true,\\\"methodName\\\":\\\"prepareToHydrateHostInstance\\\"},\\\"originalCodeFrame\\\":null}},{\\\"status\\\":\\\"fulfilled\\\",\\\"value\\\":{\\\"originalStackFrame\\\":{\\\"arguments\\\":[],\\\"file\\\":\\\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\\\",\\\"line1\\\":11634,\\\"column1\\\":15,\\\"ignored\\\":true,\\\"methodName\\\":\\\"completeWork\\\"},\\\"originalCodeFrame\\\":null}},{\\\"status\\\":\\\"fulfilled\\\",\\\"value\\\":{\\\"originalStackFrame\\\":{\\\"arguments\\\":[],\\\"file\\\":\\\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\\\",\\\"line1\\\":871,\\\"column1\\\":30,\\\"ignored\\\":true,\\\"methodName\\\":\\\"runWithFiberInDEV\\\"},\\\"originalCodeFrame\\\":null}},{\\\"status\\\":\\\"fulfilled\\\",\\\"value\\\":{\\\"originalStackFrame\\\":{\\\"arguments\\\":[],\\\"file\\\":\\\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\\\",\\\"line1\\\":15862,\\\"column1\\\":19,\\\"ignored\\\":true,\\\"methodName\\\":\\\"completeUnitOfWork\\\"},\\\"originalCodeFrame\\\":null}},{\\\"status\\\":\\\"fulfilled\\\",\\\"value\\\":{\\\"originalStackFrame\\\":{\\\"arguments\\\":[],\\\"file\\\":\\\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\\\",\\\"line1\\\":15743,\\\"column1\\\":11,\\\"ignored\\\":true,\\\"methodName\\\":\\\"performUnitOfWork\\\"},\\\"originalCodeFrame\\\":null}},{\\\"status\\\":\\\"fulfilled\\\",\\\"value\\\":{\\\"originalStackFrame\\\":{\\\"arguments\\\":[],\\\"file\\\":\\\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\\\",\\\"line1\\\":15720,\\\"column1\\\":9,\\\"ignored\\\":true,\\\"methodName\\\":\\\"workLoopConcurrentByScheduler\\\"},\\\"originalCodeFrame\\\":null}},{\\\"status\\\":\\\"fulfilled\\\",\\\"value\\\":{\\\"originalStackFrame\\\":{\\\"arguments\\\":[],\\\"file\\\":\\\"../../../../node_modules/.pnpm/next@15.5.24_@babel+core@8.0.1_@opentelemetry+api@1.9.0_@playwright+test@1.59.1_babel-p_47f2c4e84a899e922d1066379711bf62/node_modules/next/dist/compiled/react-dom/cjs/react-dom-client.development.js\\\",\\\"line1\\\":15695,\\\"column1\\\":15,\\\"ignored\\\":true,\\\"methodName\\\":\\\"renderRootConcurrent\\\"},\\\"originalCodeFrame\\\":null}},{\\\"status\\\":\\\"fulfilled\\\",\\\"value\\\":{\\\"originalStackFrame\\\":{\\\"arguments\\\":[],\\\"file\\\":\\\"../../../../node_modules/.pnpm/next@15.5.24_@babel+c\",\"encoding\":\"utf-8\",\"truncated\":true}},\"seq\":5},\"timestamp\":1792538644589}],\"dropped\":0}", + "hasPostData": true + }, + "timestamp": 1786607.92715, + "wallTime": 1792538646.37343, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 2268.504, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786607.930333, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 2310.023, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786607.971689, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"26\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 2310.031, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786607.971776, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 2310.044, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786607.972044, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 2481.555, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786608.143335, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"27\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 2481.566, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786608.143404, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 2491.262, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.146", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786608.153163, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/learning-events", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:06 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 2491.269, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.146", + "timestamp": 1786608.153262, + "dataLength": 14, + "encodedDataLength": 24 + } + }, + { + "kind": "event", + "t": 2541.457, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.146", + "timestamp": 1786608.153015, + "encodedDataLength": 274 + } + }, + { + "kind": "command", + "t": 2541.472, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.146" }, + "tEnd": 2541.697, + "result": { "body": "{\"inserted\":3}", "base64Encoded": false } + }, + { + "kind": "mark", + "t": 5626.711, + "wall": 1792538649734, + "name": "click", + "data": { + "name": "focus", + "selector": "[data-testid=\"copilot-chat-textarea\"]", + "text": "" + } + }, + { + "kind": "mark", + "t": 5638.473, + "wall": 1792538649746, + "name": "typed", + "data": { + "selector": "[data-testid=\"copilot-chat-textarea\"]", + "text": "Look up this deal's history, then ask me to approve it." + } + }, + { + "kind": "mark", + "t": 5646.295, + "wall": 1792538649754, + "name": "click", + "data": { + "name": "send", + "selector": "[data-testid=\"copilot-send-button\"]", + "text": "" + } + }, + { + "kind": "event", + "t": 5678.583, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.147", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"approveDeal\",\"description\":\"Ask the user to approve or reject the deal\",\"parameters\":{\"type\":\"object\",\"properties\":{\"dealId\":{\"type\":\"string\"},\"amount\":{\"type\":\"number\"}},\"required\":[\"dealId\",\"amount\"]}}],\"context\":[{\"description\":\"The deal the user is looking at\",\"value\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"}],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"f811d72c-7d73-4a02-9540-d9d7491c10d6\",\"role\":\"user\",\"content\":\"Look up this deal's history, then ask me to approve it.\"}]}}", + "hasPostData": true + }, + "timestamp": 1786611.339806, + "wallTime": 1792538649.785471, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 5680.673, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786611.342625, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 5738.281, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786611.400097, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"28\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 5738.287, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786611.400156, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 5961.038, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.144", + "timestamp": 1786611.622285, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[null,null,\"user_meta:e05d7a88-dd5e-43ef-9136-bc98ce68d93d\",\"thread_metadata\",{\"occurredAt\":\"2026-10-20T23:24:10.032702+00:00\",\"operation\":\"created\",\"organizationId\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"projectId\":1,\"thread\":{\"agentId\":\"default\",\"archived\":false,\"createdAt\":\"2026-10-20T23:24:10.032702+00:00\",\"createdById\":\"1_agui-e2e-demo-user\",\"endUserId\":\"agui-e2e-demo-user\",\"id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"name\":null,\"organizationId\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"projectId\":1,\"updatedAt\":\"2026-10-20T23:24:10.032702+00:00\"},\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\"}]" + } + } + }, + { + "kind": "event", + "t": 6005.089, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.147", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786611.66684, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "cache-control": "no-cache", + "Connection": "keep-alive", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:10 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 6005.253, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.147", + "timestamp": 1786611.667095, + "dataLength": 257, + "encodedDataLength": 269 + } + }, + { + "kind": "event", + "t": 6005.256, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.147", + "timestamp": 1786611.666884, + "encodedDataLength": 576 + } + }, + { + "kind": "command", + "t": 6005.263, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.147" }, + "tEnd": 6006.6, + "result": { + "body": "{\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"joinToken\":\"join-token-2\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\"}}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 6006.58, + "session": "page", + "method": "Network.webSocketCreated", + "params": { + "requestId": "68328.148", + "url": "ws://127.0.0.1:7253/client/websocket?join_token=join-token-2&vsn=2.0.0" + } + }, + { + "kind": "event", + "t": 6006.753, + "session": "page", + "method": "Network.webSocketWillSendHandshakeRequest", + "params": { + "requestId": "68328.148", + "timestamp": 1786611.668724, + "wallTime": 1792538650.114317, + "request": { + "headers": { + "Upgrade": "websocket", + "Origin": "http://127.0.0.1:7621", + "Cache-Control": "no-cache", + "Accept-Language": "en-US,en;q=0.9", + "Pragma": "no-cache", + "Connection": "Upgrade", + "Sec-WebSocket-Key": "MvEoJj0KhNc20SiDfBr/aA==", + "Accept-Encoding": "gzip, deflate, br, zstd", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Sec-WebSocket-Version": "13", + "Host": "127.0.0.1:7253", + "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits" + } + } + } + }, + { + "kind": "event", + "t": 6008.45, + "session": "page", + "method": "Network.webSocketHandshakeResponseReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786611.670274, + "response": { + "status": 101, + "statusText": "Switching Protocols", + "headers": { + "upgrade": "websocket", + "cache-control": "max-age=0, private, must-revalidate", + "date": "Tue, 20 Oct 2026 23:24:09 GMT", + "sec-websocket-accept": "v9VmeuLBU7LeWW1Z1ognG+/EA6w=", + "connection": "Upgrade" + } + } + } + }, + { + "kind": "event", + "t": 6008.469, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.148", + "timestamp": 1786611.670322, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "[\"3\",\"3\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_join\",{\"stream_mode\":\"run\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\"}]" + } + } + }, + { + "kind": "event", + "t": 6009.487, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786611.671355, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",\"3\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_reply\",{\"status\":\"ok\",\"response\":{}}]" + } + } + }, + { + "kind": "event", + "t": 6009.504, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786611.671512, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"connected\",{\"user_id\":\"1_agui-e2e-demo-user\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\"}]" + } + } + }, + { + "kind": "event", + "t": 6011.492, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786611.672359, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"input\":{\"context\":[{\"description\":\"The deal the user is looking at\",\"value\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"}],\"messages\":[{\"content\":\"Look up this deal's history, then ask me to approve it.\",\"id\":\"f811d72c-7d73-4a02-9540-d9d7491c10d6\",\"role\":\"user\"}],\"protocolVersion\":\"1.0\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"state\":{},\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"tools\":[{\"description\":\"Ask the user to approve or reject the deal\",\"name\":\"approveDeal\",\"parameters\":{\"properties\":{\"amount\":{\"type\":\"number\"},\"dealId\":{\"type\":\"string\"}},\"required\":[\"dealId\",\"amount\"],\"type\":\"object\"}}]},\"metadata\":{\"cpki_event_id\":\"d9b2e301-6273-457e-b0a1-480196133d39\",\"cpki_event_seq\":1,\"cpki_ingested\":\"01a15ba1-2602-751f-85db-8878c790ccde\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"protocolVersion\":\"1.0\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"RUN_STARTED\"}]" + } + } + }, + { + "kind": "event", + "t": 6013.692, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786611.675591, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"replay_complete\",{\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"latestEventId\":\"d9b2e301-6273-457e-b0a1-480196133d39\"}]" + } + } + }, + { + "kind": "event", + "t": 6013.713, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.144", + "timestamp": 1786611.675655, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[null,null,\"user_meta:e05d7a88-dd5e-43ef-9136-bc98ce68d93d\",\"thread_run_activity\",{\"agent_id\":\"default\",\"event_type\":\"RUN_STARTED\",\"latest_event_id\":\"d9b2e301-6273-457e-b0a1-480196133d39\",\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"thread_run_activity\"}]" + } + } + }, + { + "kind": "event", + "t": 6262.199, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.149", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"inspector/metadata\"}", + "hasPostData": true + }, + "timestamp": 1786611.923828, + "wallTime": 1792538650.369432, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 6264.648, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.150", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/learning-events", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"trajectoryId\":\"542cc753-7048-4fb6-8049-e8d96ecad7b4\",\"events\":[{\"type\":\"CUSTOM\",\"name\":\"click\",\"value\":{\"target\":{\"tag\":\"textarea\",\"role\":null,\"action\":null,\"text\":\"\",\"attributes\":{\"data-testid\":\"copilot-chat-textarea\",\"placeholder\":\"Type a message...\",\"class\":\"cpk:bg-transparent cpk:outline-none cpk:antialiased cpk:font-regular cpk:text-[16px] cpk:placeholder:text-[#00000077] cpk:dark:placeholder:text-[#fffc] cpk:w-full cpk:py-3 cpk:pr-5\",\"rows\":\"1\",\"style\":\"overflow: auto; resize: none; max-height: 144px; height: 48px;\"},\"value\":\"\",\"input\":\"pointer\"},\"route\":\"/learning/deals/deal-2\",\"url\":\"http://127.0.0.1:7621/learning/deals/deal-2\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"seq\":6},\"timestamp\":1792538649739},{\"type\":\"CUSTOM\",\"name\":\"input\",\"value\":{\"eventType\":\"input\",\"target\":{\"tag\":\"textarea\",\"role\":null,\"action\":null,\"text\":\"\",\"attributes\":{\"data-testid\":\"copilot-chat-textarea\",\"placeholder\":\"Type a message...\",\"class\":\"cpk:bg-transparent cpk:outline-none cpk:antialiased cpk:font-regular cpk:text-[16px] cpk:placeholder:text-[#00000077] cpk:dark:placeholder:text-[#fffc] cpk:w-full cpk:py-3 cpk:pr-5\",\"rows\":\"1\",\"style\":\"overflow: auto; resize: none; max-height: 144px; height: 48px;\"},\"value\":\"Look up this deal's history, then ask me to approve it.\"},\"url\":\"http://127.0.0.1:7621/learning/deals/deal-2\",\"route\":\"/learning/deals/deal-2\",\"seq\":7},\"timestamp\":1792538649754},{\"type\":\"CUSTOM\",\"name\":\"click\",\"value\":{\"target\":{\"tag\":\"button\",\"role\":null,\"action\":null,\"text\":\"\",\"attributes\":{\"data-slot\":\"button\",\"class\":\"cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2 cpk:whitespace-nowrap cpk:text-sm cpk:font-medium cpk:disabled:pointer-events-none cpk:disabled:opacity-50 cpk:[&_svg]:pointer-events-none cpk:[&_svg:not([class*='size-'])]:size-4 cpk:shrink-0 cpk:[&_svg]:shrink-0 cpk:outline-none cpk:focus-visible:border-ring cpk:focus-visible:ring-ring/50 cpk:focus-visible:ring-[3px] cpk:aria-invalid:ring-destructive/20 cpk:dark:aria-invalid:ring-destructive/40 cpk:aria-invalid:border-destructive cpk:cursor-pointer cpk:bg-black cpk:text-white cpk:dark:bg-white cpk:dark:text-black cpk:dark:focus-visible:outline-white cpk:transition-colors cpk:focus:outline-none cpk:hover:opacity-70 cpk:disabled:hover:opacity-100 cpk:disabled:cursor-not-allowed cpk:disabled:bg-[#00000014] cpk:disabled:text-[rgb(13,13,13)] cpk:dark:disabled:bg-[#454545] cpk:dark:disabled:text-white cpk:h-9 cpk:w-9 cpk:rounded-full\",\"type\":\"button\",\"data-testid\":\"copilot-send-button\",\"aria-label\":\"Send message\"},\"label\":\"Send message\",\"input\":\"pointer\"},\"route\":\"/learning/deals/deal-2\",\"url\":\"http://127.0.0.1:7621/learning/deals/deal-2\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"seq\":8},\"timestamp\":1792538649755},{\"type\":\"CUSTOM\",\"name\":\"agent.message\",\"value\":{\"messageId\":\"f811d72c-7d73-4a02-9540-d9d7491c10d6\",\"runId\":null,\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"role\":\"user\",\"textLength\":55,\"text\":\"Look up this deal's history, then ask me to approve it.\",\"seq\":9},\"timestamp\":1792538649761},{\"type\":\"CUSTOM\",\"name\":\"agent.run\",\"value\":{\"phase\":\"started\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"seq\":10},\"timestamp\":1792538650120}],\"dropped\":0}", + "hasPostData": true + }, + "timestamp": 1786611.926131, + "wallTime": 1792538650.371722, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 6264.886, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786611.926772, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 6299.292, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786611.961123, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"29\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 6299.3, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786611.961197, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 6299.432, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786611.961431, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 6494.043, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.150", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786612.155917, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/learning-events", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:10 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 6494.052, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.150", + "timestamp": 1786612.156018, + "dataLength": 14, + "encodedDataLength": 24 + } + }, + { + "kind": "event", + "t": 6502.472, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.149", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786612.164365, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "cache-control": "no-store, private", + "Connection": "keep-alive", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:10 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 6502.503, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.149", + "timestamp": 1786612.164483, + "dataLength": 244, + "encodedDataLength": 255 + } + }, + { + "kind": "event", + "t": 6502.52, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.149", + "timestamp": 1786612.164314, + "encodedDataLength": 571 + } + }, + { + "kind": "command", + "t": 6502.53, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.149" }, + "tEnd": 6502.932, + "result": { + "body": "{\"schemaVersion\":1,\"identity\":{\"organizationName\":\"Recorder E2E\",\"projectName\":\"Recorder E2E\"},\"plan\":{\"code\":\"enterprise\",\"label\":\"Enterprise\"},\"license\":{\"state\":\"valid\"},\"usage\":{\"used\":12,\"limit\":{\"kind\":\"unlimited\"},\"expiringSoonCount\":0}}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 6532.55, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786612.19422, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"30\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 6532.558, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786612.19428, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 6545.405, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.150", + "timestamp": 1786612.155793, + "encodedDataLength": 274 + } + }, + { + "kind": "command", + "t": 6545.414, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.150" }, + "tEnd": 6545.604, + "result": { "body": "{\"inserted\":5}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 6913.114, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786612.574523, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"messageId\":\"rs_03286b8662af5d1a006ad7f81b089087d0b0768bb42d0ac82b:0\",\"metadata\":{\"cpki_event_id\":\"544491f3-21fd-4c3d-ba05-b8234b6f3b8b\",\"cpki_event_seq\":2,\"cpki_ingested\":\"01a15ba1-2988-785f-b9c8-9b0c292fb5c0\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"REASONING_START\"}]" + } + } + }, + { + "kind": "event", + "t": 6913.928, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786612.575756, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"messageId\":\"rs_03286b8662af5d1a006ad7f81b089087d0b0768bb42d0ac82b:0\",\"metadata\":{\"cpki_event_id\":\"603a25a0-4861-42d4-accf-39e9e3371865\",\"cpki_event_seq\":3,\"cpki_ingested\":\"01a15ba1-2988-7f7d-a8e6-8eb115525fb5\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"role\":\"reasoning\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"REASONING_MESSAGE_START\"}]" + } + } + }, + { + "kind": "event", + "t": 7452.239, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.144", + "timestamp": 1786613.113506, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[null,null,\"user_meta:e05d7a88-dd5e-43ef-9136-bc98ce68d93d\",\"thread_metadata\",{\"occurredAt\":\"2026-10-20T23:24:11.55202+00:00\",\"operation\":\"renamed\",\"organizationId\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"projectId\":1,\"thread\":{\"agentId\":\"default\",\"archived\":false,\"createdAt\":\"2026-10-20T23:24:10.032702+00:00\",\"createdById\":\"1_agui-e2e-demo-user\",\"endUserId\":\"agui-e2e-demo-user\",\"id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"name\":\"Deal History Lookup and Approval Request\",\"organizationId\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"projectId\":1,\"updatedAt\":\"2026-10-20T23:24:11.55202+00:00\"},\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\"}]" + } + } + }, + { + "kind": "event", + "t": 9032.056, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786614.693706, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"messageId\":\"rs_03286b8662af5d1a006ad7f81b089087d0b0768bb42d0ac82b:0\",\"metadata\":{\"cpki_event_id\":\"0ee3d39a-0207-4b2e-814c-0f792ede4205\",\"cpki_event_seq\":4,\"cpki_ingested\":\"01a15ba1-31ce-7519-bb31-8748f966ad78\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"REASONING_MESSAGE_END\"}]" + } + } + }, + { + "kind": "event", + "t": 9033.278, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786614.695205, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"messageId\":\"rs_03286b8662af5d1a006ad7f81b089087d0b0768bb42d0ac82b:0\",\"metadata\":{\"cpki_event_id\":\"0cf85a5d-7ea4-49a5-b872-3855a3859786\",\"cpki_event_seq\":5,\"cpki_ingested\":\"01a15ba1-31ce-70a5-ab65-5b9274b82be8\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"REASONING_END\"}]" + } + } + }, + { + "kind": "event", + "t": 9033.577, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786614.695508, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"metadata\":{\"cpki_event_id\":\"911412c2-9773-45a8-b493-85894168620a\",\"cpki_event_seq\":6,\"cpki_ingested\":\"01a15ba1-31ce-7240-92cf-ecac8688242a\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"parentMessageId\":\"1118aa14-05af-485a-b710-3a37e8905eb5\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"toolCallName\":\"lookupDealHistory\",\"type\":\"TOOL_CALL_START\"}]" + } + } + }, + { + "kind": "event", + "t": 9034.027, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786614.695998, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"{\\\"\",\"metadata\":{\"cpki_event_id\":\"590dddc5-7313-4b79-bd98-f47be6a01375\",\"cpki_event_seq\":7,\"cpki_ingested\":\"01a15ba1-31ce-7cf1-ae91-4596bcfbf957\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 9034.498, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786614.696452, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"deal\",\"metadata\":{\"cpki_event_id\":\"c1d2f868-6da8-4c8f-9100-885706f03ea4\",\"cpki_event_seq\":8,\"cpki_ingested\":\"01a15ba1-31ce-716f-a2d3-91348553fbf6\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 9034.754, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786614.696725, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"Id\",\"metadata\":{\"cpki_event_id\":\"683ff48d-a3a7-4559-bb63-bb9381fe7cb1\",\"cpki_event_seq\":9,\"cpki_ingested\":\"01a15ba1-31ce-7ec2-aaaf-2d15544869f0\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 9035.035, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786614.696969, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"\\\":\\\"\",\"metadata\":{\"cpki_event_id\":\"06b47fd0-0902-4f5d-b8dd-e8bb6042ad3d\",\"cpki_event_seq\":10,\"cpki_ingested\":\"01a15ba1-31ce-7607-82be-c6acaca1afb3\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 9046.345, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786614.708248, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"type\":\"turbopack-subscribe\",\"path\":\"static/chunks/79abc_katex_dist_katex_min_css_ba5f61d1._.js\"}" + } + } + }, + { + "kind": "event", + "t": 9046.763, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786614.708673, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 9191.745, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786614.853542, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"31\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 9191.752, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786614.853599, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 9457.593, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786615.119261, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"deal\",\"metadata\":{\"cpki_event_id\":\"5ce17ad2-46e8-4b9a-92a3-4d9a51062cee\",\"cpki_event_seq\":11,\"cpki_ingested\":\"01a15ba1-3379-7509-8d29-9b7b89cf53e4\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 9460.608, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786615.122375, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"-\",\"metadata\":{\"cpki_event_id\":\"a39aad9a-5dcc-4ff6-9af9-353b07ebc09e\",\"cpki_event_seq\":12,\"cpki_ingested\":\"01a15ba1-3379-782b-be41-0d3f162b4686\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 9460.938, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786615.122896, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"2\",\"metadata\":{\"cpki_event_id\":\"05acb067-2ba5-41e7-9ba6-726901a5653c\",\"cpki_event_seq\":13,\"cpki_ingested\":\"01a15ba1-3379-7a5b-b7c6-05eb479c74d7\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 9461.262, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786615.123209, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"\\\"}\",\"metadata\":{\"cpki_event_id\":\"7200eb6b-4fe8-4324-9db5-6a553bb0ad25\",\"cpki_event_seq\":14,\"cpki_ingested\":\"01a15ba1-3379-7ea1-a64a-a95eb9a95908\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 9465.908, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786615.127679, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"metadata\":{\"cpki_event_id\":\"72e152e5-33f3-4a2c-8972-212a55af1755\",\"cpki_event_seq\":15,\"cpki_ingested\":\"01a15ba1-3381-7cff-b939-77f43e784839\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_END\"}]" + } + } + }, + { + "kind": "event", + "t": 9467.238, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786615.129085, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"content\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"name\\\":\\\"Globex expansion\\\",\\\"amount\\\":48000,\\\"stage\\\":\\\"review\\\",\\\"pastDiscounts\\\":[\\\"10% on renewal in 2025\\\"],\\\"paymentRecord\\\":\\\"paid on time for 3 years\\\",\\\"approvals\\\":[\\\"sales manager\\\"]}\",\"messageId\":\"8ac11a2e-9a5a-41d7-9c6b-99ea91f4bf4d\",\"metadata\":{\"cpki_event_id\":\"d0d66edc-dce2-4936-af65-ea91435bb1a6\",\"cpki_event_seq\":16,\"cpki_ingested\":\"01a15ba1-3381-7b16-b753-f573fbe099be\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"role\":\"tool\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"TOOL_CALL_RESULT\"}]" + } + } + }, + { + "kind": "event", + "t": 10266.928, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.213", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/learning-events", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"trajectoryId\":\"542cc753-7048-4fb6-8049-e8d96ecad7b4\",\"events\":[{\"type\":\"CUSTOM\",\"name\":\"agent.message\",\"value\":{\"messageId\":\"rs_03286b8662af5d1a006ad7f81b089087d0b0768bb42d0ac82b:0\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"role\":\"reasoning\",\"textLength\":0,\"text\":\"\",\"seq\":11},\"timestamp\":1792538653139},{\"type\":\"CUSTOM\",\"name\":\"tool.call\",\"value\":{\"phase\":\"started\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"toolName\":\"lookupDealHistory\",\"agentId\":\"default\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"messageId\":\"1118aa14-05af-485a-b710-3a37e8905eb5\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"seq\":12},\"timestamp\":1792538653141},{\"type\":\"CUSTOM\",\"name\":\"agent.message\",\"value\":{\"messageId\":\"8ac11a2e-9a5a-41d7-9c6b-99ea91f4bf4d\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"role\":\"tool\",\"textLength\":193,\"text\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"name\\\":\\\"Globex expansion\\\",\\\"amount\\\":48000,\\\"stage\\\":\\\"review\\\",\\\"pastDiscounts\\\":[\\\"10% on renewal in 2025\\\"],\\\"paymentRecord\\\":\\\"paid on time for 3 years\\\",\\\"approvals\\\":[\\\"sales manager\\\"]}\",\"seq\":13},\"timestamp\":1792538653574}],\"dropped\":0}", + "hasPostData": true + }, + "timestamp": 1786615.927415, + "wallTime": 1792538654.372563, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 10272.493, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786615.934025, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 10316.464, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786615.9783, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"32\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 10316.472, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786615.978364, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 10316.601, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786615.978614, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 10507.401, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.213", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786616.169203, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/learning-events", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:14 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 10507.44, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.213", + "timestamp": 1786616.169376, + "dataLength": 14, + "encodedDataLength": 24 + } + }, + { + "kind": "event", + "t": 10548.594, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.210151, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"metadata\":{\"cpki_event_id\":\"c785717a-9958-4d6b-a836-3bee1fd7e8dd\",\"cpki_event_seq\":17,\"cpki_ingested\":\"01a15ba1-37bc-74e9-953e-d931af8f3a30\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"parentMessageId\":\"1118aa14-05af-485a-b710-3a37e8905eb5\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"toolCallName\":\"approveDeal\",\"type\":\"TOOL_CALL_START\"}]" + } + } + }, + { + "kind": "event", + "t": 10557.6, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.219309, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"{\\\"\",\"metadata\":{\"cpki_event_id\":\"4c179d6a-16f9-4477-9ac3-d61239e833a5\",\"cpki_event_seq\":18,\"cpki_ingested\":\"01a15ba1-37c5-788b-837e-c37235b65ec5\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10561.508, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.223332, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"deal\",\"metadata\":{\"cpki_event_id\":\"af1ee687-464e-485d-835d-d8e116029d23\",\"cpki_event_seq\":19,\"cpki_ingested\":\"01a15ba1-37c5-7bb4-82cb-f51118cba71c\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10561.785, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.22374, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"Id\",\"metadata\":{\"cpki_event_id\":\"9080b421-2eb8-48e0-9ad8-1e4fd019ab46\",\"cpki_event_seq\":20,\"cpki_ingested\":\"01a15ba1-37c5-7d05-a4e9-73639365c376\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10561.998, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.213", + "timestamp": 1786616.169073, + "encodedDataLength": 274 + } + }, + { + "kind": "command", + "t": 10562.006, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.213" }, + "tEnd": 10565.096, + "result": { "body": "{\"inserted\":3}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 10562.058, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786616.224038, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"33\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 10562.085, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786616.22406, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 10591.929, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.253632, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"\\\":\\\"\",\"metadata\":{\"cpki_event_id\":\"cb4cad4f-6d2b-4055-8bed-4d4e228c5d62\",\"cpki_event_seq\":21,\"cpki_ingested\":\"01a15ba1-37e7-7618-a2a7-95a2f4e36c77\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10598.65, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.260417, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"deal\",\"metadata\":{\"cpki_event_id\":\"249d692e-f69a-40e6-a497-e52420ecf1e1\",\"cpki_event_seq\":22,\"cpki_ingested\":\"01a15ba1-37ee-7c5c-9f9a-363e73588986\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10601.952, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.263786, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"-\",\"metadata\":{\"cpki_event_id\":\"5f895875-262b-4dc0-920d-e000d7036d79\",\"cpki_event_seq\":23,\"cpki_ingested\":\"01a15ba1-37ee-7ded-a7c8-5befb62dc2d8\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10605.561, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.26745, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"2\",\"metadata\":{\"cpki_event_id\":\"45e4fc06-ef77-4973-8b05-95221eae938e\",\"cpki_event_seq\":24,\"cpki_ingested\":\"01a15ba1-37f4-7720-a044-006bbabb02c3\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10611.351, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.273159, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"\\\",\\\"\",\"metadata\":{\"cpki_event_id\":\"13c7135e-a4f6-4453-97c3-561a1d485b78\",\"cpki_event_seq\":25,\"cpki_ingested\":\"01a15ba1-37fb-7627-a6d8-9c96d023bb51\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10648.511, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.310269, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"amount\",\"metadata\":{\"cpki_event_id\":\"5ac73ecb-24f2-4268-bb6e-a8a250f15ac7\",\"cpki_event_seq\":26,\"cpki_ingested\":\"01a15ba1-3820-73e1-94c0-fa6e86c0ab2a\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10651.901, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.313703, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"\\\":\",\"metadata\":{\"cpki_event_id\":\"e5db8c96-a6ef-406f-a064-8c329ad7c62d\",\"cpki_event_seq\":27,\"cpki_ingested\":\"01a15ba1-3820-7cdf-9cfa-fd716dc93e7d\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10652.129, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.314068, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"480\",\"metadata\":{\"cpki_event_id\":\"6bb9acfa-81b8-43df-9e24-07d4d012cbb6\",\"cpki_event_seq\":28,\"cpki_ingested\":\"01a15ba1-3820-77cc-b38c-de49f7948284\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10656.166, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.317908, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"00\",\"metadata\":{\"cpki_event_id\":\"90ed5742-d9b6-4060-b967-d7ec9855ccb2\",\"cpki_event_seq\":29,\"cpki_ingested\":\"01a15ba1-3828-78dc-ad3c-1c90b2945b05\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10691.305, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.353034, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"}\",\"metadata\":{\"cpki_event_id\":\"cf5c1ef8-c3f2-4b73-8e3c-c9cccc43d15c\",\"cpki_event_seq\":30,\"cpki_ingested\":\"01a15ba1-3848-7637-ab77-d43cf53c0c7d\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_ARGS\"}]" + } + } + }, + { + "kind": "event", + "t": 10772.063, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.433817, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"metadata\":{\"cpki_event_id\":\"87b8e676-e13a-47c7-b45d-8adf6f3252dc\",\"cpki_event_seq\":31,\"cpki_ingested\":\"01a15ba1-389b-7035-8f38-811ca39ba7fb\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"TOOL_CALL_END\"}]" + } + } + }, + { + "kind": "event", + "t": 10905.202, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.566742, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"metadata\":{\"cpki_event_id\":\"cfc4a98e-88c8-450f-8d63-454cd76070c1\",\"cpki_event_seq\":32,\"cpki_ingested\":\"01a15ba1-3920-7892-9e5f-194ed80852fa\",\"finishReason\":\"tool-calls\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"RUN_FINISHED\",\"usage\":[{\"cachedInputTokens\":0,\"inputTokens\":838,\"model\":\"gpt-5.2\",\"outputTokens\":134,\"provider\":\"openai.responses\",\"reasoningTokens\":83,\"totalTokens\":972}]}]" + } + } + }, + { + "kind": "event", + "t": 10907.502, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.567921, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "[\"3\",\"6\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_leave\",{}]" + } + } + }, + { + "kind": "event", + "t": 10907.513, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.568074, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "[\"3\",\"7\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_leave\",{}]" + } + } + }, + { + "kind": "event", + "t": 10907.566, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.214", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"resource/request\",\"params\":{\"path\":\"/threads?agentId=default\",\"httpMethod\":\"GET\"}}", + "hasPostData": true + }, + "timestamp": 1786616.568804, + "wallTime": 1792538655.013798, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 10907.586, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.144", + "timestamp": 1786616.569537, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[null,null,\"user_meta:e05d7a88-dd5e-43ef-9136-bc98ce68d93d\",\"thread_run_activity\",{\"agent_id\":\"default\",\"event_type\":\"RUN_FINISHED\",\"latest_event_id\":\"cfc4a98e-88c8-450f-8d63-454cd76070c1\",\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"run_id\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"thread_run_activity\"}]" + } + } + }, + { + "kind": "event", + "t": 10910.554, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.572501, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",\"6\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_reply\",{\"status\":\"ok\",\"response\":{}}]" + } + } + }, + { + "kind": "event", + "t": 10910.559, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.572527, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",\"3\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_close\",{}]" + } + } + }, + { + "kind": "event", + "t": 10910.561, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.148", + "timestamp": 1786616.572538, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",\"7\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_reply\",{\"status\":\"ok\",\"response\":{}}]" + } + } + }, + { + "kind": "event", + "t": 10911.855, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786616.573668, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 10951.253, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786616.613079, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"34\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 10951.264, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786616.613154, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 10951.753, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786616.61368, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 11057.055, + "session": "page", + "method": "Network.webSocketClosed", + "params": { "requestId": "68328.148", "timestamp": 1786616.718973 } + }, + { + "kind": "event", + "t": 11142.561, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.214", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786616.804389, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:15 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 11142.585, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.214", + "timestamp": 1786616.804522, + "dataLength": 938, + "encodedDataLength": 950 + } + }, + { + "kind": "event", + "t": 11142.589, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.214", + "timestamp": 1786616.804319, + "encodedDataLength": 1232 + } + }, + { + "kind": "command", + "t": 11142.594, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.214" }, + "tEnd": 11142.85, + "result": { + "body": "{\"threads\":[{\"id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"organizationId\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"projectId\":1,\"agentId\":\"default\",\"endUserId\":\"agui-e2e-demo-user\",\"createdById\":\"1_agui-e2e-demo-user\",\"learningContainerIds\":[],\"learningContainerId\":null,\"learningContainerAssignedAt\":null,\"name\":\"Deal History Lookup and Approval Request\",\"archived\":false,\"createdAt\":\"2026-10-20T23:24:10.032Z\",\"updatedAt\":\"2026-10-20T23:24:15.213Z\"},{\"id\":\"b516d408-26eb-4fd6-9fc9-8a070c87efff\",\"organizationId\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"projectId\":1,\"agentId\":\"default\",\"endUserId\":\"agui-e2e-demo-user\",\"createdById\":\"1_agui-e2e-demo-user\",\"learningContainerIds\":[],\"learningContainerId\":null,\"learningContainerAssignedAt\":null,\"name\":\"Hello in Five Words\",\"archived\":false,\"createdAt\":\"2026-10-20T23:23:47.571Z\",\"updatedAt\":\"2026-10-20T23:23:49.062Z\"}],\"nextCursor\":null,\"joinCode\":\"e05d7a88-dd5e-43ef-9136-bc98ce68d93d\"}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 11179.83, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786616.841646, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"35\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 11179.839, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786616.84171, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 12266.947, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.215", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/learning-events", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"trajectoryId\":\"542cc753-7048-4fb6-8049-e8d96ecad7b4\",\"events\":[{\"type\":\"CUSTOM\",\"name\":\"tool.call\",\"value\":{\"phase\":\"started\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"toolName\":\"approveDeal\",\"agentId\":\"default\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"messageId\":\"1118aa14-05af-485a-b710-3a37e8905eb5\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"seq\":14},\"timestamp\":1792538654655},{\"type\":\"CUSTOM\",\"name\":\"agent.run\",\"value\":{\"phase\":\"finished\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"seq\":15},\"timestamp\":1792538655013},{\"type\":\"CUSTOM\",\"name\":\"tool.call\",\"value\":{\"phase\":\"executing\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"toolName\":\"approveDeal\",\"agentId\":\"default\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"messageId\":\"1118aa14-05af-485a-b710-3a37e8905eb5\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"seq\":16},\"timestamp\":1792538655014}],\"dropped\":0}", + "hasPostData": true + }, + "timestamp": 1786617.927597, + "wallTime": 1792538656.372514, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 12271.345, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786617.932985, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 12319.52, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786617.981343, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"36\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 12319.532, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786617.981407, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 12319.669, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786617.981649, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 12491.051, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786618.15287, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"37\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 12491.059, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786618.152928, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 12501.91, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.215", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786618.163799, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/learning-events", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:16 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 12501.919, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.215", + "timestamp": 1786618.163913, + "dataLength": 14, + "encodedDataLength": 24 + } + }, + { + "kind": "event", + "t": 12553.025, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.215", + "timestamp": 1786618.16366, + "encodedDataLength": 274 + } + }, + { + "kind": "command", + "t": 12553.039, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.215" }, + "tEnd": 12553.257, + "result": { "body": "{\"inserted\":3}", "base64Encoded": false } + }, + { + "kind": "mark", + "t": 14728.068, + "wall": 1792538658834, + "name": "click", + "data": { + "name": "approve", + "selector": "[data-copilotkit-action=\"hitl.approve\"]", + "text": "Approve" + } + }, + { + "kind": "event", + "t": 14737.189, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.216", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"agent/run\",\"params\":{\"agentId\":\"default\"},\"body\":{\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"approveDeal\",\"description\":\"Ask the user to approve or reject the deal\",\"parameters\":{\"type\":\"object\",\"properties\":{\"dealId\":{\"type\":\"string\"},\"amount\":{\"type\":\"number\"}},\"required\":[\"dealId\",\"amount\"]}}],\"context\":[{\"description\":\"The deal the user is looking at\",\"value\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"}],\"forwardedProps\":{},\"state\":{},\"messages\":[{\"id\":\"f811d72c-7d73-4a02-9540-d9d7491c10d6\",\"role\":\"user\",\"content\":\"Look up this deal's history, then ask me to approve it.\"},{\"id\":\"rs_03286b8662af5d1a006ad7f81b089087d0b0768bb42d0ac82b:0\",\"role\":\"reasoning\",\"content\":\"\",\"metadata\":{\"cpki_event_id\":\"0ee3d39a-0207-4b2e-814c-0f792ede4205\",\"cpki_event_seq\":4,\"cpki_ingested\":\"01a15ba1-31ce-7519-bb31-8748f966ad78\"}},{\"id\":\"1118aa14-05af-485a-b710-3a37e8905eb5\",\"role\":\"assistant\",\"toolCalls\":[{\"id\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"type\":\"function\",\"function\":{\"name\":\"lookupDealHistory\",\"arguments\":\"{\\\"dealId\\\":\\\"deal-2\\\"}\"},\"metadata\":{\"cpki_event_id\":\"72e152e5-33f3-4a2c-8972-212a55af1755\",\"cpki_event_seq\":15,\"cpki_ingested\":\"01a15ba1-3381-7cff-b939-77f43e784839\"}},{\"id\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"type\":\"function\",\"function\":{\"name\":\"approveDeal\",\"arguments\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"},\"metadata\":{\"cpki_event_id\":\"87b8e676-e13a-47c7-b45d-8adf6f3252dc\",\"cpki_event_seq\":31,\"cpki_ingested\":\"01a15ba1-389b-7035-8f38-811ca39ba7fb\"}}]},{\"id\":\"8ac11a2e-9a5a-41d7-9c6b-99ea91f4bf4d\",\"toolCallId\":\"call_SNLYWmpBXKyr18vBhdDjNA0Y\",\"role\":\"tool\",\"content\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"name\\\":\\\"Globex expansion\\\",\\\"amount\\\":48000,\\\"stage\\\":\\\"review\\\",\\\"pastDiscounts\\\":[\\\"10% on renewal in 2025\\\"],\\\"paymentRecord\\\":\\\"paid on time for 3 years\\\",\\\"approvals\\\":[\\\"sales manager\\\"]}\",\"metadata\":{\"cpki_event_id\":\"d0d66edc-dce2-4936-af65-ea91435bb1a6\",\"cpki_event_seq\":16,\"cpki_ingested\":\"01a15ba1-3381-7b16-b753-f573fbe099be\"}},{\"id\":\"3e22419a-757f-4f7e-b56c-3d498d3abe18\",\"role\":\"tool\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"content\":\"approved\"}]}}", + "hasPostData": true + }, + "timestamp": 1786620.398813, + "wallTime": 1792538658.843442, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 14754.038, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786620.415938, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 14814.736, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786620.476539, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"38\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 14814.745, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786620.476595, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 14814.986, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786620.476929, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 15040.674, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786620.702442, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"39\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 15040.688, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786620.702516, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 15053.182, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.216", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786620.715051, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "cache-control": "no-cache", + "Connection": "keep-alive", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:19 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 15053.205, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.216", + "timestamp": 1786620.715189, + "dataLength": 257, + "encodedDataLength": 269 + } + }, + { + "kind": "event", + "t": 15053.208, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.216", + "timestamp": 1786620.715015, + "encodedDataLength": 576 + } + }, + { + "kind": "command", + "t": 15053.213, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.216" }, + "tEnd": 15054.115, + "result": { + "body": "{\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"joinToken\":\"join-token-3\",\"realtime\":{\"clientUrl\":\"ws://127.0.0.1:7253/client\",\"topic\":\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\"}}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 15054.084, + "session": "page", + "method": "Network.webSocketCreated", + "params": { + "requestId": "68328.217", + "url": "ws://127.0.0.1:7253/client/websocket?join_token=join-token-3&vsn=2.0.0" + } + }, + { + "kind": "event", + "t": 15054.216, + "session": "page", + "method": "Network.webSocketWillSendHandshakeRequest", + "params": { + "requestId": "68328.217", + "timestamp": 1786620.716199, + "wallTime": 1792538659.160775, + "request": { + "headers": { + "Upgrade": "websocket", + "Origin": "http://127.0.0.1:7621", + "Cache-Control": "no-cache", + "Accept-Language": "en-US,en;q=0.9", + "Pragma": "no-cache", + "Connection": "Upgrade", + "Sec-WebSocket-Key": "9rFU+Szs/FmE7B7QZO+BYg==", + "Accept-Encoding": "gzip, deflate, br, zstd", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Sec-WebSocket-Version": "13", + "Host": "127.0.0.1:7253", + "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits" + } + } + } + }, + { + "kind": "event", + "t": 15055.817, + "session": "page", + "method": "Network.webSocketHandshakeResponseReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786620.717629, + "response": { + "status": 101, + "statusText": "Switching Protocols", + "headers": { + "upgrade": "websocket", + "cache-control": "max-age=0, private, must-revalidate", + "date": "Tue, 20 Oct 2026 23:24:18 GMT", + "sec-websocket-accept": "Iatp5+s3A6S+aYu7pM2rDhk52Ws=", + "connection": "Upgrade" + } + } + } + }, + { + "kind": "event", + "t": 15055.825, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.217", + "timestamp": 1786620.717709, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "[\"3\",\"3\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_join\",{\"stream_mode\":\"run\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\"}]" + } + } + }, + { + "kind": "event", + "t": 15056.973, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786620.718879, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",\"3\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_reply\",{\"status\":\"ok\",\"response\":{}}]" + } + } + }, + { + "kind": "event", + "t": 15056.978, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786620.71898, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"connected\",{\"user_id\":\"1_agui-e2e-demo-user\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\"}]" + } + } + }, + { + "kind": "event", + "t": 15058.691, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.144", + "timestamp": 1786620.72058, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[null,null,\"user_meta:e05d7a88-dd5e-43ef-9136-bc98ce68d93d\",\"thread_run_activity\",{\"agent_id\":\"default\",\"event_type\":\"RUN_STARTED\",\"latest_event_id\":\"9c1c3caa-dd2c-4b2b-bc3b-95de0084296f\",\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"thread_run_activity\"}]" + } + } + }, + { + "kind": "event", + "t": 15111.999, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786620.773568, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"input\":{\"context\":[{\"description\":\"The deal the user is looking at\",\"value\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"}],\"messages\":[{\"content\":\"\",\"id\":\"rs_03286b8662af5d1a006ad7f81b089087d0b0768bb42d0ac82b:0\",\"metadata\":{\"cpki_event_id\":\"0ee3d39a-0207-4b2e-814c-0f792ede4205\",\"cpki_event_seq\":4,\"cpki_ingested\":\"01a15ba1-31ce-7519-bb31-8748f966ad78\"},\"role\":\"reasoning\"},{\"content\":\"approved\",\"id\":\"3e22419a-757f-4f7e-b56c-3d498d3abe18\",\"role\":\"tool\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\"}],\"protocolVersion\":\"1.0\",\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"state\":{},\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"tools\":[{\"description\":\"Ask the user to approve or reject the deal\",\"name\":\"approveDeal\",\"parameters\":{\"properties\":{\"amount\":{\"type\":\"number\"},\"dealId\":{\"type\":\"string\"}},\"required\":[\"dealId\",\"amount\"],\"type\":\"object\"}}]},\"metadata\":{\"cpki_event_id\":\"9c1c3caa-dd2c-4b2b-bc3b-95de0084296f\",\"cpki_event_seq\":1,\"cpki_ingested\":\"01a15ba1-4959-74f9-a61a-13429690da2c\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"protocolVersion\":\"1.0\",\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"RUN_STARTED\"}]" + } + } + }, + { + "kind": "event", + "t": 15113.092, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786620.775038, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"replay_complete\",{\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"latestEventId\":\"9c1c3caa-dd2c-4b2b-bc3b-95de0084296f\"}]" + } + } + }, + { + "kind": "event", + "t": 15810.15, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.471476, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"8f65eaae-7013-4d67-82ae-49076d970a38\",\"cpki_event_seq\":2,\"cpki_ingested\":\"01a15ba1-4c49-79fe-92a9-c9750df67bfe\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"role\":\"assistant\",\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_START\"}]" + } + } + }, + { + "kind": "event", + "t": 15815.091, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.476732, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"9ee54f58-589d-4569-ab5c-6bff39abe4a9\",\"cpki_event_seq\":3,\"cpki_ingested\":\"01a15ba1-4c49-7338-9e94-b0da15d8f9c3\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15856.609, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.518321, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"Deal\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"2ebb4657-aa89-437d-b1e7-a35371737f63\",\"cpki_event_seq\":4,\"cpki_ingested\":\"01a15ba1-4c78-726b-a3cf-d4c8122d18a1\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15859.907, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.521695, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" history\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"1125764e-a914-40a0-bdbd-0add1c40e626\",\"cpki_event_seq\":5,\"cpki_ingested\":\"01a15ba1-4c78-72eb-b570-d175d51a1f0a\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15860.226, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.522152, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" (\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"d04cfe39-bd45-4740-a8f5-51d5804dfd49\",\"cpki_event_seq\":6,\"cpki_ingested\":\"01a15ba1-4c78-7d8e-983f-5fa0f07f15bf\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15906.399, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.568089, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"deal\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"592e96dc-2448-4d92-a907-a1e26ccdd94c\",\"cpki_event_seq\":7,\"cpki_ingested\":\"01a15ba1-4caa-7a92-b097-f6bf34f26469\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15912.191, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.57393, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"-\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"e70cfe87-0dfb-41dd-864f-58675d03f058\",\"cpki_event_seq\":8,\"cpki_ingested\":\"01a15ba1-4caf-764c-88fc-bd95241e16ab\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15914.999, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.576843, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"2\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"84258230-cd29-4b1e-863b-1ecfb60ace33\",\"cpki_event_seq\":9,\"cpki_ingested\":\"01a15ba1-4caf-71e3-8538-4331757e328d\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15915.378, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.577285, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\":\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"aa77c8cb-4bb8-4007-b6fd-ecf83557c263\",\"cpki_event_seq\":10,\"cpki_ingested\":\"01a15ba1-4caf-7b03-be1f-b0a972ad78e2\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15958.505, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.620185, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" “\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"f68999b4-21d2-4347-9146-b900948530c8\",\"cpki_event_seq\":11,\"cpki_ingested\":\"01a15ba1-4cdc-7843-b874-1fe8ec02cc2c\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15964.095, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.625854, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"Glob\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"5bfc1b94-4efd-482a-aed9-5ef375177dd2\",\"cpki_event_seq\":12,\"cpki_ingested\":\"01a15ba1-4cdc-7e9a-adc9-df9c96e755fe\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15964.385, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.626344, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"ex\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"4d0b5a4c-0d52-45af-bb11-abbdd263d194\",\"cpki_event_seq\":13,\"cpki_ingested\":\"01a15ba1-4cdc-7b83-9286-f2d0de44e233\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15964.649, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.626614, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" expansion\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"5a2e1756-1e45-4afd-b69f-b9ac79bd082d\",\"cpki_event_seq\":14,\"cpki_ingested\":\"01a15ba1-4cdc-7480-9c26-48700cc951b3\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 15964.898, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.626848, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"”,\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"5808668d-43ba-4662-b4df-737ea9476cc1\",\"cpki_event_seq\":15,\"cpki_ingested\":\"01a15ba1-4cdc-7304-9913-3fe6dd090af8\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16006.224, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.667955, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" $\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"e7ec45f3-ae21-4f01-91d9-18b4e956abd5\",\"cpki_event_seq\":16,\"cpki_ingested\":\"01a15ba1-4d0d-7118-9392-8e87247b1019\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16008.3, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.670111, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"48\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"e9fa9553-61bd-4b9c-8657-2ed11891129a\",\"cpki_event_seq\":17,\"cpki_ingested\":\"01a15ba1-4d0e-7c8d-aa09-10e11a59a190\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16011.997, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.673821, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\",\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"871b8408-cfbb-499a-a009-29f037cf0e5b\",\"cpki_event_seq\":18,\"cpki_ingested\":\"01a15ba1-4d14-79da-ac6b-df111abfc407\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16013.791, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.67564, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"000\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"54542e6b-549a-48dc-99ef-ef351dccd683\",\"cpki_event_seq\":19,\"cpki_ingested\":\"01a15ba1-4d14-797e-b145-f24fe711a866\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16014.002, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.675976, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\")**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"4cecf799-1a8b-403c-9881-00b6a2a2dde8\",\"cpki_event_seq\":20,\"cpki_ingested\":\"01a15ba1-4d14-7023-90ee-a7f4d4c06684\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16055.802, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.7175, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" \\n\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"271ff0b5-d9ba-4157-ad8d-fb0bcc8f1f22\",\"cpki_event_seq\":21,\"cpki_ingested\":\"01a15ba1-4d3f-7540-b35b-c8ded33fadc5\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16062.467, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.72429, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"-\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"93f80e73-0bb6-41c3-997a-adf209eb0f28\",\"cpki_event_seq\":22,\"cpki_ingested\":\"01a15ba1-4d46-7927-a680-a11396d7e101\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16064.307, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.72622, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" Stage\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"0b76886f-c310-4b20-9f5c-c6d3674e4d65\",\"cpki_event_seq\":23,\"cpki_ingested\":\"01a15ba1-4d46-7220-8f12-6d835296513b\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16064.635, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.72657, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\":\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"5d4a8659-b203-4e4f-819d-70c2d366bb4c\",\"cpki_event_seq\":24,\"cpki_ingested\":\"01a15ba1-4d46-72d0-a8d2-3758e7832d94\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16064.873, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.726852, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" **\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"be8d5ab0-25df-441b-b6f8-af583c8eeedd\",\"cpki_event_seq\":25,\"cpki_ingested\":\"01a15ba1-4d46-7548-9bea-118913ac1438\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16070.659, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.144", + "timestamp": 1786621.732632, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "[null,\"6\",\"phoenix\",\"heartbeat\",{}]" + } + } + }, + { + "kind": "event", + "t": 16071.334, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.144", + "timestamp": 1786621.733298, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[null,\"6\",\"phoenix\",\"phx_reply\",{\"status\":\"ok\",\"response\":{}}]" + } + } + }, + { + "kind": "event", + "t": 16108.341, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.770053, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"review\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"5657b6a8-038c-4ca1-bcd3-69f3e4bbfc29\",\"cpki_event_seq\":26,\"cpki_ingested\":\"01a15ba1-4d73-7ffc-857c-3a9f2faf609c\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16110.852, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.77265, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"a84c5518-848f-4b8a-b94f-094b51080f2a\",\"cpki_event_seq\":27,\"cpki_ingested\":\"01a15ba1-4d73-7a50-9367-74b759140e05\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16111.08, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.773017, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" \\n\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"a28e5f3e-3f3f-4b6d-b742-dbc1edcc437e\",\"cpki_event_seq\":28,\"cpki_ingested\":\"01a15ba1-4d73-76e2-b6a3-b0cdbad4e916\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16111.405, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.773282, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"-\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"2b6258ed-edd2-4333-8883-75f4911511c1\",\"cpki_event_seq\":29,\"cpki_ingested\":\"01a15ba1-4d73-7c37-a8dc-d34387e9a1af\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16154.517, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.816182, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" Past\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"cb9312c9-7e00-4b50-abed-2a86bc5afc29\",\"cpki_event_seq\":30,\"cpki_ingested\":\"01a15ba1-4da2-7126-8f05-0e132369d646\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16160.676, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.822444, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" discounts\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"c7cc30af-1d2d-45f2-8687-82c091442e93\",\"cpki_event_seq\":31,\"cpki_ingested\":\"01a15ba1-4da8-7126-96c4-68045ea4bd08\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16163.131, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.82497, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\":\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"92f36478-bd4e-4eff-b194-950980f02f73\",\"cpki_event_seq\":32,\"cpki_ingested\":\"01a15ba1-4da8-78ea-917b-a71fbc2c93f2\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16163.457, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.825409, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" **\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"ee080c06-e779-4cf2-9de9-d8c7b6c587b0\",\"cpki_event_seq\":33,\"cpki_ingested\":\"01a15ba1-4da8-7de9-b723-2547f38d5509\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16163.766, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.82569, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"10\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"4a6afe7d-5cb9-42b6-afba-ebd6640e27a3\",\"cpki_event_seq\":34,\"cpki_ingested\":\"01a15ba1-4da8-75af-a9fa-05c257b8df47\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16163.993, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.825957, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"%\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"ac57e20e-a9d5-410f-93e1-68760fdc1237\",\"cpki_event_seq\":35,\"cpki_ingested\":\"01a15ba1-4da8-7e0c-ad88-122e15ae2e21\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16210.415, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.872085, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" on\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"afa206cd-9804-44d8-ad30-5e4962ba7b2e\",\"cpki_event_seq\":36,\"cpki_ingested\":\"01a15ba1-4dd9-7322-9a5a-66ebdbfae474\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16212.851, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.874741, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" renewal\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"a1e28b8f-fbae-4f25-916e-68eb8b6d27f7\",\"cpki_event_seq\":37,\"cpki_ingested\":\"01a15ba1-4dd9-7f11-bbe1-dd0d55ab1165\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16213.132, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.875087, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" in\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"72887078-b793-4a7d-9788-4b3e25089e07\",\"cpki_event_seq\":38,\"cpki_ingested\":\"01a15ba1-4dd9-7f38-9dc5-8dfb54a3d8b7\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16213.38, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.875336, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" \",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"0cb23f10-6dba-4ce1-9146-c06682942924\",\"cpki_event_seq\":39,\"cpki_ingested\":\"01a15ba1-4dd9-7abb-801c-95c5d2bc6c4b\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16250.483, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.912184, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"202\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"518bb166-aeb2-4259-8a7a-b89b4c3eec5d\",\"cpki_event_seq\":40,\"cpki_ingested\":\"01a15ba1-4e02-756c-bfa5-91384fb83374\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16252.599, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.914453, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"5\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"93500c89-fe42-4008-bf07-cf6e8a5e002f\",\"cpki_event_seq\":41,\"cpki_ingested\":\"01a15ba1-4e02-7ba0-9d25-85b4d5d24a8c\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16252.857, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.914811, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"d056eda2-3c7f-4b05-a3a3-dcebc7bfde6b\",\"cpki_event_seq\":42,\"cpki_ingested\":\"01a15ba1-4e02-7403-98bd-6bc2c2801356\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16257.471, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.919216, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" \\n\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"8d1ef83b-64f8-4b95-b388-e7c1d48badae\",\"cpki_event_seq\":43,\"cpki_ingested\":\"01a15ba1-4e09-7498-9011-a7728363eb45\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16259.276, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.921132, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"-\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"107c3834-800c-4bd0-82f1-649e0dba1b27\",\"cpki_event_seq\":44,\"cpki_ingested\":\"01a15ba1-4e09-749b-bf02-2fe1448b9c0c\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16264.957, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.218", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/learning-events", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"trajectoryId\":\"542cc753-7048-4fb6-8049-e8d96ecad7b4\",\"events\":[{\"type\":\"CUSTOM\",\"name\":\"click\",\"value\":{\"target\":{\"tag\":\"button\",\"role\":null,\"action\":\"hitl.approve\",\"text\":\"Approve\",\"attributes\":{\"type\":\"button\",\"data-copilotkit-action\":\"hitl.approve\",\"class\":\"rounded bg-green-600 px-3 py-1 text-sm text-white disabled:opacity-50\"},\"label\":\"Approve\",\"input\":\"pointer\"},\"route\":\"/learning/deals/deal-2\",\"url\":\"http://127.0.0.1:7621/learning/deals/deal-2\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"messageId\":\"1118aa14-05af-485a-b710-3a37e8905eb5\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"toolName\":\"approveDeal\",\"toolStatus\":\"executing\",\"seq\":17},\"timestamp\":1792538658836},{\"type\":\"CUSTOM\",\"name\":\"deal.approved\",\"value\":{\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"dealId\":\"deal-2\",\"via\":\"agent\",\"seq\":18},\"timestamp\":1792538658836},{\"type\":\"CUSTOM\",\"name\":\"tool.call\",\"value\":{\"phase\":\"completed\",\"toolCallId\":\"call_C9gtpShensOH8ulrTwkZpE3n\",\"toolName\":\"approveDeal\",\"agentId\":\"default\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"messageId\":\"1118aa14-05af-485a-b710-3a37e8905eb5\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"durationMs\":3822,\"outcome\":\"ok\",\"seq\":19},\"timestamp\":1792538658836},{\"type\":\"CUSTOM\",\"name\":\"agent.run\",\"value\":{\"phase\":\"started\",\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"seq\":20},\"timestamp\":1792538659218}],\"dropped\":0}", + "hasPostData": true + }, + "timestamp": 1786621.92651, + "wallTime": 1792538660.371016, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 16266.724, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786621.928671, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 16299.255, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786621.961104, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"40\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 16299.259, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786621.961175, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 16299.41, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786621.961412, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 16300.493, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.962309, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" Payment\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"7465e12c-c3ba-413e-860a-e762bdc51fee\",\"cpki_event_seq\":45,\"cpki_ingested\":\"01a15ba1-4e34-7689-8908-433f971c2f1d\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16302.382, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.964283, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" record\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"9dba2635-f9ec-4c85-b8e9-32ee5579143c\",\"cpki_event_seq\":46,\"cpki_ingested\":\"01a15ba1-4e34-750b-a1e2-9e61576276b3\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16302.609, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.964583, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\":\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"d7e17e2e-f8f6-44c7-9a45-e3d1f4fb9dd2\",\"cpki_event_seq\":47,\"cpki_ingested\":\"01a15ba1-4e34-77fd-ba7f-a9be250f7e75\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16302.816, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.964791, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" **\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"0e91b9fa-15a3-40ca-8cb0-d212cb6c8ef1\",\"cpki_event_seq\":48,\"cpki_ingested\":\"01a15ba1-4e34-77cf-b2e7-8ea9678a373f\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16307.058, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786621.968913, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"paid\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"f2a61ac2-0eb5-4127-a951-39b10d27a6a4\",\"cpki_event_seq\":49,\"cpki_ingested\":\"01a15ba1-4e3b-78d4-a9d3-c3fbcd4b06a0\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16350.237, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.012015, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" on\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"6305aa40-8346-4704-b76a-70e34338c271\",\"cpki_event_seq\":50,\"cpki_ingested\":\"01a15ba1-4e66-7c9d-9ecb-01eb1b477fac\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16351.945, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.01387, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" time\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"ea9c7a96-d26f-4610-aede-7afa276d58e0\",\"cpki_event_seq\":51,\"cpki_ingested\":\"01a15ba1-4e66-77c0-9d2f-e73c709e77b3\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16352.168, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.014139, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" for\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"88d67205-5abc-4f56-9bf9-9aef84ba78a3\",\"cpki_event_seq\":52,\"cpki_ingested\":\"01a15ba1-4e66-714a-a410-a220b76cf26a\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16352.35, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.014336, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" \",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"2a49cef5-2cb7-4968-af05-fb681e59c337\",\"cpki_event_seq\":53,\"cpki_ingested\":\"01a15ba1-4e66-7473-8f43-13f4cda6acfc\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16358.977, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.020855, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"3\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"d866aa7d-2fdc-437d-b928-92ede53e6a64\",\"cpki_event_seq\":54,\"cpki_ingested\":\"01a15ba1-4e6f-7cbe-8a54-316b059a67f2\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16397.126, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.058901, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" years\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"c6e19e18-9468-43d9-b214-428c2eb11a74\",\"cpki_event_seq\":55,\"cpki_ingested\":\"01a15ba1-4e95-7419-9347-eae96fbe5318\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16398.867, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.060782, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"364465d1-10a3-4118-b7fc-1e842132162a\",\"cpki_event_seq\":56,\"cpki_ingested\":\"01a15ba1-4e95-749d-b4b6-16a760d16ad6\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16399.138, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.061076, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" \\n\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"e62fe0ad-7ddc-4d6e-b917-ac7a22f6feb4\",\"cpki_event_seq\":57,\"cpki_ingested\":\"01a15ba1-4e95-7e9b-af2b-1cc01712ecba\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16405.073, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.066903, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"-\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"c0bc5f46-c5c5-44aa-898c-8a7b0c9f5738\",\"cpki_event_seq\":58,\"cpki_ingested\":\"01a15ba1-4e9d-73bd-9fcc-f6a89462169e\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16406.59, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.068554, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" Appro\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"4ae2609d-1803-4746-9f7a-2829b9bcc9be\",\"cpki_event_seq\":59,\"cpki_ingested\":\"01a15ba1-4e9d-76aa-a2ae-f16f3e41bad8\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16443.581, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.105369, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"vals\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"c7df2e41-45ff-4c69-b696-d992cf61be80\",\"cpki_event_seq\":60,\"cpki_ingested\":\"01a15ba1-4ec3-7511-9990-60555964407c\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16498.74, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.160412, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" so\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"353d1cc2-d57f-4731-bc90-9abe57b791cf\",\"cpki_event_seq\":61,\"cpki_ingested\":\"01a15ba1-4ef9-726f-a3e9-8d715c3eedb2\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16512.758, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.218", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786622.174674, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/learning-events", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:20 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 16512.765, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.218", + "timestamp": 1786622.174761, + "dataLength": 14, + "encodedDataLength": 24 + } + }, + { + "kind": "event", + "t": 16523.557, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.185318, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" far\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"79a0f76c-cfec-44ff-b921-b1fda6f1354c\",\"cpki_event_seq\":62,\"cpki_ingested\":\"01a15ba1-4f0f-78cd-be2c-35be419c5212\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16525.386, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.187273, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\":\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"1170f161-13da-451a-857b-dfc0c8b95bf9\",\"cpki_event_seq\":63,\"cpki_ingested\":\"01a15ba1-4f0f-746e-b0b7-7c6205632b6e\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16525.612, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.18759, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" **\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"47c78a7a-3e43-40e5-acb6-3073d272c8a5\",\"cpki_event_seq\":64,\"cpki_ingested\":\"01a15ba1-4f0f-738b-9460-7ae60bacbbd4\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16525.8, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.187785, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"sales\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"8812dd75-fe55-4f49-be1e-d108998d7f6e\",\"cpki_event_seq\":65,\"cpki_ingested\":\"01a15ba1-4f0f-7ad3-a59e-e251ee3c21e4\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16536.207, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786622.198016, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"41\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 16536.225, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786622.198067, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 16546.785, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.20851, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" manager\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"3a1c451f-5338-4eb9-b42f-bcde27fc6fa3\",\"cpki_event_seq\":66,\"cpki_ingested\":\"01a15ba1-4f2a-79ee-9426-c70c78bc6ea8\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16548.744, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.210563, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\\n\\n\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"338d9f2e-98e8-4c4f-8082-e6bd301b3493\",\"cpki_event_seq\":67,\"cpki_ingested\":\"01a15ba1-4f2a-78ae-954a-878d105255eb\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16548.962, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.210919, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"7aa54466-ae6d-4b89-a075-cf56c42cc9b9\",\"cpki_event_seq\":68,\"cpki_ingested\":\"01a15ba1-4f2a-778d-9b13-b7ff50232d88\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16557.618, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.219369, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"Approval\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"c130a814-657f-40ff-8edb-c180e8c4adba\",\"cpki_event_seq\":69,\"cpki_ingested\":\"01a15ba1-4f35-76e3-9284-dfdadce4c1c4\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16559.525, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.221342, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" request\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"f0a7f86b-033d-4287-a445-9c7b33177c9a\",\"cpki_event_seq\":70,\"cpki_ingested\":\"01a15ba1-4f35-75c0-b9f7-cfbfbd457d15\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16564.322, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.218", + "timestamp": 1786622.174523, + "encodedDataLength": 274 + } + }, + { + "kind": "command", + "t": 16564.553, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.218" }, + "tEnd": 16564.811, + "result": { "body": "{\"inserted\":4}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 16600.244, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.262003, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"547c9fc3-2fc0-47ac-93c8-5d6f4602c01c\",\"cpki_event_seq\":71,\"cpki_ingested\":\"01a15ba1-4f60-7e7e-88bc-9c0303d350da\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16601.929, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.263853, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" \\n\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"5fc269a0-2e37-4eec-8393-5b55ffe7d846\",\"cpki_event_seq\":72,\"cpki_ingested\":\"01a15ba1-4f60-7a06-a230-28e66d8a0c10\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16645.349, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.307095, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"Approve\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"c007e4e5-aaed-4b44-87b5-4bb5c7c69022\",\"cpki_event_seq\":73,\"cpki_ingested\":\"01a15ba1-4f8d-7ebd-80c7-d130c0397919\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16697.898, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.359679, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" this\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"51d2f809-8676-4b95-82b2-562942b61059\",\"cpki_event_seq\":74,\"cpki_ingested\":\"01a15ba1-4fc1-7bf2-9c5f-7f63f37d9272\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16707.939, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.369733, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" deal\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"c60c0ab8-8f02-4083-a6c1-b258f569159d\",\"cpki_event_seq\":75,\"cpki_ingested\":\"01a15ba1-4fcc-7fef-8eb8-ebd7ae0a8ef0\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16751.097, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.412892, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" at\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"74b18285-f39d-4040-b6d3-4597b7ec3414\",\"cpki_event_seq\":76,\"cpki_ingested\":\"01a15ba1-4ff5-7d49-9a66-e0f7d417fcb9\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16752.787, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.414733, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" **\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"aa598013-dd90-4ef2-bb21-521f3cb62ba0\",\"cpki_event_seq\":77,\"cpki_ingested\":\"01a15ba1-4ff5-7f72-bbae-437930aa1330\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16752.986, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.414978, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"$\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"8487a1d4-ee4a-4e5d-a88f-c2df4a62db93\",\"cpki_event_seq\":78,\"cpki_ingested\":\"01a15ba1-4ff6-74cc-8a44-991a0da27d05\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16763.682, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.425472, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"48\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"50091895-5bdd-4cca-acff-c7494472dffd\",\"cpki_event_seq\":79,\"cpki_ingested\":\"01a15ba1-5002-7d91-b830-c6916002b5be\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16765.581, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.427498, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\",\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"71f6de10-752c-4e37-a98b-aeac88039ac2\",\"cpki_event_seq\":80,\"cpki_ingested\":\"01a15ba1-5002-767a-bc8c-f2254a865a2f\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16804.268, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.466012, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"000\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"347cd238-6ef5-4bb7-87fd-1065e2b27c52\",\"cpki_event_seq\":81,\"cpki_ingested\":\"01a15ba1-502a-7fdc-8794-27f51bf855c5\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16806.153, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.468076, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"7b5badb3-d829-4521-bb8b-2ad61e184733\",\"cpki_event_seq\":82,\"cpki_ingested\":\"01a15ba1-502a-7a06-9455-d61bd1c94396\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16806.443, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.468386, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"?\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"9e5eb1a0-c473-476c-8a2b-37eda61e3dc4\",\"cpki_event_seq\":83,\"cpki_ingested\":\"01a15ba1-502a-7534-bfba-380488dc9697\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16806.65, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.468617, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" \\n\\n\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"4f2259c0-de6a-4f15-bbf6-3a031831a44f\",\"cpki_event_seq\":84,\"cpki_ingested\":\"01a15ba1-502a-7795-96ab-b5f358f09200\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16850.618, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.512344, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"59ddb96a-0341-4113-8ccf-6a2d755170fb\",\"cpki_event_seq\":85,\"cpki_ingested\":\"01a15ba1-505a-7599-81e0-df36ede67023\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16898.952, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.560643, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"Decision\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"324bfe0f-b71c-48e7-a7c2-cea1f534722d\",\"cpki_event_seq\":86,\"cpki_ingested\":\"01a15ba1-508a-7aee-b43e-4c3e32ae6c15\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16950.045, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.611827, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" recorded\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"1bfff76c-843c-4852-b87a-68628d5551cc\",\"cpki_event_seq\":87,\"cpki_ingested\":\"01a15ba1-50bd-75cc-bfb3-62810199298b\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16962.55, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.624328, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\":**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"008dcc2c-ac9e-4958-827d-e33c2bbc9925\",\"cpki_event_seq\":88,\"cpki_ingested\":\"01a15ba1-50ca-7ec9-a541-c2d92de36b8a\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16964.403, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.626288, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\" **\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"98658c7b-2127-426c-b04f-21d7862e525a\",\"cpki_event_seq\":89,\"cpki_ingested\":\"01a15ba1-50ca-7705-8a25-f8804654b69b\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16964.722, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.626667, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"Approved\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"123709a9-7d44-4e19-a95e-492f3edd273f\",\"cpki_event_seq\":90,\"cpki_ingested\":\"01a15ba1-50ca-78af-815a-3489fd95e3d5\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 16964.9, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.626877, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\"**\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"01f46018-be6e-47d3-84c6-7f84bd5708dc\",\"cpki_event_seq\":91,\"cpki_ingested\":\"01a15ba1-50ca-7d9b-8c10-cab369f74061\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 17001.7, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.66343, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"delta\":\".\",\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"9ac7721c-2d6c-4574-9b1e-9fd3f020ff45\",\"cpki_event_seq\":92,\"cpki_ingested\":\"01a15ba1-50f1-751f-80c1-27fdb7480daa\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_CONTENT\"}]" + } + } + }, + { + "kind": "event", + "t": 17219.927, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.88084, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"metadata\":{\"cpki_event_id\":\"0b784b92-ca5a-43d7-b0d0-e1b1c9bbc4df\",\"cpki_event_seq\":93,\"cpki_ingested\":\"01a15ba1-51ca-7175-b2ec-a15a1e068832\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"TEXT_MESSAGE_END\"}]" + } + } + }, + { + "kind": "event", + "t": 17221.014, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.144", + "timestamp": 1786622.882586, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[null,null,\"user_meta:e05d7a88-dd5e-43ef-9136-bc98ce68d93d\",\"thread_run_activity\",{\"agent_id\":\"default\",\"event_type\":\"RUN_FINISHED\",\"latest_event_id\":\"26598550-4597-4f56-998d-8058ba3efb3c\",\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"thread_run_activity\"}]" + } + } + }, + { + "kind": "event", + "t": 17222.609, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.883931, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",null,\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"ag_ui_event\",{\"metadata\":{\"cpki_event_id\":\"26598550-4597-4f56-998d-8058ba3efb3c\",\"cpki_event_seq\":94,\"cpki_ingested\":\"01a15ba1-51ca-7f40-ac73-3a2bff7c051d\",\"finishReason\":\"stop\"},\"organization_id\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"project_id\":1,\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"run_id\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"thread_id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"type\":\"RUN_FINISHED\",\"usage\":[{\"cachedInputTokens\":0,\"inputTokens\":471,\"model\":\"gpt-5.2\",\"outputTokens\":94,\"provider\":\"openai.responses\",\"reasoningTokens\":0,\"totalTokens\":565}]}]" + } + } + }, + { + "kind": "event", + "t": 17224.584, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.885003, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "[\"3\",\"6\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_leave\",{}]" + } + } + }, + { + "kind": "event", + "t": 17224.588, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.885134, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "[\"3\",\"7\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_leave\",{}]" + } + } + }, + { + "kind": "event", + "t": 17224.614, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.219", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"method\":\"resource/request\",\"params\":{\"path\":\"/threads?agentId=default\",\"httpMethod\":\"GET\"}}", + "hasPostData": true + }, + "timestamp": 1786622.885827, + "wallTime": 1792538661.330275, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 17232.215, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.894082, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",\"6\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_reply\",{\"status\":\"ok\",\"response\":{}}]" + } + } + }, + { + "kind": "event", + "t": 17232.219, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.217", + "timestamp": 1786622.89413, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "[\"3\",\"3\",\"thread:493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"phx_close\",{}]" + } + } + }, + { + "kind": "event", + "t": 17232.233, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786622.894156, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 17259.113, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786622.920923, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"42\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 17259.121, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786622.921002, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 17259.249, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786622.921235, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 17375.398, + "session": "page", + "method": "Network.webSocketClosed", + "params": { "requestId": "68328.217", "timestamp": 1786623.037355 } + }, + { + "kind": "event", + "t": 17465.668, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.219", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786623.127473, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/copilotkit-learning", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "access-control-allow-origin": "*", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:21 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 17465.675, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.219", + "timestamp": 1786623.127598, + "dataLength": 938, + "encodedDataLength": 950 + } + }, + { + "kind": "event", + "t": 17465.7, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.219", + "timestamp": 1786623.127403, + "encodedDataLength": 1232 + } + }, + { + "kind": "command", + "t": 17465.711, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.219" }, + "tEnd": 17465.953, + "result": { + "body": "{\"threads\":[{\"id\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"organizationId\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"projectId\":1,\"agentId\":\"default\",\"endUserId\":\"agui-e2e-demo-user\",\"createdById\":\"1_agui-e2e-demo-user\",\"learningContainerIds\":[],\"learningContainerId\":null,\"learningContainerAssignedAt\":null,\"name\":\"Deal History Lookup and Approval Request\",\"archived\":false,\"createdAt\":\"2026-10-20T23:24:10.032Z\",\"updatedAt\":\"2026-10-20T23:24:21.499Z\"},{\"id\":\"b516d408-26eb-4fd6-9fc9-8a070c87efff\",\"organizationId\":\"392ec1e3-59dd-4798-a95b-2715d96d2e89\",\"projectId\":1,\"agentId\":\"default\",\"endUserId\":\"agui-e2e-demo-user\",\"createdById\":\"1_agui-e2e-demo-user\",\"learningContainerIds\":[],\"learningContainerId\":null,\"learningContainerAssignedAt\":null,\"name\":\"Hello in Five Words\",\"archived\":false,\"createdAt\":\"2026-10-20T23:23:47.571Z\",\"updatedAt\":\"2026-10-20T23:23:49.062Z\"}],\"nextCursor\":null,\"joinCode\":\"e05d7a88-dd5e-43ef-9136-bc98ce68d93d\"}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 17508.067, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786623.169913, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"43\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 17508.071, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786623.169965, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 18266.873, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "68328.220", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "documentURL": "http://127.0.0.1:7621/learning/deals/deal-2", + "request": { + "url": "http://127.0.0.1:7621/api/learning-events", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7621/learning/deals/deal-2", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"trajectoryId\":\"542cc753-7048-4fb6-8049-e8d96ecad7b4\",\"events\":[{\"type\":\"CUSTOM\",\"name\":\"agent.message\",\"value\":{\"messageId\":\"msg_0dccff55f37e6be2006ad7f823ed5c87d0b12a4b975b14818f\",\"runId\":\"035b60c5-6637-4076-9f4b-3e2430e47718\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"role\":\"assistant\",\"textLength\":310,\"text\":\"**Deal history (deal-2: “Globex expansion”, $48,000)** \\n- Stage: **review** \\n- Past discounts: **10% on renewal in 2025** \\n- Payment record: **paid on time for 3 years** \\n- Approvals so far: **sales manager**\\n\\n**Approval request** \\nApprove this deal at **$48,000**? \\n\\n**Decision recorded:** **Approved**.\",\"seq\":21},\"timestamp\":1792538661326},{\"type\":\"CUSTOM\",\"name\":\"agent.run\",\"value\":{\"phase\":\"finished\",\"runId\":\"4ab5dbf3-d3a3-4731-b4b3-15a1124ef8ca\",\"threadId\":\"493e05bf-fd4d-4754-b9f3-2a16bb8965a8\",\"agentId\":\"default\",\"seq\":22},\"timestamp\":1792538661329}],\"dropped\":0}", + "hasPostData": true + }, + "timestamp": 1786623.92759, + "wallTime": 1792538662.372023, + "type": "Fetch", + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 18271.579, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786623.93318, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"building\"}" + } + } + }, + { + "kind": "event", + "t": 18341.483, + "session": "page", + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "68328.107", + "timestamp": 1786624.003338, + "response": { + "opcode": 1, + "mask": false, + "payloadData": "{\"action\":\"built\",\"hash\":\"44\",\"errors\":[],\"warnings\":[]}" + } + } + }, + { + "kind": "event", + "t": 18341.487, + "session": "page", + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "68328.107", + "timestamp": 1786624.003398, + "response": { + "opcode": 1, + "mask": true, + "payloadData": "{\"event\":\"client-success\",\"clientId\":1792538644331}" + } + } + }, + { + "kind": "event", + "t": 18503.433, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "68328.220", + "loaderId": "9064C11C2EB989C8966589CAF4CDA8DC", + "timestamp": 1786624.165338, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7621/api/learning-events", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Keep-Alive": "timeout=5", + "Date": "Tue, 20 Oct 2026 23:24:22 GMT", + "content-type": "application/json", + "vary": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "Connection": "keep-alive" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "2A782C7E8EDAD649FFEB1218A1DF2349" + } + }, + { + "kind": "event", + "t": 18503.437, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "68328.220", + "timestamp": 1786624.165432, + "dataLength": 14, + "encodedDataLength": 24 + } + }, + { + "kind": "event", + "t": 18554.374, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "68328.220", + "timestamp": 1786624.165202, + "encodedDataLength": 274 + } + }, + { + "kind": "command", + "t": 18554.382, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "68328.220" }, + "tEnd": 18554.607, + "result": { "body": "{\"inserted\":2}", "base64Encoded": false } + } + ] +} diff --git a/extension/tests/fixtures/agui/late-and-aborted.json b/extension/tests/fixtures/agui/late-and-aborted.json new file mode 100644 index 000000000..3cd0e1398 --- /dev/null +++ b/extension/tests/fixtures/agui/late-and-aborted.json @@ -0,0 +1,1731 @@ +{ + "description": "The same agent at /rig/awp over SSE: one answer whose streamResourceContent was asked only at loadingFinished (Chrome refuses: already finished loading), then a run the page aborted mid-stream (HttpAgent.abortRun). Real Chrome over CDP.", + "browser": "Chrome/156.0.8078.47", + "entries": [ + { + "kind": "mark", + "t": 74.545, + "wall": 1792538903723, + "name": "navigate", + "data": { "url": "http://127.0.0.1:7631/rig/page" } + }, + { + "kind": "event", + "t": 86.581, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "AB541BED352B9ACD09CDCC6E3FC53004", + "loaderId": "AB541BED352B9ACD09CDCC6E3FC53004", + "documentURL": "http://127.0.0.1:7631/rig/page", + "request": { + "url": "http://127.0.0.1:7631/rig/page", + "method": "GET", + "headers": { + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"macOS\"" + } + }, + "timestamp": 1786865.291124, + "wallTime": 1792538903.735393, + "type": "Document", + "frameId": "9E6E1549E89957902A4D399C1F71294E", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 91.812, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "AB541BED352B9ACD09CDCC6E3FC53004", + "loaderId": "AB541BED352B9ACD09CDCC6E3FC53004", + "timestamp": 1786865.296338, + "type": "Document", + "response": { + "url": "http://127.0.0.1:7631/rig/page", + "status": 200, + "statusText": "OK", + "headers": { + "Content-Length": "533641", + "Content-Type": "text/html", + "Date": "Tue, 20 Oct 2026 23:28:23 GMT" + }, + "mimeType": "text/html" + }, + "hasExtraInfo": true, + "frameId": "9E6E1549E89957902A4D399C1F71294E" + } + }, + { + "kind": "event", + "t": 106.092, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "AB541BED352B9ACD09CDCC6E3FC53004", + "timestamp": 1786865.298649, + "dataLength": 135063, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 112.486, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "AB541BED352B9ACD09CDCC6E3FC53004", + "timestamp": 1786865.312015, + "dataLength": 398578, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 140.661, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "AB541BED352B9ACD09CDCC6E3FC53004", + "timestamp": 1786865.303715, + "encodedDataLength": 533746 + } + }, + { + "kind": "mark", + "t": 1118.559, + "wall": 1792538904767, + "name": "click", + "data": { "name": "late", "selector": "#late", "text": "Ask, read late" } + }, + { + "kind": "event", + "t": 1124.576, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "78382.2", + "loaderId": "AB541BED352B9ACD09CDCC6E3FC53004", + "documentURL": "http://127.0.0.1:7631/rig/page", + "request": { + "url": "http://127.0.0.1:7631/rig/awp?late=1", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7631/rig/page", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Accept": "text/event-stream", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"threadId\":\"ea18e1f8-8310-4ace-8280-f332ea1a5aa2\",\"runId\":\"92148d4b-61b6-42e2-821f-fc684648f2df\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"f67d5a2c-9000-41c7-82bc-783d621717cf\",\"role\":\"user\",\"content\":\"Should we approve deal-2 at its amount?\"}],\"tools\":[],\"context\":[{\"description\":\"The deal the user is looking at\",\"value\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"}],\"forwardedProps\":{}}", + "hasPostData": true + }, + "timestamp": 1786866.327352, + "wallTime": 1792538904.772112, + "type": "Fetch", + "frameId": "9E6E1549E89957902A4D399C1F71294E", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 1135.189, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "78382.2", + "loaderId": "AB541BED352B9ACD09CDCC6E3FC53004", + "timestamp": 1786866.339115, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7631/rig/awp?late=1", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + "Date": "Tue, 20 Oct 2026 23:28:24 GMT", + "Content-Type": "text/event-stream" + }, + "mimeType": "text/event-stream" + }, + "hasExtraInfo": true, + "frameId": "9E6E1549E89957902A4D399C1F71294E" + } + }, + { + "kind": "event", + "t": 1136.416, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786866.339754, + "dataLength": 151, + "encodedDataLength": 157 + } + }, + { + "kind": "event", + "t": 2194.488, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786867.397549, + "dataLength": 235, + "encodedDataLength": 241 + } + }, + { + "kind": "event", + "t": 2691.007, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786867.894936, + "dataLength": 212, + "encodedDataLength": 218 + } + }, + { + "kind": "event", + "t": 2694.534, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786867.898604, + "dataLength": 171, + "encodedDataLength": 177 + } + }, + { + "kind": "event", + "t": 2698.742, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786867.902762, + "dataLength": 92, + "encodedDataLength": 98 + } + }, + { + "kind": "event", + "t": 2707.74, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786867.911966, + "dataLength": 93, + "encodedDataLength": 99 + } + }, + { + "kind": "event", + "t": 2719.882, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786867.924068, + "dataLength": 91, + "encodedDataLength": 97 + } + }, + { + "kind": "event", + "t": 2751.107, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786867.955325, + "dataLength": 94, + "encodedDataLength": 100 + } + }, + { + "kind": "event", + "t": 2772.085, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786867.976248, + "dataLength": 93, + "encodedDataLength": 99 + } + }, + { + "kind": "event", + "t": 2790.036, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786867.994333, + "dataLength": 90, + "encodedDataLength": 96 + } + }, + { + "kind": "event", + "t": 2798.388, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.002153, + "dataLength": 636, + "encodedDataLength": 650 + } + }, + { + "kind": "event", + "t": 3647.097, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.850858, + "dataLength": 139, + "encodedDataLength": 145 + } + }, + { + "kind": "event", + "t": 3674.429, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.878557, + "dataLength": 139, + "encodedDataLength": 145 + } + }, + { + "kind": "event", + "t": 3676.172, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.880515, + "dataLength": 143, + "encodedDataLength": 149 + } + }, + { + "kind": "event", + "t": 3678.824, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.883216, + "dataLength": 141, + "encodedDataLength": 147 + } + }, + { + "kind": "event", + "t": 3714.778, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.919023, + "dataLength": 137, + "encodedDataLength": 143 + } + }, + { + "kind": "event", + "t": 3716.836, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.921134, + "dataLength": 137, + "encodedDataLength": 143 + } + }, + { + "kind": "event", + "t": 3718.874, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.923262, + "dataLength": 139, + "encodedDataLength": 145 + } + }, + { + "kind": "event", + "t": 3725.639, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.929838, + "dataLength": 276, + "encodedDataLength": 283 + } + }, + { + "kind": "event", + "t": 3750.835, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.955024, + "dataLength": 137, + "encodedDataLength": 143 + } + }, + { + "kind": "event", + "t": 3755.688, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.959968, + "dataLength": 139, + "encodedDataLength": 145 + } + }, + { + "kind": "event", + "t": 3758.329, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.962615, + "dataLength": 283, + "encodedDataLength": 290 + } + }, + { + "kind": "event", + "t": 3787.733, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786868.991932, + "dataLength": 138, + "encodedDataLength": 144 + } + }, + { + "kind": "event", + "t": 3796.179, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.000436, + "dataLength": 140, + "encodedDataLength": 146 + } + }, + { + "kind": "event", + "t": 3827.188, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.031432, + "dataLength": 139, + "encodedDataLength": 145 + } + }, + { + "kind": "event", + "t": 3859.191, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.06348, + "dataLength": 141, + "encodedDataLength": 147 + } + }, + { + "kind": "event", + "t": 3872.101, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.076341, + "dataLength": 287, + "encodedDataLength": 294 + } + }, + { + "kind": "event", + "t": 3900.475, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.104711, + "dataLength": 141, + "encodedDataLength": 147 + } + }, + { + "kind": "event", + "t": 3907.389, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.111661, + "dataLength": 137, + "encodedDataLength": 143 + } + }, + { + "kind": "event", + "t": 3913.824, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.118088, + "dataLength": 137, + "encodedDataLength": 143 + } + }, + { + "kind": "event", + "t": 3922.756, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.12698, + "dataLength": 282, + "encodedDataLength": 289 + } + }, + { + "kind": "event", + "t": 3936.959, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.141206, + "dataLength": 141, + "encodedDataLength": 147 + } + }, + { + "kind": "event", + "t": 3942.749, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.147054, + "dataLength": 138, + "encodedDataLength": 144 + } + }, + { + "kind": "event", + "t": 3945.933, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.150298, + "dataLength": 142, + "encodedDataLength": 148 + } + }, + { + "kind": "event", + "t": 3947.95, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.152359, + "dataLength": 137, + "encodedDataLength": 143 + } + }, + { + "kind": "event", + "t": 3950.284, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.154614, + "dataLength": 275, + "encodedDataLength": 282 + } + }, + { + "kind": "event", + "t": 3990.713, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.194836, + "dataLength": 426, + "encodedDataLength": 439 + } + }, + { + "kind": "event", + "t": 4014.329, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.218508, + "dataLength": 139, + "encodedDataLength": 145 + } + }, + { + "kind": "event", + "t": 4057.365, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.261609, + "dataLength": 144, + "encodedDataLength": 150 + } + }, + { + "kind": "event", + "t": 4068.307, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.272562, + "dataLength": 140, + "encodedDataLength": 146 + } + }, + { + "kind": "event", + "t": 4073.755, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.278032, + "dataLength": 142, + "encodedDataLength": 148 + } + }, + { + "kind": "event", + "t": 4077.021, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.281296, + "dataLength": 289, + "encodedDataLength": 296 + } + }, + { + "kind": "event", + "t": 4099.054, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.30333, + "dataLength": 137, + "encodedDataLength": 143 + } + }, + { + "kind": "event", + "t": 4108.053, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.312292, + "dataLength": 139, + "encodedDataLength": 145 + } + }, + { + "kind": "event", + "t": 4111.208, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.315477, + "dataLength": 281, + "encodedDataLength": 288 + } + }, + { + "kind": "event", + "t": 4132.993, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.337244, + "dataLength": 140, + "encodedDataLength": 146 + } + }, + { + "kind": "event", + "t": 4140.737, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.345044, + "dataLength": 141, + "encodedDataLength": 147 + } + }, + { + "kind": "event", + "t": 4142.724, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.347103, + "dataLength": 139, + "encodedDataLength": 145 + } + }, + { + "kind": "event", + "t": 4146.84, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.351177, + "dataLength": 144, + "encodedDataLength": 150 + } + }, + { + "kind": "event", + "t": 4156.229, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.360522, + "dataLength": 137, + "encodedDataLength": 143 + } + }, + { + "kind": "event", + "t": 4387.836, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.591005, + "dataLength": 320, + "encodedDataLength": 332 + } + }, + { + "kind": "event", + "t": 4387.845, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "78382.2", + "timestamp": 1786869.590898, + "encodedDataLength": 9477 + } + }, + { + "kind": "command", + "t": 4387.867, + "session": "page", + "method": "Network.streamResourceContent", + "params": { "requestId": "78382.2" }, + "tEnd": 4391.139, + "error": "Request with the provided ID has already finished loading" + }, + { + "kind": "command", + "t": 4387.896, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "78382.2" }, + "tEnd": 4391.423, + "result": { + "body": "data: {\"type\":\"RUN_STARTED\",\"threadId\":\"ea18e1f8-8310-4ace-8280-f332ea1a5aa2\",\"runId\":\"92148d4b-61b6-42e2-821f-fc684648f2df\",\"protocolVersion\":\"1.0\"}\n\ndata: {\"type\":\"REASONING_START\",\"messageId\":\"rs_084a0d81249d37b7006ad7f919dbd487d0afda450855257ee8:0\"}\n\ndata: {\"type\":\"REASONING_MESSAGE_START\",\"messageId\":\"rs_084a0d81249d37b7006ad7f919dbd487d0afda450855257ee8:0\",\"role\":\"reasoning\"}\n\ndata: {\"type\":\"REASONING_MESSAGE_END\",\"messageId\":\"rs_084a0d81249d37b7006ad7f919dbd487d0afda450855257ee8:0\"}\n\ndata: {\"type\":\"REASONING_END\",\"messageId\":\"rs_084a0d81249d37b7006ad7f919dbd487d0afda450855257ee8:0\"}\n\ndata: {\"type\":\"TOOL_CALL_START\",\"parentMessageId\":\"829e0499-0cc5-4b3e-bf3c-ca0e32dd4388\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"toolCallName\":\"lookupDealHistory\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"delta\":\"{\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"delta\":\"deal\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"delta\":\"Id\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"delta\":\"\\\":\\\"\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"delta\":\"deal\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"delta\":\"-\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"delta\":\"2\"}\n\ndata: {\"type\":\"TOOL_CALL_ARGS\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"delta\":\"\\\"}\"}\n\ndata: {\"type\":\"TOOL_CALL_END\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\"}\n\ndata: {\"type\":\"TOOL_CALL_RESULT\",\"role\":\"tool\",\"messageId\":\"653c8a23-000e-420a-8042-eb9304216324\",\"toolCallId\":\"call_NBu3TXeSniXTePInyBV8CXRE\",\"content\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"name\\\":\\\"Globex expansion\\\",\\\"amount\\\":48000,\\\"stage\\\":\\\"review\\\",\\\"pastDiscounts\\\":[\\\"10% on renewal in 2025\\\"],\\\"paymentRecord\\\":\\\"paid on time for 3 years\\\",\\\"approvals\\\":[\\\"sales manager\\\"]}\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"Yes\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"—\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"approve\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" deal\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"-\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"2\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" at\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" $\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"48\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\",\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"000\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" given\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" Glob\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"ex\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"’s\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" on\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"-time\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" payment\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" record\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" over\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" \"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"3\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" years\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" and\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" only\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" a\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" prior\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" \"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"10\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\"%\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" renewal\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" discount\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\".\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" It\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" already\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" has\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" sales\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" manager\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" approval\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\",\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" so\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" it\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" looks\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" low\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" risk\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" to\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\" proceed\"}\n\ndata: {\"type\":\"TEXT_MESSAGE_CHUNK\",\"role\":\"assistant\",\"messageId\":\"msg_084a0d81249d37b7006ad7f91b508c87d0bf0661a441c85882\",\"delta\":\".\"}\n\ndata: {\"type\":\"RUN_FINISHED\",\"threadId\":\"ea18e1f8-8310-4ace-8280-f332ea1a5aa2\",\"runId\":\"92148d4b-61b6-42e2-821f-fc684648f2df\",\"metadata\":{\"finishReason\":\"stop\"},\"usage\":[{\"provider\":\"openai.responses\",\"model\":\"gpt-5.2\",\"inputTokens\":626,\"outputTokens\":93,\"totalTokens\":719,\"reasoningTokens\":17,\"cachedInputTokens\":0}]}\n\n", + "base64Encoded": false + } + }, + { + "kind": "mark", + "t": 6397.385, + "wall": 1792538910046, + "name": "click", + "data": { + "name": "abort", + "selector": "#abort", + "text": "Ask, then stop" + } + }, + { + "kind": "event", + "t": 6399.51, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "78382.3", + "loaderId": "AB541BED352B9ACD09CDCC6E3FC53004", + "documentURL": "http://127.0.0.1:7631/rig/page", + "request": { + "url": "http://127.0.0.1:7631/rig/awp", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7631/rig/page", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Accept": "text/event-stream", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"threadId\":\"768807a3-5bf4-4e98-8e5b-330e065ea76f\",\"runId\":\"949a34b8-0df9-4f4f-ab91-add0eb5166b9\",\"protocolVersion\":\"1.0\",\"state\":{},\"messages\":[{\"id\":\"836d64b4-a9dc-4263-a302-f4ae95a9fc1a\",\"role\":\"user\",\"content\":\"Should we approve deal-2 at its amount?\"}],\"tools\":[],\"context\":[{\"description\":\"The deal the user is looking at\",\"value\":\"{\\\"dealId\\\":\\\"deal-2\\\",\\\"amount\\\":48000}\"}],\"forwardedProps\":{}}", + "hasPostData": true + }, + "timestamp": 1786871.603053, + "wallTime": 1792538910.047391, + "type": "Fetch", + "frameId": "9E6E1549E89957902A4D399C1F71294E", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 6406.216, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "78382.3", + "loaderId": "AB541BED352B9ACD09CDCC6E3FC53004", + "timestamp": 1786871.610393, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7631/rig/awp", + "status": 200, + "statusText": "OK", + "headers": { + "Transfer-Encoding": "chunked", + "Cache-Control": "no-cache", + "Date": "Tue, 20 Oct 2026 23:28:29 GMT", + "Content-Type": "text/event-stream" + }, + "mimeType": "text/event-stream" + }, + "hasExtraInfo": true, + "frameId": "9E6E1549E89957902A4D399C1F71294E" + } + }, + { + "kind": "command", + "t": 6406.24, + "session": "page", + "method": "Network.streamResourceContent", + "params": { "requestId": "78382.3" }, + "tEnd": 6406.576, + "result": { + "bufferedData": "ZGF0YTogeyJ0eXBlIjoiUlVOX1NUQVJURUQiLCJ0aHJlYWRJZCI6Ijc2ODgwN2EzLTViZjQtNGU5OC04ZTViLTMzMGUwNjVlYTc2ZiIsInJ1bklkIjoiOTQ5YTM0YjgtMGRmOS00ZjRmLWFiOTEtYWRkMGViNTE2NmI5IiwicHJvdG9jb2xWZXJzaW9uIjoiMS4wIn0KCg==" + } + }, + { + "kind": "event", + "t": 6406.374, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786871.610659, + "dataLength": 151, + "encodedDataLength": 157 + } + }, + { + "kind": "event", + "t": 7363.131, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786872.567231, + "dataLength": 235, + "encodedDataLength": 241, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX1NUQVJUIiwibWVzc2FnZUlkIjoicnNfMDQ2ZmEyZjBmYjQ1MjQwODAwNmFkN2Y5MWYwNWRjODdkMGJjYTIxNzFmODNmZDgxMDY6MCJ9CgpkYXRhOiB7InR5cGUiOiJSRUFTT05JTkdfTUVTU0FHRV9TVEFSVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJyb2xlIjoicmVhc29uaW5nIn0KCg==" + } + }, + { + "kind": "event", + "t": 7996.925, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.200801, + "dataLength": 157, + "encodedDataLength": 163, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IioqTG9va2luZyB1cCBkZWFsIGhpc3RvcnkqKlxuXG5JIn0KCg==" + } + }, + { + "kind": "event", + "t": 8060.278, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.26436, + "dataLength": 258, + "encodedDataLength": 265, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBuZWVkIn0KCmRhdGE6IHsidHlwZSI6IlJFQVNPTklOR19NRVNTQUdFX0NPTlRFTlQiLCJtZXNzYWdlSWQiOiJyc18wNDZmYTJmMGZiNDUyNDA4MDA2YWQ3ZjkxZjA1ZGM4N2QwYmNhMjE3MWY4M2ZkODEwNjowIiwiZGVsdGEiOiIgdG8ifQoK" + } + }, + { + "kind": "event", + "t": 8085.325, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.289521, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBjYWxsIn0KCg==" + } + }, + { + "kind": "event", + "t": 8087.301, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.291622, + "dataLength": 129, + "encodedDataLength": 135, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB0aGUifQoK" + } + }, + { + "kind": "event", + "t": 8090.99, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.29522, + "dataLength": 516, + "encodedDataLength": 530, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB0b29sIn0KCmRhdGE6IHsidHlwZSI6IlJFQVNPTklOR19NRVNTQUdFX0NPTlRFTlQiLCJtZXNzYWdlSWQiOiJyc18wNDZmYTJmMGZiNDUyNDA4MDA2YWQ3ZjkxZjA1ZGM4N2QwYmNhMjE3MWY4M2ZkODEwNjowIiwiZGVsdGEiOiIgdG8ifQoKZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBsb29rIn0KCmRhdGE6IHsidHlwZSI6IlJFQVNPTklOR19NRVNTQUdFX0NPTlRFTlQiLCJtZXNzYWdlSWQiOiJyc18wNDZmYTJmMGZiNDUyNDA4MDA2YWQ3ZjkxZjA1ZGM4N2QwYmNhMjE3MWY4M2ZkODEwNjowIiwiZGVsdGEiOiIgdXAifQoK" + } + }, + { + "kind": "event", + "t": 8096.067, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.300395, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBkZWFsIn0KCg==" + } + }, + { + "kind": "event", + "t": 8097.829, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.302163, + "dataLength": 259, + "encodedDataLength": 266, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBoaXN0b3J5In0KCmRhdGE6IHsidHlwZSI6IlJFQVNPTklOR19NRVNTQUdFX0NPTlRFTlQiLCJtZXNzYWdlSWQiOiJyc18wNDZmYTJmMGZiNDUyNDA4MDA2YWQ3ZjkxZjA1ZGM4N2QwYmNhMjE3MWY4M2ZkODEwNjowIiwiZGVsdGEiOiIuIn0KCg==" + } + }, + { + "kind": "event", + "t": 8117.294, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.321401, + "dataLength": 261, + "encodedDataLength": 268, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBBZnRlciJ9CgpkYXRhOiB7InR5cGUiOiJSRUFTT05JTkdfTUVTU0FHRV9DT05URU5UIiwibWVzc2FnZUlkIjoicnNfMDQ2ZmEyZjBmYjQ1MjQwODAwNmFkN2Y5MWYwNWRjODdkMGJjYTIxNzFmODNmZDgxMDY6MCIsImRlbHRhIjoiIHRoYXQifQoK" + } + }, + { + "kind": "event", + "t": 8122.051, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.326398, + "dataLength": 126, + "encodedDataLength": 132, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiwifQoK" + } + }, + { + "kind": "event", + "t": 8124.945, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.329325, + "dataLength": 127, + "encodedDataLength": 133, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBJIn0KCg==" + } + }, + { + "kind": "event", + "t": 8127.205, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.331587, + "dataLength": 131, + "encodedDataLength": 137, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB0aGluayJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8130.465, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.334895, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBJJ2xsIn0KCg==" + } + }, + { + "kind": "event", + "t": 8132.928, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.337052, + "dataLength": 656, + "encodedDataLength": 676, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBwcm92aWRlIn0KCmRhdGE6IHsidHlwZSI6IlJFQVNPTklOR19NRVNTQUdFX0NPTlRFTlQiLCJtZXNzYWdlSWQiOiJyc18wNDZmYTJmMGZiNDUyNDA4MDA2YWQ3ZjkxZjA1ZGM4N2QwYmNhMjE3MWY4M2ZkODEwNjowIiwiZGVsdGEiOiIgdHdvIn0KCmRhdGE6IHsidHlwZSI6IlJFQVNPTklOR19NRVNTQUdFX0NPTlRFTlQiLCJtZXNzYWdlSWQiOiJyc18wNDZmYTJmMGZiNDUyNDA4MDA2YWQ3ZjkxZjA1ZGM4N2QwYmNhMjE3MWY4M2ZkODEwNjowIiwiZGVsdGEiOiIgc2hvcnQifQoKZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBzZW50ZW5jZXMifQoKZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBhcyJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8139.016, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.343383, + "dataLength": 141, + "encodedDataLength": 147, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiByZWNvbW1lbmRhdGlvbnMifQoK" + } + }, + { + "kind": "event", + "t": 8144.635, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.349003, + "dataLength": 126, + "encodedDataLength": 132, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6Ii4ifQoK" + } + }, + { + "kind": "event", + "t": 8151.2, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.355484, + "dataLength": 133, + "encodedDataLength": 139, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBLZWVwaW5nIn0KCg==" + } + }, + { + "kind": "event", + "t": 8157.39, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.361716, + "dataLength": 128, + "encodedDataLength": 134, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBpdCJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8205.69, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.409934, + "dataLength": 133, + "encodedDataLength": 139, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBjb25jaXNlIn0KCg==" + } + }, + { + "kind": "event", + "t": 8209.791, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.414101, + "dataLength": 257, + "encodedDataLength": 264, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBpcyJ9CgpkYXRhOiB7InR5cGUiOiJSRUFTT05JTkdfTUVTU0FHRV9DT05URU5UIiwibWVzc2FnZUlkIjoicnNfMDQ2ZmEyZjBmYjQ1MjQwODAwNmFkN2Y5MWYwNWRjODdkMGJjYTIxNzFmODNmZDgxMDY6MCIsImRlbHRhIjoiIGtleSJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8212.314, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.416573, + "dataLength": 255, + "encodedDataLength": 261, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiwifQoKZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBhbmQifQoK" + } + }, + { + "kind": "event", + "t": 8234.061, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.438237, + "dataLength": 259, + "encodedDataLength": 266, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBJIn0KCmRhdGE6IHsidHlwZSI6IlJFQVNPTklOR19NRVNTQUdFX0NPTlRFTlQiLCJtZXNzYWdlSWQiOiJyc18wNDZmYTJmMGZiNDUyNDA4MDA2YWQ3ZjkxZjA1ZGM4N2QwYmNhMjE3MWY4M2ZkODEwNjowIiwiZGVsdGEiOiIgcmVhbGx5In0KCg==" + } + }, + { + "kind": "event", + "t": 8238.278, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.442526, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB3YW50In0KCg==" + } + }, + { + "kind": "event", + "t": 8248.598, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.452902, + "dataLength": 128, + "encodedDataLength": 134, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB0byJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8255.654, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.459869, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBtYWtlIn0KCg==" + } + }, + { + "kind": "event", + "t": 8261.589, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.465835, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBzdXJlIn0KCg==" + } + }, + { + "kind": "event", + "t": 8265.598, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.469901, + "dataLength": 259, + "encodedDataLength": 266, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB0aGUifQoKZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB1c2VyIn0KCg==" + } + }, + { + "kind": "event", + "t": 8267.63, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.471995, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBnZXRzIn0KCg==" + } + }, + { + "kind": "event", + "t": 8277.153, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.481425, + "dataLength": 132, + "encodedDataLength": 138, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB1c2VmdWwifQoK" + } + }, + { + "kind": "event", + "t": 8283.752, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.488061, + "dataLength": 270, + "encodedDataLength": 282, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBpbmZvcm1hdGlvbiJ9CgpkYXRhOiB7InR5cGUiOiJSRUFTT05JTkdfTUVTU0FHRV9DT05URU5UIiwibWVzc2FnZUlkIjoicnNfMDQ2ZmEyZjBmYjQ1MjQwODAwNmFkN2Y5MWYwNWRjODdkMGJjYTIxNzFmODNmZDgxMDY6MCIsImRlbHRhIjoiIHF1aWNrbHkifQoK" + } + }, + { + "kind": "event", + "t": 8286.777, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.49115, + "dataLength": 126, + "encodedDataLength": 132, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6Ii4ifQoK" + } + }, + { + "kind": "event", + "t": 8294.31, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.498588, + "dataLength": 127, + "encodedDataLength": 133, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBJIn0KCg==" + } + }, + { + "kind": "event", + "t": 8296.616, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.500997, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IuKAmWxsIn0KCg==" + } + }, + { + "kind": "event", + "t": 8298.862, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.503238, + "dataLength": 264, + "encodedDataLength": 276, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBzdW1tYXJpemUifQoKZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB0aGUifQoK" + } + }, + { + "kind": "event", + "t": 8300.429, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.504797, + "dataLength": 134, + "encodedDataLength": 140, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBmaW5kaW5ncyJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8305.879, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.510218, + "dataLength": 137, + "encodedDataLength": 143, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBlZmZlY3RpdmVseSJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8310.137, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.514471, + "dataLength": 128, + "encodedDataLength": 134, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBzbyJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8322.638, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.526994, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB0aGV5In0KCg==" + } + }, + { + "kind": "event", + "t": 8324.712, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.529041, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBoYXZlIn0KCg==" + } + }, + { + "kind": "event", + "t": 8332.739, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.537039, + "dataLength": 127, + "encodedDataLength": 133, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBhIn0KCg==" + } + }, + { + "kind": "event", + "t": 8336.178, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.540571, + "dataLength": 131, + "encodedDataLength": 137, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBjbGVhciJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8343.138, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.547465, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB2aWV3In0KCg==" + } + }, + { + "kind": "event", + "t": 8356.159, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.560444, + "dataLength": 128, + "encodedDataLength": 134, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBvZiJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8365.351, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.569607, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB3aGF0In0KCg==" + } + }, + { + "kind": "event", + "t": 8371.295, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.575582, + "dataLength": 128, + "encodedDataLength": 134, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB0byJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8375.558, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.579875, + "dataLength": 134, + "encodedDataLength": 140, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBjb25zaWRlciJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8384.009, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.5883, + "dataLength": 126, + "encodedDataLength": 132, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6Ii4ifQoK" + } + }, + { + "kind": "event", + "t": 8388.248, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.59259, + "dataLength": 128, + "encodedDataLength": 134, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBBbCJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8396.556, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.600147, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6InJpZ2h0In0KCg==" + } + }, + { + "kind": "event", + "t": 8401.7, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.605967, + "dataLength": 126, + "encodedDataLength": 132, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiwifQoK" + } + }, + { + "kind": "event", + "t": 8410.905, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.615143, + "dataLength": 129, + "encodedDataLength": 135, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBsZXQifQoK" + } + }, + { + "kind": "event", + "t": 8415.354, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.619617, + "dataLength": 129, + "encodedDataLength": 135, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IuKAmXMifQoK" + } + }, + { + "kind": "event", + "t": 8419.891, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.624116, + "dataLength": 129, + "encodedDataLength": 135, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBnZXQifQoK" + } + }, + { + "kind": "event", + "t": 8424.976, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.629227, + "dataLength": 130, + "encodedDataLength": 136, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiB0aGlzIn0KCg==" + } + }, + { + "kind": "event", + "t": 8429.322, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.633615, + "dataLength": 131, + "encodedDataLength": 137, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiBnb2luZyJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8437.322, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.641558, + "dataLength": 126, + "encodedDataLength": 132, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfQ09OVEVOVCIsIm1lc3NhZ2VJZCI6InJzXzA0NmZhMmYwZmI0NTI0MDgwMDZhZDdmOTFmMDVkYzg3ZDBiY2EyMTcxZjgzZmQ4MTA2OjAiLCJkZWx0YSI6IiEifQoK" + } + }, + { + "kind": "event", + "t": 8472.031, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.676186, + "dataLength": 212, + "encodedDataLength": 218, + "data": "ZGF0YTogeyJ0eXBlIjoiUkVBU09OSU5HX01FU1NBR0VfRU5EIiwibWVzc2FnZUlkIjoicnNfMDQ2ZmEyZjBmYjQ1MjQwODAwNmFkN2Y5MWYwNWRjODdkMGJjYTIxNzFmODNmZDgxMDY6MCJ9CgpkYXRhOiB7InR5cGUiOiJSRUFTT05JTkdfRU5EIiwibWVzc2FnZUlkIjoicnNfMDQ2ZmEyZjBmYjQ1MjQwODAwNmFkN2Y5MWYwNWRjODdkMGJjYTIxNzFmODNmZDgxMDY6MCJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8479.224, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.683362, + "dataLength": 171, + "encodedDataLength": 177, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX1NUQVJUIiwicGFyZW50TWVzc2FnZUlkIjoiNDA1N2Q4MDQtMzlmMS00NDM5LTkyZmUtYTFhMDgyY2EyNmY3IiwidG9vbENhbGxJZCI6ImNhbGxfaFR4dGM2WklhZldOQ1dMV2djeG10Y1VEIiwidG9vbENhbGxOYW1lIjoibG9va3VwRGVhbEhpc3RvcnkifQoK" + } + }, + { + "kind": "event", + "t": 8484.096, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.688322, + "dataLength": 92, + "encodedDataLength": 98, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF9oVHh0YzZaSWFmV05DV0xXZ2N4bXRjVUQiLCJkZWx0YSI6IntcIiJ9Cgo=" + } + }, + { + "kind": "event", + "t": 8495.53, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.699712, + "dataLength": 93, + "encodedDataLength": 99, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF9oVHh0YzZaSWFmV05DV0xXZ2N4bXRjVUQiLCJkZWx0YSI6ImRlYWwifQoK" + } + }, + { + "kind": "event", + "t": 8531.401, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.735623, + "dataLength": 91, + "encodedDataLength": 97, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF9oVHh0YzZaSWFmV05DV0xXZ2N4bXRjVUQiLCJkZWx0YSI6IklkIn0KCg==" + } + }, + { + "kind": "event", + "t": 8566.586, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.770808, + "dataLength": 94, + "encodedDataLength": 100, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF9oVHh0YzZaSWFmV05DV0xXZ2N4bXRjVUQiLCJkZWx0YSI6IlwiOlwiIn0KCg==" + } + }, + { + "kind": "event", + "t": 8592.402, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "78382.3", + "timestamp": 1786873.796449, + "dataLength": 819, + "encodedDataLength": 839, + "data": "ZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF9oVHh0YzZaSWFmV05DV0xXZ2N4bXRjVUQiLCJkZWx0YSI6ImRlYWwifQoKZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF9oVHh0YzZaSWFmV05DV0xXZ2N4bXRjVUQiLCJkZWx0YSI6Ii0ifQoKZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF9oVHh0YzZaSWFmV05DV0xXZ2N4bXRjVUQiLCJkZWx0YSI6IjIifQoKZGF0YTogeyJ0eXBlIjoiVE9PTF9DQUxMX0FSR1MiLCJ0b29sQ2FsbElkIjoiY2FsbF9oVHh0YzZaSWFmV05DV0xXZ2N4bXRjVUQiLCJkZWx0YSI6IlwifSJ9CgpkYXRhOiB7InR5cGUiOiJUT09MX0NBTExfRU5EIiwidG9vbENhbGxJZCI6ImNhbGxfaFR4dGM2WklhZldOQ1dMV2djeG10Y1VEIn0KCmRhdGE6IHsidHlwZSI6IlRPT0xfQ0FMTF9SRVNVTFQiLCJyb2xlIjoidG9vbCIsIm1lc3NhZ2VJZCI6IjJhZGExNWY3LTdiZTktNGU5Yy1iZTM5LWVkY2RiOTc0MGI3YiIsInRvb2xDYWxsSWQiOiJjYWxsX2hUeHRjNlpJYWZXTkNXTFdnY3htdGNVRCIsImNvbnRlbnQiOiJ7XCJkZWFsSWRcIjpcImRlYWwtMlwiLFwibmFtZVwiOlwiR2xvYmV4IGV4cGFuc2lvblwiLFwiYW1vdW50XCI6NDgwMDAsXCJzdGFnZVwiOlwicmV2aWV3XCIsXCJwYXN0RGlzY291bnRzXCI6W1wiMTAlIG9uIHJlbmV3YWwgaW4gMjAyNVwiXSxcInBheW1lbnRSZWNvcmRcIjpcInBhaWQgb24gdGltZSBmb3IgMyB5ZWFyc1wiLFwiYXBwcm92YWxzXCI6W1wic2FsZXMgbWFuYWdlclwiXX0ifQoK" + } + }, + { + "kind": "event", + "t": 8900.622, + "session": "page", + "method": "Network.loadingFailed", + "params": { + "requestId": "78382.3", + "timestamp": 1786874.104803, + "type": "Fetch", + "errorText": "net::ERR_ABORTED", + "canceled": true + } + } + ] +} diff --git a/extension/tests/fixtures/agui/learning-page-copilotkit.json b/extension/tests/fixtures/agui/learning-page-copilotkit.json new file mode 100644 index 000000000..9cf8cbb39 --- /dev/null +++ b/extension/tests/fixtures/agui/learning-page-copilotkit.json @@ -0,0 +1,254 @@ +{ + "description": "CopilotKit's own capture of the learning deal page (CopilotKit main c21eeda029, v2 react demo, single-route runtime, real BuiltInAgent on OpenAI gpt-5.2 with reasoning, the backend lookupDealHistory and the page's human-in-the-loop approveDeal), recorded by the demo's legacy learning sink in real Chrome while a person clicked the chat box, typed, clicked Send and then Approve: the Thread's thread.linked, its agent.run, agent.message and tool.call events, and the clicks on the deal page with the agent context CopilotKit added. Recorded side by side with the Recorder in the end-to-end run of 2026-10-21 (run 1); the same page's own traffic, in another session, is single-route-sse.json and intelligence-websocket.json.", + "copilotkit": "c21eeda029", + "events": [ + { + "name": "thread.linked", + "timestamp": 1792542755731, + "value": { + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "hasMessages": false, + "reason": "open", + "seq": 5 + } + }, + { + "name": "click", + "timestamp": 1792542758733, + "value": { + "target": { + "tag": "textarea", + "role": null, + "action": null, + "text": "", + "attributes": { + "data-testid": "copilot-chat-textarea", + "placeholder": "Type a message...", + "class": "cpk:bg-transparent cpk:outline-none cpk:antialiased cpk:font-regular cpk:text-[16px] cpk:placeholder:text-[#00000077] cpk:dark:placeholder:text-[#fffc] cpk:w-full cpk:py-3 cpk:pr-5", + "rows": "1", + "style": "overflow: auto; resize: none; max-height: 144px; height: 48px;" + }, + "value": "", + "input": "pointer" + }, + "route": "/learning/deals/deal-1", + "url": "http://127.0.0.1:7620/learning/deals/deal-1", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "seq": 6 + } + }, + { + "name": "click", + "timestamp": 1792542764489, + "value": { + "target": { + "tag": "button", + "role": null, + "action": null, + "text": "", + "attributes": { + "data-slot": "button", + "class": "cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2 cpk:whitespace-nowrap cpk:text-sm cpk:font-medium cpk:disabled:pointer-events-none cpk:disabled:opacity-50 cpk:[&_svg]:pointer-events-none cpk:[&_svg:not([class*='size-'])]:size-4 cpk:shrink-0 cpk:[&_svg]:shrink-0 cpk:outline-none cpk:focus-visible:border-ring cpk:focus-visible:ring-ring/50 cpk:focus-visible:ring-[3px] cpk:aria-invalid:ring-destructive/20 cpk:dark:aria-invalid:ring-destructive/40 cpk:aria-invalid:border-destructive cpk:cursor-pointer cpk:bg-black cpk:text-white cpk:dark:bg-white cpk:dark:text-black cpk:dark:focus-visible:outline-white cpk:transition-colors cpk:focus:outline-none cpk:hover:opacity-70 cpk:disabled:hover:opacity-100 cpk:disabled:cursor-not-allowed cpk:disabled:bg-[#00000014] cpk:disabled:text-[rgb(13,13,13)] cpk:dark:disabled:bg-[#454545] cpk:dark:disabled:text-white cpk:h-9 cpk:w-9 cpk:rounded-full", + "type": "button", + "data-testid": "copilot-send-button", + "aria-label": "Send message" + }, + "label": "Send message", + "input": "pointer" + }, + "route": "/learning/deals/deal-1", + "url": "http://127.0.0.1:7620/learning/deals/deal-1", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "seq": 8 + } + }, + { + "name": "agent.message", + "timestamp": 1792542764492, + "value": { + "messageId": "8817a504-3cfa-4fd0-8c1b-8373f3200a1f", + "runId": null, + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "role": "user", + "textLength": 110, + "text": "Please look up this deal's history and then get it approved. Card on file for the invoice: 4111 1111 1111 1111", + "seq": 9 + } + }, + { + "name": "agent.run", + "timestamp": 1792542764742, + "value": { + "phase": "started", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "seq": 10 + } + }, + { + "name": "agent.message", + "timestamp": 1792542767714, + "value": { + "messageId": "rs_0530f8014c243060006ad8082e3a2887d0ba5f96532cc91265:0", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "role": "reasoning", + "textLength": 0, + "text": "", + "seq": 11 + } + }, + { + "name": "tool.call", + "timestamp": 1792542767714, + "value": { + "phase": "started", + "toolCallId": "call_X1STYNUUDKbTQqUA84uOwVc8", + "toolName": "lookupDealHistory", + "agentId": "default", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "messageId": "906697cb-e70e-4621-8690-fd8756b73b99", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "seq": 12 + } + }, + { + "name": "agent.message", + "timestamp": 1792542767755, + "value": { + "messageId": "65fb4911-a205-445c-beab-dbcf63685bb3", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "role": "tool", + "textLength": 188, + "text": "{\"dealId\":\"deal-1\",\"name\":\"Acme renewal\",\"amount\":12000,\"stage\":\"ready\",\"pastDiscounts\":[\"10% on renewal in 2025\"],\"paymentRecord\":\"paid on time for 3 years\",\"approvals\":[\"sales manager\"]}", + "seq": 13 + } + }, + { + "name": "tool.call", + "timestamp": 1792542768945, + "value": { + "phase": "started", + "toolCallId": "call_B8zX2k4ZuUx09Qfa6BetfCo7", + "toolName": "approveDeal", + "agentId": "default", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "messageId": "906697cb-e70e-4621-8690-fd8756b73b99", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "seq": 14 + } + }, + { + "name": "agent.run", + "timestamp": 1792542769409, + "value": { + "phase": "finished", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "seq": 15 + } + }, + { + "name": "tool.call", + "timestamp": 1792542769410, + "value": { + "phase": "executing", + "toolCallId": "call_B8zX2k4ZuUx09Qfa6BetfCo7", + "toolName": "approveDeal", + "agentId": "default", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "messageId": "906697cb-e70e-4621-8690-fd8756b73b99", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "seq": 16 + } + }, + { + "name": "click", + "timestamp": 1792542771878, + "value": { + "target": { + "tag": "button", + "role": null, + "action": "hitl.approve", + "text": "Approve", + "attributes": { + "type": "button", + "data-copilotkit-action": "hitl.approve", + "class": "rounded bg-green-600 px-3 py-1 text-sm text-white disabled:opacity-50" + }, + "label": "Approve", + "input": "pointer" + }, + "route": "/learning/deals/deal-1", + "url": "http://127.0.0.1:7620/learning/deals/deal-1", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "messageId": "906697cb-e70e-4621-8690-fd8756b73b99", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "toolCallId": "call_B8zX2k4ZuUx09Qfa6BetfCo7", + "toolName": "approveDeal", + "toolStatus": "executing", + "seq": 17 + } + }, + { + "name": "tool.call", + "timestamp": 1792542771878, + "value": { + "phase": "completed", + "toolCallId": "call_B8zX2k4ZuUx09Qfa6BetfCo7", + "toolName": "approveDeal", + "agentId": "default", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "messageId": "906697cb-e70e-4621-8690-fd8756b73b99", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "durationMs": 2468, + "outcome": "ok", + "seq": 19 + } + }, + { + "name": "agent.run", + "timestamp": 1792542772117, + "value": { + "phase": "started", + "runId": "14198bcd-3f32-4cf9-a29f-15f7fc492753", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "seq": 20 + } + }, + { + "name": "agent.message", + "timestamp": 1792542774781, + "value": { + "messageId": "msg_0ad4e0cafee4a0a5006ad80834ea2c87d0bc6c744ddf5ebf8c", + "runId": "b29c7a8c-bb6c-4eeb-82dd-e2475b2ea9d3", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "role": "assistant", + "textLength": 427, + "text": "Deal history (deal-1, **Acme renewal**, **$12,000**):\n- Stage: **ready**\n- Past discounts: **10% on renewal in 2025**\n- Payment record: **paid on time for 3 years**\n- Approvals so far: **sales manager**\n\nApproval request sent for **$12,000**. Decision: **approved**.\n\nAlso, I can\u2019t accept or store card numbers here\u2014please remove the card details from chat and enter/update payment info only in your approved billing system UI.", + "seq": 21 + } + }, + { + "name": "agent.run", + "timestamp": 1792542774781, + "value": { + "phase": "finished", + "runId": "14198bcd-3f32-4cf9-a29f-15f7fc492753", + "threadId": "bf7f6a4f-e5a7-40fd-8d08-046fdcb4dec7", + "agentId": "default", + "seq": 22 + } + } + ] +} diff --git a/extension/tests/fixtures/agui/openbot-chat.json b/extension/tests/fixtures/agui/openbot-chat.json new file mode 100644 index 000000000..875e27ce4 --- /dev/null +++ b/extension/tests/fixtures/agui/openbot-chat.json @@ -0,0 +1,3094 @@ +{ + "description": "OpenBot's own chat (the fused Recorder branch's end-to-end environment, 2026-10-20: OpenBot on 127.0.0.1:7410 with its CopilotKit runtime in Intelligence mode, a local CopilotKit Intelligence on :7253), real Chrome over CDP: the home page's composer, a question typed and sent to the default Bot (General Assistant). OpenBot creates the channel, posts /api/copilotkit/agent/general-assistant/connect (its body past 64 KiB, read with getRequestPostData), which answers join credentials for thread:; the page joins that topic on Intelligence's socket, which replays nothing and goes idle, and leaves; then /run, which this environment's Intelligence refused (403 Failed to initialize thread), as in end-to-end run 3b.", + "browser": "Chrome/156.0.8078.47", + "entries": [ + { + "kind": "mark", + "t": 100.292, + "wall": 1792545034545, + "name": "navigate", + "data": { "url": "http://127.0.0.1:7410/" } + }, + { + "kind": "event", + "t": 104.113, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "36D9CD26313B1A16A705032388E88152", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/", + "method": "GET", + "headers": { + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"macOS\"" + } + }, + "timestamp": 1792996.093796, + "wallTime": 1792545034.548283, + "type": "Document", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 113.149, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "36D9CD26313B1A16A705032388E88152", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.103556, + "type": "Document", + "response": { + "url": "http://127.0.0.1:7410/", + "status": 200, + "statusText": "OK", + "headers": { + "Content-Type": "text/html; charset=utf-8", + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "content-length": "891" + }, + "mimeType": "text/html" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 125.224, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.111343, + "dataLength": 891, + "encodedDataLength": 0 + } + }, + { + "kind": "event", + "t": 130.444, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.102957, + "encodedDataLength": 1008 + } + }, + { + "kind": "event", + "t": 170.473, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.20", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/me", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.160291, + "wallTime": 1792545034.614894, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 175.696, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.20", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.165983, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/me", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json", + "Content-Length": "140" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 175.732, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.20", + "timestamp": 1792996.166321, + "dataLength": 140, + "encodedDataLength": 140 + } + }, + { + "kind": "event", + "t": 175.734, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.20", + "timestamp": 1792996.165947, + "encodedDataLength": 249 + } + }, + { + "kind": "command", + "t": 175.746, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.20" }, + "tEnd": 178.581, + "result": { + "body": "{\"user\":{\"id\":\"dev-local-user\",\"email\":\"dev@openbot.local\",\"role\":\"admin\",\"onboarding\":{\"step\":0,\"completedAt\":\"2026-10-19T02:02:28.501Z\"}}}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 266.024, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.45", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/admin/enterprise/me", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.24828, + "wallTime": 1792545034.702438, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 266.436, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.46", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/components/catalogue", + "method": "PUT", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"components\":[{\"name\":\"askApproval\",\"title\":\"Approval\",\"kind\":\"decision\",\"description\":\"Ask the person to approve or decline something, and WAIT for their answer. Use before doing anything you cannot undo, spending money, sending a message, changing a record. You are given their decision and any reason they typed.\"},{\"name\":\"askChoice\",\"title\":\"Choice\",\"kind\":\"decision\",\"description\":\"Ask the person to pick one of several options, and WAIT for their answer. Use when you cannot sensibly guess which one they meant. You are given the id of the option they chose.\"},{\"name\":\"askForm\",\"title\":\"Form\",\"kind\":\"decision\",\"description\":\"Ask the person to fill in a short form and WAIT for their answers. Use when several related details are needed together. Provide up to eight labeled text, email, number, select, or textarea fields with unique IDs. Mark required fields and give options for selects. Never request passwords or secrets. The result has status submitted and values keyed by field ID; numbers are returned as numbers and blank optional fields are omitted.\"},{\"name\":\"showActivityReport\",\"title\":\"Activity report\",\"kind\":\"card\",\"description\":\"Show what this deployment has actually been doing, read from its own records rather than from anything you know. Use for 'what have the Bots been up to' and 'what has been refused'. You choose the report and the period; the figures are read for you and you will not see them.\"},{\"name\":\"showAreaChart\",\"title\":\"Area chart\",\"kind\":\"chart\",\"description\":\"The same as showLineChart with the area under each line filled. Use for volume or accumulation rather than for a rate.\"},{\"name\":\"showBarChart\",\"title\":\"Bar chart\",\"kind\":\"chart\",\"description\":\"Show values as a bar chart. Use when comparing a handful of named things, teams, months, categories. Not for a trend over time, which is showLineChart.\"},{\"name\":\"showChecklist\",\"title\":\"Checklist\",\"kind\":\"card\",\"description\":\"Show a list of things and which are done. Reporting only, the person cannot tick these, so do not use it to ask for anything.\"},{\"name\":\"showLineChart\",\"title\":\"Line chart\",\"kind\":\"chart\",\"description\":\"Show one or more series over an ordered axis, usually time. Every series must have one value per label.\"},{\"name\":\"showMetrics\",\"title\":\"Headline figures\",\"kind\":\"card\",\"description\":\"Show up to six headline figures, each with an optional movement. Use for a summary somebody reads at a glance.\"},{\"name\":\"showNotice\",\"title\":\"Notice\",\"kind\":\"card\",\"description\":\"Show a headline, a short explanation and optional supporting points. Use instead of writing several paragraphs of prose.\"},{\"name\":\"showPieChart\",\"title\":\"Donut chart\",\"kind\":\"chart\",\"description\":\"Show how a whole is divided, as a donut with a legend. Use only when the parts sum to something meaningful, and prefer a bar chart above about six slices.\"},{\"name\":\"showProgress\",\"title\":\"Progress against target\",\"kind\":\"chart\",\"description\":\"Show values against their targets as progress bars. Use for 'are we there yet' questions, budget spent against budget, done against planned.\"},{\"name\":\"showQuote\",\"title\":\"Quotation\",\"kind\":\"card\",\"description\":\"Show a quotation with its attribution. Use when the exact words matter, something a person said, or a line from a document you were given.\"},{\"name\":\"showRecord\",\"title\":\"Record\",\"kind\":\"card\",\"description\":\"Show one thing and its fields, an order, a person, a ticket. Use instead of describing a record in prose.\"},{\"name\":\"showTable\",\"title\":\"Data table\",\"kind\":\"card\",\"description\":\"Show a sortable table to compare records, plans, or other structured facts. Supply up to eight columns and 100 rows, with exactly one cell per column. Use numbers for quantities so sorting is numeric. Show only data you have; do not invent rows.\"}]}", + "hasPostData": true + }, + "timestamp": 1792996.249224, + "wallTime": 1792545034.703376, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 266.471, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.47", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/sandboxed/published", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.24946, + "wallTime": 1792545034.70359, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 266.489, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.48", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/bots/attention", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.249699, + "wallTime": 1792545034.703842, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 266.565, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.49", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/settings/preferences", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.250652, + "wallTime": 1792545034.704789, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 266.647, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.50", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/channels", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.251661, + "wallTime": 1792545034.70581, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 266.733, + "session": "page", + "method": "Network.webSocketCreated", + "params": { + "requestId": "81494.51", + "url": "ws://127.0.0.1:7410/api/channels/events" + } + }, + { + "kind": "event", + "t": 267.05, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.52", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/capabilities", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.252936, + "wallTime": 1792545034.707076, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 267.162, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.53", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/agents", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.25349, + "wallTime": 1792545034.707636, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 267.167, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.54", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/copilotkit/info", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.256372, + "wallTime": 1792545034.710477, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 282.529, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.47", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.272945, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/sandboxed/published", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json", + "Content-Length": "17" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 282.553, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.46", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.273057, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/components/catalogue", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json", + "Content-Length": "12" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 282.561, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.45", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.273104, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/admin/enterprise/me", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json", + "Content-Length": "207" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 283.738, + "session": "page", + "method": "Network.webSocketWillSendHandshakeRequest", + "params": { + "requestId": "81494.51", + "timestamp": 1792996.274105, + "wallTime": 1792545034.7282, + "request": { + "headers": { + "Upgrade": "websocket", + "Origin": "http://127.0.0.1:7410", + "Cache-Control": "no-cache", + "Accept-Language": "en-US,en;q=0.9", + "Pragma": "no-cache", + "Connection": "Upgrade", + "Sec-WebSocket-Key": "XZUJMvGp9bLu4pKWJN5IOw==", + "Accept-Encoding": "gzip, deflate, br, zstd", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "Sec-WebSocket-Version": "13", + "Host": "127.0.0.1:7410", + "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits" + } + } + } + }, + { + "kind": "event", + "t": 283.747, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.49", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.274152, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/settings/preferences", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json", + "Content-Length": "80" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 283.75, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.52", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.274206, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/capabilities", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json", + "Content-Length": "185" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 283.753, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.48", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.274245, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/bots/attention", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json", + "Content-Length": "11" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 283.755, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.53", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.274269, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/agents", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json", + "Content-Length": "1204" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 283.765, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.50", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.274294, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/channels", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json", + "Content-Length": "822" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 283.899, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.47", + "timestamp": 1792996.274415, + "dataLength": 17, + "encodedDataLength": 17 + } + }, + { + "kind": "event", + "t": 283.906, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.47", + "timestamp": 1792996.254604, + "encodedDataLength": 125 + } + }, + { + "kind": "command", + "t": 283.924, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.47" }, + "tEnd": 284.746, + "result": { "body": "{\"components\":[]}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 283.954, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.46", + "timestamp": 1792996.274479, + "dataLength": 12, + "encodedDataLength": 12 + } + }, + { + "kind": "event", + "t": 283.956, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.46", + "timestamp": 1792996.254949, + "encodedDataLength": 120 + } + }, + { + "kind": "command", + "t": 283.958, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.46" }, + "tEnd": 284.785, + "result": { "body": "{\"added\":[]}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 283.987, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.45", + "timestamp": 1792996.274515, + "dataLength": 207, + "encodedDataLength": 207 + } + }, + { + "kind": "event", + "t": 283.989, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.45", + "timestamp": 1792996.254984, + "encodedDataLength": 316 + } + }, + { + "kind": "command", + "t": 283.996, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.45" }, + "tEnd": 284.825, + "result": { + "body": "{\"capabilities\":{\"useBots\":true,\"cloudBrowser\":true,\"cloudNetwork\":true,\"cloudComputer\":true,\"localComputer\":true,\"customRules\":true,\"passwordManager\":true,\"slackTeams\":true,\"teamBots\":true},\"enforced\":true}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 284.294, + "session": "page", + "method": "Network.webSocketHandshakeResponseReceived", + "params": { + "requestId": "81494.51", + "timestamp": 1792996.274561, + "response": { + "status": 101, + "statusText": "Switching Protocols", + "headers": { + "Upgrade": "websocket", + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Sec-WebSocket-Accept": "NsShk0cqw+MbmwAXqKZ/hTjFCGE=", + "Connection": "Upgrade" + } + } + } + }, + { + "kind": "event", + "t": 284.301, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.49", + "timestamp": 1792996.27483, + "dataLength": 80, + "encodedDataLength": 80 + } + }, + { + "kind": "event", + "t": 284.309, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.49", + "timestamp": 1792996.256456, + "encodedDataLength": 188 + } + }, + { + "kind": "command", + "t": 284.314, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.49" }, + "tEnd": 285.297, + "result": { + "body": "{\"preferences\":{\"messageListEmphasis\":\"thread\",\"selfHostBannerDismissed\":false}}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 284.33, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.52", + "timestamp": 1792996.274857, + "dataLength": 185, + "encodedDataLength": 185 + } + }, + { + "kind": "event", + "t": 284.331, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.52", + "timestamp": 1792996.256499, + "encodedDataLength": 294 + } + }, + { + "kind": "command", + "t": 284.332, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.52" }, + "tEnd": 285.337, + "result": { + "body": "{\"mode\":\"intelligence\",\"durableHistory\":true,\"generativeUi\":true,\"selfHostBanner\":false,\"transcription\":false,\"voice\":false,\"authProviders\":[],\"ssoConfigured\":false,\"ssoRequired\":false}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 284.351, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.48", + "timestamp": 1792996.274877, + "dataLength": 11, + "encodedDataLength": 11 + } + }, + { + "kind": "event", + "t": 284.352, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.48", + "timestamp": 1792996.26157, + "encodedDataLength": 119 + } + }, + { + "kind": "command", + "t": 284.353, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.48" }, + "tEnd": 285.349, + "result": { "body": "{\"bots\":[]}", "base64Encoded": false } + }, + { + "kind": "event", + "t": 284.36, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.53", + "timestamp": 1792996.274893, + "dataLength": 1204, + "encodedDataLength": 1204 + } + }, + { + "kind": "event", + "t": 284.361, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.53", + "timestamp": 1792996.263398, + "encodedDataLength": 1314 + } + }, + { + "kind": "command", + "t": 284.361, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.53" }, + "tEnd": 285.414, + "result": { + "body": "{\"agents\":[{\"id\":\"agent_cff5369c-e63e-48ff-9022-2e9d2ef9c228\",\"name\":\"Refund Clerk\",\"title\":\"Order refunds\",\"roleDescription\":\"Process customer refunds in the store admin.\",\"avatarSeed\":\"agent_cff5369c-e63e-48ff-9022-2e9d2ef9c228\",\"visibility\":\"private\",\"hidden\":false,\"pinned\":false,\"systemOwned\":false,\"endpoint\":null,\"hasAuth\":false,\"hasCallbackToken\":false,\"canManage\":true,\"mine\":true,\"assignedToMe\":false,\"builtIn\":false},{\"id\":\"general-assistant\",\"name\":\"General Assistant\",\"title\":\"Everyday Work\",\"roleDescription\":\"Help with everyday work using clear, concise, and accurate answers.\",\"avatarSeed\":\"general-assistant\",\"visibility\":\"public\",\"hidden\":false,\"pinned\":false,\"systemOwned\":true,\"endpoint\":null,\"hasAuth\":false,\"hasCallbackToken\":false,\"canManage\":false,\"mine\":false,\"assignedToMe\":false,\"builtIn\":false},{\"id\":\"knowledge\",\"name\":\"Knowledge\",\"title\":\"Company Knowledge\",\"roleDescription\":\"Help answer company knowledge questions and cite sources when available.\",\"avatarSeed\":\"knowledge\",\"visibility\":\"public\",\"hidden\":false,\"pinned\":false,\"systemOwned\":true,\"endpoint\":null,\"hasAuth\":false,\"hasCallbackToken\":false,\"canManage\":false,\"mine\":false,\"assignedToMe\":false,\"builtIn\":false}]}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 284.373, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.50", + "timestamp": 1792996.274926, + "dataLength": 822, + "encodedDataLength": 822 + } + }, + { + "kind": "event", + "t": 284.377, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.50", + "timestamp": 1792996.264045, + "encodedDataLength": 931 + } + }, + { + "kind": "command", + "t": 284.377, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.50" }, + "tEnd": 285.432, + "result": { + "body": "{\"channels\":[{\"id\":\"channel_130fb252-2c9e-4859-bdc9-c8d119dc9fcc\",\"name\":\"General Assistant\",\"agentIds\":[\"general-assistant\"],\"threadId\":\"55569917-dab5-85c6-9b02-9d329ba72087\",\"active\":true,\"lastMessageAt\":\"2026-10-21T00:40:32.206Z\",\"summary\":null,\"lastMessage\":\"Hi! In one short sentence, what is 2 plus 2?\",\"lastMessageAgentId\":null,\"createdAt\":\"2026-10-21T00:40:32.125Z\",\"pinned\":false,\"lastReadAt\":null},{\"id\":\"channel_142bc746-e79f-490f-a879-ca3f561adc4f\",\"name\":\"General Assistant\",\"agentIds\":[\"general-assistant\"],\"threadId\":\"55569917-dab5-8a87-a212-4c199f2ecb70\",\"active\":true,\"lastMessageAt\":\"2026-10-21T00:38:20.681Z\",\"summary\":null,\"lastMessage\":\"Hi! In one short sentence, what is 2 plus 2?\",\"lastMessageAgentId\":null,\"createdAt\":\"2026-10-21T00:38:20.577Z\",\"pinned\":false,\"lastReadAt\":null}],\"nextCursor\":null}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 332.565, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.54", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.322982, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/copilotkit/info", + "status": 200, + "statusText": "OK", + "headers": { + "Access-Control-Allow-Origin": "*", + "Content-Length": "1799", + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 332.628, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.54", + "timestamp": 1792996.323161, + "dataLength": 1799, + "encodedDataLength": 1799 + } + }, + { + "kind": "event", + "t": 332.637, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.54", + "timestamp": 1792996.322924, + "encodedDataLength": 1941 + } + }, + { + "kind": "command", + "t": 332.644, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.54" }, + "tEnd": 334.022, + "result": { + "body": "{\"version\":\"1.79.1\",\"agents\":{\"agent_cff5369c-e63e-48ff-9022-2e9d2ef9c228\":{\"name\":\"agent_cff5369c-e63e-48ff-9022-2e9d2ef9c228\",\"description\":\"Refund Clerk\",\"className\":\"RunBuiltAgent\",\"capabilities\":{\"tools\":{\"supported\":true,\"clientProvided\":true},\"transport\":{\"streaming\":true},\"humanInTheLoop\":{\"interrupts\":true}}},\"general-assistant\":{\"name\":\"general-assistant\",\"description\":\"General Assistant\",\"className\":\"RunBuiltAgent\",\"capabilities\":{\"tools\":{\"supported\":true,\"clientProvided\":true},\"transport\":{\"streaming\":true},\"humanInTheLoop\":{\"interrupts\":true}}},\"knowledge\":{\"name\":\"knowledge\",\"description\":\"Knowledge\",\"className\":\"RunBuiltAgent\",\"capabilities\":{\"tools\":{\"supported\":true,\"clientProvided\":true},\"transport\":{\"streaming\":true},\"humanInTheLoop\":{\"interrupts\":true}}}},\"audioFileTranscriptionEnabled\":false,\"mode\":\"intelligence\",\"threadEndpoints\":{\"list\":true,\"inspect\":true,\"mutations\":true,\"realtimeMetadata\":true},\"suggestions\":true,\"intelligence\":{\"wsUrl\":\"ws://127.0.0.1:7253/client\"},\"inspectorMetadata\":true,\"inspectorLearning\":true,\"a2uiEnabled\":true,\"a2ui\":{\"enabled\":true},\"openGenerativeUIEnabled\":true,\"licenseStatus\":\"valid\",\"runtimeEntitlements\":{\"status\":\"ready\",\"entitlement\":{\"active\":true,\"source\":\"selfHostedDeploymentLicense\",\"features\":{\"sdk.angular\":true,\"deployment_via_helm_chart\":true,\"analytics\":true,\"self_learning\":true,\"trajectories\":true,\"msteams\":true,\"memory\":true,\"managed_channels\":true,\"managed_channels.slack\":true,\"managed_channels.teams\":true},\"limits\":{\"threads.retention_hours\":0,\"threads.max_count\":0,\"multimodal_storage_gb\":0,\"managed_channels.max_channels\":0,\"managed_channels.ingress_retry_minutes\":60,\"managed_channels.max_lifetime_turns\":0},\"planCode\":\"enterprise\",\"entitlementSource\":\"enterprise_override\"}},\"telemetryDisabled\":false}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 333.957, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.55", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/copilotkit/inspector-metadata", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792996.324466, + "wallTime": 1792545034.778578, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": false + } + }, + { + "kind": "event", + "t": 358.081, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.55", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792996.348202, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/copilotkit/inspector-metadata", + "status": 200, + "statusText": "OK", + "headers": { + "Cache-Control": "no-store, private", + "Access-Control-Allow-Origin": "*", + "Content-Length": "244", + "Date": "Wed, 21 Oct 2026 01:10:33 GMT", + "Content-Type": "application/json" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 358.087, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.55", + "timestamp": 1792996.348297, + "dataLength": 244, + "encodedDataLength": 244 + } + }, + { + "kind": "event", + "t": 358.089, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.55", + "timestamp": 1792996.348143, + "encodedDataLength": 419 + } + }, + { + "kind": "command", + "t": 358.097, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.55" }, + "tEnd": 358.337, + "result": { + "body": "{\"schemaVersion\":1,\"identity\":{\"organizationName\":\"Recorder E2E\",\"projectName\":\"Recorder E2E\"},\"plan\":{\"code\":\"enterprise\",\"label\":\"Enterprise\"},\"license\":{\"state\":\"valid\"},\"usage\":{\"used\":14,\"limit\":{\"kind\":\"unlimited\"},\"expiringSoonCount\":0}}", + "base64Encoded": false + } + }, + { + "kind": "mark", + "t": 3398.077, + "wall": 1792545037842, + "name": "click", + "data": { + "name": "focus", + "selector": "[aria-label=\"Message\"]", + "text": "" + } + }, + { + "kind": "mark", + "t": 3410.276, + "wall": 1792545037855, + "name": "typed", + "data": { + "selector": "[aria-label=\"Message\"]", + "text": "Hi! In one short sentence, what is 2 plus 2?" + } + }, + { + "kind": "mark", + "t": 3912.804, + "wall": 1792545038357, + "name": "click", + "data": { + "name": "send", + "selector": "button[aria-label=\"Send message\"]", + "text": "" + } + }, + { + "kind": "event", + "t": 3919.711, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.56", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/route", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"text\":\"Hi! In one short sentence, what is 2 plus 2?\"}", + "hasPostData": true + }, + "timestamp": 1792999.907346, + "wallTime": 1792545038.361477, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 3961.067, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.56", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792999.951553, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/route", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:37 GMT", + "Content-Type": "application/json", + "Content-Length": "184" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 3961.86, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.56", + "timestamp": 1792999.952428, + "dataLength": 184, + "encodedDataLength": 184 + } + }, + { + "kind": "event", + "t": 3961.922, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.56", + "timestamp": 1792999.95241, + "encodedDataLength": 293 + } + }, + { + "kind": "command", + "t": 3961.94, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.56" }, + "tEnd": 3962.842, + "result": { + "body": "{\"agentId\":\"general-assistant\",\"name\":\"General Assistant\",\"reason\":\"sent to your default while the router was unreachable\",\"fallback\":true,\"viaMention\":false,\"undecided\":\"unreachable\"}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 3962.819, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.57", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/channels", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"agentIds\":[\"general-assistant\"]}", + "hasPostData": true + }, + "timestamp": 1792999.953255, + "wallTime": 1792545038.407374, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 3980.705, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.57", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792999.971153, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/channels", + "status": 201, + "statusText": "Created", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:37 GMT", + "Content-Type": "application/json", + "Content-Length": "210" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 3980.785, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.57", + "timestamp": 1792999.97127, + "dataLength": 210, + "encodedDataLength": 210 + } + }, + { + "kind": "event", + "t": 3980.788, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.57", + "timestamp": 1792999.971095, + "encodedDataLength": 324 + } + }, + { + "kind": "command", + "t": 3980.795, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.57" }, + "tEnd": 3981.372, + "result": { + "body": "{\"channel\":{\"id\":\"channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721\",\"name\":\"General Assistant\",\"agentIds\":[\"general-assistant\"],\"threadId\":\"55569917-dab5-8360-bd50-5860f0846698\",\"active\":true,\"lastMessageAt\":null}}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 3981.221, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.58", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/", + "request": { + "url": "http://127.0.0.1:7410/api/channels", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.971694, + "wallTime": 1792545038.425819, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 3986.214, + "session": "page", + "method": "Network.responseReceived", + "params": { + "requestId": "81494.58", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "timestamp": 1792999.976719, + "type": "Fetch", + "response": { + "url": "http://127.0.0.1:7410/api/channels", + "status": 200, + "statusText": "OK", + "headers": { + "Date": "Wed, 21 Oct 2026 01:10:37 GMT", + "Content-Type": "application/json", + "Content-Length": "1153" + }, + "mimeType": "application/json" + }, + "hasExtraInfo": true, + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0" + } + }, + { + "kind": "event", + "t": 3986.23, + "session": "page", + "method": "Network.dataReceived", + "params": { + "requestId": "81494.58", + "timestamp": 1792999.976807, + "dataLength": 1153, + "encodedDataLength": 1153 + } + }, + { + "kind": "event", + "t": 3986.232, + "session": "page", + "method": "Network.loadingFinished", + "params": { + "requestId": "81494.58", + "timestamp": 1792999.976651, + "encodedDataLength": 1263 + } + }, + { + "kind": "command", + "t": 3986.238, + "session": "page", + "method": "Network.getResponseBody", + "params": { "requestId": "81494.58" }, + "tEnd": 3992.407, + "result": { + "body": "{\"channels\":[{\"id\":\"channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721\",\"name\":\"General Assistant\",\"agentIds\":[\"general-assistant\"],\"threadId\":\"55569917-dab5-8360-bd50-5860f0846698\",\"active\":true,\"lastMessageAt\":null,\"summary\":null,\"lastMessage\":null,\"lastMessageAgentId\":null,\"createdAt\":\"2026-10-21T01:10:38.407Z\",\"pinned\":false,\"lastReadAt\":null},{\"id\":\"channel_130fb252-2c9e-4859-bdc9-c8d119dc9fcc\",\"name\":\"General Assistant\",\"agentIds\":[\"general-assistant\"],\"threadId\":\"55569917-dab5-85c6-9b02-9d329ba72087\",\"active\":true,\"lastMessageAt\":\"2026-10-21T00:40:32.206Z\",\"summary\":null,\"lastMessage\":\"Hi! In one short sentence, what is 2 plus 2?\",\"lastMessageAgentId\":null,\"createdAt\":\"2026-10-21T00:40:32.125Z\",\"pinned\":false,\"lastReadAt\":null},{\"id\":\"channel_142bc746-e79f-490f-a879-ca3f561adc4f\",\"name\":\"General Assistant\",\"agentIds\":[\"general-assistant\"],\"threadId\":\"55569917-dab5-8a87-a212-4c199f2ecb70\",\"active\":true,\"lastMessageAt\":\"2026-10-21T00:38:20.681Z\",\"summary\":null,\"lastMessage\":\"Hi! In one short sentence, what is 2 plus 2?\",\"lastMessageAgentId\":null,\"createdAt\":\"2026-10-21T00:38:20.577Z\",\"pinned\":false,\"lastReadAt\":null}],\"nextCursor\":null}", + "base64Encoded": false + } + }, + { + "kind": "event", + "t": 4012.387, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.60", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/computers/general-assistant/control", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.993839, + "wallTime": 1792545038.448024, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4012.438, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.61", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/bots/general-assistant/lifecycle", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.994118, + "wallTime": 1792545038.448266, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4012.58, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.62", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/agents", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.994515, + "wallTime": 1792545038.448663, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4012.718, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.63", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/voice/sessions?channelId=channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.995249, + "wallTime": 1792545038.449404, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4012.857, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.64", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/plugins/for/general-assistant", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.995584, + "wallTime": 1792545038.449741, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4013.046, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.65", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/channels/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721/busy", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "content-type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "postData": "{\"busy\":true}", + "hasPostData": true + }, + "timestamp": 1792999.995903, + "wallTime": 1792545038.45005, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4013.174, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.66", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/channels/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.996164, + "wallTime": 1792545038.450307, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4013.265, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.67", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/channels", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.996329, + "wallTime": 1792545038.450469, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4013.376, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.68", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/agents/general-assistant", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.998054, + "wallTime": 1792545038.452199, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4013.416, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.69", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/components/for-agent/general-assistant", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.998336, + "wallTime": 1792545038.452479, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4013.615, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.70", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/computers/general-assistant/control", + "method": "GET", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "sec-ch-ua-mobile": "?0" + } + }, + "timestamp": 1792999.998625, + "wallTime": 1792545038.452786, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "event", + "t": 4013.69, + "session": "page", + "method": "Network.requestWillBeSent", + "params": { + "requestId": "81494.71", + "loaderId": "36D9CD26313B1A16A705032388E88152", + "documentURL": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "request": { + "url": "http://127.0.0.1:7410/api/copilotkit/agent/general-assistant/connect", + "method": "POST", + "headers": { + "sec-ch-ua-platform": "\"macOS\"", + "Referer": "http://127.0.0.1:7410/channel/channel_ad1f9107-6439-43f1-ba8b-a4cceebc6721", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + "sec-ch-ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"", + "Content-Type": "application/json", + "sec-ch-ua-mobile": "?0" + }, + "hasPostData": true + }, + "timestamp": 1793000.00228, + "wallTime": 1792545038.456399, + "type": "Fetch", + "frameId": "79C9BC04B59C70F71BEFF327DFC6B4F0", + "hasUserGesture": true + } + }, + { + "kind": "command", + "t": 4013.721, + "session": "page", + "method": "Network.getRequestPostData", + "params": { "requestId": "81494.71" }, + "tEnd": 4023.961, + "result": { + "postData": "{\"threadId\":\"55569917-dab5-8360-bd50-5860f0846698\",\"runId\":\"0ad6ad8f-0811-443f-bce0-af99bf07cb79\",\"protocolVersion\":\"1.0\",\"tools\":[{\"name\":\"generateSandboxedUi\",\"description\":\"Generate sandboxed UI. IMPORTANT: The generated code runs in a sandboxed iframe WITHOUT same-origin access. Do NOT use localStorage, sessionStorage, document.cookie, IndexedDB, or fetch/XMLHttpRequest to same-origin URLs. To communicate with the host application, use Websandbox.connection.remote.(args) which returns a Promise.\\n\\nYou CAN use external libraries from CDNs by including ', + "options.html": '', + "offscreen.html": + '', + "content.js": "(function(){console.log('content');})();", + "page-network.js": "(function(){console.log('page');})();", + "managed_schema.json": "{}", + "icons/16.png": "x", + "icons/128.png": "x", +}); + +let roots: string[] = []; + +function fixture(overrides: Record = {}): string { + const dir = mkdtempSync(join(tmpdir(), "verify-dist-")); + roots.push(dir); + const all: Record = { ...files(), ...overrides }; + for (const [name, content] of Object.entries(all)) { + if (content === null) continue; + const path = join(dir, name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } + return dir; +} + +beforeEach(() => { + roots = []; +}); +afterEach(() => { + for (const dir of roots) rmSync(dir, { recursive: true, force: true }); +}); + +describe("verifyDist", () => { + test("a good directory has no problems", () => { + expect(verifyDist(fixture(), release)).toEqual([]); + }); + + test("a content script that starts with an import is not classic", () => { + const dir = fixture({ "content.js": 'import "x";\nconsole.log(1);' }); + expect(verifyDist(dir, release)).toEqual([ + "content.js must be a classic script", + ]); + }); + + test("a page-network script with an export is not classic", () => { + const dir = fixture({ "page-network.js": "var a = 1;\nexport { a };" }); + expect(verifyDist(dir, release)).toEqual([ + "page-network.js must be a classic script", + ]); + }); + + test("an html page loading a remote script reports remote code", () => { + const dir = fixture({ + "popup.html": '', + }); + expect(verifyDist(dir, release)).toEqual(["popup.html loads remote code"]); + }); + + test("an unquoted remote script src reports remote code", () => { + const dir = fixture({ + "popup.html": "", + }); + expect(verifyDist(dir, release)).toEqual(["popup.html loads remote code"]); + }); + + test("a remote src after a data-src attribute reports remote code", () => { + const dir = fixture({ + "popup.html": + '', + }); + expect(verifyDist(dir, release)).toEqual(["popup.html loads remote code"]); + }); + + test("a protocol-relative stylesheet reports remote code", () => { + const dir = fixture({ + "options.html": '', + }); + expect(verifyDist(dir, release)).toEqual([ + "options.html loads remote code", + ]); + }); + + test("a missing offscreen.html is reported", () => { + expect(verifyDist(fixture({ "offscreen.html": null }), release)).toEqual([ + "missing offscreen.html", + ]); + }); + + test("a file the manifest names but the directory lacks is reported", () => { + expect(verifyDist(fixture({ "icons/128.png": null }), release)).toEqual([ + "missing icons/128.png", + ]); + }); + + test("a missing manifest is reported alone", () => { + expect(verifyDist(fixture({ "manifest.json": null }), release)).toEqual([ + "manifest.json is missing or is not valid JSON", + ]); + }); + + test("an unparseable manifest is reported", () => { + expect(verifyDist(fixture({ "manifest.json": "{" }), release)).toEqual([ + "manifest.json is missing or is not valid JSON", + ]); + }); + + test("an e2e hook in a release build is reported", () => { + const dir = fixture({ + "service-worker.js": "const x = 'RECORDER_E2E_FLOOD';", + }); + expect(verifyDist(dir, release)).toEqual([ + "service-worker.js contains an e2e-only hook (RECORDER_E2E)", + ]); + }); + + test("the e2e variant allows the e2e hooks", () => { + const dir = fixture({ + "service-worker.js": "const x = 'RECORDER_E2E_FLOOD';", + }); + expect(verifyDist(dir, { variant: "e2e", rootVersion: "0.2.0" })).toEqual( + [], + ); + }); + + test("a source map in a release build is reported", () => { + const dir = fixture({ "assets/popup.js.map": "{}" }); + expect(verifyDist(dir, release)).toEqual([ + "source map assets/popup.js.map in a release build", + ]); + }); + + test("a manifest version that is not the root version is reported", () => { + const dir = fixture({ "manifest.json": manifest("0.1.0") }); + expect(verifyDist(dir, release)).toEqual([ + "manifest version 0.1.0 is not the release version 0.2.0", + ]); + }); +}); diff --git a/extension/tests/voice.test.ts b/extension/tests/voice.test.ts new file mode 100644 index 000000000..837c9c543 --- /dev/null +++ b/extension/tests/voice.test.ts @@ -0,0 +1,638 @@ +import { describe, expect, test } from "bun:test"; +import { + type CapturedEvent, + REDACTED, + scrubTranscript, +} from "@openbot/shared/recorder-masking"; +import { + browserName, + FLUSH_MS, + NO_WORDS_MS, + NO_WORDS_SENTENCE, + startVoice, + type VoiceDeps, + voiceSentence, +} from "../src/offscreen/voice"; +import type { VoiceCapture } from "../src/protocol/messages"; +import { + type FakeTrack, + fakeMicrophone, + fakeSpeech, +} from "./support/fake-speech"; + +const START = 1_800_000_000_000; + +/** Timers that run only when the test advances the clock. */ +function virtualClock() { + let time = START; + let next = 1; + const pending = new Map void; at: number }>(); + return { + now: () => time, + timers: { + set(fn: () => void, ms: number) { + const handle = next++; + pending.set(handle, { fn, at: time + Math.max(0, ms) }); + return handle; + }, + clear(handle: unknown) { + pending.delete(handle as number); + }, + }, + async advance(ms: number) { + const target = time + ms; + for (;;) { + await flush(); + let due: [number, { fn: () => void; at: number }] | undefined; + for (const entry of pending) + if (entry[1].at <= target && (!due || entry[1].at < due[1].at)) + due = entry; + if (!due) break; + pending.delete(due[0]); + time = Math.max(time, due[1].at); + due[1].fn(); + } + time = target; + await flush(); + }, + }; +} + +const flush = async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +type Setup = { + clock: ReturnType; + events: CapturedEvent[]; + statuses: VoiceCapture[]; + tracks: FakeTrack[]; + constraints: MediaStreamConstraints[]; + speech: ReturnType; + last(): VoiceCapture | undefined; + recognition( + index?: number, + ): InstanceType["Recognition"]>; +}; + +function setup( + options: { + muted?: boolean; + language?: string; + noRecognition?: boolean; + microphone?: () => Promise; + digests?: Set; + browser?: string; + } = {}, +) { + const clock = virtualClock(); + const speech = fakeSpeech(); + const microphone = fakeMicrophone(options.microphone); + const s: Setup = { + clock, + events: [], + statuses: [], + tracks: microphone.tracks, + constraints: microphone.constraints, + speech, + last: () => s.statuses.at(-1), + recognition(index = -1) { + const found = speech.instances.at(index); + if (!found) throw new Error("no recognition was made"); + return found; + }, + }; + const deps: VoiceDeps = { + Recognition: options.noRecognition ? null : speech.Recognition, + getUserMedia: microphone.getUserMedia, + now: clock.now, + timers: clock.timers, + }; + const voice = startVoice( + { + language: options.language ?? "en-US", + muted: options.muted ?? false, + emit: (event) => s.events.push(event), + status: (voice) => s.statuses.push(voice), + mask: (text, before) => + scrubTranscript(text, { digests: options.digests, before }), + ...(options.browser ? { browser: options.browser } : {}), + }, + deps, + ); + return { ...s, voice }; +} + +const domError = (name: string) => + Object.assign(new Error(name), { name }) as Error; + +describe("voice", () => { + test("opens the microphone and listens to its track in the recording's language, continuously, with interim words", async () => { + const s = setup({ language: "de-DE" }); + await s.clock.advance(0); + expect(s.statuses[0]).toEqual({ state: "starting" }); + expect(s.constraints).toHaveLength(1); + const recognition = s.recognition(); + expect(recognition.started).toBe(true); + expect(recognition.track as unknown).toBe(s.tracks[0]); + expect(recognition.lang).toBe("de-DE"); + expect(recognition.continuous).toBe(true); + expect(recognition.interimResults).toBe(true); + // The browser's own recognition with its defaults: nothing is forced onto the device. + expect(recognition.processLocally).toBeUndefined(); + recognition.begin(); + expect(s.last()).toEqual({ state: "listening" }); + }); + + test("interim words only update what the popup shows; each final phrase is one transcript event with its times", async () => { + const s = setup(); + await s.clock.advance(0); + const recognition = s.recognition(); + recognition.begin(); + await s.clock.advance(1_000); + recognition.say([["I am opening", false]]); + await s.clock.advance(100); + recognition.say([["I am opening the order", false]]); + expect(s.events).toHaveLength(0); + await s.clock.advance(400); + expect(s.last()).toEqual({ + state: "listening", + heard: "I am opening the order", + }); + await s.clock.advance(1_500); + recognition.say([["I am opening the order page", true]]); + expect(s.events).toEqual([ + { + type: "CUSTOM", + name: "transcript", + timestamp: START + 1_000, + value: { + text: "I am opening the order page", + startedAt: new Date(START + 1_000).toISOString(), + endedAt: new Date(START + 3_000).toISOString(), + language: "en-US", + }, + }, + ]); + expect(s.last()?.heard).toBe("I am opening the order page"); + // Chrome repeats a final result in later events; it is sent once. + recognition.say( + [ + ["I am opening the order page", true], + [" to refund", false], + ], + 0, + ); + recognition.say( + [ + ["I am opening the order page", true], + [" to refund it", true], + ], + 1, + ); + expect( + s.events.map((event) => (event.value as { text: string }).text), + ).toEqual(["I am opening the order page", "to refund it"]); + }); + + test("a phrase Chrome showed while the one before was still being spoken starts when that one ended", async () => { + const s = setup(); + await s.clock.advance(0); + const recognition = s.recognition(); + recognition.begin(); + await s.clock.advance(1_000); + recognition.say([ + ["I am opening", false], + [" the", false], + ]); + await s.clock.advance(2_000); + recognition.say([["I am opening the order page", true]]); + await s.clock.advance(3_000); + recognition.say( + [ + ["I am opening the order page", true], + ["the customer was charged twice", true], + ], + 1, + ); + expect(s.events.map((event) => event.timestamp)).toEqual([ + START + 1_000, + START + 3_000, + ]); + expect(s.events[1]?.value).toMatchObject({ + startedAt: new Date(START + 3_000).toISOString(), + endedAt: new Date(START + 6_000).toISOString(), + }); + }); + + test("a spoken card number or typed secret is masked in the event and in the words the popup shows", async () => { + const s = setup({ + digests: new Set([ + new Bun.CryptoHasher("sha256").update("hunter2").digest("hex"), + ]), + }); + await s.clock.advance(0); + const recognition = s.recognition(); + recognition.begin(); + recognition.say([["my card number is $4,2424,2424,2424,242", false]]); + await s.clock.advance(500); + expect(s.last()?.heard).toBe(`my card number is $${REDACTED}`); + recognition.say([["my card number is 4242 4242 4242 4242", true]]); + recognition.say( + [ + ["my card number is 4242 4242 4242 4242", true], + [" and my password is hunter2", true], + ], + 1, + ); + expect( + s.events.map((event) => (event.value as { text: string }).text), + ).toEqual([ + `my card number is ${REDACTED}`, + `and my password is ${REDACTED}`, + ]); + expect(JSON.stringify(s.statuses)).not.toContain("4242"); + expect(JSON.stringify(s.statuses)).not.toContain("hunter2"); + }); + + test("a card number cut short is masked in the words the popup shows while it is still being said", async () => { + const s = setup(); + await s.clock.advance(0); + const recognition = s.recognition(); + recognition.begin(); + // What Chrome showed in a real run while the number was being read out. + recognition.say([["my card number is 4012-8", false]]); + await s.clock.advance(500); + expect(s.last()?.heard).toBe(`my card number is ${REDACTED}`); + expect(JSON.stringify(s.statuses)).not.toContain("4012"); + }); + + test("a number read out after a pause is masked as following the phrase that named the card", async () => { + const s = setup(); + await s.clock.advance(0); + const recognition = s.recognition(); + recognition.begin(); + recognition.say([["my card number is", true]]); + recognition.say( + [ + ["my card number is", true], + ["4012", false], + ], + 1, + ); + await s.clock.advance(500); + expect(s.last()?.heard).toBe(REDACTED); + recognition.say( + [ + ["my card number is", true], + ["4012 8", true], + ], + 1, + ); + expect( + s.events.map((event) => (event.value as { text: string }).text), + ).toEqual(["my card number is", REDACTED]); + expect(JSON.stringify([s.events, s.statuses])).not.toContain("4012"); + }); + + test("speech that never comes back as words says so after a while, keeps listening, and clears once words arrive", async () => { + const s = setup(); + await s.clock.advance(0); + const recognition = s.recognition(); + recognition.begin(); + // As Edge did in a real run: it hears speech and raises no error, but every final phrase is empty. + recognition.hearSpeech(); + const empties: [string, boolean][] = []; + for (let phrase = 0; phrase < 6; phrase++) { + await s.clock.advance(3_000); + empties.push(["", true]); + recognition.say(empties); + } + expect(s.last()?.reason).toBeUndefined(); + await s.clock.advance(NO_WORDS_MS - 18_000); + expect(s.last()).toEqual({ state: "listening", reason: NO_WORDS_SENTENCE }); + expect(NO_WORDS_SENTENCE).toBe( + "This browser is not turning your speech into words. Recording carries on without narration.", + ); + expect(s.events).toHaveLength(0); + expect(recognition.aborts).toBe(0); + expect(recognition.stops).toBe(0); + expect(s.tracks[0]?.readyState).toBe("live"); + // Chrome ends the recognition and another listens: the sentence stays. + recognition.end(); + await s.clock.advance(0); + const next = s.recognition(); + expect(next).not.toBe(recognition); + next.begin(); + expect(s.last()).toEqual({ state: "listening", reason: NO_WORDS_SENTENCE }); + next.say([["", true]]); + expect(s.last()?.reason).toBe(NO_WORDS_SENTENCE); + // A real phrase: it is sent and the sentence goes. + next.say( + [ + ["", true], + ["I am opening the order page", true], + ], + 1, + ); + expect(s.events).toHaveLength(1); + expect(s.last()).toEqual({ + state: "listening", + heard: "I am opening the order page", + }); + }); + + test("silence never says the browser is not turning speech into words, and a mute forgets that it did", async () => { + const quiet = setup(); + await quiet.clock.advance(0); + quiet.recognition().begin(); + await quiet.clock.advance(5 * NO_WORDS_MS); + expect(quiet.statuses.some((status) => status.reason)).toBe(false); + + const s = setup(); + await s.clock.advance(0); + s.recognition().begin(); + s.recognition().hearSpeech(); + await s.clock.advance(NO_WORDS_MS); + expect(s.last()?.reason).toBe(NO_WORDS_SENTENCE); + s.voice.mute(true); + s.recognition().end(); + expect(s.last()).toEqual({ state: "muted" }); + s.voice.mute(false); + await s.clock.advance(0); + s.recognition().begin(); + expect(s.last()).toEqual({ state: "listening" }); + await s.clock.advance(NO_WORDS_MS); + expect(s.last()).toEqual({ state: "listening" }); + }); + + test("a recognition Chrome ends is restarted on the same microphone, with a growing delay when it keeps ending at once", async () => { + const s = setup(); + await s.clock.advance(0); + s.recognition().begin(); + await s.clock.advance(10_000); + s.recognition().error("no-speech"); + s.recognition().end(); + await s.clock.advance(0); + expect(s.speech.instances).toHaveLength(2); + expect(s.constraints).toHaveLength(1); + // Ending within a moment of starting, again and again: 500 ms, then 1 s. + s.recognition().end(); + await s.clock.advance(499); + expect(s.speech.instances).toHaveLength(2); + await s.clock.advance(1); + expect(s.speech.instances).toHaveLength(3); + s.recognition().end(); + await s.clock.advance(999); + expect(s.speech.instances).toHaveLength(3); + await s.clock.advance(1); + expect(s.speech.instances).toHaveLength(4); + }); + + test("a microphone the browser has not allowed says so in a plain sentence, and nothing listens", async () => { + const s = setup({ + microphone: async () => { + throw domError("NotAllowedError"); + }, + }); + await s.clock.advance(0); + expect(s.last()).toEqual({ + state: "failed", + problem: "microphone", + reason: + "The Recorder is not allowed to use the microphone. Recording goes on without your voice.", + }); + expect(s.speech.instances).toHaveLength(0); + }); + + test("no microphone, no speech recognition, a blocked service and an unsupported language each say so and let go of the microphone", async () => { + const missing = setup({ + microphone: async () => { + throw domError("NotFoundError"); + }, + }); + await missing.clock.advance(0); + expect(missing.last()?.problem).toBe("no-microphone"); + + const none = setup({ noRecognition: true }); + await none.clock.advance(0); + expect(none.last()).toEqual({ + state: "failed", + problem: "no-recognition", + reason: + "This browser cannot turn speech into text. Recording goes on without your voice.", + }); + expect(none.constraints).toHaveLength(0); + + const blocked = setup(); + await blocked.clock.advance(0); + blocked.recognition().error("service-not-allowed"); + expect(blocked.last()?.problem).toBe("service"); + expect(blocked.tracks[0]?.readyState).toBe("ended"); + expect(blocked.recognition().aborts).toBe(1); + + const revoked = setup(); + await revoked.clock.advance(0); + revoked.recognition().error("not-allowed"); + expect(revoked.last()?.problem).toBe("microphone"); + expect(revoked.tracks[0]?.readyState).toBe("ended"); + + const welsh = setup({ language: "cy-GB" }); + await welsh.clock.advance(0); + welsh.recognition().error("language-not-supported"); + welsh.recognition().end(); + await welsh.clock.advance(5_000); + expect(welsh.tracks[0]?.readyState).toBe("ended"); + expect(welsh.last()).toEqual({ + state: "failed", + problem: "language", + reason: voiceSentence("language", "cy-GB"), + }); + expect(welsh.last()?.reason).toContain("Welsh"); + expect(welsh.speech.instances).toHaveLength(1); + + const unplugged = setup(); + await unplugged.clock.advance(0); + unplugged.tracks[0]?.onended?.(); + expect(unplugged.last()?.problem).toBe("no-microphone"); + }); + + test("a speech service out of reach says so and tries again with a growing delay", async () => { + const s = setup(); + await s.clock.advance(0); + s.recognition().begin(); + s.recognition().error("network"); + s.recognition().end(); + expect(s.last()).toEqual({ + state: "failed", + problem: "network", + reason: voiceSentence("network", "en-US"), + }); + await s.clock.advance(1_999); + expect(s.speech.instances).toHaveLength(1); + await s.clock.advance(1); + expect(s.speech.instances).toHaveLength(2); + s.recognition().begin(); + expect(s.last()?.state).toBe("listening"); + }); + + test("in Edge the speech service the popup names is Edge's, never Chrome's", async () => { + // Edge run 1: the popup said "Chrome's speech service cannot be reached" in Edge. What Edge 155 and Chrome 156 on + // macOS say they are, with their client hints and without them. + const edge = { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36 Edg/155.0.0.0", + userAgentData: { + brands: [ + { brand: "Microsoft Edge", version: "155" }, + { brand: "Not.A/Brand", version: "99" }, + { brand: "Chromium", version: "155" }, + ], + }, + }; + const chrome = { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", + userAgentData: { + brands: [ + { brand: "Chromium", version: "156" }, + { brand: "Google Chrome", version: "156" }, + { brand: "Not_A Brand", version: "24" }, + ], + }, + }; + expect(browserName(edge)).toBe("Edge"); + expect(browserName({ userAgent: edge.userAgent })).toBe("Edge"); + expect(browserName(chrome)).toBe("Chrome"); + expect(browserName({ userAgent: chrome.userAgent })).toBe("Chrome"); + + const s = setup({ browser: browserName(edge) }); + await s.clock.advance(0); + s.recognition().begin(); + s.recognition().error("network"); + expect(s.last()?.reason).toBe( + "Edge's speech service cannot be reached. Recording goes on, and voice tries again in a moment.", + ); + s.recognition().end(); + await s.clock.advance(2_000); + s.recognition().begin(); + s.recognition().error("service-not-allowed"); + expect(s.last()?.reason).toBe( + "Edge's speech recognition is turned off or not available here. Recording goes on without your voice.", + ); + expect(JSON.stringify(s.statuses)).not.toContain("Chrome"); + }); + + test("mute keeps the phrase in progress, lets go of the microphone, and unmute opens it again", async () => { + const s = setup(); + await s.clock.advance(0); + const first = s.recognition(); + first.begin(); + first.say([["before the mute", false]]); + s.voice.mute(true); + expect(s.last()).toEqual({ state: "muted" }); + expect(first.stops).toBe(1); + first.say([["before the mute I said", true]]); + first.end(); + await s.clock.advance(0); + expect(s.events).toHaveLength(1); + expect(s.last()).toEqual({ state: "muted" }); + expect(s.tracks[0]?.readyState).toBe("ended"); + await s.clock.advance(60_000); + expect(s.speech.instances).toHaveLength(1); + + s.voice.mute(false); + await s.clock.advance(0); + expect(s.constraints).toHaveLength(2); + expect(s.speech.instances).toHaveLength(2); + s.recognition().begin(); + expect(s.last()?.state).toBe("listening"); + }); + + test("a recording that starts muted opens no microphone until it is unmuted", async () => { + const s = setup({ muted: true }); + await s.clock.advance(0); + expect(s.constraints).toHaveLength(0); + expect(s.statuses).toEqual([{ state: "muted" }]); + s.voice.mute(false); + await s.clock.advance(0); + expect(s.constraints).toHaveLength(1); + }); + + test("unmuting after a failure tries again", async () => { + let refuse = true; + const s = setup({ + microphone: async () => { + if (refuse) throw domError("NotAllowedError"); + }, + }); + await s.clock.advance(0); + expect(s.last()?.problem).toBe("microphone"); + refuse = false; + s.voice.mute(false); + await s.clock.advance(0); + expect(s.speech.instances).toHaveLength(1); + }); + + test("stop sends the phrase in progress, then lets go of the microphone, and sends nothing after", async () => { + const s = setup(); + await s.clock.advance(0); + const recognition = s.recognition(); + recognition.begin(); + recognition.say([["and now I press", false]]); + let stopped = false; + const stopping = s.voice.stop().then(() => { + stopped = true; + }); + await s.clock.advance(0); + expect(recognition.stops).toBe(1); + expect(stopped).toBe(false); + recognition.say([["and now I press send", true]]); + recognition.end(); + await stopping; + expect( + s.events.map((event) => (event.value as { text: string }).text), + ).toEqual(["and now I press send"]); + expect(s.tracks[0]?.readyState).toBe("ended"); + recognition.say([["too late", true]], 1); + s.voice.mute(false); + await s.clock.advance(60_000); + expect(s.events).toHaveLength(1); + expect(s.speech.instances).toHaveLength(1); + }); + + test("stop gives up waiting for a speech service that never ends", async () => { + const s = setup(); + await s.clock.advance(0); + const recognition = s.recognition(); + recognition.begin(); + let stopped = false; + void s.voice.stop().then(() => { + stopped = true; + }); + await s.clock.advance(FLUSH_MS - 1); + expect(stopped).toBe(false); + await s.clock.advance(1); + expect(stopped).toBe(true); + expect(recognition.aborts).toBe(1); + expect(s.tracks[0]?.readyState).toBe("ended"); + }); + + test("a stop while the microphone is still opening lets go of it as soon as it opens", async () => { + let open: () => void = () => {}; + const s = setup({ + microphone: () => + new Promise((resolve) => { + open = resolve; + }), + }); + await s.clock.advance(0); + const stopping = s.voice.stop(); + open(); + await stopping; + await s.clock.advance(0); + expect(s.tracks[0]?.readyState).toBe("ended"); + expect(s.speech.instances).toHaveLength(0); + }); +}); diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 000000000..5e4e654bf --- /dev/null +++ b/extension/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["chrome", "bun"], + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { "@/*": ["../app/src/*"] } + }, + "include": ["src", "scripts", "tests", "e2e"] +} diff --git a/package.json b/package.json index c4324173a..4aff29b38 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "server", "worker", "shared", - "toolkit" + "toolkit", + "extension" ], "scripts": { "build": "bun run generate:app-config && bun run --filter '*' build", @@ -42,5 +43,8 @@ }, "overrides": { "lodash-es": "4.18.0" + }, + "patchedDependencies": { + "@copilotkit/learning@0.0.1": "patches/@copilotkit%2Flearning@0.0.1.patch" } } diff --git a/patches/@copilotkit%2Flearning@0.0.1.patch b/patches/@copilotkit%2Flearning@0.0.1.patch new file mode 100644 index 000000000..2732656af --- /dev/null +++ b/patches/@copilotkit%2Flearning@0.0.1.patch @@ -0,0 +1,1838 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index 19c7c0a2fc607c6044877274f99c5efc76112ea5..c3401f15b223af549bd3ff8ddb7b7c12c058064e 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -117,13 +117,17 @@ function createRedactor() { + const toggled = (records) => { + for (const record of records) if (record.oldValue?.toLowerCase() === "password") seen.add(record.target); + }; +- /** Replaces remembered values, raw and in their JSON and URL encodings. */ +- const scrub = (text) => { ++ /** ++ * Replaces remembered values, raw and in their JSON and URL encodings, plus ++ * `normalize(value)` when given and at least 4 characters long. ++ */ ++ const scrub = (text, normalize) => { + for (const value of [...remembered.values()].sort((a, b) => b.length - a.length)) for (const form of [ + value, + JSON.stringify(value).slice(1, -1), + encodeURIComponent(value), +- new URLSearchParams({ v: value }).toString().slice(2) ++ new URLSearchParams({ v: value }).toString().slice(2), ++ ...[normalize?.(value) ?? ""].filter((normal) => normal.length >= 4) + ]) text = /^\d+$/.test(form) ? text.replace(RegExp(`(?"']*[^\s<>"'.,;:!?)]/gi; ++const EMAIL = /[\p{L}\p{N}_.%+-]{1,64}@[\p{L}\p{N}-]{1,63}(?:\.[\p{L}\p{N}-]{1,63})+/gu; ++const JWT = /(^|[^\w-])(?:eyJ[\w-]*\.[\w-]*\.[\w-]*|[\w-]{16,}\.[\w-]{16,}\.[\w-]{16,}|(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,})/g; ++const TOKEN_RUN = /[\w+/=-]{24,}/g; ++const roleOf = (element) => (element.getAttribute("role") ?? "").trim().split(/\s+/)[0].toLowerCase(); ++const idRefs = (element, name) => (element.getAttribute(name) ?? "").split(/\s+/).filter(Boolean); ++const rootOf = (element) => { ++ const root = element.getRootNode(); ++ return root instanceof Document || root instanceof ShadowRoot ? root : null; ++}; ++/** Buttons, links with `href`, `summary`, and elements with a control role. */ ++const isControl = (element) => { ++ const role = roleOf(element); ++ return role ? CONTROL_ROLE.test(role) : element.matches("button, a[href], summary"); ++}; ++/** Text-like inputs, text areas, selects, and anything inside editable content or a text-entry role. */ ++const holdsValue = (element) => element instanceof HTMLInputElement ? !NON_TEXT_INPUT.test(element.type) : element.matches("textarea, select") || element.closest(TEXT_ENTRY) !== null; ++/** ++* The name an image contributes: `alt` of an `img`, or `aria-label` (else a ++* plain ``) of an `svg` or `role="img"` element. `undefined` when the ++* element is not an image. ++*/ ++function imageName(element) { ++ if (element.localName === "img") return element.getAttribute("alt"); ++ if (element.localName !== "svg" && roleOf(element) !== "img") return void 0; ++ const title = Array.from(element.children).find((child) => child.localName === "title" && child.childElementCount === 0); ++ return element.getAttribute("aria-label") ?? title?.textContent ?? null; ++} ++/** ++* Rendered text. Inline formatting joins words; other elements separate them. ++* Skips hidden subtrees, uses image names, and does ++* not walk into images. Yields nothing for editable content or editable ++* descendants. In `strict` mode (a control's own text) it also yields nothing ++* past the walk bounds, for card-like structure, and for text that looks like ++* separate fields: two `div`s that directly hold text, or three sibling ++* elements that each carry text. ++*/ ++function safeText(root, strict) { ++ if (holdsValue(root)) return ""; ++ let text = ""; ++ let nodes = 0; ++ let paragraphs = 0; ++ let div; ++ const bearers = /* @__PURE__ */ new Map(); ++ const add = (part) => text = (text + part).replace(/\s+/g, " "); ++ const bear = (start) => { ++ if (!strict) return true; ++ for (let child = start, parent = start.parentNode; child !== root && parent !== null; child = parent, parent = parent.parentNode) { ++ const siblings = bearers.get(parent) ?? /* @__PURE__ */ new Set(); ++ bearers.set(parent, siblings); ++ if (siblings.has(child)) break; ++ if (siblings.add(child).size > 2) return false; ++ } ++ return true; ++ }; ++ let node = root.firstChild; ++ while (node !== null) { ++ if (++nodes > MAX_WALK_NODES || text.length > MAX_WALK_CHARS) { ++ if (strict) return ""; ++ break; ++ } ++ let descend = false; ++ if (node instanceof Text) { ++ const parent = node.parentNode; ++ let holder = parent; ++ while (holder !== root && PHRASING.test(holder.localName)) holder = holder.parentElement; ++ if (/\S/.test(node.data) && (!bear(parent) || strict && holder.localName === "div" && (div ??= holder) !== holder)) return ""; ++ add(node.data); ++ } else if (node instanceof Element) { ++ if (node.matches(EDITABLE)) return ""; ++ if (strict && node.matches(STRUCTURE) && !(root.localName === "summary" && node.matches(HEADING)) && (node.localName !== "p" || ++paragraphs > 1)) return ""; ++ const name = node.matches(SKIPPED) ? null : imageName(node); ++ if (name) { ++ if (!bear(node)) return ""; ++ add(` ${name} `); ++ } else if (name === void 0) { ++ if (!PHRASING.test(node.localName)) add(" "); ++ descend = true; ++ } ++ } ++ if (descend && node.firstChild !== null) node = node.firstChild; ++ else { ++ while (node !== root && node.nextSibling === null) { ++ node = node.parentNode; ++ if (!PHRASING.test(node.localName)) add(" "); ++ } ++ node = node === root ? null : node.nextSibling; ++ } ++ } ++ return text; ++} ++/** An option or tree item of an autocomplete or search popup, whose text usually repeats what the user typed. */ ++const isSuggestion = (element) => (element.localName === "option" || /^(option|treeitem)$/.test(roleOf(element))) && inSuggestionList(element); ++/** Whether `element` is, or is inside, a combobox, a datalist, or the popup a combobox or autocomplete field controls or owns. */ ++function inSuggestionList(element) { ++ if (element.closest("[role~=combobox], datalist") !== null) return true; ++ const root = rootOf(element); ++ return root !== null && Array.from(root.querySelectorAll("[role~=combobox], [aria-autocomplete]")).some((owner) => [...idRefs(owner, "aria-controls"), ...idRefs(owner, "aria-owns")].some((id) => root.getElementById(id)?.contains(element))); ++} ++function labelledBy(element) { ++ const root = rootOf(element); ++ return root === null ? null : idRefs(element, "aria-labelledby").map((id) => { ++ const referenced = root.getElementById(id); ++ return referenced === null || inSuggestionList(referenced) ? "" : safeText(referenced, false); ++ }).join(" "); ++} ++/** A control's own text, unless it is longer than a label: then it is content, not a name. */ ++const normalize = (text) => text.replace(IGNORABLE, "").replace(/\s+/g, " ").trim(); ++function contentText(element) { ++ const text = safeText(element, true); ++ return Array.from(normalize(text)).length > MAX_LABEL_LENGTH ? "" : text; ++} ++/** Removes invisible characters, credentials, emails, URLs, and tokens, then caps the length. */ ++function cleanLabel(raw, redact) { ++ const chars = Array.from(redact.scrub(normalize(redact.body(raw.replace(IGNORABLE, "")) ?? ""), normalize).replace(URL_LIKE, "[url]").replace(EMAIL, "[email]").replace(JWT, `$1${REDACTED}`).replace(TOKEN_RUN, (run) => /\d/.test(run) ? REDACTED : run)); ++ return chars.length > MAX_LABEL_LENGTH ? `${chars.slice(0, MAX_LABEL_LENGTH - 1).join("").trimEnd()}…` : chars.join(""); ++} ++function firstLabel(sources, redact) { ++ for (const source of sources) { ++ const label = cleanLabel(source() ?? "", redact); ++ if (label) return label; ++ } ++} ++/** ++* A short name for the clicked control, from the first source that yields one: ++* `aria-labelledby`, `aria-label`, an input caption or `alt`, `title` (controls ++* only), then the text of a control named by its content. Containers, cards, ++* and editable content never contribute their text. An autocomplete suggestion, ++* or anything inside one, gets no label from any source; `data-copilotkit-action` ++* names it instead. An icon without its own name takes the name of the control ++* around it. ++*/ ++function clickLabel(element, redact) { ++ const option = element.closest("option, [role~=option], [role~=treeitem]"); ++ if (option !== null && isSuggestion(option)) return void 0; ++ const named = [() => labelledBy(element), () => element.getAttribute("aria-label")]; ++ if (/^(img|svg)$/.test(element.localName) || /^(img|presentation|none)$/.test(roleOf(element))) { ++ const own = firstLabel([...named, () => imageName(element) ?? null], redact); ++ if (own !== void 0) return own; ++ let control = element.parentElement; ++ while (control !== null && !isControl(control)) control = control.parentElement; ++ return control === null ? void 0 : clickLabel(control, redact); ++ } ++ const control = isControl(element) && !holdsValue(element); ++ const captionInput = element instanceof HTMLInputElement && CAPTION_INPUT.test(element.type); ++ return firstLabel([ ++ ...named, ++ () => captionInput ? element.type === "image" ? element.getAttribute("alt") : element.value : null, ++ () => control || captionInput ? element.getAttribute("title") : null, ++ () => control ? contentText(element) : null ++ ], redact); ++} + /** Snapshot live control properties; HTML attributes do not reflect edited values. */ + function readControlValue(element, redact) { + if (element instanceof HTMLInputElement) return { +@@ -241,7 +412,7 @@ function readControlValue(element, redact) { + selectedValues: Array.from(element.selectedOptions, (option) => option.value) + }; + if (element instanceof HTMLTextAreaElement) return { value: element.value }; +- if (element.matches("[contenteditable]:not([contenteditable=\"false\"])")) return { value: element.textContent ?? "" }; ++ if (element.matches(CONTENTEDITABLE)) return { value: element.textContent ?? "" }; + return {}; + } + function eventElement(event) { +@@ -262,12 +433,21 @@ function isNear(origin, event) { + function installClickCapture(params) { + const { emit, getRoute, enrich, isTrusted = (event) => event.isTrusted, redact = createRedactor() } = params; + let origin = null; ++ const describeClick = (el) => { ++ const described = describeTarget(el, redact); ++ if (described === null) return null; ++ try { ++ const label = clickLabel(described.element, redact); ++ if (label !== void 0) described.target.label = label; ++ } catch {} ++ return described; ++ }; + const onPointerDown = (event) => { + try { + if (event.isPrimary === false) return; + const el = eventElement(event); + origin = { +- described: el === null ? null : describeTarget(el, redact), ++ described: el === null ? null : describeClick(el), + x: event.clientX, + y: event.clientY + }; +@@ -282,7 +462,7 @@ function installClickCapture(params) { + if (!isTrusted(event)) return; + const el = eventElement(event); + if (el === null) return; +- const described = pointerOrigin !== null && isRootOrDetached(el) && isNear(pointerOrigin, event) ? pointerOrigin.described : describeTarget(el, redact); ++ const described = pointerOrigin !== null && isRootOrDetached(el) && isNear(pointerOrigin, event) ? pointerOrigin.described : describeClick(el); + if (described === null) return; + const input = event.detail === 0 ? "keyboard" : "pointer"; + emit("click", { +@@ -312,41 +492,138 @@ function installClickCapture(params) { + + //#endregion + //#region src/inputs.ts +-/** Records user edits without changing the control or interfering with its listeners. */ ++const TEXT_DEBOUNCE_MS = 300; ++const TEXT_INPUT_TYPES = new Set([ ++ "text", ++ "search", ++ "tel", ++ "url", ++ "email", ++ "password", ++ "number" ++]); ++/** Records settled user edits without interfering with the host app's controls. */ + function installInputCapture(params) { + const { emit, isTrusted = (event) => event.isTrusted, redact = createRedactor() } = params; +- const onInput = (event) => { ++ let active = true; ++ const pending = /* @__PURE__ */ new Map(); ++ const changeEcho = /* @__PURE__ */ new WeakMap(); ++ const composing = /* @__PURE__ */ new WeakMap(); ++ const cancel = (element) => { ++ clearTimeout(pending.get(element)?.timer); ++ pending.delete(element); ++ }; ++ const deliver = (element, edit) => { + try { +- if (!isTrusted(event)) return; +- const [element] = event.composedPath(); +- if (!(element instanceof Element)) return; +- const described = describeTarget(element, redact); +- if (described === null) return; +- const value = readControlValue(element, redact); +- if (!("value" in value)) return; +- emit("input", { +- eventType: event.type, ++ if (!active || describeTarget(element, redact) === null) return; ++ if (redact.isPassword(element) && edit.state.value !== REDACTED) return; ++ if (edit.payload.eventType === "input") changeEcho.set(element, JSON.stringify(edit.state)); ++ edit.payload.url = redact.url(edit.payload.url); ++ edit.payload.target.text = redact.url(edit.payload.target.text); ++ edit.payload.target.attributes = Object.fromEntries(Object.entries(edit.payload.target.attributes).map(([key, value]) => [key, redact.url(value)])); ++ emit("input", edit.payload); ++ } catch {} ++ }; ++ const flush = () => { ++ const edits = [...pending]; ++ for (const [element] of edits) cancel(element); ++ for (const [element, edit] of edits) deliver(element, edit); ++ }; ++ const observe = (element, eventType, settle) => { ++ if (pending.size > 0 && !pending.has(element)) flush(); ++ if (!active) return; ++ const described = describeTarget(element, redact); ++ if (described === null) { ++ cancel(element); ++ return; ++ } ++ const state = readControlValue(element, redact); ++ if (!("value" in state)) return; ++ const text = element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement && TEXT_INPUT_TYPES.has(element.type) || element.matches("[contenteditable]:not([contenteditable=\"false\"])"); ++ if (!text) flush(); ++ const signature = JSON.stringify(state); ++ const echo = eventType === "change" && (signature === JSON.stringify(pending.get(element)?.state) || signature === changeEcho.get(element)); ++ changeEcho.delete(element); ++ if (echo) { ++ flush(); ++ changeEcho.delete(element); ++ return; ++ } ++ cancel(element); ++ const edit = { ++ state, ++ payload: { ++ eventType, + target: { + ...described.target, +- ...value ++ ...state + }, + url: redact.url(location.href), + route: location.pathname +- }); ++ } ++ }; ++ if (!text) { ++ deliver(element, edit); ++ return; ++ } ++ if (settle) edit.timer = setTimeout(() => { ++ cancel(element); ++ deliver(element, edit); ++ }, TEXT_DEBOUNCE_MS); ++ pending.set(element, edit); ++ }; ++ const onEdit = (event) => { ++ try { ++ if (!active || !isTrusted(event)) return; ++ const [element] = event.composedPath(); ++ if (!(element instanceof Element)) return; ++ if (event.type === "compositionstart") { ++ composing.set(element, true); ++ clearTimeout(pending.get(element)?.timer); ++ return; ++ } ++ if (event.type === "compositionend") composing.set(element, false); ++ const inComposition = composing.get(element) === true || composing.get(element) !== false && "isComposing" in event && event.isComposing === true; ++ observe(element, event.type === "compositionend" ? "input" : event.type, !inComposition); ++ if (event.type === "change") flush(); + } catch {} + }; +- window.addEventListener("input", onInput, { ++ const onAction = (event) => { ++ if (event.type !== "pagehide" && !isTrusted(event)) return; ++ if (event.type === "keydown") { ++ const [element] = event.composedPath(); ++ if (!(event instanceof KeyboardEvent) || event.key !== "Enter" || event.isComposing || event.keyCode === 229 || element instanceof Element && composing.get(element) === true) return; ++ } ++ flush(); ++ }; ++ const editTypes = [ ++ "input", ++ "change", ++ "compositionstart", ++ "compositionend" ++ ]; ++ const actionTypes = [ ++ "click", ++ "submit", ++ "focusout", ++ "pagehide", ++ "keydown" ++ ]; ++ for (const type of editTypes) window.addEventListener(type, onEdit, { + capture: true, + passive: true + }); +- window.addEventListener("change", onInput, { ++ for (const type of actionTypes) window.addEventListener(type, onAction, { + capture: true, + passive: true + }); +- return () => { +- window.removeEventListener("input", onInput, { capture: true }); +- window.removeEventListener("change", onInput, { capture: true }); ++ const uninstall = () => { ++ active = false; ++ for (const element of pending.keys()) cancel(element); ++ for (const type of editTypes) window.removeEventListener(type, onEdit, true); ++ for (const type of actionTypes) window.removeEventListener(type, onAction, true); + }; ++ return Object.assign(uninstall, { flush }); + } + + //#endregion +@@ -866,6 +1143,8 @@ function createCollector(options) { + const record = (name, value) => { + const current = session; + if (current === null) return; ++ if (name !== "input" && name !== "network") current.flushInputs?.(); ++ if (session !== current) return; + try { + seq += 1; + const event = { +@@ -878,7 +1157,7 @@ function createCollector(options) { + timestamp: Date.now() + }; + const kept = beforeSend === void 0 ? event : beforeSend(event); +- if (kept === null) return; ++ if (kept === null || session !== current) return; + current.queue.push(kept); + if (current.queue.length >= FLUSH_SIZE) flush(); + } catch {} +@@ -921,10 +1200,14 @@ function createCollector(options) { + ignoreUrls, + redact + })); +- if (capture.inputs) current.uninstalls.push(installInputCapture({ +- emit: recordCurrent, +- redact +- })); ++ if (capture.inputs) { ++ const inputs = installInputCapture({ ++ emit: recordCurrent, ++ redact ++ }); ++ current.flushInputs = inputs.flush; ++ current.uninstalls.push(inputs); ++ } + const onPageHide = () => flush({ beacon: true }); + window.addEventListener("pagehide", onPageHide); + current.uninstalls.push(() => window.removeEventListener("pagehide", onPageHide)); +@@ -939,6 +1222,8 @@ function createCollector(options) { + const stop = () => { + const current = session; + if (current === null) return; ++ current.flushInputs?.(); ++ if (session !== current) return; + clearInterval(current.timer); + for (const uninstall of current.uninstalls) uninstall(); + flush(); +@@ -1018,7 +1303,9 @@ function isJson(value, ancestors = /* @__PURE__ */ new Set()) { + */ + function createTrajectoryCollector(options) { + let active = false; ++ let generation = 0; + let uninstalls = []; ++ let flushInputs; + const report = (code, message) => { + try { + options.onError?.({ +@@ -1030,6 +1317,9 @@ function createTrajectoryCollector(options) { + const getRoute = () => toRoute(location.pathname); + const record = (name, value) => { + if (!active) return; ++ const current = generation; ++ if (name !== "input" && name !== "network") flushInputs?.(); ++ if (!active || generation !== current) return; + let payload; + try { + const event = { +@@ -1039,7 +1329,7 @@ function createTrajectoryCollector(options) { + value + }; + const kept = options.beforeSend === void 0 ? event : options.beforeSend(event); +- if (kept === null || !active) return; ++ if (kept === null || !active || generation !== current) return; + if (kept.type !== "CUSTOM" || typeof kept.name !== "string" || !kept.name.trim() || kept.name.length > 200 || kept.name.includes("\0") || !Number.isFinite(kept.timestamp) || kept.timestamp <= 0 || kept.timestamp >= 0xe677d21fdc00 || !isJson(kept.value)) { + report("INVALID_EVENT", "The event must contain a name, timestamp, and JSON value."); + return; +@@ -1069,6 +1359,12 @@ function createTrajectoryCollector(options) { + record(name, value); + }; + const stop = () => { ++ const current = generation; ++ const flush = flushInputs; ++ flushInputs = void 0; ++ flush?.(); ++ if (generation !== current) return; ++ generation++; + active = false; + const installed = uninstalls; + uninstalls = []; +@@ -1078,6 +1374,7 @@ function createTrajectoryCollector(options) { + start() { + if (active || typeof window === "undefined") return; + active = true; ++ generation++; + if (typeof window.addEventListener !== "function") return; + const redact = createRedactor(); + try { +@@ -1091,10 +1388,14 @@ function createTrajectoryCollector(options) { + emit: capture, + redact + })); +- if (options.capture?.inputs !== false) uninstalls.push(installInputCapture({ +- emit: capture, +- redact +- })); ++ if (options.capture?.inputs !== false) { ++ const inputs = installInputCapture({ ++ emit: capture, ++ redact ++ }); ++ flushInputs = inputs.flush; ++ uninstalls.push(inputs); ++ } + if (options.capture?.network !== false) uninstalls.push(installNetworkCapture({ + emit: capture, + ignoreUrls: options.ignoreUrls ?? [], +diff --git a/dist/index.cjs.map b/dist/index.cjs.map +index b5fdc7f18550aab2cc4b87e7c1d89684ddb32c51..5092d78d0912d0991330b0e8c66e221d7c692fa7 100644 +--- a/dist/index.cjs.map ++++ b/dist/index.cjs.map +@@ -1 +1 @@ +-{"version":3,"file":"index.cjs","names":[],"sources":["../src/types.ts","../src/redact.ts","../src/clicks.ts","../src/inputs.ts","../src/navigation.ts","../src/network-body.ts","../src/network.ts","../src/routes.ts","../src/collector.ts","../src/http-sink.ts","../src/trajectory-collector.ts"],"sourcesContent":["/** Event names the collector and CopilotKit Core emit. `emit()` rejects them. */\nexport const BUILT_IN_EVENT_NAMES = [\n \"page\",\n \"navigation\",\n \"click\",\n \"input\",\n \"network\",\n \"thread.linked\",\n \"agent.run\",\n \"agent.message\",\n \"tool.call\",\n] as const;\n\nexport type BuiltInEventName = (typeof BUILT_IN_EVENT_NAMES)[number];\n\n/** Replaces values that never leave the browser, even with full capture: passwords and credentials. */\nexport const REDACTED = \"[redacted]\";\n\n/** One captured interaction, shaped as an AG-UI `CUSTOM` event. */\nexport interface LearningEvent<V = Record<string, unknown>> {\n type: \"CUSTOM\";\n name: string;\n value: V;\n timestamp: number;\n}\n\n/** What a sink receives. The Trajectory scope sits on the batch, not on each event. */\nexport interface LearningBatch {\n trajectoryId: string;\n learningContainerIds?: string[];\n events: LearningEvent[];\n /** Events lost since the previous batch (queue overflow or a failed send). */\n dropped: number;\n}\n\nexport interface SinkSendOptions {\n /** True when the page is going away; use a transport that survives unload. */\n beacon?: boolean;\n}\n\n/** Receives batches. `url`, when set, is never captured as a network event. */\nexport type LearningSink = ((\n batch: LearningBatch,\n options?: SinkSendOptions,\n) => void | Promise<void>) & { readonly url?: string };\n\nexport interface ClickTarget {\n tag: string;\n role: string | null;\n action: string | null;\n text: string;\n attributes: Record<string, string>;\n value?: string;\n checked?: boolean;\n selectedValues?: string[];\n files?: { name: string; type: string; size: number; lastModified: number }[];\n input: \"pointer\" | \"keyboard\";\n}\n\n/** Adds fields to a click event, from the element the user clicked. */\nexport type EnrichFn = (target: Element) => Record<string, unknown> | undefined;\n\nexport type Emit = (name: string, value: Record<string, unknown>) => void;\n\nexport interface CaptureOptions {\n clicks?: boolean;\n navigation?: boolean;\n network?: boolean;\n inputs?: boolean;\n}\n\nexport interface CollectorOptions {\n sink: LearningSink;\n /** @deprecated Capture retains raw URLs; route templates no longer transform events. */\n routes?: string[];\n /** URLs (prefix match on the absolute URL) or patterns that network capture skips. */\n ignoreUrls?: (string | RegExp)[];\n /** Turns capture modules on or off. All are on by default. */\n capture?: CaptureOptions;\n /** Runs last, in the browser. Return the event to send, or `null` to drop it. */\n beforeSend?: (event: LearningEvent) => LearningEvent | null;\n enrich?: EnrichFn;\n}\n\nexport interface StartOptions {\n trajectoryId: string;\n learningContainerIds?: string[];\n}\n\nexport interface Collector {\n start(options: StartOptions): void;\n stop(): void;\n /** Developer events. Built-in names warn and are ignored. */\n emit(name: string, value: Record<string, unknown>): void;\n /** @internal For @copilotkit/core only: emits built-in names. */\n ɵemit(name: BuiltInEventName, value: Record<string, unknown>): void;\n readonly trajectoryId: string | null;\n}\n","import { REDACTED } from \"./types\";\n\n// Passwords and unambiguous credentials never leave the browser, even with full\n// capture. Keys are split into lowercase words (`accessToken`, `access_token`,\n// `Access-Token` all become \"access token\"). These match anywhere in the joined words:\nconst CREDENTIAL =\n /password|passwd|passphrase|passcode|credential|api(key|token)|privatekey|subscriptionkey|(access|refresh|auth|session)token|clientsecret/;\n// These match only as whole words (`secret` but not `secretary`, `id token` but\n// not `invalid token`), as the last word (`Set-Cookie` but not `cookieConsent`),\n// or as the entire key.\nconst CREDENTIAL_WORD =\n / (secret|id ?token)s? | (authorization|cookies?|jwt|bearer ?token|[cx]srf ?token) $|^ (pwd|pass|token|otp) $/;\n// Metadata about a credential is not the credential: `passwordExpiresAt`, `credentialId`.\nconst METADATA =\n / (ids?|at|in|expires?|expiry|length|count|enabled|required|policy|hint|type) $/;\nconst PASSWORD_AUTOCOMPLETE = /(current|new)-password|one-time-code/;\n// A JSON string, possibly cut off, with the `:` that makes it a key.\nconst TOKEN = /\"((?:\\\\[\\s\\S]|[^\"\\\\])*)(\"?)(\\s*:\\s*)?/g;\n// A lone trailing backslash belongs to a cut-off string.\nconst STRING = /\"(?:\\\\[\\s\\S]?|[^\"\\\\])*\"?/y;\n// `key=value` and `key: value` outside JSON strings: forms, query strings,\n// GraphQL, YAML. A `:` needs a space or end after it, so `host:port` is no pair.\n// The key length bound keeps scanning long unbroken text linear.\n// An auth scheme (`Bearer x`) belongs to the value.\nconst PAIR =\n /([\\w$.%+[\\]-]{1,64})(\\s*(?:=|:(?=\\s|$))\\s*)((?:(?:basic|bearer|digest|token) +)?[^\\s&,;)}\\]]*)/gi;\n// `scheme://user:pass@` anywhere in text; up to the last `@` before the host.\n// The scheme length bound keeps scanning long words linear.\nconst USERINFO = /([a-z][\\w+.-]{0,31}:\\/\\/)[^\\s/?#\"]*@/gi;\n/** Text beyond this is neither redacted nor kept; snapshots keep 4 KiB of the result. */\nexport const TEXT_LIMIT = 16 * 1024;\nconst REMEMBERED_VALUES = 20;\n\nexport const isCredentialKey = (key: string) => {\n const words = ` ${key\n .replace(/%([\\da-f]{2})/gi, (_, hex: string) =>\n String.fromCharCode(parseInt(hex, 16)),\n )\n .replace(/([a-z\\d])(?=[A-Z])/g, \"$1 \")\n .toLowerCase()\n .split(/[^a-z\\d]+/)\n .filter(Boolean)\n .join(\" \")} `;\n return (\n !METADATA.test(words) &&\n (CREDENTIAL.test(words.replace(/ /g, \"\")) || CREDENTIAL_WORD.test(words))\n );\n};\n\nconst decode = (body: string) => {\n try {\n return JSON.parse(`\"${body}\"`) as string;\n } catch {\n return body;\n }\n};\n\n/** The end of the JSON value at `start`: a string, a balanced object or array, or a bare literal. */\nconst endOfValue = (text: string, start: number) => {\n let depth = 0;\n for (let i = start; i < text.length; i++) {\n const char = text[i]!;\n if (char === '\"') {\n STRING.lastIndex = i;\n STRING.test(text);\n i = STRING.lastIndex - 1;\n } else if (char === \"{\" || char === \"[\") {\n depth++;\n continue;\n } else if (char === \"}\" || char === \"]\") {\n if (--depth < 0) return i;\n } else {\n if (!depth && (char === \",\" || /\\s/.test(char))) return i;\n continue;\n }\n if (!depth) return i + 1;\n }\n return text.length;\n};\n\n/**\n * Redacts credential values in JSON (valid or cut off) and in key/value text,\n * and recurses into string values that hold JSON, forms, or URLs. Unchanged\n * parts keep their exact original text.\n */\nconst redactText = (text: string, depth = 0): string => {\n const token = new RegExp(TOKEN);\n let pending = false;\n const pairs = (part: string) => {\n const segment = part.replace(USERINFO, \"$1\");\n return segment.replace(\n PAIR,\n (pair, key: string, separator: string, value: string, offset: number) => {\n if (!isCredentialKey(key)) return pair;\n // The value may continue in a following string: `password:\"x\"`.\n if (offset + pair.length === segment.length) {\n pending = true;\n if (!value) return pair;\n }\n return key + separator + REDACTED;\n },\n );\n };\n let out = \"\";\n let last = 0;\n let match: RegExpExecArray | null;\n while ((match = token.exec(text))) {\n const [raw, body, closed, colon] = match;\n pending = false;\n out += pairs(text.slice(last, match.index));\n let end = token.lastIndex;\n let replacement = raw;\n const value = body!.includes(\"\\\\\") ? decode(body!) : body!;\n if (colon) {\n if (isCredentialKey(value)) {\n const valueEnd = endOfValue(text, end);\n // Booleans and null say whether a credential exists, not what it is.\n if (!/^(true|false|null|)$/.test(text.slice(end, valueEnd))) {\n replacement += `\"${REDACTED}\"`;\n end = valueEnd;\n }\n }\n } else if (pending) replacement = `\"${REDACTED}\"`;\n else if (depth < 4 && /[=:\"]/.test(value)) {\n const redacted = redactText(value, depth + 1);\n if (redacted !== value)\n replacement = JSON.stringify(redacted).slice(\n 0,\n closed ? undefined : -1,\n );\n }\n out += replacement;\n last = token.lastIndex = end;\n }\n return out + pairs(text.slice(last));\n};\n\n/** Per-session redaction state: password-like fields and the values typed into them. */\nexport function createRedactor() {\n const seen = new WeakSet<Element>();\n // Latest value per field, so each keystroke replaces the previous prefix.\n const remembered = new Map<Element, string>();\n let observer: MutationObserver | undefined;\n\n const toggled = (records: MutationRecord[]) => {\n for (const record of records)\n if (record.oldValue?.toLowerCase() === \"password\")\n seen.add(record.target as Element);\n };\n\n /** Replaces remembered values, raw and in their JSON and URL encodings. */\n const scrub = (text: string) => {\n for (const value of [...remembered.values()].sort(\n (a, b) => b.length - a.length,\n ))\n for (const form of [\n value,\n JSON.stringify(value).slice(1, -1),\n encodeURIComponent(value),\n new URLSearchParams({ v: value }).toString().slice(2),\n ])\n text = /^\\d+$/.test(form)\n ? // One-time codes must not match inside longer numbers.\n text.replace(RegExp(`(?<!\\\\d)${form}(?!\\\\d)`, \"g\"), REDACTED)\n : text.split(form).join(REDACTED);\n return text;\n };\n\n /** True for current, former (show-password toggles), and autocomplete password fields. */\n const isPassword = (element: Element) => {\n if (observer) toggled(observer.takeRecords());\n if (\n !(element instanceof HTMLInputElement) ||\n !(\n element.type === \"password\" ||\n seen.has(element) ||\n isCredentialKey(element.name) ||\n PASSWORD_AUTOCOMPLETE.test(element.getAttribute(\"autocomplete\") ?? \"\")\n )\n )\n return false;\n seen.add(element);\n if (element.value.length >= 4) {\n remembered.delete(element);\n remembered.set(element, element.value);\n if (remembered.size > REMEMBERED_VALUES)\n remembered.delete(remembered.keys().next().value!);\n }\n return true;\n };\n\n return {\n isPassword,\n /**\n * Tracks password fields for the session: existing ones, `type` toggles away\n * from \"password\", and typed values even when input capture is off.\n */\n watch() {\n // Each part is optional: a partial DOM (tests, embedded webviews, hardened\n // pages) must not stop capture from starting or break the host app.\n try {\n for (const input of document.querySelectorAll(\"input[type=password]\"))\n seen.add(input);\n } catch {\n // Fields are still caught on first sight by isPassword.\n }\n let current: MutationObserver | undefined;\n try {\n current = new MutationObserver(toggled);\n current.observe(document, {\n subtree: true,\n attributeFilter: [\"type\"],\n attributeOldValue: true,\n });\n observer = current;\n } catch {\n current = undefined;\n }\n const remember = (event: Event) => {\n try {\n const [target] = event.composedPath();\n if (target instanceof Element) isPassword(target);\n } catch {\n // Capture must never break an edit in the host app.\n }\n };\n for (const type of [\"input\", \"change\"])\n window.addEventListener(type, remember, true);\n return () => {\n current?.disconnect();\n if (current !== undefined && observer === current) observer = undefined;\n for (const type of [\"input\", \"change\"])\n window.removeEventListener(type, remember, true);\n };\n },\n field: (key: string, value: string) =>\n isCredentialKey(key) ? REDACTED : scrub(redactText(value)),\n /** Returns the redacted body text, or undefined if redaction failed. */\n body(text: string) {\n try {\n return scrub(redactText(text.slice(0, TEXT_LIMIT)));\n } catch {\n return undefined;\n }\n },\n /** Drops userinfo and redacts credential values; also applied to element attributes. */\n url(value: string) {\n try {\n return scrub(value.replace(/[^#]+/g, (part) => redactText(part)));\n } catch {\n return REDACTED;\n }\n },\n };\n}\n\nexport type Redactor = ReturnType<typeof createRedactor>;\n","import { createRedactor, isCredentialKey } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { ClickTarget, Emit, EnrichFn } from \"./types\";\n\nconst INTERACTIVE =\n 'button, a[href], input, select, textarea, label, summary, [role], [data-copilotkit-action], [contenteditable]:not([contenteditable=\"false\"])';\nconst RECOVERY_DISTANCE_PX = 5;\n// `[role]` also matches containers such as role=\"main\"; cap their text so one\n// click cannot exceed the 16 KiB event limit and be dropped.\nconst MAX_TEXT_LENGTH = 1000;\n\n/** `closest()` that continues through shadow roots to their hosts. */\nfunction closestAcrossShadow(el: Element, selector: string) {\n let current: Element | null = el;\n while (current !== null) {\n const match = current.closest(selector);\n if (match !== null) return match;\n const root = current.getRootNode();\n current = root instanceof ShadowRoot ? root.host : null;\n }\n return null;\n}\n\ninterface Described {\n element: Element;\n target: Omit<ClickTarget, \"input\">;\n}\n\ninterface PointerOrigin {\n described: Described | null;\n x: number;\n y: number;\n}\n\n/**\n * Finds the nearest interactive element and retains its text, attributes, and value.\n * The explicit `data-copilotkit-ignore` opt-out still applies.\n */\nexport function describeTarget(el: Element, redact: Redactor) {\n if (closestAcrossShadow(el, \"[data-copilotkit-ignore]\") !== null) return null;\n const element = el.closest(INTERACTIVE) ?? el;\n // React and server markup can mirror the password into the value attribute.\n const password = redact.isPassword(element);\n const described: Described = {\n element,\n target: {\n tag: element.localName,\n role: element.getAttribute(\"role\"),\n action: element.getAttribute(\"data-copilotkit-action\"),\n text: (element.textContent ?? \"\").slice(0, MAX_TEXT_LENGTH),\n attributes: Object.fromEntries(\n Array.from(element.attributes, ({ name, value }) => [\n name,\n // Credential-named attributes (`data-api-key`) are redacted whole;\n // URL-valued ones (`href`, `action`, …) can carry credentials too.\n (password && name === \"value\") ||\n isCredentialKey(name.replace(/^data-/, \"\"))\n ? REDACTED\n : redact.url(value),\n ]),\n ),\n ...readControlValue(element, redact),\n },\n };\n return described;\n}\n\n/** Snapshot live control properties; HTML attributes do not reflect edited values. */\nexport function readControlValue(element: Element, redact: Redactor) {\n if (element instanceof HTMLInputElement) {\n return {\n value: redact.isPassword(element) ? REDACTED : element.value,\n ...([\"checkbox\", \"radio\"].includes(element.type)\n ? { checked: element.checked }\n : {}),\n ...(element.type === \"file\"\n ? {\n files: Array.from(element.files ?? [], (file) => ({\n name: file.name,\n type: file.type,\n size: file.size,\n lastModified: file.lastModified,\n })),\n }\n : {}),\n };\n }\n if (element instanceof HTMLSelectElement) {\n return {\n value: element.value,\n selectedValues: Array.from(\n element.selectedOptions,\n (option) => option.value,\n ),\n };\n }\n if (element instanceof HTMLTextAreaElement) return { value: element.value };\n if (element.matches('[contenteditable]:not([contenteditable=\"false\"])')) {\n return { value: element.textContent ?? \"\" };\n }\n return {};\n}\n\nfunction eventElement(event: Event) {\n const [first] = event.composedPath();\n return first instanceof Element ? first : null;\n}\n\nfunction isRootOrDetached(el: Element) {\n return el.localName === \"body\" || el.localName === \"html\" || !el.isConnected;\n}\n\nfunction isNear(origin: PointerOrigin, event: MouseEvent) {\n return (\n Math.abs(origin.x - event.clientX) <= RECOVERY_DISTANCE_PX &&\n Math.abs(origin.y - event.clientY) <= RECOVERY_DISTANCE_PX\n );\n}\n\n/**\n * Listens for clicks in the capture phase on `window`, so handlers that stop\n * propagation do not hide them. The target is described at `pointerdown`,\n * because a re-render before `click` can move the click to `<body>`.\n */\nexport function installClickCapture(params: {\n emit: Emit;\n getRoute: () => string;\n enrich?: EnrichFn;\n // ponytail: test seam only. jsdom cannot create trusted events; the collector never passes this.\n isTrusted?: (event: Event) => boolean;\n redact?: Redactor;\n}) {\n const {\n emit,\n getRoute,\n enrich,\n isTrusted = (event) => event.isTrusted,\n redact = createRedactor(),\n } = params;\n let origin: PointerOrigin | null = null;\n\n const onPointerDown = (event: PointerEvent) => {\n try {\n if (event.isPrimary === false) return;\n const el = eventElement(event);\n origin = {\n described: el === null ? null : describeTarget(el, redact),\n x: event.clientX,\n y: event.clientY,\n };\n } catch {\n origin = null;\n }\n };\n\n const onClick = (event: MouseEvent) => {\n const pointerOrigin = origin;\n origin = null;\n try {\n if (!isTrusted(event)) return;\n const el = eventElement(event);\n if (el === null) return;\n const canRecover =\n pointerOrigin !== null &&\n isRootOrDetached(el) &&\n isNear(pointerOrigin, event);\n const described = canRecover\n ? pointerOrigin.described\n : describeTarget(el, redact);\n if (described === null) return;\n const input = event.detail === 0 ? \"keyboard\" : \"pointer\";\n emit(\"click\", {\n target: { ...described.target, input },\n route: getRoute(),\n url: redact.url(location.href),\n ...enrich?.(described.element),\n });\n } catch {\n // Capture must never break the host app's click.\n }\n };\n\n window.addEventListener(\"pointerdown\", onPointerDown, {\n capture: true,\n passive: true,\n });\n window.addEventListener(\"click\", onClick, { capture: true, passive: true });\n return () => {\n window.removeEventListener(\"pointerdown\", onPointerDown, { capture: true });\n window.removeEventListener(\"click\", onClick, { capture: true });\n };\n}\n","import { describeTarget, readControlValue } from \"./clicks\";\nimport { createRedactor } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport type { Emit } from \"./types\";\n\n/** Records user edits without changing the control or interfering with its listeners. */\nexport function installInputCapture(params: {\n emit: Emit;\n isTrusted?: (event: Event) => boolean;\n redact?: Redactor;\n}) {\n const {\n emit,\n isTrusted = (event: Event) => event.isTrusted,\n redact = createRedactor(),\n } = params;\n const onInput = (event: Event) => {\n try {\n if (!isTrusted(event)) return;\n const [element] = event.composedPath();\n if (!(element instanceof Element)) return;\n const described = describeTarget(element, redact);\n if (described === null) return;\n const value = readControlValue(element, redact);\n if (!(\"value\" in value)) return;\n emit(\"input\", {\n eventType: event.type,\n target: { ...described.target, ...value },\n url: redact.url(location.href),\n route: location.pathname,\n });\n } catch {\n // Capture must never break an edit in the host app.\n }\n };\n window.addEventListener(\"input\", onInput, { capture: true, passive: true });\n window.addEventListener(\"change\", onInput, { capture: true, passive: true });\n return () => {\n window.removeEventListener(\"input\", onInput, { capture: true });\n window.removeEventListener(\"change\", onInput, { capture: true });\n };\n}\n","import { createRedactor } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport type { Emit } from \"./types\";\n\ntype NavigationType = \"push\" | \"replace\" | \"traverse\" | \"reload\";\ntype HistoryMethod = \"pushState\" | \"replaceState\";\n\n/** The part of the Navigation API this module uses (not in TypeScript's DOM lib yet). */\ninterface NavigationTarget {\n addEventListener(\n type: \"currententrychange\",\n listener: (event: Event) => void,\n ): void;\n removeEventListener(\n type: \"currententrychange\",\n listener: (event: Event) => void,\n ): void;\n}\n\nfunction isNavigationTarget(value: unknown): value is NavigationTarget {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"addEventListener\" in value &&\n \"removeEventListener\" in value\n );\n}\n\nfunction getNavigation() {\n const navigation: unknown = Reflect.get(window, \"navigation\");\n return isNavigationTarget(navigation) ? navigation : null;\n}\n\nfunction toNavigationType(value: unknown): NavigationType {\n switch (value) {\n case \"replace\":\n case \"traverse\":\n case \"reload\":\n return value;\n default:\n return \"push\";\n }\n}\n\n/**\n * Emits `navigation` when the full URL changes in a single-page app.\n * Uses the Navigation API when it exists. Otherwise patches `pushState` and\n * `replaceState` on the prototype and on the instance, because some routers\n * (Next.js) wrap the instance method.\n */\nexport function installNavigationCapture(params: {\n emit: Emit;\n routes?: string[];\n redact?: Redactor;\n}) {\n const { emit, redact = createRedactor() } = params;\n let current = location.href;\n\n const check = (navigationType: NavigationType) => {\n try {\n const next = location.href;\n if (next === current) return;\n emit(\"navigation\", {\n from: redact.url(current),\n to: redact.url(next),\n navigationType,\n });\n current = next;\n } catch {\n // Capture must never break navigation.\n }\n };\n\n const navigation = getNavigation();\n if (navigation !== null) {\n const onEntryChange = (event: Event) =>\n check(toNavigationType(Reflect.get(event, \"navigationType\")));\n navigation.addEventListener(\"currententrychange\", onEntryChange);\n return () =>\n navigation.removeEventListener(\"currententrychange\", onEntryChange);\n }\n\n return installHistoryFallback(check);\n}\n\nfunction installHistoryFallback(\n check: (navigationType: NavigationType) => void,\n) {\n let active = true;\n const restores: (() => void)[] = [];\n\n const patch = (target: History, method: HistoryMethod) => {\n const original = target[method];\n const wrapper: History[HistoryMethod] = function (this: History, ...args) {\n const result = original.apply(this, args);\n if (active) check(method === \"pushState\" ? \"push\" : \"replace\");\n return result;\n };\n target[method] = wrapper;\n restores.push(() => {\n // Restore only if no one wrapped it after us; otherwise our wrapper stays as a pass-through.\n if (target[method] === wrapper) target[method] = original;\n });\n };\n\n const methods: HistoryMethod[] = [\"pushState\", \"replaceState\"];\n for (const method of methods) {\n patch(History.prototype, method);\n if (Object.prototype.hasOwnProperty.call(history, method))\n patch(history, method);\n }\n\n const onPopState = () => check(\"traverse\");\n window.addEventListener(\"popstate\", onPopState);\n window.addEventListener(\"hashchange\", onPopState);\n\n return () => {\n active = false;\n window.removeEventListener(\"popstate\", onPopState);\n window.removeEventListener(\"hashchange\", onPopState);\n for (const restore of restores) restore();\n };\n}\n","import { TEXT_LIMIT } from \"./redact\";\nimport type { Redactor } from \"./redact\";\n\n/** A bounded observation of a body; never the stream returned to the app. */\nexport interface BodySnapshot {\n status:\n | \"complete\"\n | \"empty\"\n | \"truncated\"\n | \"unavailable\"\n | \"interrupted\"\n | \"timeout\";\n text?: string;\n encoding?: \"utf-8\" | \"base64\";\n truncated?: true;\n reason?: string;\n}\n\nconst BODY_BYTES = 4096;\nconst READ_TIMEOUT_MS = 1000;\nconst encoder = new TextEncoder();\nconst redactionFailed = (): BodySnapshot => ({\n status: \"unavailable\",\n reason: \"redaction-failed\",\n});\ntype RedactText = (text: string) => string | undefined;\n\n/** Redacts up to TEXT_LIMIT bytes of text first, then cuts the result to BODY_BYTES. */\nfunction snapshot(\n bytes: Uint8Array,\n status: BodySnapshot[\"status\"],\n contentType: string,\n redact: RedactText,\n reason?: string,\n): BodySnapshot {\n let text: string | undefined;\n if (!isBinary(contentType))\n try {\n // Declared text decodes lossily so it is always redacted; untyped bytes\n // that are not UTF-8 are treated as binary.\n text = new TextDecoder(\"utf-8\", { fatal: !contentType }).decode(bytes, {\n stream: status !== \"complete\",\n });\n } catch {\n /* Not UTF-8: keep the bytes as base64. */\n }\n if (text !== undefined) {\n text = redact(text);\n if (text === undefined) return redactionFailed();\n // PostgreSQL jsonb rejects NUL in strings; base64 retains the redacted bytes.\n if (text.includes(\"\\0\")) {\n bytes = encoder.encode(text);\n text = undefined;\n }\n }\n const binary = text === undefined;\n // Binary bodies are not inspected; only their first bytes are kept.\n text ??= btoa(String.fromCharCode(...bytes.subarray(0, BODY_BYTES)));\n const result: BodySnapshot = {\n status: status === \"complete\" && bytes.length === 0 ? \"empty\" : status,\n text,\n encoding: binary ? \"base64\" : \"utf-8\",\n ...(reason ? { reason } : {}),\n };\n if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)\n return result;\n result.status = status === \"complete\" ? \"truncated\" : status;\n result.truncated = true;\n // Bound the serialized field too: quotes/control characters expand in JSON.\n let low = 0;\n let high = text.length;\n while (low < high) {\n const middle = Math.ceil((low + high) / 2);\n result.text = text.slice(0, middle);\n if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)\n low = middle;\n else high = middle - 1;\n }\n if (binary)\n low -= low % 4; // Keep complete base64 groups.\n else if (low > 0 && /[\\uD800-\\uDBFF]/.test(text[low - 1]!)) low--;\n result.text = text.slice(0, low);\n return result;\n}\n\nfunction snapshotText(\n value: string,\n redact: RedactText,\n status: BodySnapshot[\"status\"] = \"complete\",\n reason?: string,\n): BodySnapshot {\n return snapshot(\n encoder.encode(value.slice(0, TEXT_LIMIT)),\n value.length > TEXT_LIMIT && status === \"complete\" ? \"truncated\" : status,\n \"text/plain\",\n redact,\n reason,\n );\n}\n\nfunction isBinary(contentType: string) {\n return (\n contentType !== \"\" &&\n !/(^text\\/|json|xml|javascript|graphql|x-www-form-urlencoded|multipart\\/form-data)/i.test(\n contentType,\n )\n );\n}\n\n/** Cancel only cloned/synthetic bodies. Cancelling a tee may wait for the app's branch. */\nexport function createBodyCapture(redact: Redactor) {\n const cancellations = new Set<() => void>();\n let active = true;\n const read = (\n stream: ReadableStream<Uint8Array> | null,\n contentType = \"\",\n ): Promise<BodySnapshot> => {\n if (!stream) return Promise.resolve({ status: \"empty\" });\n if (!active)\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"capture-stopped\",\n });\n let reader: ReadableStreamDefaultReader<Uint8Array>;\n try {\n reader = stream.getReader();\n } catch {\n return Promise.resolve({ status: \"unavailable\", reason: \"body-locked\" });\n }\n return new Promise((resolve) => {\n const bytes = new Uint8Array(TEXT_LIMIT);\n let length = 0;\n let finished = false;\n const finish = (status: BodySnapshot[\"status\"], reason?: string) => {\n if (finished) return;\n finished = true;\n clearTimeout(timer);\n cancellations.delete(cancel);\n resolve(\n snapshot(\n bytes.subarray(0, length),\n status,\n contentType,\n redact.body,\n reason,\n ),\n );\n // Do not await cancellation: a cloned fetch stream can share a live SSE source.\n void reader.cancel().catch(() => {});\n };\n const cancel = () => finish(\"interrupted\", \"capture-stopped\");\n const timer = setTimeout(\n () => finish(\"timeout\", \"body-read-timeout\"),\n READ_TIMEOUT_MS,\n );\n cancellations.add(cancel);\n void (async () => {\n try {\n for (;;) {\n const chunk = await reader.read();\n if (finished) break;\n if (chunk.done) {\n finish(\"complete\");\n break;\n }\n const kept = chunk.value.subarray(0, TEXT_LIMIT - length);\n bytes.set(kept, length);\n length += kept.length;\n if (length === TEXT_LIMIT) {\n finish(\"truncated\");\n break;\n }\n }\n } catch {\n finish(\"interrupted\", \"body-read-failed\");\n } finally {\n try {\n reader.releaseLock();\n } catch {\n /* A cancelled read may still be settling. */\n }\n }\n })();\n });\n };\n const body = (\n value: BodyInit | null | undefined,\n contentType = \"\",\n ): Promise<BodySnapshot> => {\n try {\n if (value == null) return Promise.resolve({ status: \"empty\" });\n if (value instanceof URLSearchParams) value = value.toString();\n if (typeof value === \"string\")\n return Promise.resolve(snapshotText(value, redact.body));\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n // A caller-owned init.body cannot be teed without locking/replacing it.\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"caller-owned-stream\",\n });\n }\n if (value instanceof FormData) {\n const form = new FormData();\n value.forEach((field, key) =>\n form.append(\n key,\n typeof field === \"string\" ? redact.field(key, field) : field,\n ),\n );\n value = form;\n }\n const response = new Response(value);\n return read(\n response.body,\n contentType || response.headers.get(\"content-type\") || \"\",\n );\n } catch {\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"body-not-readable\",\n });\n }\n };\n return {\n read,\n body,\n text: (value: string, status?: BodySnapshot[\"status\"], reason?: string) =>\n snapshotText(value, redact.body, status, reason),\n stop() {\n active = false;\n for (const cancel of cancellations) cancel();\n },\n };\n}\n","import { createBodyCapture } from \"./network-body\";\nimport type { BodySnapshot } from \"./network-body\";\nimport { createRedactor, isCredentialKey } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { Emit } from \"./types\";\n\ntype Outcome = \"ok\" | \"error\" | \"aborted\";\ninterface RequestParts {\n url: string;\n method: string;\n headers: Record<string, string>;\n}\ninterface ResponseParts {\n url?: string;\n headers: Record<string, string>;\n body: Promise<BodySnapshot>;\n}\nconst emptyBody = () => Promise.resolve<BodySnapshot>({ status: \"empty\" });\nconst unavailableBody = (reason: string) =>\n Promise.resolve<BodySnapshot>({ status: \"unavailable\", reason });\nconst toAbsolute = (url: string) => new URL(url, location.href).href;\n// Credentials carry no product signal and must never leave the browser, even\n// with full capture. The header name stays, so the event still shows it was sent.\n// Names the credential-key matcher misses; it covers `authorization`, `cookie`,\n// `x-api-key`, `x-csrf-token`, and similar.\nconst CREDENTIAL_HEADERS = new Set([\n \"x-amz-security-token\",\n \"private-token\",\n \"x-token\",\n]);\nconst headerValue = (name: string, value: string) =>\n CREDENTIAL_HEADERS.has(name.toLowerCase()) || isCredentialKey(name)\n ? REDACTED\n : value;\nconst headerValues = (headers: Headers) =>\n Object.fromEntries(\n Array.from(headers.entries(), ([name, value]) => [\n name,\n headerValue(name, value),\n ]),\n );\n\n// Checks the name, not instanceof: errors can come from another realm.\nfunction isAbortError(error: unknown) {\n return (\n typeof error === \"object\" &&\n error !== null &&\n \"name\" in error &&\n error.name === \"AbortError\"\n );\n}\n\n/** Records browser-visible request/response data without consuming the app's bodies. */\nexport function installNetworkCapture(params: {\n emit: Emit;\n routes?: string[];\n ignoreUrls: (string | RegExp)[];\n redact?: Redactor;\n}) {\n let active = true;\n const { redact = createRedactor() } = params;\n const bodies = createBodyCapture(redact);\n const ignoreUrls = params.ignoreUrls.map((pattern) =>\n typeof pattern === \"string\"\n ? toAbsolute(pattern)\n : new RegExp(pattern.source, pattern.flags),\n );\n const isIgnored = (url: string) =>\n ignoreUrls.some((pattern) => {\n if (typeof pattern === \"string\") return url.startsWith(pattern);\n pattern.lastIndex = 0;\n return pattern.test(url);\n });\n\n const record: RecordFn = (\n transport,\n request,\n requestBody,\n startedAt,\n status,\n outcome,\n response,\n ) => {\n const durationMs = Math.round(performance.now() - startedAt);\n const completedAt = Date.now();\n void Promise.all([\n requestBody,\n response?.body ?? unavailableBody(\"no-response\"),\n ])\n .then(([sent, received]) => {\n if (!active) return;\n const href = redact.url(request.url);\n const url = new URL(href);\n params.emit(\"network\", {\n transport,\n method: request.method,\n url: href,\n origin: url.origin,\n route: url.pathname,\n status,\n durationMs,\n completedAt,\n outcome,\n request: { headers: request.headers, body: sent },\n response: {\n ...(response?.url ? { url: redact.url(response.url) } : {}),\n headers: response?.headers ?? {},\n body: received,\n },\n });\n })\n .catch(() => {\n /* Capture must never affect the host request. */\n });\n };\n\n const originalFetch = globalThis.fetch;\n const wrappedFetch: typeof fetch = (input, init) => {\n if (!active) return originalFetch.call(globalThis, input, init);\n let request: RequestParts;\n let ignored: boolean;\n try {\n const source =\n typeof Request !== \"undefined\" && input instanceof Request\n ? input\n : undefined;\n request = {\n url: toAbsolute(source ? source.url : String(input)),\n method: (init?.method ?? source?.method ?? \"GET\").toUpperCase(),\n headers: headerValues(new Headers(init?.headers ?? source?.headers)),\n };\n ignored = isIgnored(request.url);\n } catch {\n return originalFetch.call(globalThis, input, init);\n }\n if (ignored) return originalFetch.call(globalThis, input, init);\n const startedAt = performance.now();\n let requestBody: Promise<BodySnapshot>;\n try {\n requestBody =\n init?.body != null\n ? bodies.body(init.body, request.headers[\"content-type\"])\n : typeof Request !== \"undefined\" && input instanceof Request\n ? bodies.read(input.clone().body, request.headers[\"content-type\"])\n : emptyBody();\n } catch {\n requestBody = unavailableBody(\"request-clone-failed\");\n }\n const failed = (error: unknown) =>\n record(\n \"fetch\",\n request,\n requestBody,\n startedAt,\n null,\n isAbortError(error) ? \"aborted\" : \"error\",\n );\n let promise: Promise<Response>;\n try {\n promise = originalFetch.call(globalThis, input, init);\n } catch (error) {\n failed(error);\n throw error;\n }\n void promise\n .then((response) => {\n if (!active) return;\n let responseBody: Promise<BodySnapshot>;\n try {\n responseBody =\n response.type === \"opaque\" || response.type === \"opaqueredirect\"\n ? unavailableBody(\"opaque-response\")\n : bodies.read(\n response.clone().body,\n response.headers.get(\"content-type\") ?? \"\",\n );\n } catch {\n responseBody = unavailableBody(\"response-clone-failed\");\n }\n record(\n \"fetch\",\n request,\n requestBody,\n startedAt,\n response.status,\n \"ok\",\n {\n url: response.url,\n headers: headerValues(response.headers),\n body: responseBody,\n },\n );\n }, failed)\n .catch(() => {});\n // Return the identical native Promise, Response, and rejection to the caller.\n return promise;\n };\n if (typeof originalFetch === \"function\") globalThis.fetch = wrappedFetch;\n const uninstallXhr = patchXhr(isIgnored, record, bodies);\n return () => {\n active = false;\n bodies.stop();\n if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;\n uninstallXhr();\n };\n}\n\ntype RecordFn = (\n transport: \"fetch\" | \"xhr\",\n request: RequestParts,\n requestBody: Promise<BodySnapshot>,\n startedAt: number,\n status: number | null,\n outcome: Outcome,\n response?: ResponseParts,\n) => void;\n\nfunction patchXhr(\n isIgnored: (url: string) => boolean,\n record: RecordFn,\n bodies: ReturnType<typeof createBodyCapture>,\n) {\n if (typeof XMLHttpRequest !== \"function\") return () => {};\n const proto = XMLHttpRequest.prototype;\n const originalOpen = proto.open;\n const originalSend = proto.send;\n const originalSetHeader = proto.setRequestHeader;\n const requests = new WeakMap<XMLHttpRequest, RequestParts>();\n const listeners = new Set<() => void>();\n // open() on an in-flight XHR ends it without `loadend`; its listeners must not\n // report the next request with the old request's details.\n const inFlight = new WeakMap<XMLHttpRequest, () => void>();\n let active = true;\n const wrappedOpen = function (\n this: XMLHttpRequest,\n method: string,\n url: string | URL,\n ...rest: unknown[]\n ) {\n inFlight.get(this)?.();\n const result = Reflect.apply(originalOpen, this, [method, url, ...rest]);\n try {\n requests.set(this, {\n url: toAbsolute(String(url)),\n method: method.toUpperCase(),\n headers: {},\n });\n } catch {\n requests.delete(this);\n }\n return result;\n };\n const wrappedSetHeader = function (\n this: XMLHttpRequest,\n name: string,\n value: string,\n ) {\n const result = originalSetHeader.call(this, name, value);\n const request = requests.get(this);\n if (active && request) {\n try {\n const headers = new Headers(request.headers);\n headers.append(name, value);\n request.headers = headerValues(headers);\n } catch {\n /* Preserve successful native calls even if observation fails. */\n }\n }\n return result;\n };\n const wrappedSend = function (\n this: XMLHttpRequest,\n body?: Document | XMLHttpRequestBodyInit | null,\n ) {\n const request = requests.get(this);\n if (!active || !request || isIgnored(request.url))\n return originalSend.call(this, body);\n const startedAt = performance.now();\n let requestBody: Promise<BodySnapshot>;\n try {\n requestBody =\n typeof Document !== \"undefined\" && body instanceof Document\n ? Promise.resolve(\n bodies.text(new XMLSerializer().serializeToString(body)),\n )\n : bodies.body(\n body as BodyInit | null | undefined,\n request.headers[\"content-type\"],\n );\n } catch {\n requestBody = unavailableBody(\"request-body-unavailable\");\n }\n let aborted = false;\n const onAbort = () => {\n aborted = true;\n };\n const cleanup = () => {\n this.removeEventListener(\"abort\", onAbort);\n this.removeEventListener(\"loadend\", onEnd);\n listeners.delete(cleanup);\n if (inFlight.get(this) === cleanup) inFlight.delete(this);\n };\n const onEnd = () => {\n cleanup();\n if (!active) return;\n const outcome = aborted ? \"aborted\" : this.status === 0 ? \"error\" : \"ok\";\n const headers: Record<string, string> = {};\n try {\n for (const line of this.getAllResponseHeaders().split(/\\r?\\n/)) {\n const split = line.indexOf(\":\");\n if (split > 0) {\n const name = line.slice(0, split).toLowerCase();\n headers[name] = headerValue(name, line.slice(split + 1).trim());\n }\n }\n } catch {\n /* Cross-origin or failed requests can hide response headers. */\n }\n let responseBody: Promise<BodySnapshot>;\n try {\n if (this.responseType === \"\" || this.responseType === \"text\") {\n responseBody = Promise.resolve(\n bodies.text(\n this.responseText,\n outcome === \"ok\" ? \"complete\" : \"interrupted\",\n outcome === \"ok\" ? undefined : `request-${outcome}`,\n ),\n );\n } else if (this.responseType === \"json\")\n responseBody = Promise.resolve(\n bodies.text(JSON.stringify(this.response)),\n );\n else if (this.responseType === \"document\")\n responseBody = this.responseXML\n ? Promise.resolve(\n bodies.text(\n new XMLSerializer().serializeToString(this.responseXML),\n ),\n )\n : unavailableBody(\"empty-document\");\n else\n responseBody = bodies.body(\n this.response as BodyInit,\n headers[\"content-type\"] ?? \"application/octet-stream\",\n );\n } catch {\n responseBody = unavailableBody(\"response-body-unavailable\");\n }\n record(\n \"xhr\",\n request,\n requestBody,\n startedAt,\n outcome === \"ok\" ? this.status : null,\n outcome,\n {\n url: this.responseURL,\n headers,\n body: responseBody,\n },\n );\n };\n this.addEventListener(\"abort\", onAbort);\n this.addEventListener(\"loadend\", onEnd);\n listeners.add(cleanup);\n inFlight.set(this, cleanup);\n try {\n return originalSend.call(this, body);\n } catch (error) {\n cleanup();\n record(\n \"xhr\",\n request,\n requestBody,\n startedAt,\n null,\n isAbortError(error) ? \"aborted\" : \"error\",\n );\n throw error;\n }\n };\n proto.open = wrappedOpen;\n proto.send = wrappedSend;\n proto.setRequestHeader = wrappedSetHeader;\n return () => {\n active = false;\n for (const cleanup of listeners) cleanup();\n if (proto.open === wrappedOpen) proto.open = originalOpen;\n if (proto.send === wrappedSend) proto.send = originalSend;\n if (proto.setRequestHeader === wrappedSetHeader)\n proto.setRequestHeader = originalSetHeader;\n };\n}\n","/** Returns the original pathname. Capture no longer masks or categorizes routes. */\nexport function toRoute(pathname: string, _templates: string[] = []) {\n return pathname;\n}\n\n/** Resolves a URL without removing path segments, query parameters, or fragments. */\nexport function toOriginAndRoute(\n url: string,\n _templates: string[] = [],\n base: string = globalThis.location?.href ?? \"http://localhost/\",\n) {\n const parsed = new URL(url, base);\n return { url: parsed.href, origin: parsed.origin, route: parsed.pathname };\n}\n","import { installClickCapture } from \"./clicks\";\nimport { installInputCapture } from \"./inputs\";\nimport { installNavigationCapture } from \"./navigation\";\nimport { installNetworkCapture } from \"./network\";\nimport { createRedactor } from \"./redact\";\nimport { toRoute } from \"./routes\";\nimport { BUILT_IN_EVENT_NAMES } from \"./types\";\nimport type {\n BuiltInEventName,\n Collector,\n CollectorOptions,\n LearningBatch,\n LearningEvent,\n SinkSendOptions,\n StartOptions,\n} from \"./types\";\n\n// ponytail: flushing at 50 also bounds the queue at 50; add a separate cap only if sends become async-buffered.\nconst FLUSH_SIZE = 50;\nconst FLUSH_INTERVAL_MS = 2000;\nconst BUILT_IN = new Set<string>(BUILT_IN_EVENT_NAMES);\n\ninterface Session {\n trajectoryId: string;\n learningContainerIds: string[] | undefined;\n queue: LearningEvent[];\n dropped: number;\n uninstalls: (() => void)[];\n timer: ReturnType<typeof setInterval> | undefined;\n}\n\n/**\n * Creates a collector. Nothing is installed until `start()`: no listeners,\n * no patches, no timers, no buffer. `stop()` removes all of them and sends\n * queued events once, without retries.\n *\n * @example\n * const collector = createCollector({ sink: httpSink(\"/api/learning-events\") });\n * collector.start({ trajectoryId: crypto.randomUUID() });\n */\nexport function createCollector(options: CollectorOptions) {\n const { sink, routes, beforeSend, enrich } = options;\n const capture = {\n clicks: true,\n navigation: true,\n network: true,\n inputs: true,\n ...options.capture,\n };\n let session: Session | null = null;\n let seq = 0;\n\n const getRoute = () => toRoute(location.pathname, routes);\n\n const flush = (sendOptions?: SinkSendOptions) => {\n const current = session;\n if (current === null) return;\n if (current.queue.length === 0 && current.dropped === 0) return;\n const events = current.queue.splice(0);\n const dropped = current.dropped;\n current.dropped = 0;\n const batch: LearningBatch = {\n trajectoryId: current.trajectoryId,\n learningContainerIds: current.learningContainerIds,\n events,\n dropped,\n };\n const onFailure = () => {\n // No retry: report the loss in the next batch instead.\n if (session === current) current.dropped += events.length + dropped;\n };\n try {\n Promise.resolve(sink(batch, sendOptions)).catch(onFailure);\n } catch {\n onFailure();\n }\n };\n\n const record = (name: string, value: Record<string, unknown>) => {\n const current = session;\n if (current === null) return;\n try {\n seq += 1;\n const event: LearningEvent = {\n type: \"CUSTOM\",\n name,\n value: { ...value, seq },\n timestamp: Date.now(),\n };\n const kept = beforeSend === undefined ? event : beforeSend(event);\n if (kept === null) return;\n current.queue.push(kept);\n if (current.queue.length >= FLUSH_SIZE) flush();\n } catch {\n // Capture must never break the host app.\n }\n };\n\n const start = ({ trajectoryId, learningContainerIds }: StartOptions) => {\n if (session !== null) {\n if (session.trajectoryId !== trajectoryId) {\n console.warn(\n `[@copilotkit/learning] Trajectory \"${session.trajectoryId}\" is active. Stop it before you start \"${trajectoryId}\".`,\n );\n }\n return;\n }\n if (\n typeof window === \"undefined\" ||\n typeof window.addEventListener !== \"function\"\n )\n return;\n const current: Session = {\n trajectoryId,\n learningContainerIds,\n queue: [],\n dropped: 0,\n uninstalls: [],\n timer: undefined,\n };\n session = current;\n const recordCurrent: typeof record = (name, value) => {\n if (session === current) record(name, value);\n };\n // One per session: fields seen and values typed are forgotten on stop().\n const redact = createRedactor();\n current.uninstalls.push(redact.watch());\n const ignoreUrls = [\n ...(options.ignoreUrls ?? []),\n ...(sink.url === undefined ? [] : [sink.url]),\n ];\n if (capture.clicks)\n current.uninstalls.push(\n installClickCapture({ emit: recordCurrent, getRoute, enrich, redact }),\n );\n if (capture.navigation)\n current.uninstalls.push(\n installNavigationCapture({ emit: recordCurrent, routes, redact }),\n );\n if (capture.network)\n current.uninstalls.push(\n installNetworkCapture({\n emit: recordCurrent,\n routes,\n ignoreUrls,\n redact,\n }),\n );\n if (capture.inputs)\n current.uninstalls.push(\n installInputCapture({ emit: recordCurrent, redact }),\n );\n const onPageHide = () => flush({ beacon: true });\n window.addEventListener(\"pagehide\", onPageHide);\n current.uninstalls.push(() =>\n window.removeEventListener(\"pagehide\", onPageHide),\n );\n current.timer = setInterval(() => flush(), FLUSH_INTERVAL_MS);\n record(\"page\", {\n route: getRoute(),\n url: redact.url(location.href),\n title: document.title,\n referrer: redact.url(document.referrer),\n });\n };\n\n const stop = () => {\n const current = session;\n if (current === null) return;\n clearInterval(current.timer);\n for (const uninstall of current.uninstalls) uninstall();\n flush();\n session = null;\n };\n\n const emit = (name: string, value: Record<string, unknown>) => {\n if (BUILT_IN.has(name)) {\n console.warn(\n `[@copilotkit/learning] \"${name}\" is a built-in event name. Use your own name, such as \"deal.approved\".`,\n );\n return;\n }\n record(name, value);\n };\n\n const ɵemit = (name: BuiltInEventName, value: Record<string, unknown>) =>\n record(name, value);\n\n const collector: Collector = {\n start,\n stop,\n emit,\n ɵemit,\n get trajectoryId() {\n return session?.trajectoryId ?? null;\n },\n };\n return collector;\n}\n","import type { LearningBatch, LearningSink, SinkSendOptions } from \"./types\";\n\n/**\n * A sink that POSTs each batch as JSON to `url`.\n *\n * Uses `fetch` with `keepalive`. When the page is going away it uses\n * `navigator.sendBeacon`, which cannot send custom headers. There is no retry:\n * a failed send rejects, and the collector counts those events as dropped.\n *\n * @example createCollector({ sink: httpSink(\"/api/learning-events\") })\n */\nexport function httpSink(\n url: string,\n init: { headers?: Record<string, string> } = {},\n) {\n const send = async (batch: LearningBatch, options: SinkSendOptions = {}) => {\n const body = JSON.stringify(batch);\n const canBeacon =\n options.beacon === true && typeof navigator.sendBeacon === \"function\";\n if (canBeacon) {\n const queued = navigator.sendBeacon(\n url,\n new Blob([body], { type: \"application/json\" }),\n );\n if (!queued) throw new Error(\"sendBeacon refused the batch\");\n return;\n }\n const response = await fetch(url, {\n method: \"POST\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", ...init.headers },\n body,\n });\n if (!response.ok) throw new Error(`Sink responded with ${response.status}`);\n };\n const sink: LearningSink = Object.assign(send, { url });\n return sink;\n}\n","import { installClickCapture } from \"./clicks\";\nimport { installNavigationCapture } from \"./navigation\";\nimport { installInputCapture } from \"./inputs\";\nimport { installNetworkCapture } from \"./network\";\nimport { createRedactor } from \"./redact\";\nimport { toRoute } from \"./routes\";\nimport { BUILT_IN_EVENT_NAMES } from \"./types\";\nimport type {\n JsonValue,\n TrajectoryCaptureOptions,\n TrajectoryCollector,\n TrajectoryEvent,\n} from \"./trajectory-types\";\n\nexport const MAX_TRAJECTORY_EVENT_BYTES = 16 * 1024;\nconst RESERVED_NAMES = new Set<string>(BUILT_IN_EVENT_NAMES);\n\nfunction isJson(\n value: unknown,\n ancestors = new Set<object>(),\n): value is JsonValue {\n if (typeof value === \"string\") return !value.includes(\"\\0\");\n if (value === null || typeof value === \"boolean\") return true;\n if (typeof value === \"number\") return Number.isFinite(value);\n if (typeof value !== \"object\" || ancestors.has(value)) return false;\n if (\n !Array.isArray(value) &&\n Object.getPrototypeOf(value) !== Object.prototype &&\n Object.getPrototypeOf(value) !== null\n )\n return false;\n ancestors.add(value);\n if (\n !Array.isArray(value) &&\n Object.keys(value).some((key) => key.includes(\"\\0\"))\n )\n return false;\n const values = Array.isArray(value) ? value : Object.values(value);\n for (const item of values) {\n if (!isJson(item, ancestors)) return false;\n }\n ancestors.delete(value);\n return true;\n}\n\n/**\n * Captures outside-chat activity as plain AG-UI events, without a send queue.\n * Core starts this collector only after authentication and channel join.\n */\nexport function createTrajectoryCollector(\n options: TrajectoryCaptureOptions & {\n send: (event: TrajectoryEvent) => void;\n },\n): TrajectoryCollector {\n let active = false;\n let uninstalls: (() => void)[] = [];\n\n const report = (code: string, message: string) => {\n try {\n options.onError?.({ code, message });\n } catch {\n // Diagnostic callbacks must not interrupt the host app.\n }\n };\n\n const getRoute = () => toRoute(location.pathname);\n\n const record = (name: string, value: JsonValue) => {\n if (!active) return;\n let payload: TrajectoryEvent;\n try {\n const event: TrajectoryEvent = {\n type: \"CUSTOM\",\n name,\n timestamp: Date.now(),\n value,\n };\n const kept =\n options.beforeSend === undefined ? event : options.beforeSend(event);\n if (kept === null || !active) return;\n if (\n kept.type !== \"CUSTOM\" ||\n typeof kept.name !== \"string\" ||\n !kept.name.trim() ||\n kept.name.length > 200 ||\n kept.name.includes(\"\\0\") ||\n !Number.isFinite(kept.timestamp) ||\n kept.timestamp <= 0 ||\n kept.timestamp >= 253_402_300_800_000 ||\n !isJson(kept.value)\n ) {\n report(\n \"INVALID_EVENT\",\n \"The event must contain a name, timestamp, and JSON value.\",\n );\n return;\n }\n // Keep only the contract envelope and snapshot the value before delivery.\n const serialized = JSON.stringify({\n type: kept.type,\n name: kept.name,\n timestamp: kept.timestamp,\n value: kept.value,\n });\n if (\n new TextEncoder().encode(serialized).byteLength >\n MAX_TRAJECTORY_EVENT_BYTES\n ) {\n report(\"EVENT_TOO_LARGE\", \"The serialized event exceeds 16 KiB.\");\n return;\n }\n payload = JSON.parse(serialized) as TrajectoryEvent;\n } catch {\n report(\"INVALID_EVENT\", \"The event could not be serialized as JSON.\");\n return;\n }\n try {\n options.send(payload);\n } catch {\n report(\n \"PERSISTENCE_UNKNOWN\",\n \"Event delivery failed. The event will not be retried.\",\n );\n }\n };\n\n const capture = (name: string, value: Record<string, unknown>) => {\n // Shared capture modules produce JSON; record validates and snapshots before sending.\n record(name, value as JsonValue);\n };\n\n const stop = () => {\n active = false;\n const installed = uninstalls;\n uninstalls = [];\n for (const uninstall of installed) uninstall();\n };\n\n return {\n start() {\n if (active || typeof window === \"undefined\") return;\n active = true;\n // Native callers can emit developer events without browser observers.\n if (typeof window.addEventListener !== \"function\") return;\n // One per session: fields seen and values typed are forgotten on stop().\n const redact = createRedactor();\n try {\n uninstalls.push(redact.watch());\n if (options.capture?.clicks !== false) {\n uninstalls.push(\n installClickCapture({ emit: capture, getRoute, redact }),\n );\n }\n if (options.capture?.navigation !== false) {\n uninstalls.push(installNavigationCapture({ emit: capture, redact }));\n }\n if (options.capture?.inputs !== false) {\n uninstalls.push(installInputCapture({ emit: capture, redact }));\n }\n if (options.capture?.network !== false) {\n uninstalls.push(\n installNetworkCapture({\n emit: capture,\n ignoreUrls: options.ignoreUrls ?? [],\n redact,\n }),\n );\n }\n record(\"page\", {\n route: getRoute(),\n url: redact.url(location.href),\n title: document.title,\n referrer: redact.url(document.referrer),\n });\n } catch {\n stop();\n report(\"CAPTURE_FAILED\", \"Browser capture could not start.\");\n }\n },\n stop,\n emit(name, value) {\n if (!active) return;\n if (RESERVED_NAMES.has(name)) {\n report(\n \"INVALID_EVENT\",\n \"Use a developer event name, not a reserved capture name.\",\n );\n return;\n }\n record(name, value);\n },\n };\n}\n"],"mappings":";;;;AACA,MAAa,uBAAuB;CAClC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;;AAKD,MAAa,WAAW;;;;ACXxB,MAAM,aACJ;AAIF,MAAM,kBACJ;AAEF,MAAM,WACJ;AACF,MAAM,wBAAwB;AAE9B,MAAM,QAAQ;AAEd,MAAM,SAAS;AAKf,MAAM,OACJ;AAGF,MAAM,WAAW;;AAEjB,MAAa,aAAa,KAAK;AAC/B,MAAM,oBAAoB;AAE1B,MAAa,mBAAmB,QAAgB;CAC9C,MAAM,QAAQ,IAAI,IACf,QAAQ,oBAAoB,GAAG,QAC9B,OAAO,aAAa,SAAS,KAAK,GAAG,CAAC,CACvC,CACA,QAAQ,uBAAuB,MAAM,CACrC,aAAa,CACb,MAAM,YAAY,CAClB,OAAO,QAAQ,CACf,KAAK,IAAI,CAAC;AACb,QACE,CAAC,SAAS,KAAK,MAAM,KACpB,WAAW,KAAK,MAAM,QAAQ,MAAM,GAAG,CAAC,IAAI,gBAAgB,KAAK,MAAM;;AAI5E,MAAM,UAAU,SAAiB;AAC/B,KAAI;AACF,SAAO,KAAK,MAAM,IAAI,KAAK,GAAG;SACxB;AACN,SAAO;;;;AAKX,MAAM,cAAc,MAAc,UAAkB;CAClD,IAAI,QAAQ;AACZ,MAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,KAAK;EACxC,MAAM,OAAO,KAAK;AAClB,MAAI,SAAS,MAAK;AAChB,UAAO,YAAY;AACnB,UAAO,KAAK,KAAK;AACjB,OAAI,OAAO,YAAY;aACd,SAAS,OAAO,SAAS,KAAK;AACvC;AACA;aACS,SAAS,OAAO,SAAS,KAClC;OAAI,EAAE,QAAQ,EAAG,QAAO;SACnB;AACL,OAAI,CAAC,UAAU,SAAS,OAAO,KAAK,KAAK,KAAK,EAAG,QAAO;AACxD;;AAEF,MAAI,CAAC,MAAO,QAAO,IAAI;;AAEzB,QAAO,KAAK;;;;;;;AAQd,MAAM,cAAc,MAAc,QAAQ,MAAc;CACtD,MAAM,QAAQ,IAAI,OAAO,MAAM;CAC/B,IAAI,UAAU;CACd,MAAM,SAAS,SAAiB;EAC9B,MAAM,UAAU,KAAK,QAAQ,UAAU,KAAK;AAC5C,SAAO,QAAQ,QACb,OACC,MAAM,KAAa,WAAmB,OAAe,WAAmB;AACvE,OAAI,CAAC,gBAAgB,IAAI,CAAE,QAAO;AAElC,OAAI,SAAS,KAAK,WAAW,QAAQ,QAAQ;AAC3C,cAAU;AACV,QAAI,CAAC,MAAO,QAAO;;AAErB,UAAO,MAAM,YAAY;IAE5B;;CAEH,IAAI,MAAM;CACV,IAAI,OAAO;CACX,IAAI;AACJ,QAAQ,QAAQ,MAAM,KAAK,KAAK,EAAG;EACjC,MAAM,CAAC,KAAK,MAAM,QAAQ,SAAS;AACnC,YAAU;AACV,SAAO,MAAM,KAAK,MAAM,MAAM,MAAM,MAAM,CAAC;EAC3C,IAAI,MAAM,MAAM;EAChB,IAAI,cAAc;EAClB,MAAM,QAAQ,KAAM,SAAS,KAAK,GAAG,OAAO,KAAM,GAAG;AACrD,MAAI,OACF;OAAI,gBAAgB,MAAM,EAAE;IAC1B,MAAM,WAAW,WAAW,MAAM,IAAI;AAEtC,QAAI,CAAC,uBAAuB,KAAK,KAAK,MAAM,KAAK,SAAS,CAAC,EAAE;AAC3D,oBAAe,IAAI,SAAS;AAC5B,WAAM;;;aAGD,QAAS,eAAc,IAAI,SAAS;WACtC,QAAQ,KAAK,QAAQ,KAAK,MAAM,EAAE;GACzC,MAAM,WAAW,WAAW,OAAO,QAAQ,EAAE;AAC7C,OAAI,aAAa,MACf,eAAc,KAAK,UAAU,SAAS,CAAC,MACrC,GACA,SAAS,SAAY,GACtB;;AAEL,SAAO;AACP,SAAO,MAAM,YAAY;;AAE3B,QAAO,MAAM,MAAM,KAAK,MAAM,KAAK,CAAC;;;AAItC,SAAgB,iBAAiB;CAC/B,MAAM,uBAAO,IAAI,SAAkB;CAEnC,MAAM,6BAAa,IAAI,KAAsB;CAC7C,IAAI;CAEJ,MAAM,WAAW,YAA8B;AAC7C,OAAK,MAAM,UAAU,QACnB,KAAI,OAAO,UAAU,aAAa,KAAK,WACrC,MAAK,IAAI,OAAO,OAAkB;;;CAIxC,MAAM,SAAS,SAAiB;AAC9B,OAAK,MAAM,SAAS,CAAC,GAAG,WAAW,QAAQ,CAAC,CAAC,MAC1C,GAAG,MAAM,EAAE,SAAS,EAAE,OACxB,CACC,MAAK,MAAM,QAAQ;GACjB;GACA,KAAK,UAAU,MAAM,CAAC,MAAM,GAAG,GAAG;GAClC,mBAAmB,MAAM;GACzB,IAAI,gBAAgB,EAAE,GAAG,OAAO,CAAC,CAAC,UAAU,CAAC,MAAM,EAAE;GACtD,CACC,QAAO,QAAQ,KAAK,KAAK,GAErB,KAAK,QAAQ,OAAO,WAAW,KAAK,UAAU,IAAI,EAAE,SAAS,GAC7D,KAAK,MAAM,KAAK,CAAC,KAAK,SAAS;AACvC,SAAO;;;CAIT,MAAM,cAAc,YAAqB;AACvC,MAAI,SAAU,SAAQ,SAAS,aAAa,CAAC;AAC7C,MACE,EAAE,mBAAmB,qBACrB,EACE,QAAQ,SAAS,cACjB,KAAK,IAAI,QAAQ,IACjB,gBAAgB,QAAQ,KAAK,IAC7B,sBAAsB,KAAK,QAAQ,aAAa,eAAe,IAAI,GAAG,EAGxE,QAAO;AACT,OAAK,IAAI,QAAQ;AACjB,MAAI,QAAQ,MAAM,UAAU,GAAG;AAC7B,cAAW,OAAO,QAAQ;AAC1B,cAAW,IAAI,SAAS,QAAQ,MAAM;AACtC,OAAI,WAAW,OAAO,kBACpB,YAAW,OAAO,WAAW,MAAM,CAAC,MAAM,CAAC,MAAO;;AAEtD,SAAO;;AAGT,QAAO;EACL;EAKA,QAAQ;AAGN,OAAI;AACF,SAAK,MAAM,SAAS,SAAS,iBAAiB,uBAAuB,CACnE,MAAK,IAAI,MAAM;WACX;GAGR,IAAI;AACJ,OAAI;AACF,cAAU,IAAI,iBAAiB,QAAQ;AACvC,YAAQ,QAAQ,UAAU;KACxB,SAAS;KACT,iBAAiB,CAAC,OAAO;KACzB,mBAAmB;KACpB,CAAC;AACF,eAAW;WACL;AACN,cAAU;;GAEZ,MAAM,YAAY,UAAiB;AACjC,QAAI;KACF,MAAM,CAAC,UAAU,MAAM,cAAc;AACrC,SAAI,kBAAkB,QAAS,YAAW,OAAO;YAC3C;;AAIV,QAAK,MAAM,QAAQ,CAAC,SAAS,SAAS,CACpC,QAAO,iBAAiB,MAAM,UAAU,KAAK;AAC/C,gBAAa;AACX,aAAS,YAAY;AACrB,QAAI,YAAY,UAAa,aAAa,QAAS,YAAW;AAC9D,SAAK,MAAM,QAAQ,CAAC,SAAS,SAAS,CACpC,QAAO,oBAAoB,MAAM,UAAU,KAAK;;;EAGtD,QAAQ,KAAa,UACnB,gBAAgB,IAAI,GAAG,WAAW,MAAM,WAAW,MAAM,CAAC;EAE5D,KAAK,MAAc;AACjB,OAAI;AACF,WAAO,MAAM,WAAW,KAAK,MAAM,GAAG,WAAW,CAAC,CAAC;WAC7C;AACN;;;EAIJ,IAAI,OAAe;AACjB,OAAI;AACF,WAAO,MAAM,MAAM,QAAQ,WAAW,SAAS,WAAW,KAAK,CAAC,CAAC;WAC3D;AACN,WAAO;;;EAGZ;;;;;ACxPH,MAAM,cACJ;AACF,MAAM,uBAAuB;AAG7B,MAAM,kBAAkB;;AAGxB,SAAS,oBAAoB,IAAa,UAAkB;CAC1D,IAAI,UAA0B;AAC9B,QAAO,YAAY,MAAM;EACvB,MAAM,QAAQ,QAAQ,QAAQ,SAAS;AACvC,MAAI,UAAU,KAAM,QAAO;EAC3B,MAAM,OAAO,QAAQ,aAAa;AAClC,YAAU,gBAAgB,aAAa,KAAK,OAAO;;AAErD,QAAO;;;;;;AAkBT,SAAgB,eAAe,IAAa,QAAkB;AAC5D,KAAI,oBAAoB,IAAI,2BAA2B,KAAK,KAAM,QAAO;CACzE,MAAM,UAAU,GAAG,QAAQ,YAAY,IAAI;CAE3C,MAAM,WAAW,OAAO,WAAW,QAAQ;AAsB3C,QArB6B;EAC3B;EACA,QAAQ;GACN,KAAK,QAAQ;GACb,MAAM,QAAQ,aAAa,OAAO;GAClC,QAAQ,QAAQ,aAAa,yBAAyB;GACtD,OAAO,QAAQ,eAAe,IAAI,MAAM,GAAG,gBAAgB;GAC3D,YAAY,OAAO,YACjB,MAAM,KAAK,QAAQ,aAAa,EAAE,MAAM,YAAY,CAClD,MAGC,YAAY,SAAS,WACtB,gBAAgB,KAAK,QAAQ,UAAU,GAAG,CAAC,GACvC,WACA,OAAO,IAAI,MAAM,CACtB,CAAC,CACH;GACD,GAAG,iBAAiB,SAAS,OAAO;GACrC;EACF;;;AAKH,SAAgB,iBAAiB,SAAkB,QAAkB;AACnE,KAAI,mBAAmB,iBACrB,QAAO;EACL,OAAO,OAAO,WAAW,QAAQ,GAAG,WAAW,QAAQ;EACvD,GAAI,CAAC,YAAY,QAAQ,CAAC,SAAS,QAAQ,KAAK,GAC5C,EAAE,SAAS,QAAQ,SAAS,GAC5B,EAAE;EACN,GAAI,QAAQ,SAAS,SACjB,EACE,OAAO,MAAM,KAAK,QAAQ,SAAS,EAAE,GAAG,UAAU;GAChD,MAAM,KAAK;GACX,MAAM,KAAK;GACX,MAAM,KAAK;GACX,cAAc,KAAK;GACpB,EAAE,EACJ,GACD,EAAE;EACP;AAEH,KAAI,mBAAmB,kBACrB,QAAO;EACL,OAAO,QAAQ;EACf,gBAAgB,MAAM,KACpB,QAAQ,kBACP,WAAW,OAAO,MACpB;EACF;AAEH,KAAI,mBAAmB,oBAAqB,QAAO,EAAE,OAAO,QAAQ,OAAO;AAC3E,KAAI,QAAQ,QAAQ,qDAAmD,CACrE,QAAO,EAAE,OAAO,QAAQ,eAAe,IAAI;AAE7C,QAAO,EAAE;;AAGX,SAAS,aAAa,OAAc;CAClC,MAAM,CAAC,SAAS,MAAM,cAAc;AACpC,QAAO,iBAAiB,UAAU,QAAQ;;AAG5C,SAAS,iBAAiB,IAAa;AACrC,QAAO,GAAG,cAAc,UAAU,GAAG,cAAc,UAAU,CAAC,GAAG;;AAGnE,SAAS,OAAO,QAAuB,OAAmB;AACxD,QACE,KAAK,IAAI,OAAO,IAAI,MAAM,QAAQ,IAAI,wBACtC,KAAK,IAAI,OAAO,IAAI,MAAM,QAAQ,IAAI;;;;;;;AAS1C,SAAgB,oBAAoB,QAOjC;CACD,MAAM,EACJ,MACA,UACA,QACA,aAAa,UAAU,MAAM,WAC7B,SAAS,gBAAgB,KACvB;CACJ,IAAI,SAA+B;CAEnC,MAAM,iBAAiB,UAAwB;AAC7C,MAAI;AACF,OAAI,MAAM,cAAc,MAAO;GAC/B,MAAM,KAAK,aAAa,MAAM;AAC9B,YAAS;IACP,WAAW,OAAO,OAAO,OAAO,eAAe,IAAI,OAAO;IAC1D,GAAG,MAAM;IACT,GAAG,MAAM;IACV;UACK;AACN,YAAS;;;CAIb,MAAM,WAAW,UAAsB;EACrC,MAAM,gBAAgB;AACtB,WAAS;AACT,MAAI;AACF,OAAI,CAAC,UAAU,MAAM,CAAE;GACvB,MAAM,KAAK,aAAa,MAAM;AAC9B,OAAI,OAAO,KAAM;GAKjB,MAAM,YAHJ,kBAAkB,QAClB,iBAAiB,GAAG,IACpB,OAAO,eAAe,MAAM,GAE1B,cAAc,YACd,eAAe,IAAI,OAAO;AAC9B,OAAI,cAAc,KAAM;GACxB,MAAM,QAAQ,MAAM,WAAW,IAAI,aAAa;AAChD,QAAK,SAAS;IACZ,QAAQ;KAAE,GAAG,UAAU;KAAQ;KAAO;IACtC,OAAO,UAAU;IACjB,KAAK,OAAO,IAAI,SAAS,KAAK;IAC9B,GAAG,SAAS,UAAU,QAAQ;IAC/B,CAAC;UACI;;AAKV,QAAO,iBAAiB,eAAe,eAAe;EACpD,SAAS;EACT,SAAS;EACV,CAAC;AACF,QAAO,iBAAiB,SAAS,SAAS;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AAC3E,cAAa;AACX,SAAO,oBAAoB,eAAe,eAAe,EAAE,SAAS,MAAM,CAAC;AAC3E,SAAO,oBAAoB,SAAS,SAAS,EAAE,SAAS,MAAM,CAAC;;;;;;;ACxLnE,SAAgB,oBAAoB,QAIjC;CACD,MAAM,EACJ,MACA,aAAa,UAAiB,MAAM,WACpC,SAAS,gBAAgB,KACvB;CACJ,MAAM,WAAW,UAAiB;AAChC,MAAI;AACF,OAAI,CAAC,UAAU,MAAM,CAAE;GACvB,MAAM,CAAC,WAAW,MAAM,cAAc;AACtC,OAAI,EAAE,mBAAmB,SAAU;GACnC,MAAM,YAAY,eAAe,SAAS,OAAO;AACjD,OAAI,cAAc,KAAM;GACxB,MAAM,QAAQ,iBAAiB,SAAS,OAAO;AAC/C,OAAI,EAAE,WAAW,OAAQ;AACzB,QAAK,SAAS;IACZ,WAAW,MAAM;IACjB,QAAQ;KAAE,GAAG,UAAU;KAAQ,GAAG;KAAO;IACzC,KAAK,OAAO,IAAI,SAAS,KAAK;IAC9B,OAAO,SAAS;IACjB,CAAC;UACI;;AAIV,QAAO,iBAAiB,SAAS,SAAS;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AAC3E,QAAO,iBAAiB,UAAU,SAAS;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AAC5E,cAAa;AACX,SAAO,oBAAoB,SAAS,SAAS,EAAE,SAAS,MAAM,CAAC;AAC/D,SAAO,oBAAoB,UAAU,SAAS,EAAE,SAAS,MAAM,CAAC;;;;;;ACpBpE,SAAS,mBAAmB,OAA2C;AACrE,QACE,OAAO,UAAU,YACjB,UAAU,QACV,sBAAsB,SACtB,yBAAyB;;AAI7B,SAAS,gBAAgB;CACvB,MAAM,aAAsB,QAAQ,IAAI,QAAQ,aAAa;AAC7D,QAAO,mBAAmB,WAAW,GAAG,aAAa;;AAGvD,SAAS,iBAAiB,OAAgC;AACxD,SAAQ,OAAR;EACE,KAAK;EACL,KAAK;EACL,KAAK,SACH,QAAO;EACT,QACE,QAAO;;;;;;;;;AAUb,SAAgB,yBAAyB,QAItC;CACD,MAAM,EAAE,MAAM,SAAS,gBAAgB,KAAK;CAC5C,IAAI,UAAU,SAAS;CAEvB,MAAM,SAAS,mBAAmC;AAChD,MAAI;GACF,MAAM,OAAO,SAAS;AACtB,OAAI,SAAS,QAAS;AACtB,QAAK,cAAc;IACjB,MAAM,OAAO,IAAI,QAAQ;IACzB,IAAI,OAAO,IAAI,KAAK;IACpB;IACD,CAAC;AACF,aAAU;UACJ;;CAKV,MAAM,aAAa,eAAe;AAClC,KAAI,eAAe,MAAM;EACvB,MAAM,iBAAiB,UACrB,MAAM,iBAAiB,QAAQ,IAAI,OAAO,iBAAiB,CAAC,CAAC;AAC/D,aAAW,iBAAiB,sBAAsB,cAAc;AAChE,eACE,WAAW,oBAAoB,sBAAsB,cAAc;;AAGvE,QAAO,uBAAuB,MAAM;;AAGtC,SAAS,uBACP,OACA;CACA,IAAI,SAAS;CACb,MAAM,WAA2B,EAAE;CAEnC,MAAM,SAAS,QAAiB,WAA0B;EACxD,MAAM,WAAW,OAAO;EACxB,MAAM,UAAkC,SAAyB,GAAG,MAAM;GACxE,MAAM,SAAS,SAAS,MAAM,MAAM,KAAK;AACzC,OAAI,OAAQ,OAAM,WAAW,cAAc,SAAS,UAAU;AAC9D,UAAO;;AAET,SAAO,UAAU;AACjB,WAAS,WAAW;AAElB,OAAI,OAAO,YAAY,QAAS,QAAO,UAAU;IACjD;;AAIJ,MAAK,MAAM,UADsB,CAAC,aAAa,eAAe,EAChC;AAC5B,QAAM,QAAQ,WAAW,OAAO;AAChC,MAAI,OAAO,UAAU,eAAe,KAAK,SAAS,OAAO,CACvD,OAAM,SAAS,OAAO;;CAG1B,MAAM,mBAAmB,MAAM,WAAW;AAC1C,QAAO,iBAAiB,YAAY,WAAW;AAC/C,QAAO,iBAAiB,cAAc,WAAW;AAEjD,cAAa;AACX,WAAS;AACT,SAAO,oBAAoB,YAAY,WAAW;AAClD,SAAO,oBAAoB,cAAc,WAAW;AACpD,OAAK,MAAM,WAAW,SAAU,UAAS;;;;;;ACtG7C,MAAM,aAAa;AACnB,MAAM,kBAAkB;AACxB,MAAM,UAAU,IAAI,aAAa;AACjC,MAAM,yBAAuC;CAC3C,QAAQ;CACR,QAAQ;CACT;;AAID,SAAS,SACP,OACA,QACA,aACA,QACA,QACc;CACd,IAAI;AACJ,KAAI,CAAC,SAAS,YAAY,CACxB,KAAI;AAGF,SAAO,IAAI,YAAY,SAAS,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC,OAAO,OAAO,EACrE,QAAQ,WAAW,YACpB,CAAC;SACI;AAGV,KAAI,SAAS,QAAW;AACtB,SAAO,OAAO,KAAK;AACnB,MAAI,SAAS,OAAW,QAAO,iBAAiB;AAEhD,MAAI,KAAK,SAAS,KAAK,EAAE;AACvB,WAAQ,QAAQ,OAAO,KAAK;AAC5B,UAAO;;;CAGX,MAAM,SAAS,SAAS;AAExB,UAAS,KAAK,OAAO,aAAa,GAAG,MAAM,SAAS,GAAG,WAAW,CAAC,CAAC;CACpE,MAAM,SAAuB;EAC3B,QAAQ,WAAW,cAAc,MAAM,WAAW,IAAI,UAAU;EAChE;EACA,UAAU,SAAS,WAAW;EAC9B,GAAI,SAAS,EAAE,QAAQ,GAAG,EAAE;EAC7B;AACD,KAAI,QAAQ,OAAO,KAAK,UAAU,OAAO,CAAC,CAAC,UAAU,WACnD,QAAO;AACT,QAAO,SAAS,WAAW,aAAa,cAAc;AACtD,QAAO,YAAY;CAEnB,IAAI,MAAM;CACV,IAAI,OAAO,KAAK;AAChB,QAAO,MAAM,MAAM;EACjB,MAAM,SAAS,KAAK,MAAM,MAAM,QAAQ,EAAE;AAC1C,SAAO,OAAO,KAAK,MAAM,GAAG,OAAO;AACnC,MAAI,QAAQ,OAAO,KAAK,UAAU,OAAO,CAAC,CAAC,UAAU,WACnD,OAAM;MACH,QAAO,SAAS;;AAEvB,KAAI,OACF,QAAO,MAAM;UACN,MAAM,KAAK,kBAAkB,KAAK,KAAK,MAAM,GAAI,CAAE;AAC5D,QAAO,OAAO,KAAK,MAAM,GAAG,IAAI;AAChC,QAAO;;AAGT,SAAS,aACP,OACA,QACA,SAAiC,YACjC,QACc;AACd,QAAO,SACL,QAAQ,OAAO,MAAM,MAAM,GAAG,WAAW,CAAC,EAC1C,MAAM,SAAS,cAAc,WAAW,aAAa,cAAc,QACnE,cACA,QACA,OACD;;AAGH,SAAS,SAAS,aAAqB;AACrC,QACE,gBAAgB,MAChB,CAAC,oFAAoF,KACnF,YACD;;;AAKL,SAAgB,kBAAkB,QAAkB;CAClD,MAAM,gCAAgB,IAAI,KAAiB;CAC3C,IAAI,SAAS;CACb,MAAM,QACJ,QACA,cAAc,OACY;AAC1B,MAAI,CAAC,OAAQ,QAAO,QAAQ,QAAQ,EAAE,QAAQ,SAAS,CAAC;AACxD,MAAI,CAAC,OACH,QAAO,QAAQ,QAAQ;GACrB,QAAQ;GACR,QAAQ;GACT,CAAC;EACJ,IAAI;AACJ,MAAI;AACF,YAAS,OAAO,WAAW;UACrB;AACN,UAAO,QAAQ,QAAQ;IAAE,QAAQ;IAAe,QAAQ;IAAe,CAAC;;AAE1E,SAAO,IAAI,SAAS,YAAY;GAC9B,MAAM,QAAQ,IAAI,WAAW,WAAW;GACxC,IAAI,SAAS;GACb,IAAI,WAAW;GACf,MAAM,UAAU,QAAgC,WAAoB;AAClE,QAAI,SAAU;AACd,eAAW;AACX,iBAAa,MAAM;AACnB,kBAAc,OAAO,OAAO;AAC5B,YACE,SACE,MAAM,SAAS,GAAG,OAAO,EACzB,QACA,aACA,OAAO,MACP,OACD,CACF;AAED,IAAK,OAAO,QAAQ,CAAC,YAAY,GAAG;;GAEtC,MAAM,eAAe,OAAO,eAAe,kBAAkB;GAC7D,MAAM,QAAQ,iBACN,OAAO,WAAW,oBAAoB,EAC5C,gBACD;AACD,iBAAc,IAAI,OAAO;AACzB,IAAM,YAAY;AAChB,QAAI;AACF,cAAS;MACP,MAAM,QAAQ,MAAM,OAAO,MAAM;AACjC,UAAI,SAAU;AACd,UAAI,MAAM,MAAM;AACd,cAAO,WAAW;AAClB;;MAEF,MAAM,OAAO,MAAM,MAAM,SAAS,GAAG,aAAa,OAAO;AACzD,YAAM,IAAI,MAAM,OAAO;AACvB,gBAAU,KAAK;AACf,UAAI,WAAW,YAAY;AACzB,cAAO,YAAY;AACnB;;;YAGE;AACN,YAAO,eAAe,mBAAmB;cACjC;AACR,SAAI;AACF,aAAO,aAAa;aACd;;OAIR;IACJ;;CAEJ,MAAM,QACJ,OACA,cAAc,OACY;AAC1B,MAAI;AACF,OAAI,SAAS,KAAM,QAAO,QAAQ,QAAQ,EAAE,QAAQ,SAAS,CAAC;AAC9D,OAAI,iBAAiB,gBAAiB,SAAQ,MAAM,UAAU;AAC9D,OAAI,OAAO,UAAU,SACnB,QAAO,QAAQ,QAAQ,aAAa,OAAO,OAAO,KAAK,CAAC;AAC1D,OACE,OAAO,mBAAmB,eAC1B,iBAAiB,eAGjB,QAAO,QAAQ,QAAQ;IACrB,QAAQ;IACR,QAAQ;IACT,CAAC;AAEJ,OAAI,iBAAiB,UAAU;IAC7B,MAAM,OAAO,IAAI,UAAU;AAC3B,UAAM,SAAS,OAAO,QACpB,KAAK,OACH,KACA,OAAO,UAAU,WAAW,OAAO,MAAM,KAAK,MAAM,GAAG,MACxD,CACF;AACD,YAAQ;;GAEV,MAAM,WAAW,IAAI,SAAS,MAAM;AACpC,UAAO,KACL,SAAS,MACT,eAAe,SAAS,QAAQ,IAAI,eAAe,IAAI,GACxD;UACK;AACN,UAAO,QAAQ,QAAQ;IACrB,QAAQ;IACR,QAAQ;IACT,CAAC;;;AAGN,QAAO;EACL;EACA;EACA,OAAO,OAAe,QAAiC,WACrD,aAAa,OAAO,OAAO,MAAM,QAAQ,OAAO;EAClD,OAAO;AACL,YAAS;AACT,QAAK,MAAM,UAAU,cAAe,SAAQ;;EAE/C;;;;;ACzNH,MAAM,kBAAkB,QAAQ,QAAsB,EAAE,QAAQ,SAAS,CAAC;AAC1E,MAAM,mBAAmB,WACvB,QAAQ,QAAsB;CAAE,QAAQ;CAAe;CAAQ,CAAC;AAClE,MAAM,cAAc,QAAgB,IAAI,IAAI,KAAK,SAAS,KAAK,CAAC;AAKhE,MAAM,qBAAqB,IAAI,IAAI;CACjC;CACA;CACA;CACD,CAAC;AACF,MAAM,eAAe,MAAc,UACjC,mBAAmB,IAAI,KAAK,aAAa,CAAC,IAAI,gBAAgB,KAAK,GAC/D,WACA;AACN,MAAM,gBAAgB,YACpB,OAAO,YACL,MAAM,KAAK,QAAQ,SAAS,GAAG,CAAC,MAAM,WAAW,CAC/C,MACA,YAAY,MAAM,MAAM,CACzB,CAAC,CACH;AAGH,SAAS,aAAa,OAAgB;AACpC,QACE,OAAO,UAAU,YACjB,UAAU,QACV,UAAU,SACV,MAAM,SAAS;;;AAKnB,SAAgB,sBAAsB,QAKnC;CACD,IAAI,SAAS;CACb,MAAM,EAAE,SAAS,gBAAgB,KAAK;CACtC,MAAM,SAAS,kBAAkB,OAAO;CACxC,MAAM,aAAa,OAAO,WAAW,KAAK,YACxC,OAAO,YAAY,WACf,WAAW,QAAQ,GACnB,IAAI,OAAO,QAAQ,QAAQ,QAAQ,MAAM,CAC9C;CACD,MAAM,aAAa,QACjB,WAAW,MAAM,YAAY;AAC3B,MAAI,OAAO,YAAY,SAAU,QAAO,IAAI,WAAW,QAAQ;AAC/D,UAAQ,YAAY;AACpB,SAAO,QAAQ,KAAK,IAAI;GACxB;CAEJ,MAAM,UACJ,WACA,SACA,aACA,WACA,QACA,SACA,aACG;EACH,MAAM,aAAa,KAAK,MAAM,YAAY,KAAK,GAAG,UAAU;EAC5D,MAAM,cAAc,KAAK,KAAK;AAC9B,EAAK,QAAQ,IAAI,CACf,aACA,UAAU,QAAQ,gBAAgB,cAAc,CACjD,CAAC,CACC,MAAM,CAAC,MAAM,cAAc;AAC1B,OAAI,CAAC,OAAQ;GACb,MAAM,OAAO,OAAO,IAAI,QAAQ,IAAI;GACpC,MAAM,MAAM,IAAI,IAAI,KAAK;AACzB,UAAO,KAAK,WAAW;IACrB;IACA,QAAQ,QAAQ;IAChB,KAAK;IACL,QAAQ,IAAI;IACZ,OAAO,IAAI;IACX;IACA;IACA;IACA;IACA,SAAS;KAAE,SAAS,QAAQ;KAAS,MAAM;KAAM;IACjD,UAAU;KACR,GAAI,UAAU,MAAM,EAAE,KAAK,OAAO,IAAI,SAAS,IAAI,EAAE,GAAG,EAAE;KAC1D,SAAS,UAAU,WAAW,EAAE;KAChC,MAAM;KACP;IACF,CAAC;IACF,CACD,YAAY,GAEX;;CAGN,MAAM,gBAAgB,WAAW;CACjC,MAAM,gBAA8B,OAAO,SAAS;AAClD,MAAI,CAAC,OAAQ,QAAO,cAAc,KAAK,YAAY,OAAO,KAAK;EAC/D,IAAI;EACJ,IAAI;AACJ,MAAI;GACF,MAAM,SACJ,OAAO,YAAY,eAAe,iBAAiB,UAC/C,QACA;AACN,aAAU;IACR,KAAK,WAAW,SAAS,OAAO,MAAM,OAAO,MAAM,CAAC;IACpD,SAAS,MAAM,UAAU,QAAQ,UAAU,OAAO,aAAa;IAC/D,SAAS,aAAa,IAAI,QAAQ,MAAM,WAAW,QAAQ,QAAQ,CAAC;IACrE;AACD,aAAU,UAAU,QAAQ,IAAI;UAC1B;AACN,UAAO,cAAc,KAAK,YAAY,OAAO,KAAK;;AAEpD,MAAI,QAAS,QAAO,cAAc,KAAK,YAAY,OAAO,KAAK;EAC/D,MAAM,YAAY,YAAY,KAAK;EACnC,IAAI;AACJ,MAAI;AACF,iBACE,MAAM,QAAQ,OACV,OAAO,KAAK,KAAK,MAAM,QAAQ,QAAQ,gBAAgB,GACvD,OAAO,YAAY,eAAe,iBAAiB,UACjD,OAAO,KAAK,MAAM,OAAO,CAAC,MAAM,QAAQ,QAAQ,gBAAgB,GAChE,WAAW;UACb;AACN,iBAAc,gBAAgB,uBAAuB;;EAEvD,MAAM,UAAU,UACd,OACE,SACA,SACA,aACA,WACA,MACA,aAAa,MAAM,GAAG,YAAY,QACnC;EACH,IAAI;AACJ,MAAI;AACF,aAAU,cAAc,KAAK,YAAY,OAAO,KAAK;WAC9C,OAAO;AACd,UAAO,MAAM;AACb,SAAM;;AAER,EAAK,QACF,MAAM,aAAa;AAClB,OAAI,CAAC,OAAQ;GACb,IAAI;AACJ,OAAI;AACF,mBACE,SAAS,SAAS,YAAY,SAAS,SAAS,mBAC5C,gBAAgB,kBAAkB,GAClC,OAAO,KACL,SAAS,OAAO,CAAC,MACjB,SAAS,QAAQ,IAAI,eAAe,IAAI,GACzC;WACD;AACN,mBAAe,gBAAgB,wBAAwB;;AAEzD,UACE,SACA,SACA,aACA,WACA,SAAS,QACT,MACA;IACE,KAAK,SAAS;IACd,SAAS,aAAa,SAAS,QAAQ;IACvC,MAAM;IACP,CACF;KACA,OAAO,CACT,YAAY,GAAG;AAElB,SAAO;;AAET,KAAI,OAAO,kBAAkB,WAAY,YAAW,QAAQ;CAC5D,MAAM,eAAe,SAAS,WAAW,QAAQ,OAAO;AACxD,cAAa;AACX,WAAS;AACT,SAAO,MAAM;AACb,MAAI,WAAW,UAAU,aAAc,YAAW,QAAQ;AAC1D,gBAAc;;;AAclB,SAAS,SACP,WACA,QACA,QACA;AACA,KAAI,OAAO,mBAAmB,WAAY,cAAa;CACvD,MAAM,QAAQ,eAAe;CAC7B,MAAM,eAAe,MAAM;CAC3B,MAAM,eAAe,MAAM;CAC3B,MAAM,oBAAoB,MAAM;CAChC,MAAM,2BAAW,IAAI,SAAuC;CAC5D,MAAM,4BAAY,IAAI,KAAiB;CAGvC,MAAM,2BAAW,IAAI,SAAqC;CAC1D,IAAI,SAAS;CACb,MAAM,cAAc,SAElB,QACA,KACA,GAAG,MACH;AACA,WAAS,IAAI,KAAK,IAAI;EACtB,MAAM,SAAS,QAAQ,MAAM,cAAc,MAAM;GAAC;GAAQ;GAAK,GAAG;GAAK,CAAC;AACxE,MAAI;AACF,YAAS,IAAI,MAAM;IACjB,KAAK,WAAW,OAAO,IAAI,CAAC;IAC5B,QAAQ,OAAO,aAAa;IAC5B,SAAS,EAAE;IACZ,CAAC;UACI;AACN,YAAS,OAAO,KAAK;;AAEvB,SAAO;;CAET,MAAM,mBAAmB,SAEvB,MACA,OACA;EACA,MAAM,SAAS,kBAAkB,KAAK,MAAM,MAAM,MAAM;EACxD,MAAM,UAAU,SAAS,IAAI,KAAK;AAClC,MAAI,UAAU,QACZ,KAAI;GACF,MAAM,UAAU,IAAI,QAAQ,QAAQ,QAAQ;AAC5C,WAAQ,OAAO,MAAM,MAAM;AAC3B,WAAQ,UAAU,aAAa,QAAQ;UACjC;AAIV,SAAO;;CAET,MAAM,cAAc,SAElB,MACA;EACA,MAAM,UAAU,SAAS,IAAI,KAAK;AAClC,MAAI,CAAC,UAAU,CAAC,WAAW,UAAU,QAAQ,IAAI,CAC/C,QAAO,aAAa,KAAK,MAAM,KAAK;EACtC,MAAM,YAAY,YAAY,KAAK;EACnC,IAAI;AACJ,MAAI;AACF,iBACE,OAAO,aAAa,eAAe,gBAAgB,WAC/C,QAAQ,QACN,OAAO,KAAK,IAAI,eAAe,CAAC,kBAAkB,KAAK,CAAC,CACzD,GACD,OAAO,KACL,MACA,QAAQ,QAAQ,gBACjB;UACD;AACN,iBAAc,gBAAgB,2BAA2B;;EAE3D,IAAI,UAAU;EACd,MAAM,gBAAgB;AACpB,aAAU;;EAEZ,MAAM,gBAAgB;AACpB,QAAK,oBAAoB,SAAS,QAAQ;AAC1C,QAAK,oBAAoB,WAAW,MAAM;AAC1C,aAAU,OAAO,QAAQ;AACzB,OAAI,SAAS,IAAI,KAAK,KAAK,QAAS,UAAS,OAAO,KAAK;;EAE3D,MAAM,cAAc;AAClB,YAAS;AACT,OAAI,CAAC,OAAQ;GACb,MAAM,UAAU,UAAU,YAAY,KAAK,WAAW,IAAI,UAAU;GACpE,MAAM,UAAkC,EAAE;AAC1C,OAAI;AACF,SAAK,MAAM,QAAQ,KAAK,uBAAuB,CAAC,MAAM,QAAQ,EAAE;KAC9D,MAAM,QAAQ,KAAK,QAAQ,IAAI;AAC/B,SAAI,QAAQ,GAAG;MACb,MAAM,OAAO,KAAK,MAAM,GAAG,MAAM,CAAC,aAAa;AAC/C,cAAQ,QAAQ,YAAY,MAAM,KAAK,MAAM,QAAQ,EAAE,CAAC,MAAM,CAAC;;;WAG7D;GAGR,IAAI;AACJ,OAAI;AACF,QAAI,KAAK,iBAAiB,MAAM,KAAK,iBAAiB,OACpD,gBAAe,QAAQ,QACrB,OAAO,KACL,KAAK,cACL,YAAY,OAAO,aAAa,eAChC,YAAY,OAAO,SAAY,WAAW,UAC3C,CACF;aACQ,KAAK,iBAAiB,OAC/B,gBAAe,QAAQ,QACrB,OAAO,KAAK,KAAK,UAAU,KAAK,SAAS,CAAC,CAC3C;aACM,KAAK,iBAAiB,WAC7B,gBAAe,KAAK,cAChB,QAAQ,QACN,OAAO,KACL,IAAI,eAAe,CAAC,kBAAkB,KAAK,YAAY,CACxD,CACF,GACD,gBAAgB,iBAAiB;QAErC,gBAAe,OAAO,KACpB,KAAK,UACL,QAAQ,mBAAmB,2BAC5B;WACG;AACN,mBAAe,gBAAgB,4BAA4B;;AAE7D,UACE,OACA,SACA,aACA,WACA,YAAY,OAAO,KAAK,SAAS,MACjC,SACA;IACE,KAAK,KAAK;IACV;IACA,MAAM;IACP,CACF;;AAEH,OAAK,iBAAiB,SAAS,QAAQ;AACvC,OAAK,iBAAiB,WAAW,MAAM;AACvC,YAAU,IAAI,QAAQ;AACtB,WAAS,IAAI,MAAM,QAAQ;AAC3B,MAAI;AACF,UAAO,aAAa,KAAK,MAAM,KAAK;WAC7B,OAAO;AACd,YAAS;AACT,UACE,OACA,SACA,aACA,WACA,MACA,aAAa,MAAM,GAAG,YAAY,QACnC;AACD,SAAM;;;AAGV,OAAM,OAAO;AACb,OAAM,OAAO;AACb,OAAM,mBAAmB;AACzB,cAAa;AACX,WAAS;AACT,OAAK,MAAM,WAAW,UAAW,UAAS;AAC1C,MAAI,MAAM,SAAS,YAAa,OAAM,OAAO;AAC7C,MAAI,MAAM,SAAS,YAAa,OAAM,OAAO;AAC7C,MAAI,MAAM,qBAAqB,iBAC7B,OAAM,mBAAmB;;;;;;;ACtY/B,SAAgB,QAAQ,UAAkB,aAAuB,EAAE,EAAE;AACnE,QAAO;;;AAIT,SAAgB,iBACd,KACA,aAAuB,EAAE,EACzB,OAAe,WAAW,UAAU,QAAQ,qBAC5C;CACA,MAAM,SAAS,IAAI,IAAI,KAAK,KAAK;AACjC,QAAO;EAAE,KAAK,OAAO;EAAM,QAAQ,OAAO;EAAQ,OAAO,OAAO;EAAU;;;;;ACM5E,MAAM,aAAa;AACnB,MAAM,oBAAoB;AAC1B,MAAM,WAAW,IAAI,IAAY,qBAAqB;;;;;;;;;;AAoBtD,SAAgB,gBAAgB,SAA2B;CACzD,MAAM,EAAE,MAAM,QAAQ,YAAY,WAAW;CAC7C,MAAM,UAAU;EACd,QAAQ;EACR,YAAY;EACZ,SAAS;EACT,QAAQ;EACR,GAAG,QAAQ;EACZ;CACD,IAAI,UAA0B;CAC9B,IAAI,MAAM;CAEV,MAAM,iBAAiB,QAAQ,SAAS,UAAU,OAAO;CAEzD,MAAM,SAAS,gBAAkC;EAC/C,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,MAAI,QAAQ,MAAM,WAAW,KAAK,QAAQ,YAAY,EAAG;EACzD,MAAM,SAAS,QAAQ,MAAM,OAAO,EAAE;EACtC,MAAM,UAAU,QAAQ;AACxB,UAAQ,UAAU;EAClB,MAAM,QAAuB;GAC3B,cAAc,QAAQ;GACtB,sBAAsB,QAAQ;GAC9B;GACA;GACD;EACD,MAAM,kBAAkB;AAEtB,OAAI,YAAY,QAAS,SAAQ,WAAW,OAAO,SAAS;;AAE9D,MAAI;AACF,WAAQ,QAAQ,KAAK,OAAO,YAAY,CAAC,CAAC,MAAM,UAAU;UACpD;AACN,cAAW;;;CAIf,MAAM,UAAU,MAAc,UAAmC;EAC/D,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,MAAI;AACF,UAAO;GACP,MAAM,QAAuB;IAC3B,MAAM;IACN;IACA,OAAO;KAAE,GAAG;KAAO;KAAK;IACxB,WAAW,KAAK,KAAK;IACtB;GACD,MAAM,OAAO,eAAe,SAAY,QAAQ,WAAW,MAAM;AACjE,OAAI,SAAS,KAAM;AACnB,WAAQ,MAAM,KAAK,KAAK;AACxB,OAAI,QAAQ,MAAM,UAAU,WAAY,QAAO;UACzC;;CAKV,MAAM,SAAS,EAAE,cAAc,2BAAyC;AACtE,MAAI,YAAY,MAAM;AACpB,OAAI,QAAQ,iBAAiB,aAC3B,SAAQ,KACN,sCAAsC,QAAQ,aAAa,yCAAyC,aAAa,IAClH;AAEH;;AAEF,MACE,OAAO,WAAW,eAClB,OAAO,OAAO,qBAAqB,WAEnC;EACF,MAAM,UAAmB;GACvB;GACA;GACA,OAAO,EAAE;GACT,SAAS;GACT,YAAY,EAAE;GACd,OAAO;GACR;AACD,YAAU;EACV,MAAM,iBAAgC,MAAM,UAAU;AACpD,OAAI,YAAY,QAAS,QAAO,MAAM,MAAM;;EAG9C,MAAM,SAAS,gBAAgB;AAC/B,UAAQ,WAAW,KAAK,OAAO,OAAO,CAAC;EACvC,MAAM,aAAa,CACjB,GAAI,QAAQ,cAAc,EAAE,EAC5B,GAAI,KAAK,QAAQ,SAAY,EAAE,GAAG,CAAC,KAAK,IAAI,CAC7C;AACD,MAAI,QAAQ,OACV,SAAQ,WAAW,KACjB,oBAAoB;GAAE,MAAM;GAAe;GAAU;GAAQ;GAAQ,CAAC,CACvE;AACH,MAAI,QAAQ,WACV,SAAQ,WAAW,KACjB,yBAAyB;GAAE,MAAM;GAAe;GAAQ;GAAQ,CAAC,CAClE;AACH,MAAI,QAAQ,QACV,SAAQ,WAAW,KACjB,sBAAsB;GACpB,MAAM;GACN;GACA;GACA;GACD,CAAC,CACH;AACH,MAAI,QAAQ,OACV,SAAQ,WAAW,KACjB,oBAAoB;GAAE,MAAM;GAAe;GAAQ,CAAC,CACrD;EACH,MAAM,mBAAmB,MAAM,EAAE,QAAQ,MAAM,CAAC;AAChD,SAAO,iBAAiB,YAAY,WAAW;AAC/C,UAAQ,WAAW,WACjB,OAAO,oBAAoB,YAAY,WAAW,CACnD;AACD,UAAQ,QAAQ,kBAAkB,OAAO,EAAE,kBAAkB;AAC7D,SAAO,QAAQ;GACb,OAAO,UAAU;GACjB,KAAK,OAAO,IAAI,SAAS,KAAK;GAC9B,OAAO,SAAS;GAChB,UAAU,OAAO,IAAI,SAAS,SAAS;GACxC,CAAC;;CAGJ,MAAM,aAAa;EACjB,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,gBAAc,QAAQ,MAAM;AAC5B,OAAK,MAAM,aAAa,QAAQ,WAAY,YAAW;AACvD,SAAO;AACP,YAAU;;CAGZ,MAAM,QAAQ,MAAc,UAAmC;AAC7D,MAAI,SAAS,IAAI,KAAK,EAAE;AACtB,WAAQ,KACN,2BAA2B,KAAK,yEACjC;AACD;;AAEF,SAAO,MAAM,MAAM;;CAGrB,MAAM,SAAS,MAAwB,UACrC,OAAO,MAAM,MAAM;AAWrB,QAT6B;EAC3B;EACA;EACA;EACA;EACA,IAAI,eAAe;AACjB,UAAO,SAAS,gBAAgB;;EAEnC;;;;;;;;;;;;;;ACzLH,SAAgB,SACd,KACA,OAA6C,EAAE,EAC/C;CACA,MAAM,OAAO,OAAO,OAAsB,UAA2B,EAAE,KAAK;EAC1E,MAAM,OAAO,KAAK,UAAU,MAAM;AAGlC,MADE,QAAQ,WAAW,QAAQ,OAAO,UAAU,eAAe,YAC9C;AAKb,OAAI,CAJW,UAAU,WACvB,KACA,IAAI,KAAK,CAAC,KAAK,EAAE,EAAE,MAAM,oBAAoB,CAAC,CAC/C,CACY,OAAM,IAAI,MAAM,+BAA+B;AAC5D;;EAEF,MAAM,WAAW,MAAM,MAAM,KAAK;GAChC,QAAQ;GACR,WAAW;GACX,SAAS;IAAE,gBAAgB;IAAoB,GAAG,KAAK;IAAS;GAChE;GACD,CAAC;AACF,MAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,uBAAuB,SAAS,SAAS;;AAG7E,QAD2B,OAAO,OAAO,MAAM,EAAE,KAAK,CAAC;;;;;ACrBzD,MAAa,6BAA6B,KAAK;AAC/C,MAAM,iBAAiB,IAAI,IAAY,qBAAqB;AAE5D,SAAS,OACP,OACA,4BAAY,IAAI,KAAa,EACT;AACpB,KAAI,OAAO,UAAU,SAAU,QAAO,CAAC,MAAM,SAAS,KAAK;AAC3D,KAAI,UAAU,QAAQ,OAAO,UAAU,UAAW,QAAO;AACzD,KAAI,OAAO,UAAU,SAAU,QAAO,OAAO,SAAS,MAAM;AAC5D,KAAI,OAAO,UAAU,YAAY,UAAU,IAAI,MAAM,CAAE,QAAO;AAC9D,KACE,CAAC,MAAM,QAAQ,MAAM,IACrB,OAAO,eAAe,MAAM,KAAK,OAAO,aACxC,OAAO,eAAe,MAAM,KAAK,KAEjC,QAAO;AACT,WAAU,IAAI,MAAM;AACpB,KACE,CAAC,MAAM,QAAQ,MAAM,IACrB,OAAO,KAAK,MAAM,CAAC,MAAM,QAAQ,IAAI,SAAS,KAAK,CAAC,CAEpD,QAAO;CACT,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,QAAQ,OAAO,OAAO,MAAM;AAClE,MAAK,MAAM,QAAQ,OACjB,KAAI,CAAC,OAAO,MAAM,UAAU,CAAE,QAAO;AAEvC,WAAU,OAAO,MAAM;AACvB,QAAO;;;;;;AAOT,SAAgB,0BACd,SAGqB;CACrB,IAAI,SAAS;CACb,IAAI,aAA6B,EAAE;CAEnC,MAAM,UAAU,MAAc,YAAoB;AAChD,MAAI;AACF,WAAQ,UAAU;IAAE;IAAM;IAAS,CAAC;UAC9B;;CAKV,MAAM,iBAAiB,QAAQ,SAAS,SAAS;CAEjD,MAAM,UAAU,MAAc,UAAqB;AACjD,MAAI,CAAC,OAAQ;EACb,IAAI;AACJ,MAAI;GACF,MAAM,QAAyB;IAC7B,MAAM;IACN;IACA,WAAW,KAAK,KAAK;IACrB;IACD;GACD,MAAM,OACJ,QAAQ,eAAe,SAAY,QAAQ,QAAQ,WAAW,MAAM;AACtE,OAAI,SAAS,QAAQ,CAAC,OAAQ;AAC9B,OACE,KAAK,SAAS,YACd,OAAO,KAAK,SAAS,YACrB,CAAC,KAAK,KAAK,MAAM,IACjB,KAAK,KAAK,SAAS,OACnB,KAAK,KAAK,SAAS,KAAK,IACxB,CAAC,OAAO,SAAS,KAAK,UAAU,IAChC,KAAK,aAAa,KAClB,KAAK,aAAa,kBAClB,CAAC,OAAO,KAAK,MAAM,EACnB;AACA,WACE,iBACA,4DACD;AACD;;GAGF,MAAM,aAAa,KAAK,UAAU;IAChC,MAAM,KAAK;IACX,MAAM,KAAK;IACX,WAAW,KAAK;IAChB,OAAO,KAAK;IACb,CAAC;AACF,OACE,IAAI,aAAa,CAAC,OAAO,WAAW,CAAC,aACrC,4BACA;AACA,WAAO,mBAAmB,uCAAuC;AACjE;;AAEF,aAAU,KAAK,MAAM,WAAW;UAC1B;AACN,UAAO,iBAAiB,6CAA6C;AACrE;;AAEF,MAAI;AACF,WAAQ,KAAK,QAAQ;UACf;AACN,UACE,uBACA,wDACD;;;CAIL,MAAM,WAAW,MAAc,UAAmC;AAEhE,SAAO,MAAM,MAAmB;;CAGlC,MAAM,aAAa;AACjB,WAAS;EACT,MAAM,YAAY;AAClB,eAAa,EAAE;AACf,OAAK,MAAM,aAAa,UAAW,YAAW;;AAGhD,QAAO;EACL,QAAQ;AACN,OAAI,UAAU,OAAO,WAAW,YAAa;AAC7C,YAAS;AAET,OAAI,OAAO,OAAO,qBAAqB,WAAY;GAEnD,MAAM,SAAS,gBAAgB;AAC/B,OAAI;AACF,eAAW,KAAK,OAAO,OAAO,CAAC;AAC/B,QAAI,QAAQ,SAAS,WAAW,MAC9B,YAAW,KACT,oBAAoB;KAAE,MAAM;KAAS;KAAU;KAAQ,CAAC,CACzD;AAEH,QAAI,QAAQ,SAAS,eAAe,MAClC,YAAW,KAAK,yBAAyB;KAAE,MAAM;KAAS;KAAQ,CAAC,CAAC;AAEtE,QAAI,QAAQ,SAAS,WAAW,MAC9B,YAAW,KAAK,oBAAoB;KAAE,MAAM;KAAS;KAAQ,CAAC,CAAC;AAEjE,QAAI,QAAQ,SAAS,YAAY,MAC/B,YAAW,KACT,sBAAsB;KACpB,MAAM;KACN,YAAY,QAAQ,cAAc,EAAE;KACpC;KACD,CAAC,CACH;AAEH,WAAO,QAAQ;KACb,OAAO,UAAU;KACjB,KAAK,OAAO,IAAI,SAAS,KAAK;KAC9B,OAAO,SAAS;KAChB,UAAU,OAAO,IAAI,SAAS,SAAS;KACxC,CAAC;WACI;AACN,UAAM;AACN,WAAO,kBAAkB,mCAAmC;;;EAGhE;EACA,KAAK,MAAM,OAAO;AAChB,OAAI,CAAC,OAAQ;AACb,OAAI,eAAe,IAAI,KAAK,EAAE;AAC5B,WACE,iBACA,2DACD;AACD;;AAEF,UAAO,MAAM,MAAM;;EAEtB"} +\ No newline at end of file ++{"version":3,"file":"index.cjs","names":[],"sources":["../src/types.ts","../src/redact.ts","../src/clicks.ts","../src/inputs.ts","../src/navigation.ts","../src/network-body.ts","../src/network.ts","../src/routes.ts","../src/collector.ts","../src/http-sink.ts","../src/trajectory-collector.ts"],"sourcesContent":["/** Event names the collector and CopilotKit Core emit. `emit()` rejects them. */\nexport const BUILT_IN_EVENT_NAMES = [\n \"page\",\n \"navigation\",\n \"click\",\n \"input\",\n \"network\",\n \"thread.linked\",\n \"agent.run\",\n \"agent.message\",\n \"tool.call\",\n] as const;\n\nexport type BuiltInEventName = (typeof BUILT_IN_EVENT_NAMES)[number];\n\n/** Replaces values that never leave the browser, even with full capture: passwords and credentials. */\nexport const REDACTED = \"[redacted]\";\n\n/** One captured interaction, shaped as an AG-UI `CUSTOM` event. */\nexport interface LearningEvent<V = Record<string, unknown>> {\n type: \"CUSTOM\";\n name: string;\n value: V;\n timestamp: number;\n}\n\n/** What a sink receives. The Trajectory scope sits on the batch, not on each event. */\nexport interface LearningBatch {\n trajectoryId: string;\n learningContainerIds?: string[];\n events: LearningEvent[];\n /** Events lost since the previous batch (queue overflow or a failed send). */\n dropped: number;\n}\n\nexport interface SinkSendOptions {\n /** True when the page is going away; use a transport that survives unload. */\n beacon?: boolean;\n}\n\n/** Receives batches. `url`, when set, is never captured as a network event. */\nexport type LearningSink = ((\n batch: LearningBatch,\n options?: SinkSendOptions,\n) => void | Promise<void>) & { readonly url?: string };\n\nexport interface ClickTarget {\n tag: string;\n role: string | null;\n action: string | null;\n text: string;\n /**\n * Short, cleaned name of the clicked control (click events only). Comes from\n * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title`, or a\n * control's own text. Omitted when no safe name exists.\n */\n label?: string;\n attributes: Record<string, string>;\n value?: string;\n checked?: boolean;\n selectedValues?: string[];\n files?: { name: string; type: string; size: number; lastModified: number }[];\n input: \"pointer\" | \"keyboard\";\n}\n\n/** Adds fields to a click event, from the element the user clicked. */\nexport type EnrichFn = (target: Element) => Record<string, unknown> | undefined;\n\nexport type Emit = (name: string, value: Record<string, unknown>) => void;\n\nexport interface CaptureOptions {\n clicks?: boolean;\n navigation?: boolean;\n network?: boolean;\n inputs?: boolean;\n}\n\nexport interface CollectorOptions {\n sink: LearningSink;\n /** @deprecated Capture retains raw URLs; route templates no longer transform events. */\n routes?: string[];\n /** URLs (prefix match on the absolute URL) or patterns that network capture skips. */\n ignoreUrls?: (string | RegExp)[];\n /** Turns capture modules on or off. All are on by default. */\n capture?: CaptureOptions;\n /** Runs last, in the browser. Return the event to send, or `null` to drop it. */\n beforeSend?: (event: LearningEvent) => LearningEvent | null;\n enrich?: EnrichFn;\n}\n\nexport interface StartOptions {\n trajectoryId: string;\n learningContainerIds?: string[];\n}\n\nexport interface Collector {\n start(options: StartOptions): void;\n stop(): void;\n /** Developer events. Built-in names warn and are ignored. */\n emit(name: string, value: Record<string, unknown>): void;\n /** @internal For @copilotkit/core only: emits built-in names. */\n ɵemit(name: BuiltInEventName, value: Record<string, unknown>): void;\n readonly trajectoryId: string | null;\n}\n","import { REDACTED } from \"./types\";\n\n// Passwords and unambiguous credentials never leave the browser, even with full\n// capture. Keys are split into lowercase words (`accessToken`, `access_token`,\n// `Access-Token` all become \"access token\"). These match anywhere in the joined words:\nconst CREDENTIAL =\n /password|passwd|passphrase|passcode|credential|api(key|token)|privatekey|subscriptionkey|(access|refresh|auth|session)token|clientsecret/;\n// These match only as whole words (`secret` but not `secretary`, `id token` but\n// not `invalid token`), as the last word (`Set-Cookie` but not `cookieConsent`),\n// or as the entire key.\nconst CREDENTIAL_WORD =\n / (secret|id ?token)s? | (authorization|cookies?|jwt|bearer ?token|[cx]srf ?token) $|^ (pwd|pass|token|otp) $/;\n// Metadata about a credential is not the credential: `passwordExpiresAt`, `credentialId`.\nconst METADATA =\n / (ids?|at|in|expires?|expiry|length|count|enabled|required|policy|hint|type) $/;\nconst PASSWORD_AUTOCOMPLETE = /(current|new)-password|one-time-code/;\n// A JSON string, possibly cut off, with the `:` that makes it a key.\nconst TOKEN = /\"((?:\\\\[\\s\\S]|[^\"\\\\])*)(\"?)(\\s*:\\s*)?/g;\n// A lone trailing backslash belongs to a cut-off string.\nconst STRING = /\"(?:\\\\[\\s\\S]?|[^\"\\\\])*\"?/y;\n// `key=value` and `key: value` outside JSON strings: forms, query strings,\n// GraphQL, YAML. A `:` needs a space or end after it, so `host:port` is no pair.\n// The key length bound keeps scanning long unbroken text linear.\n// An auth scheme (`Bearer x`) belongs to the value.\nconst PAIR =\n /([\\w$.%+[\\]-]{1,64})(\\s*(?:=|:(?=\\s|$))\\s*)((?:(?:basic|bearer|digest|token) +)?[^\\s&,;)}\\]]*)/gi;\n// `scheme://user:pass@` anywhere in text; up to the last `@` before the host.\n// The scheme length bound keeps scanning long words linear.\nconst USERINFO = /([a-z][\\w+.-]{0,31}:\\/\\/)[^\\s/?#\"]*@/gi;\n/** Text beyond this is neither redacted nor kept; snapshots keep 4 KiB of the result. */\nexport const TEXT_LIMIT = 16 * 1024;\nconst REMEMBERED_VALUES = 20;\n\nexport const isCredentialKey = (key: string) => {\n const words = ` ${key\n .replace(/%([\\da-f]{2})/gi, (_, hex: string) =>\n String.fromCharCode(parseInt(hex, 16)),\n )\n .replace(/([a-z\\d])(?=[A-Z])/g, \"$1 \")\n .toLowerCase()\n .split(/[^a-z\\d]+/)\n .filter(Boolean)\n .join(\" \")} `;\n return (\n !METADATA.test(words) &&\n (CREDENTIAL.test(words.replace(/ /g, \"\")) || CREDENTIAL_WORD.test(words))\n );\n};\n\nconst decode = (body: string) => {\n try {\n return JSON.parse(`\"${body}\"`) as string;\n } catch {\n return body;\n }\n};\n\n/** The end of the JSON value at `start`: a string, a balanced object or array, or a bare literal. */\nconst endOfValue = (text: string, start: number) => {\n let depth = 0;\n for (let i = start; i < text.length; i++) {\n const char = text[i]!;\n if (char === '\"') {\n STRING.lastIndex = i;\n STRING.test(text);\n i = STRING.lastIndex - 1;\n } else if (char === \"{\" || char === \"[\") {\n depth++;\n continue;\n } else if (char === \"}\" || char === \"]\") {\n if (--depth < 0) return i;\n } else {\n if (!depth && (char === \",\" || /\\s/.test(char))) return i;\n continue;\n }\n if (!depth) return i + 1;\n }\n return text.length;\n};\n\n/**\n * Redacts credential values in JSON (valid or cut off) and in key/value text,\n * and recurses into string values that hold JSON, forms, or URLs. Unchanged\n * parts keep their exact original text.\n */\nconst redactText = (text: string, depth = 0): string => {\n const token = new RegExp(TOKEN);\n let pending = false;\n const pairs = (part: string) => {\n const segment = part.replace(USERINFO, \"$1\");\n return segment.replace(\n PAIR,\n (pair, key: string, separator: string, value: string, offset: number) => {\n if (!isCredentialKey(key)) return pair;\n // The value may continue in a following string: `password:\"x\"`.\n if (offset + pair.length === segment.length) {\n pending = true;\n if (!value) return pair;\n }\n return key + separator + REDACTED;\n },\n );\n };\n let out = \"\";\n let last = 0;\n let match: RegExpExecArray | null;\n while ((match = token.exec(text))) {\n const [raw, body, closed, colon] = match;\n pending = false;\n out += pairs(text.slice(last, match.index));\n let end = token.lastIndex;\n let replacement = raw;\n const value = body!.includes(\"\\\\\") ? decode(body!) : body!;\n if (colon) {\n if (isCredentialKey(value)) {\n const valueEnd = endOfValue(text, end);\n // Booleans and null say whether a credential exists, not what it is.\n if (!/^(true|false|null|)$/.test(text.slice(end, valueEnd))) {\n replacement += `\"${REDACTED}\"`;\n end = valueEnd;\n }\n }\n } else if (pending) replacement = `\"${REDACTED}\"`;\n else if (depth < 4 && /[=:\"]/.test(value)) {\n const redacted = redactText(value, depth + 1);\n if (redacted !== value)\n replacement = JSON.stringify(redacted).slice(\n 0,\n closed ? undefined : -1,\n );\n }\n out += replacement;\n last = token.lastIndex = end;\n }\n return out + pairs(text.slice(last));\n};\n\n/** Per-session redaction state: password-like fields and the values typed into them. */\nexport function createRedactor() {\n const seen = new WeakSet<Element>();\n // Latest value per field, so each keystroke replaces the previous prefix.\n const remembered = new Map<Element, string>();\n let observer: MutationObserver | undefined;\n\n const toggled = (records: MutationRecord[]) => {\n for (const record of records)\n if (record.oldValue?.toLowerCase() === \"password\")\n seen.add(record.target as Element);\n };\n\n /**\n * Replaces remembered values, raw and in their JSON and URL encodings, plus\n * `normalize(value)` when given and at least 4 characters long.\n */\n const scrub = (text: string, normalize?: (value: string) => string) => {\n for (const value of [...remembered.values()].sort(\n (a, b) => b.length - a.length,\n ))\n for (const form of [\n value,\n JSON.stringify(value).slice(1, -1),\n encodeURIComponent(value),\n new URLSearchParams({ v: value }).toString().slice(2),\n ...[normalize?.(value) ?? \"\"].filter((normal) => normal.length >= 4),\n ])\n text = /^\\d+$/.test(form)\n ? // One-time codes must not match inside longer numbers.\n text.replace(RegExp(`(?<!\\\\d)${form}(?!\\\\d)`, \"g\"), REDACTED)\n : text.split(form).join(REDACTED);\n return text;\n };\n\n /** True for current, former (show-password toggles), and autocomplete password fields. */\n const isPassword = (element: Element) => {\n if (observer) toggled(observer.takeRecords());\n if (\n !(element instanceof HTMLInputElement) ||\n !(\n element.type === \"password\" ||\n seen.has(element) ||\n isCredentialKey(element.name) ||\n PASSWORD_AUTOCOMPLETE.test(element.getAttribute(\"autocomplete\") ?? \"\")\n )\n )\n return false;\n seen.add(element);\n if (element.value.length >= 4) {\n remembered.delete(element);\n remembered.set(element, element.value);\n if (remembered.size > REMEMBERED_VALUES)\n remembered.delete(remembered.keys().next().value!);\n }\n return true;\n };\n\n return {\n isPassword,\n scrub,\n /**\n * Tracks password fields for the session: existing ones, `type` toggles away\n * from \"password\", and typed values even when input capture is off.\n */\n watch() {\n // Each part is optional: a partial DOM (tests, embedded webviews, hardened\n // pages) must not stop capture from starting or break the host app.\n try {\n for (const input of document.querySelectorAll(\"input[type=password]\"))\n seen.add(input);\n } catch {\n // Fields are still caught on first sight by isPassword.\n }\n let current: MutationObserver | undefined;\n try {\n current = new MutationObserver(toggled);\n current.observe(document, {\n subtree: true,\n attributeFilter: [\"type\"],\n attributeOldValue: true,\n });\n observer = current;\n } catch {\n current = undefined;\n }\n const remember = (event: Event) => {\n try {\n const [target] = event.composedPath();\n if (target instanceof Element) isPassword(target);\n } catch {\n // Capture must never break an edit in the host app.\n }\n };\n for (const type of [\"input\", \"change\"])\n window.addEventListener(type, remember, true);\n return () => {\n current?.disconnect();\n if (current !== undefined && observer === current) observer = undefined;\n for (const type of [\"input\", \"change\"])\n window.removeEventListener(type, remember, true);\n };\n },\n field: (key: string, value: string) =>\n isCredentialKey(key) ? REDACTED : scrub(redactText(value)),\n /** Returns the redacted body text, or undefined if redaction failed. */\n body(text: string) {\n try {\n return scrub(redactText(text.slice(0, TEXT_LIMIT)));\n } catch {\n return undefined;\n }\n },\n /** Drops userinfo and redacts credential values; also applied to element attributes. */\n url(value: string) {\n try {\n return scrub(value.replace(/[^#]+/g, (part) => redactText(part)));\n } catch {\n return REDACTED;\n }\n },\n };\n}\n\nexport type Redactor = ReturnType<typeof createRedactor>;\n","import { createRedactor, isCredentialKey } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { ClickTarget, Emit, EnrichFn } from \"./types\";\n\nconst INTERACTIVE =\n 'button, a[href], input, select, textarea, label, summary, [role], [data-copilotkit-action], [contenteditable]:not([contenteditable=\"false\"])';\nconst RECOVERY_DISTANCE_PX = 5;\n// `[role]` also matches containers such as role=\"main\"; cap their text so one\n// click cannot exceed the 16 KiB event limit and be dropped.\nconst MAX_TEXT_LENGTH = 1000;\n\n/** `closest()` that continues through shadow roots to their hosts. */\nfunction closestAcrossShadow(el: Element, selector: string) {\n let current: Element | null = el;\n while (current !== null) {\n const match = current.closest(selector);\n if (match !== null) return match;\n const root = current.getRootNode();\n current = root instanceof ShadowRoot ? root.host : null;\n }\n return null;\n}\n\ninterface Described {\n element: Element;\n target: Omit<ClickTarget, \"input\">;\n}\n\ninterface PointerOrigin {\n described: Described | null;\n x: number;\n y: number;\n}\n\n/**\n * Finds the nearest interactive element and retains its text, attributes, and value.\n * The explicit `data-copilotkit-ignore` opt-out still applies.\n */\nexport function describeTarget(el: Element, redact: Redactor) {\n if (closestAcrossShadow(el, \"[data-copilotkit-ignore]\") !== null) return null;\n const element = el.closest(INTERACTIVE) ?? el;\n // React and server markup can mirror the password into the value attribute.\n const password = redact.isPassword(element);\n const described: Described = {\n element,\n target: {\n tag: element.localName,\n role: element.getAttribute(\"role\"),\n action: element.getAttribute(\"data-copilotkit-action\"),\n text: (element.textContent ?? \"\").slice(0, MAX_TEXT_LENGTH),\n attributes: Object.fromEntries(\n Array.from(element.attributes, ({ name, value }) => [\n name,\n // Credential-named attributes (`data-api-key`) are redacted whole;\n // URL-valued ones (`href`, `action`, …) can carry credentials too.\n (password && name === \"value\") ||\n isCredentialKey(name.replace(/^data-/, \"\"))\n ? REDACTED\n : redact.url(value),\n ]),\n ),\n ...readControlValue(element, redact),\n },\n };\n return described;\n}\n\nconst MAX_LABEL_LENGTH = 80;\n// The text walk stops past these bounds. For a control's own text, that means no name.\nconst MAX_WALK_NODES = 1000;\nconst MAX_WALK_CHARS = 4 * MAX_LABEL_LENGTH;\n// Controls whose name comes from their content. An explicit role overrides the\n// tag, so `<li role=\"option\">` qualifies and `<a href role=\"row\">` does not.\nconst CONTROL_ROLE =\n /^(button|link|tab|menuitem(checkbox|radio)?|option|checkbox|switch|radio|treeitem)$/;\nconst CONTENTEDITABLE = '[contenteditable]:not([contenteditable=\"false\"])';\nconst TEXT_ENTRY = `${CONTENTEDITABLE}, [role~=textbox], [role~=searchbox], [role~=combobox]`;\nconst EDITABLE = `input, textarea, select, ${TEXT_ENTRY}`;\n// Card-like structure: a control containing it gets no name from its text,\n// except for one lone paragraph, or headings inside `summary`.\nconst HEADING = \"h1, h2, h3, h4, h5, h6, [role~=heading]\";\nconst STRUCTURE = `${HEADING}, p, ul, ol, li, dl, table, article, section, blockquote`;\nconst SKIPPED = 'script, style, template, [hidden], [aria-hidden=\"true\"]';\n// Inline elements that do not separate words.\nconst PHRASING =\n /^(b|i|u|em|strong|mark|small|sub|sup|abbr|code|kbd|s|q|cite|dfn|var|time|data|bdi|bdo|wbr|span)$/;\nconst CAPTION_INPUT = /^(submit|button|reset|image)$/;\n// Input types that do not hold typed text.\nconst NON_TEXT_INPUT =\n /^(submit|button|reset|image|checkbox|radio|range|color|file|hidden)$/;\n// Includes bidi controls, zero-width characters, soft hyphens, word joiners,\n// variation selectors, and tag characters.\nconst IGNORABLE = /\\p{Default_Ignorable_Code_Point}/gu;\n// Quantifier bounds and run-start anchors keep each pattern linear. No\n// lookbehind: a regex literal with one fails to parse in older Safari.\n// A URL ends before trailing sentence punctuation.\nconst URL_LIKE =\n /(?:\\b[a-z][\\w+.-]{0,31}:\\/\\/|\\bwww\\.)[^\\s<>\"']*[^\\s<>\"'.,;:!?)]/gi;\nconst EMAIL =\n /[\\p{L}\\p{N}_.%+-]{1,64}@[\\p{L}\\p{N}-]{1,63}(?:\\.[\\p{L}\\p{N}-]{1,63})+/gu;\n// JWTs, and three dotted segments that are each long or contain a digit, but\n// not dotted names such as `settings.notifications.preferences`.\nconst JWT =\n /(^|[^\\w-])(?:eyJ[\\w-]*\\.[\\w-]*\\.[\\w-]*|[\\w-]{16,}\\.[\\w-]{16,}\\.[\\w-]{16,}|(?=[\\w-]*\\d)[\\w-]{8,}\\.(?=[\\w-]*\\d)[\\w-]{8,}\\.(?=[\\w-]*\\d)[\\w-]{8,})/g;\nconst TOKEN_RUN = /[\\w+/=-]{24,}/g;\n\nconst roleOf = (element: Element) =>\n (element.getAttribute(\"role\") ?? \"\").trim().split(/\\s+/)[0]!.toLowerCase();\n\nconst idRefs = (element: Element, name: string) =>\n (element.getAttribute(name) ?? \"\").split(/\\s+/).filter(Boolean);\n\nconst rootOf = (element: Element) => {\n const root = element.getRootNode();\n return root instanceof Document || root instanceof ShadowRoot ? root : null;\n};\n\n/** Buttons, links with `href`, `summary`, and elements with a control role. */\nconst isControl = (element: Element) => {\n const role = roleOf(element);\n return role\n ? CONTROL_ROLE.test(role)\n : element.matches(\"button, a[href], summary\");\n};\n\n/** Text-like inputs, text areas, selects, and anything inside editable content or a text-entry role. */\nconst holdsValue = (element: Element) =>\n element instanceof HTMLInputElement\n ? !NON_TEXT_INPUT.test(element.type)\n : element.matches(\"textarea, select\") ||\n element.closest(TEXT_ENTRY) !== null;\n\n/**\n * The name an image contributes: `alt` of an `img`, or `aria-label` (else a\n * plain `<title>`) of an `svg` or `role=\"img\"` element. `undefined` when the\n * element is not an image.\n */\nfunction imageName(element: Element) {\n if (element.localName === \"img\") return element.getAttribute(\"alt\");\n if (element.localName !== \"svg\" && roleOf(element) !== \"img\")\n return undefined;\n const title = Array.from(element.children).find(\n (child) => child.localName === \"title\" && child.childElementCount === 0,\n );\n return element.getAttribute(\"aria-label\") ?? title?.textContent ?? null;\n}\n\n/**\n * Rendered text. Inline formatting joins words; other elements separate them.\n * Skips hidden subtrees, uses image names, and does\n * not walk into images. Yields nothing for editable content or editable\n * descendants. In `strict` mode (a control's own text) it also yields nothing\n * past the walk bounds, for card-like structure, and for text that looks like\n * separate fields: two `div`s that directly hold text, or three sibling\n * elements that each carry text.\n */\nfunction safeText(root: Element, strict: boolean) {\n if (holdsValue(root)) return \"\";\n let text = \"\";\n let nodes = 0;\n let paragraphs = 0;\n // The first div that holds text; a second one means separate fields.\n let div: Element | undefined;\n const bearers = new Map<Node, Set<Node>>();\n const add = (part: string) => (text = (text + part).replace(/\\s+/g, \" \"));\n // Marks `start` and its ancestors below root as carrying text. False once\n // three siblings do.\n const bear = (start: Node) => {\n if (!strict) return true;\n for (\n let child = start, parent = start.parentNode;\n child !== root && parent !== null;\n child = parent, parent = parent.parentNode\n ) {\n const siblings = bearers.get(parent) ?? new Set<Node>();\n bearers.set(parent, siblings);\n if (siblings.has(child)) break;\n if (siblings.add(child).size > 2) return false;\n }\n return true;\n };\n let node: Node | null = root.firstChild;\n while (node !== null) {\n if (++nodes > MAX_WALK_NODES || text.length > MAX_WALK_CHARS) {\n if (strict) return \"\";\n break;\n }\n let descend = false;\n if (node instanceof Text) {\n const parent = node.parentNode as Element;\n // The block that holds this text, through inline elements.\n let holder = parent;\n while (holder !== root && PHRASING.test(holder.localName))\n holder = holder.parentElement!;\n if (\n /\\S/.test(node.data) &&\n (!bear(parent) ||\n (strict && holder.localName === \"div\" && (div ??= holder) !== holder))\n )\n return \"\";\n add(node.data);\n } else if (node instanceof Element) {\n if (node.matches(EDITABLE)) return \"\";\n if (\n strict &&\n node.matches(STRUCTURE) &&\n !(root.localName === \"summary\" && node.matches(HEADING)) &&\n (node.localName !== \"p\" || ++paragraphs > 1)\n )\n return \"\";\n const name = node.matches(SKIPPED) ? null : imageName(node);\n if (name) {\n if (!bear(node)) return \"\";\n add(` ${name} `);\n } else if (name === undefined) {\n if (!PHRASING.test(node.localName)) add(\" \");\n descend = true;\n }\n }\n if (descend && node.firstChild !== null) node = node.firstChild;\n else {\n while (node !== root && node.nextSibling === null) {\n node = node.parentNode!;\n if (!PHRASING.test((node as Element).localName)) add(\" \");\n }\n node = node === root ? null : node.nextSibling;\n }\n }\n return text;\n}\n\n/** An option or tree item of an autocomplete or search popup, whose text usually repeats what the user typed. */\nconst isSuggestion = (element: Element) =>\n (element.localName === \"option\" ||\n /^(option|treeitem)$/.test(roleOf(element))) &&\n inSuggestionList(element);\n\n/** Whether `element` is, or is inside, a combobox, a datalist, or the popup a combobox or autocomplete field controls or owns. */\nfunction inSuggestionList(element: Element) {\n if (element.closest(\"[role~=combobox], datalist\") !== null) return true;\n const root = rootOf(element);\n return (\n root !== null &&\n Array.from(\n root.querySelectorAll(\"[role~=combobox], [aria-autocomplete]\"),\n ).some((owner) =>\n [...idRefs(owner, \"aria-controls\"), ...idRefs(owner, \"aria-owns\")].some(\n (id) => root.getElementById(id)?.contains(element),\n ),\n )\n );\n}\n\nfunction labelledBy(element: Element) {\n const root = rootOf(element);\n return root === null\n ? null\n : idRefs(element, \"aria-labelledby\")\n .map((id) => {\n const referenced = root.getElementById(id);\n return referenced === null || inSuggestionList(referenced)\n ? \"\"\n : safeText(referenced, false);\n })\n .join(\" \");\n}\n\n/** A control's own text, unless it is longer than a label: then it is content, not a name. */\nconst normalize = (text: string) =>\n text.replace(IGNORABLE, \"\").replace(/\\s+/g, \" \").trim();\n\nfunction contentText(element: Element) {\n const text = safeText(element, true);\n return Array.from(normalize(text)).length > MAX_LABEL_LENGTH ? \"\" : text;\n}\n\n/** Removes invisible characters, credentials, emails, URLs, and tokens, then caps the length. */\nfunction cleanLabel(raw: string, redact: Redactor) {\n const chars = Array.from(\n // Scrub again after collapsing, also for remembered values that differ\n // in whitespace or invisible characters.\n redact\n .scrub(\n normalize(redact.body(raw.replace(IGNORABLE, \"\")) ?? \"\"),\n normalize,\n )\n .replace(URL_LIKE, \"[url]\")\n .replace(EMAIL, \"[email]\")\n .replace(JWT, `$1${REDACTED}`)\n .replace(TOKEN_RUN, (run) => (/\\d/.test(run) ? REDACTED : run)),\n );\n return chars.length > MAX_LABEL_LENGTH\n ? `${chars\n .slice(0, MAX_LABEL_LENGTH - 1)\n .join(\"\")\n .trimEnd()}…`\n : chars.join(\"\");\n}\n\nfunction firstLabel(sources: (() => string | null)[], redact: Redactor) {\n for (const source of sources) {\n const label = cleanLabel(source() ?? \"\", redact);\n if (label) return label;\n }\n return undefined;\n}\n\n/**\n * A short name for the clicked control, from the first source that yields one:\n * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title` (controls\n * only), then the text of a control named by its content. Containers, cards,\n * and editable content never contribute their text. An autocomplete suggestion,\n * or anything inside one, gets no label from any source; `data-copilotkit-action`\n * names it instead. An icon without its own name takes the name of the control\n * around it.\n */\nfunction clickLabel(element: Element, redact: Redactor): string | undefined {\n const option = element.closest(\"option, [role~=option], [role~=treeitem]\");\n if (option !== null && isSuggestion(option)) return undefined;\n // aria-labelledby and aria-label apply to any other target.\n const named = [\n () => labelledBy(element),\n () => element.getAttribute(\"aria-label\"),\n ];\n if (\n /^(img|svg)$/.test(element.localName) ||\n /^(img|presentation|none)$/.test(roleOf(element))\n ) {\n const own = firstLabel(\n [...named, () => imageName(element) ?? null],\n redact,\n );\n if (own !== undefined) return own;\n // parentElement is null at a shadow root, so the search stays inside it.\n let control = element.parentElement;\n while (control !== null && !isControl(control))\n control = control.parentElement;\n return control === null ? undefined : clickLabel(control, redact);\n }\n const control = isControl(element) && !holdsValue(element);\n const captionInput =\n element instanceof HTMLInputElement && CAPTION_INPUT.test(element.type);\n return firstLabel(\n [\n ...named,\n // The caption of a button-like input, or the `alt` of an image input.\n () =>\n captionInput\n ? element.type === \"image\"\n ? element.getAttribute(\"alt\")\n : element.value\n : null,\n () => (control || captionInput ? element.getAttribute(\"title\") : null),\n () => (control ? contentText(element) : null),\n ],\n redact,\n );\n}\n\n/** Snapshot live control properties; HTML attributes do not reflect edited values. */\nexport function readControlValue(element: Element, redact: Redactor) {\n if (element instanceof HTMLInputElement) {\n return {\n value: redact.isPassword(element) ? REDACTED : element.value,\n ...([\"checkbox\", \"radio\"].includes(element.type)\n ? { checked: element.checked }\n : {}),\n ...(element.type === \"file\"\n ? {\n files: Array.from(element.files ?? [], (file) => ({\n name: file.name,\n type: file.type,\n size: file.size,\n lastModified: file.lastModified,\n })),\n }\n : {}),\n };\n }\n if (element instanceof HTMLSelectElement) {\n return {\n value: element.value,\n selectedValues: Array.from(\n element.selectedOptions,\n (option) => option.value,\n ),\n };\n }\n if (element instanceof HTMLTextAreaElement) return { value: element.value };\n if (element.matches(CONTENTEDITABLE)) {\n return { value: element.textContent ?? \"\" };\n }\n return {};\n}\n\nfunction eventElement(event: Event) {\n const [first] = event.composedPath();\n return first instanceof Element ? first : null;\n}\n\nfunction isRootOrDetached(el: Element) {\n return el.localName === \"body\" || el.localName === \"html\" || !el.isConnected;\n}\n\nfunction isNear(origin: PointerOrigin, event: MouseEvent) {\n return (\n Math.abs(origin.x - event.clientX) <= RECOVERY_DISTANCE_PX &&\n Math.abs(origin.y - event.clientY) <= RECOVERY_DISTANCE_PX\n );\n}\n\n/**\n * Listens for clicks in the capture phase on `window`, so handlers that stop\n * propagation do not hide them. The target is described at `pointerdown`,\n * because a re-render before `click` can move the click to `<body>`.\n */\nexport function installClickCapture(params: {\n emit: Emit;\n getRoute: () => string;\n enrich?: EnrichFn;\n // ponytail: test seam only. jsdom cannot create trusted events; the collector never passes this.\n isTrusted?: (event: Event) => boolean;\n redact?: Redactor;\n}) {\n const {\n emit,\n getRoute,\n enrich,\n isTrusted = (event) => event.isTrusted,\n redact = createRedactor(),\n } = params;\n let origin: PointerOrigin | null = null;\n\n // Input events share describeTarget; only clicks carry a label.\n const describeClick = (el: Element) => {\n const described = describeTarget(el, redact);\n if (described === null) return null;\n try {\n const label = clickLabel(described.element, redact);\n if (label !== undefined) described.target.label = label;\n } catch {\n // A failed label must not drop the click.\n }\n return described;\n };\n\n const onPointerDown = (event: PointerEvent) => {\n try {\n if (event.isPrimary === false) return;\n const el = eventElement(event);\n origin = {\n described: el === null ? null : describeClick(el),\n x: event.clientX,\n y: event.clientY,\n };\n } catch {\n origin = null;\n }\n };\n\n const onClick = (event: MouseEvent) => {\n const pointerOrigin = origin;\n origin = null;\n try {\n if (!isTrusted(event)) return;\n const el = eventElement(event);\n if (el === null) return;\n const canRecover =\n pointerOrigin !== null &&\n isRootOrDetached(el) &&\n isNear(pointerOrigin, event);\n const described = canRecover\n ? pointerOrigin.described\n : describeClick(el);\n if (described === null) return;\n const input = event.detail === 0 ? \"keyboard\" : \"pointer\";\n emit(\"click\", {\n target: { ...described.target, input },\n route: getRoute(),\n url: redact.url(location.href),\n ...enrich?.(described.element),\n });\n } catch {\n // Capture must never break the host app's click.\n }\n };\n\n window.addEventListener(\"pointerdown\", onPointerDown, {\n capture: true,\n passive: true,\n });\n window.addEventListener(\"click\", onClick, { capture: true, passive: true });\n return () => {\n window.removeEventListener(\"pointerdown\", onPointerDown, { capture: true });\n window.removeEventListener(\"click\", onClick, { capture: true });\n };\n}\n","import { describeTarget, readControlValue } from \"./clicks\";\nimport { createRedactor } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { ClickTarget, Emit } from \"./types\";\n\nconst TEXT_DEBOUNCE_MS = 300;\nconst TEXT_INPUT_TYPES = new Set([\n \"text\",\n \"search\",\n \"tel\",\n \"url\",\n \"email\",\n \"password\",\n \"number\",\n]);\ntype ControlValue = ReturnType<typeof readControlValue>;\ninterface Edit {\n state: ControlValue;\n payload: {\n eventType: string;\n target: Omit<ClickTarget, \"input\">;\n url: string;\n route: string;\n };\n timer?: ReturnType<typeof setTimeout>;\n}\n\n/** Records settled user edits without interfering with the host app's controls. */\nexport function installInputCapture(params: {\n emit: Emit;\n isTrusted?: (event: Event) => boolean;\n redact?: Redactor;\n}) {\n const {\n emit,\n isTrusted = (event: Event) => event.isTrusted,\n redact = createRedactor(),\n } = params;\n let active = true;\n const pending = new Map<Element, Edit>();\n // A native change can echo a settled input on blur. Consume that one echo;\n // every fresh trusted input is a new edit, even if its redacted value matches.\n const changeEcho = new WeakMap<Element, string>();\n const composing = new WeakMap<Element, boolean>();\n\n const cancel = (element: Element) => {\n clearTimeout(pending.get(element)?.timer);\n pending.delete(element);\n };\n const deliver = (element: Element, edit: Edit) => {\n try {\n // Recheck privacy, but retain the observed value and route even if a host\n // handler reset or removed the control before this trailing delivery.\n if (!active || describeTarget(element, redact) === null) return;\n // A host can reset the live value before making this field sensitive.\n // Drop the old snapshot: mirrored text/attributes may contain its value.\n if (redact.isPassword(element) && edit.state.value !== REDACTED) return;\n if (edit.payload.eventType === \"input\") {\n changeEcho.set(element, JSON.stringify(edit.state));\n }\n edit.payload.url = redact.url(edit.payload.url);\n edit.payload.target.text = redact.url(edit.payload.target.text);\n edit.payload.target.attributes = Object.fromEntries(\n Object.entries(edit.payload.target.attributes).map(([key, value]) => [\n key,\n redact.url(value),\n ]),\n );\n emit(\"input\", edit.payload);\n } catch {\n // Capture must never break an edit or a later action in the host app.\n }\n };\n const flush = () => {\n // Clear before callbacks: an emit can synchronously stop or restart capture.\n const edits = [...pending];\n for (const [element] of edits) cancel(element);\n for (const [element, edit] of edits) deliver(element, edit);\n };\n const observe = (element: Element, eventType: string, settle: boolean) => {\n if (pending.size > 0 && !pending.has(element)) flush();\n if (!active) return;\n const described = describeTarget(element, redact);\n if (described === null) {\n cancel(element);\n return;\n }\n const state = readControlValue(element, redact);\n if (!(\"value\" in state)) return;\n const text =\n element instanceof HTMLTextAreaElement ||\n (element instanceof HTMLInputElement &&\n TEXT_INPUT_TYPES.has(element.type)) ||\n element.matches('[contenteditable]:not([contenteditable=\"false\"])');\n if (!text) flush();\n const signature = JSON.stringify(state);\n const echo =\n eventType === \"change\" &&\n (signature === JSON.stringify(pending.get(element)?.state) ||\n signature === changeEcho.get(element));\n changeEcho.delete(element);\n if (echo) {\n flush();\n changeEcho.delete(element);\n return;\n }\n cancel(element);\n const edit: Edit = {\n state,\n payload: {\n eventType,\n target: { ...described.target, ...state },\n url: redact.url(location.href),\n route: location.pathname,\n },\n };\n if (!text) {\n deliver(element, edit);\n return;\n }\n if (settle) {\n edit.timer = setTimeout(() => {\n cancel(element);\n deliver(element, edit);\n }, TEXT_DEBOUNCE_MS);\n }\n pending.set(element, edit);\n };\n const onEdit = (event: Event) => {\n try {\n if (!active || !isTrusted(event)) return;\n const [element] = event.composedPath();\n if (!(element instanceof Element)) return;\n if (event.type === \"compositionstart\") {\n composing.set(element, true);\n clearTimeout(pending.get(element)?.timer);\n return;\n }\n if (event.type === \"compositionend\") composing.set(element, false);\n // Keep a draft during composition for explicit action/stop flushes. An\n // end event takes precedence over a late input's stale isComposing flag.\n const inComposition =\n composing.get(element) === true ||\n (composing.get(element) !== false &&\n \"isComposing\" in event &&\n event.isComposing === true);\n observe(\n element,\n event.type === \"compositionend\" ? \"input\" : event.type,\n !inComposition,\n );\n if (event.type === \"change\") flush();\n } catch {\n // Capture must never break an edit in the host app.\n }\n };\n const onAction = (event: Event) => {\n if (event.type !== \"pagehide\" && !isTrusted(event)) return;\n if (event.type === \"keydown\") {\n const [element] = event.composedPath();\n // Window capture runs before no-form send handlers can reset the control.\n // IME completion can precede its final keydown, which still reports 229.\n if (\n !(event instanceof KeyboardEvent) ||\n event.key !== \"Enter\" ||\n event.isComposing ||\n event.keyCode === 229 ||\n (element instanceof Element && composing.get(element) === true)\n )\n return;\n }\n flush();\n };\n const editTypes = [\"input\", \"change\", \"compositionstart\", \"compositionend\"];\n const actionTypes = [\"click\", \"submit\", \"focusout\", \"pagehide\", \"keydown\"];\n for (const type of editTypes)\n window.addEventListener(type, onEdit, { capture: true, passive: true });\n for (const type of actionTypes)\n window.addEventListener(type, onAction, { capture: true, passive: true });\n const uninstall = () => {\n active = false;\n for (const element of pending.keys()) cancel(element);\n for (const type of editTypes)\n window.removeEventListener(type, onEdit, true);\n for (const type of actionTypes)\n window.removeEventListener(type, onAction, true);\n };\n return Object.assign(uninstall, { flush });\n}\n","import { createRedactor } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport type { Emit } from \"./types\";\n\ntype NavigationType = \"push\" | \"replace\" | \"traverse\" | \"reload\";\ntype HistoryMethod = \"pushState\" | \"replaceState\";\n\n/** The part of the Navigation API this module uses (not in TypeScript's DOM lib yet). */\ninterface NavigationTarget {\n addEventListener(\n type: \"currententrychange\",\n listener: (event: Event) => void,\n ): void;\n removeEventListener(\n type: \"currententrychange\",\n listener: (event: Event) => void,\n ): void;\n}\n\nfunction isNavigationTarget(value: unknown): value is NavigationTarget {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"addEventListener\" in value &&\n \"removeEventListener\" in value\n );\n}\n\nfunction getNavigation() {\n const navigation: unknown = Reflect.get(window, \"navigation\");\n return isNavigationTarget(navigation) ? navigation : null;\n}\n\nfunction toNavigationType(value: unknown): NavigationType {\n switch (value) {\n case \"replace\":\n case \"traverse\":\n case \"reload\":\n return value;\n default:\n return \"push\";\n }\n}\n\n/**\n * Emits `navigation` when the full URL changes in a single-page app.\n * Uses the Navigation API when it exists. Otherwise patches `pushState` and\n * `replaceState` on the prototype and on the instance, because some routers\n * (Next.js) wrap the instance method.\n */\nexport function installNavigationCapture(params: {\n emit: Emit;\n routes?: string[];\n redact?: Redactor;\n}) {\n const { emit, redact = createRedactor() } = params;\n let current = location.href;\n\n const check = (navigationType: NavigationType) => {\n try {\n const next = location.href;\n if (next === current) return;\n emit(\"navigation\", {\n from: redact.url(current),\n to: redact.url(next),\n navigationType,\n });\n current = next;\n } catch {\n // Capture must never break navigation.\n }\n };\n\n const navigation = getNavigation();\n if (navigation !== null) {\n const onEntryChange = (event: Event) =>\n check(toNavigationType(Reflect.get(event, \"navigationType\")));\n navigation.addEventListener(\"currententrychange\", onEntryChange);\n return () =>\n navigation.removeEventListener(\"currententrychange\", onEntryChange);\n }\n\n return installHistoryFallback(check);\n}\n\nfunction installHistoryFallback(\n check: (navigationType: NavigationType) => void,\n) {\n let active = true;\n const restores: (() => void)[] = [];\n\n const patch = (target: History, method: HistoryMethod) => {\n const original = target[method];\n const wrapper: History[HistoryMethod] = function (this: History, ...args) {\n const result = original.apply(this, args);\n if (active) check(method === \"pushState\" ? \"push\" : \"replace\");\n return result;\n };\n target[method] = wrapper;\n restores.push(() => {\n // Restore only if no one wrapped it after us; otherwise our wrapper stays as a pass-through.\n if (target[method] === wrapper) target[method] = original;\n });\n };\n\n const methods: HistoryMethod[] = [\"pushState\", \"replaceState\"];\n for (const method of methods) {\n patch(History.prototype, method);\n if (Object.prototype.hasOwnProperty.call(history, method))\n patch(history, method);\n }\n\n const onPopState = () => check(\"traverse\");\n window.addEventListener(\"popstate\", onPopState);\n window.addEventListener(\"hashchange\", onPopState);\n\n return () => {\n active = false;\n window.removeEventListener(\"popstate\", onPopState);\n window.removeEventListener(\"hashchange\", onPopState);\n for (const restore of restores) restore();\n };\n}\n","import { TEXT_LIMIT } from \"./redact\";\nimport type { Redactor } from \"./redact\";\n\n/** A bounded observation of a body; never the stream returned to the app. */\nexport interface BodySnapshot {\n status:\n | \"complete\"\n | \"empty\"\n | \"truncated\"\n | \"unavailable\"\n | \"interrupted\"\n | \"timeout\";\n text?: string;\n encoding?: \"utf-8\" | \"base64\";\n truncated?: true;\n reason?: string;\n}\n\nconst BODY_BYTES = 4096;\nconst READ_TIMEOUT_MS = 1000;\nconst encoder = new TextEncoder();\nconst redactionFailed = (): BodySnapshot => ({\n status: \"unavailable\",\n reason: \"redaction-failed\",\n});\ntype RedactText = (text: string) => string | undefined;\n\n/** Redacts up to TEXT_LIMIT bytes of text first, then cuts the result to BODY_BYTES. */\nfunction snapshot(\n bytes: Uint8Array,\n status: BodySnapshot[\"status\"],\n contentType: string,\n redact: RedactText,\n reason?: string,\n): BodySnapshot {\n let text: string | undefined;\n if (!isBinary(contentType))\n try {\n // Declared text decodes lossily so it is always redacted; untyped bytes\n // that are not UTF-8 are treated as binary.\n text = new TextDecoder(\"utf-8\", { fatal: !contentType }).decode(bytes, {\n stream: status !== \"complete\",\n });\n } catch {\n /* Not UTF-8: keep the bytes as base64. */\n }\n if (text !== undefined) {\n text = redact(text);\n if (text === undefined) return redactionFailed();\n // PostgreSQL jsonb rejects NUL in strings; base64 retains the redacted bytes.\n if (text.includes(\"\\0\")) {\n bytes = encoder.encode(text);\n text = undefined;\n }\n }\n const binary = text === undefined;\n // Binary bodies are not inspected; only their first bytes are kept.\n text ??= btoa(String.fromCharCode(...bytes.subarray(0, BODY_BYTES)));\n const result: BodySnapshot = {\n status: status === \"complete\" && bytes.length === 0 ? \"empty\" : status,\n text,\n encoding: binary ? \"base64\" : \"utf-8\",\n ...(reason ? { reason } : {}),\n };\n if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)\n return result;\n result.status = status === \"complete\" ? \"truncated\" : status;\n result.truncated = true;\n // Bound the serialized field too: quotes/control characters expand in JSON.\n let low = 0;\n let high = text.length;\n while (low < high) {\n const middle = Math.ceil((low + high) / 2);\n result.text = text.slice(0, middle);\n if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)\n low = middle;\n else high = middle - 1;\n }\n if (binary)\n low -= low % 4; // Keep complete base64 groups.\n else if (low > 0 && /[\\uD800-\\uDBFF]/.test(text[low - 1]!)) low--;\n result.text = text.slice(0, low);\n return result;\n}\n\nfunction snapshotText(\n value: string,\n redact: RedactText,\n status: BodySnapshot[\"status\"] = \"complete\",\n reason?: string,\n): BodySnapshot {\n return snapshot(\n encoder.encode(value.slice(0, TEXT_LIMIT)),\n value.length > TEXT_LIMIT && status === \"complete\" ? \"truncated\" : status,\n \"text/plain\",\n redact,\n reason,\n );\n}\n\nfunction isBinary(contentType: string) {\n return (\n contentType !== \"\" &&\n !/(^text\\/|json|xml|javascript|graphql|x-www-form-urlencoded|multipart\\/form-data)/i.test(\n contentType,\n )\n );\n}\n\n/** Cancel only cloned/synthetic bodies. Cancelling a tee may wait for the app's branch. */\nexport function createBodyCapture(redact: Redactor) {\n const cancellations = new Set<() => void>();\n let active = true;\n const read = (\n stream: ReadableStream<Uint8Array> | null,\n contentType = \"\",\n ): Promise<BodySnapshot> => {\n if (!stream) return Promise.resolve({ status: \"empty\" });\n if (!active)\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"capture-stopped\",\n });\n let reader: ReadableStreamDefaultReader<Uint8Array>;\n try {\n reader = stream.getReader();\n } catch {\n return Promise.resolve({ status: \"unavailable\", reason: \"body-locked\" });\n }\n return new Promise((resolve) => {\n const bytes = new Uint8Array(TEXT_LIMIT);\n let length = 0;\n let finished = false;\n const finish = (status: BodySnapshot[\"status\"], reason?: string) => {\n if (finished) return;\n finished = true;\n clearTimeout(timer);\n cancellations.delete(cancel);\n resolve(\n snapshot(\n bytes.subarray(0, length),\n status,\n contentType,\n redact.body,\n reason,\n ),\n );\n // Do not await cancellation: a cloned fetch stream can share a live SSE source.\n void reader.cancel().catch(() => {});\n };\n const cancel = () => finish(\"interrupted\", \"capture-stopped\");\n const timer = setTimeout(\n () => finish(\"timeout\", \"body-read-timeout\"),\n READ_TIMEOUT_MS,\n );\n cancellations.add(cancel);\n void (async () => {\n try {\n for (;;) {\n const chunk = await reader.read();\n if (finished) break;\n if (chunk.done) {\n finish(\"complete\");\n break;\n }\n const kept = chunk.value.subarray(0, TEXT_LIMIT - length);\n bytes.set(kept, length);\n length += kept.length;\n if (length === TEXT_LIMIT) {\n finish(\"truncated\");\n break;\n }\n }\n } catch {\n finish(\"interrupted\", \"body-read-failed\");\n } finally {\n try {\n reader.releaseLock();\n } catch {\n /* A cancelled read may still be settling. */\n }\n }\n })();\n });\n };\n const body = (\n value: BodyInit | null | undefined,\n contentType = \"\",\n ): Promise<BodySnapshot> => {\n try {\n if (value == null) return Promise.resolve({ status: \"empty\" });\n if (value instanceof URLSearchParams) value = value.toString();\n if (typeof value === \"string\")\n return Promise.resolve(snapshotText(value, redact.body));\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n // A caller-owned init.body cannot be teed without locking/replacing it.\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"caller-owned-stream\",\n });\n }\n if (value instanceof FormData) {\n const form = new FormData();\n value.forEach((field, key) =>\n form.append(\n key,\n typeof field === \"string\" ? redact.field(key, field) : field,\n ),\n );\n value = form;\n }\n const response = new Response(value);\n return read(\n response.body,\n contentType || response.headers.get(\"content-type\") || \"\",\n );\n } catch {\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"body-not-readable\",\n });\n }\n };\n return {\n read,\n body,\n text: (value: string, status?: BodySnapshot[\"status\"], reason?: string) =>\n snapshotText(value, redact.body, status, reason),\n stop() {\n active = false;\n for (const cancel of cancellations) cancel();\n },\n };\n}\n","import { createBodyCapture } from \"./network-body\";\nimport type { BodySnapshot } from \"./network-body\";\nimport { createRedactor, isCredentialKey } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { Emit } from \"./types\";\n\ntype Outcome = \"ok\" | \"error\" | \"aborted\";\ninterface RequestParts {\n url: string;\n method: string;\n headers: Record<string, string>;\n}\ninterface ResponseParts {\n url?: string;\n headers: Record<string, string>;\n body: Promise<BodySnapshot>;\n}\nconst emptyBody = () => Promise.resolve<BodySnapshot>({ status: \"empty\" });\nconst unavailableBody = (reason: string) =>\n Promise.resolve<BodySnapshot>({ status: \"unavailable\", reason });\nconst toAbsolute = (url: string) => new URL(url, location.href).href;\n// Credentials carry no product signal and must never leave the browser, even\n// with full capture. The header name stays, so the event still shows it was sent.\n// Names the credential-key matcher misses; it covers `authorization`, `cookie`,\n// `x-api-key`, `x-csrf-token`, and similar.\nconst CREDENTIAL_HEADERS = new Set([\n \"x-amz-security-token\",\n \"private-token\",\n \"x-token\",\n]);\nconst headerValue = (name: string, value: string) =>\n CREDENTIAL_HEADERS.has(name.toLowerCase()) || isCredentialKey(name)\n ? REDACTED\n : value;\nconst headerValues = (headers: Headers) =>\n Object.fromEntries(\n Array.from(headers.entries(), ([name, value]) => [\n name,\n headerValue(name, value),\n ]),\n );\n\n// Checks the name, not instanceof: errors can come from another realm.\nfunction isAbortError(error: unknown) {\n return (\n typeof error === \"object\" &&\n error !== null &&\n \"name\" in error &&\n error.name === \"AbortError\"\n );\n}\n\n/** Records browser-visible request/response data without consuming the app's bodies. */\nexport function installNetworkCapture(params: {\n emit: Emit;\n routes?: string[];\n ignoreUrls: (string | RegExp)[];\n redact?: Redactor;\n}) {\n let active = true;\n const { redact = createRedactor() } = params;\n const bodies = createBodyCapture(redact);\n const ignoreUrls = params.ignoreUrls.map((pattern) =>\n typeof pattern === \"string\"\n ? toAbsolute(pattern)\n : new RegExp(pattern.source, pattern.flags),\n );\n const isIgnored = (url: string) =>\n ignoreUrls.some((pattern) => {\n if (typeof pattern === \"string\") return url.startsWith(pattern);\n pattern.lastIndex = 0;\n return pattern.test(url);\n });\n\n const record: RecordFn = (\n transport,\n request,\n requestBody,\n startedAt,\n status,\n outcome,\n response,\n ) => {\n const durationMs = Math.round(performance.now() - startedAt);\n const completedAt = Date.now();\n void Promise.all([\n requestBody,\n response?.body ?? unavailableBody(\"no-response\"),\n ])\n .then(([sent, received]) => {\n if (!active) return;\n const href = redact.url(request.url);\n const url = new URL(href);\n params.emit(\"network\", {\n transport,\n method: request.method,\n url: href,\n origin: url.origin,\n route: url.pathname,\n status,\n durationMs,\n completedAt,\n outcome,\n request: { headers: request.headers, body: sent },\n response: {\n ...(response?.url ? { url: redact.url(response.url) } : {}),\n headers: response?.headers ?? {},\n body: received,\n },\n });\n })\n .catch(() => {\n /* Capture must never affect the host request. */\n });\n };\n\n const originalFetch = globalThis.fetch;\n const wrappedFetch: typeof fetch = (input, init) => {\n if (!active) return originalFetch.call(globalThis, input, init);\n let request: RequestParts;\n let ignored: boolean;\n try {\n const source =\n typeof Request !== \"undefined\" && input instanceof Request\n ? input\n : undefined;\n request = {\n url: toAbsolute(source ? source.url : String(input)),\n method: (init?.method ?? source?.method ?? \"GET\").toUpperCase(),\n headers: headerValues(new Headers(init?.headers ?? source?.headers)),\n };\n ignored = isIgnored(request.url);\n } catch {\n return originalFetch.call(globalThis, input, init);\n }\n if (ignored) return originalFetch.call(globalThis, input, init);\n const startedAt = performance.now();\n let requestBody: Promise<BodySnapshot>;\n try {\n requestBody =\n init?.body != null\n ? bodies.body(init.body, request.headers[\"content-type\"])\n : typeof Request !== \"undefined\" && input instanceof Request\n ? bodies.read(input.clone().body, request.headers[\"content-type\"])\n : emptyBody();\n } catch {\n requestBody = unavailableBody(\"request-clone-failed\");\n }\n const failed = (error: unknown) =>\n record(\n \"fetch\",\n request,\n requestBody,\n startedAt,\n null,\n isAbortError(error) ? \"aborted\" : \"error\",\n );\n let promise: Promise<Response>;\n try {\n promise = originalFetch.call(globalThis, input, init);\n } catch (error) {\n failed(error);\n throw error;\n }\n void promise\n .then((response) => {\n if (!active) return;\n let responseBody: Promise<BodySnapshot>;\n try {\n responseBody =\n response.type === \"opaque\" || response.type === \"opaqueredirect\"\n ? unavailableBody(\"opaque-response\")\n : bodies.read(\n response.clone().body,\n response.headers.get(\"content-type\") ?? \"\",\n );\n } catch {\n responseBody = unavailableBody(\"response-clone-failed\");\n }\n record(\n \"fetch\",\n request,\n requestBody,\n startedAt,\n response.status,\n \"ok\",\n {\n url: response.url,\n headers: headerValues(response.headers),\n body: responseBody,\n },\n );\n }, failed)\n .catch(() => {});\n // Return the identical native Promise, Response, and rejection to the caller.\n return promise;\n };\n if (typeof originalFetch === \"function\") globalThis.fetch = wrappedFetch;\n const uninstallXhr = patchXhr(isIgnored, record, bodies);\n return () => {\n active = false;\n bodies.stop();\n if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;\n uninstallXhr();\n };\n}\n\ntype RecordFn = (\n transport: \"fetch\" | \"xhr\",\n request: RequestParts,\n requestBody: Promise<BodySnapshot>,\n startedAt: number,\n status: number | null,\n outcome: Outcome,\n response?: ResponseParts,\n) => void;\n\nfunction patchXhr(\n isIgnored: (url: string) => boolean,\n record: RecordFn,\n bodies: ReturnType<typeof createBodyCapture>,\n) {\n if (typeof XMLHttpRequest !== \"function\") return () => {};\n const proto = XMLHttpRequest.prototype;\n const originalOpen = proto.open;\n const originalSend = proto.send;\n const originalSetHeader = proto.setRequestHeader;\n const requests = new WeakMap<XMLHttpRequest, RequestParts>();\n const listeners = new Set<() => void>();\n // open() on an in-flight XHR ends it without `loadend`; its listeners must not\n // report the next request with the old request's details.\n const inFlight = new WeakMap<XMLHttpRequest, () => void>();\n let active = true;\n const wrappedOpen = function (\n this: XMLHttpRequest,\n method: string,\n url: string | URL,\n ...rest: unknown[]\n ) {\n inFlight.get(this)?.();\n const result = Reflect.apply(originalOpen, this, [method, url, ...rest]);\n try {\n requests.set(this, {\n url: toAbsolute(String(url)),\n method: method.toUpperCase(),\n headers: {},\n });\n } catch {\n requests.delete(this);\n }\n return result;\n };\n const wrappedSetHeader = function (\n this: XMLHttpRequest,\n name: string,\n value: string,\n ) {\n const result = originalSetHeader.call(this, name, value);\n const request = requests.get(this);\n if (active && request) {\n try {\n const headers = new Headers(request.headers);\n headers.append(name, value);\n request.headers = headerValues(headers);\n } catch {\n /* Preserve successful native calls even if observation fails. */\n }\n }\n return result;\n };\n const wrappedSend = function (\n this: XMLHttpRequest,\n body?: Document | XMLHttpRequestBodyInit | null,\n ) {\n const request = requests.get(this);\n if (!active || !request || isIgnored(request.url))\n return originalSend.call(this, body);\n const startedAt = performance.now();\n let requestBody: Promise<BodySnapshot>;\n try {\n requestBody =\n typeof Document !== \"undefined\" && body instanceof Document\n ? Promise.resolve(\n bodies.text(new XMLSerializer().serializeToString(body)),\n )\n : bodies.body(\n body as BodyInit | null | undefined,\n request.headers[\"content-type\"],\n );\n } catch {\n requestBody = unavailableBody(\"request-body-unavailable\");\n }\n let aborted = false;\n const onAbort = () => {\n aborted = true;\n };\n const cleanup = () => {\n this.removeEventListener(\"abort\", onAbort);\n this.removeEventListener(\"loadend\", onEnd);\n listeners.delete(cleanup);\n if (inFlight.get(this) === cleanup) inFlight.delete(this);\n };\n const onEnd = () => {\n cleanup();\n if (!active) return;\n const outcome = aborted ? \"aborted\" : this.status === 0 ? \"error\" : \"ok\";\n const headers: Record<string, string> = {};\n try {\n for (const line of this.getAllResponseHeaders().split(/\\r?\\n/)) {\n const split = line.indexOf(\":\");\n if (split > 0) {\n const name = line.slice(0, split).toLowerCase();\n headers[name] = headerValue(name, line.slice(split + 1).trim());\n }\n }\n } catch {\n /* Cross-origin or failed requests can hide response headers. */\n }\n let responseBody: Promise<BodySnapshot>;\n try {\n if (this.responseType === \"\" || this.responseType === \"text\") {\n responseBody = Promise.resolve(\n bodies.text(\n this.responseText,\n outcome === \"ok\" ? \"complete\" : \"interrupted\",\n outcome === \"ok\" ? undefined : `request-${outcome}`,\n ),\n );\n } else if (this.responseType === \"json\")\n responseBody = Promise.resolve(\n bodies.text(JSON.stringify(this.response)),\n );\n else if (this.responseType === \"document\")\n responseBody = this.responseXML\n ? Promise.resolve(\n bodies.text(\n new XMLSerializer().serializeToString(this.responseXML),\n ),\n )\n : unavailableBody(\"empty-document\");\n else\n responseBody = bodies.body(\n this.response as BodyInit,\n headers[\"content-type\"] ?? \"application/octet-stream\",\n );\n } catch {\n responseBody = unavailableBody(\"response-body-unavailable\");\n }\n record(\n \"xhr\",\n request,\n requestBody,\n startedAt,\n outcome === \"ok\" ? this.status : null,\n outcome,\n {\n url: this.responseURL,\n headers,\n body: responseBody,\n },\n );\n };\n this.addEventListener(\"abort\", onAbort);\n this.addEventListener(\"loadend\", onEnd);\n listeners.add(cleanup);\n inFlight.set(this, cleanup);\n try {\n return originalSend.call(this, body);\n } catch (error) {\n cleanup();\n record(\n \"xhr\",\n request,\n requestBody,\n startedAt,\n null,\n isAbortError(error) ? \"aborted\" : \"error\",\n );\n throw error;\n }\n };\n proto.open = wrappedOpen;\n proto.send = wrappedSend;\n proto.setRequestHeader = wrappedSetHeader;\n return () => {\n active = false;\n for (const cleanup of listeners) cleanup();\n if (proto.open === wrappedOpen) proto.open = originalOpen;\n if (proto.send === wrappedSend) proto.send = originalSend;\n if (proto.setRequestHeader === wrappedSetHeader)\n proto.setRequestHeader = originalSetHeader;\n };\n}\n","/** Returns the original pathname. Capture no longer masks or categorizes routes. */\nexport function toRoute(pathname: string, _templates: string[] = []) {\n return pathname;\n}\n\n/** Resolves a URL without removing path segments, query parameters, or fragments. */\nexport function toOriginAndRoute(\n url: string,\n _templates: string[] = [],\n base: string = globalThis.location?.href ?? \"http://localhost/\",\n) {\n const parsed = new URL(url, base);\n return { url: parsed.href, origin: parsed.origin, route: parsed.pathname };\n}\n","import { installClickCapture } from \"./clicks\";\nimport { installInputCapture } from \"./inputs\";\nimport { installNavigationCapture } from \"./navigation\";\nimport { installNetworkCapture } from \"./network\";\nimport { createRedactor } from \"./redact\";\nimport { toRoute } from \"./routes\";\nimport { BUILT_IN_EVENT_NAMES } from \"./types\";\nimport type {\n BuiltInEventName,\n Collector,\n CollectorOptions,\n LearningBatch,\n LearningEvent,\n SinkSendOptions,\n StartOptions,\n} from \"./types\";\n\n// ponytail: flushing at 50 also bounds the queue at 50; add a separate cap only if sends become async-buffered.\nconst FLUSH_SIZE = 50;\nconst FLUSH_INTERVAL_MS = 2000;\nconst BUILT_IN = new Set<string>(BUILT_IN_EVENT_NAMES);\n\ninterface Session {\n trajectoryId: string;\n learningContainerIds: string[] | undefined;\n queue: LearningEvent[];\n dropped: number;\n uninstalls: (() => void)[];\n timer: ReturnType<typeof setInterval> | undefined;\n flushInputs?: () => void;\n}\n\n/**\n * Creates a collector. Nothing is installed until `start()`: no listeners,\n * no patches, no timers, no buffer. `stop()` removes all of them and sends\n * queued events once, without retries.\n *\n * @example\n * const collector = createCollector({ sink: httpSink(\"/api/learning-events\") });\n * collector.start({ trajectoryId: crypto.randomUUID() });\n */\nexport function createCollector(options: CollectorOptions) {\n const { sink, routes, beforeSend, enrich } = options;\n const capture = {\n clicks: true,\n navigation: true,\n network: true,\n inputs: true,\n ...options.capture,\n };\n let session: Session | null = null;\n let seq = 0;\n\n const getRoute = () => toRoute(location.pathname, routes);\n\n const flush = (sendOptions?: SinkSendOptions) => {\n const current = session;\n if (current === null) return;\n if (current.queue.length === 0 && current.dropped === 0) return;\n const events = current.queue.splice(0);\n const dropped = current.dropped;\n current.dropped = 0;\n const batch: LearningBatch = {\n trajectoryId: current.trajectoryId,\n learningContainerIds: current.learningContainerIds,\n events,\n dropped,\n };\n const onFailure = () => {\n // No retry: report the loss in the next batch instead.\n if (session === current) current.dropped += events.length + dropped;\n };\n try {\n Promise.resolve(sink(batch, sendOptions)).catch(onFailure);\n } catch {\n onFailure();\n }\n };\n\n const record = (name: string, value: Record<string, unknown>) => {\n const current = session;\n if (current === null) return;\n if (name !== \"input\" && name !== \"network\") current.flushInputs?.();\n if (session !== current) return;\n try {\n seq += 1;\n const event: LearningEvent = {\n type: \"CUSTOM\",\n name,\n value: { ...value, seq },\n timestamp: Date.now(),\n };\n const kept = beforeSend === undefined ? event : beforeSend(event);\n if (kept === null || session !== current) return;\n current.queue.push(kept);\n if (current.queue.length >= FLUSH_SIZE) flush();\n } catch {\n // Capture must never break the host app.\n }\n };\n\n const start = ({ trajectoryId, learningContainerIds }: StartOptions) => {\n if (session !== null) {\n if (session.trajectoryId !== trajectoryId) {\n console.warn(\n `[@copilotkit/learning] Trajectory \"${session.trajectoryId}\" is active. Stop it before you start \"${trajectoryId}\".`,\n );\n }\n return;\n }\n if (\n typeof window === \"undefined\" ||\n typeof window.addEventListener !== \"function\"\n )\n return;\n const current: Session = {\n trajectoryId,\n learningContainerIds,\n queue: [],\n dropped: 0,\n uninstalls: [],\n timer: undefined,\n };\n session = current;\n const recordCurrent: typeof record = (name, value) => {\n if (session === current) record(name, value);\n };\n // One per session: fields seen and values typed are forgotten on stop().\n const redact = createRedactor();\n current.uninstalls.push(redact.watch());\n const ignoreUrls = [\n ...(options.ignoreUrls ?? []),\n ...(sink.url === undefined ? [] : [sink.url]),\n ];\n if (capture.clicks)\n current.uninstalls.push(\n installClickCapture({ emit: recordCurrent, getRoute, enrich, redact }),\n );\n if (capture.navigation)\n current.uninstalls.push(\n installNavigationCapture({ emit: recordCurrent, routes, redact }),\n );\n if (capture.network)\n current.uninstalls.push(\n installNetworkCapture({\n emit: recordCurrent,\n routes,\n ignoreUrls,\n redact,\n }),\n );\n if (capture.inputs) {\n const inputs = installInputCapture({ emit: recordCurrent, redact });\n current.flushInputs = inputs.flush;\n current.uninstalls.push(inputs);\n }\n const onPageHide = () => flush({ beacon: true });\n window.addEventListener(\"pagehide\", onPageHide);\n current.uninstalls.push(() =>\n window.removeEventListener(\"pagehide\", onPageHide),\n );\n current.timer = setInterval(() => flush(), FLUSH_INTERVAL_MS);\n record(\"page\", {\n route: getRoute(),\n url: redact.url(location.href),\n title: document.title,\n referrer: redact.url(document.referrer),\n });\n };\n\n const stop = () => {\n const current = session;\n if (current === null) return;\n current.flushInputs?.();\n if (session !== current) return;\n clearInterval(current.timer);\n for (const uninstall of current.uninstalls) uninstall();\n flush();\n session = null;\n };\n\n const emit = (name: string, value: Record<string, unknown>) => {\n if (BUILT_IN.has(name)) {\n console.warn(\n `[@copilotkit/learning] \"${name}\" is a built-in event name. Use your own name, such as \"deal.approved\".`,\n );\n return;\n }\n record(name, value);\n };\n\n const ɵemit = (name: BuiltInEventName, value: Record<string, unknown>) =>\n record(name, value);\n\n const collector: Collector = {\n start,\n stop,\n emit,\n ɵemit,\n get trajectoryId() {\n return session?.trajectoryId ?? null;\n },\n };\n return collector;\n}\n","import type { LearningBatch, LearningSink, SinkSendOptions } from \"./types\";\n\n/**\n * A sink that POSTs each batch as JSON to `url`.\n *\n * Uses `fetch` with `keepalive`. When the page is going away it uses\n * `navigator.sendBeacon`, which cannot send custom headers. There is no retry:\n * a failed send rejects, and the collector counts those events as dropped.\n *\n * @example createCollector({ sink: httpSink(\"/api/learning-events\") })\n */\nexport function httpSink(\n url: string,\n init: { headers?: Record<string, string> } = {},\n) {\n const send = async (batch: LearningBatch, options: SinkSendOptions = {}) => {\n const body = JSON.stringify(batch);\n const canBeacon =\n options.beacon === true && typeof navigator.sendBeacon === \"function\";\n if (canBeacon) {\n const queued = navigator.sendBeacon(\n url,\n new Blob([body], { type: \"application/json\" }),\n );\n if (!queued) throw new Error(\"sendBeacon refused the batch\");\n return;\n }\n const response = await fetch(url, {\n method: \"POST\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", ...init.headers },\n body,\n });\n if (!response.ok) throw new Error(`Sink responded with ${response.status}`);\n };\n const sink: LearningSink = Object.assign(send, { url });\n return sink;\n}\n","import { installClickCapture } from \"./clicks\";\nimport { installNavigationCapture } from \"./navigation\";\nimport { installInputCapture } from \"./inputs\";\nimport { installNetworkCapture } from \"./network\";\nimport { createRedactor } from \"./redact\";\nimport { toRoute } from \"./routes\";\nimport { BUILT_IN_EVENT_NAMES } from \"./types\";\nimport type {\n JsonValue,\n TrajectoryCaptureOptions,\n TrajectoryCollector,\n TrajectoryEvent,\n} from \"./trajectory-types\";\n\nexport const MAX_TRAJECTORY_EVENT_BYTES = 16 * 1024;\nconst RESERVED_NAMES = new Set<string>(BUILT_IN_EVENT_NAMES);\n\nfunction isJson(\n value: unknown,\n ancestors = new Set<object>(),\n): value is JsonValue {\n if (typeof value === \"string\") return !value.includes(\"\\0\");\n if (value === null || typeof value === \"boolean\") return true;\n if (typeof value === \"number\") return Number.isFinite(value);\n if (typeof value !== \"object\" || ancestors.has(value)) return false;\n if (\n !Array.isArray(value) &&\n Object.getPrototypeOf(value) !== Object.prototype &&\n Object.getPrototypeOf(value) !== null\n )\n return false;\n ancestors.add(value);\n if (\n !Array.isArray(value) &&\n Object.keys(value).some((key) => key.includes(\"\\0\"))\n )\n return false;\n const values = Array.isArray(value) ? value : Object.values(value);\n for (const item of values) {\n if (!isJson(item, ancestors)) return false;\n }\n ancestors.delete(value);\n return true;\n}\n\n/**\n * Captures outside-chat activity as plain AG-UI events, without a send queue.\n * Core starts this collector only after authentication and channel join.\n */\nexport function createTrajectoryCollector(\n options: TrajectoryCaptureOptions & {\n send: (event: TrajectoryEvent) => void;\n },\n): TrajectoryCollector {\n let active = false;\n let generation = 0;\n let uninstalls: (() => void)[] = [];\n let flushInputs: (() => void) | undefined;\n\n const report = (code: string, message: string) => {\n try {\n options.onError?.({ code, message });\n } catch {\n // Diagnostic callbacks must not interrupt the host app.\n }\n };\n\n const getRoute = () => toRoute(location.pathname);\n\n const record = (name: string, value: JsonValue) => {\n if (!active) return;\n const current = generation;\n if (name !== \"input\" && name !== \"network\") flushInputs?.();\n if (!active || generation !== current) return;\n let payload: TrajectoryEvent;\n try {\n const event: TrajectoryEvent = {\n type: \"CUSTOM\",\n name,\n timestamp: Date.now(),\n value,\n };\n const kept =\n options.beforeSend === undefined ? event : options.beforeSend(event);\n if (kept === null || !active || generation !== current) return;\n if (\n kept.type !== \"CUSTOM\" ||\n typeof kept.name !== \"string\" ||\n !kept.name.trim() ||\n kept.name.length > 200 ||\n kept.name.includes(\"\\0\") ||\n !Number.isFinite(kept.timestamp) ||\n kept.timestamp <= 0 ||\n kept.timestamp >= 253_402_300_800_000 ||\n !isJson(kept.value)\n ) {\n report(\n \"INVALID_EVENT\",\n \"The event must contain a name, timestamp, and JSON value.\",\n );\n return;\n }\n // Keep only the contract envelope and snapshot the value before delivery.\n const serialized = JSON.stringify({\n type: kept.type,\n name: kept.name,\n timestamp: kept.timestamp,\n value: kept.value,\n });\n if (\n new TextEncoder().encode(serialized).byteLength >\n MAX_TRAJECTORY_EVENT_BYTES\n ) {\n report(\"EVENT_TOO_LARGE\", \"The serialized event exceeds 16 KiB.\");\n return;\n }\n payload = JSON.parse(serialized) as TrajectoryEvent;\n } catch {\n report(\"INVALID_EVENT\", \"The event could not be serialized as JSON.\");\n return;\n }\n try {\n options.send(payload);\n } catch {\n report(\n \"PERSISTENCE_UNKNOWN\",\n \"Event delivery failed. The event will not be retried.\",\n );\n }\n };\n\n const capture = (name: string, value: Record<string, unknown>) => {\n // Shared capture modules produce JSON; record validates and snapshots before sending.\n record(name, value as JsonValue);\n };\n\n const stop = () => {\n const current = generation;\n const flush = flushInputs;\n flushInputs = undefined;\n flush?.();\n if (generation !== current) return;\n generation++;\n active = false;\n const installed = uninstalls;\n uninstalls = [];\n for (const uninstall of installed) uninstall();\n };\n\n return {\n start() {\n if (active || typeof window === \"undefined\") return;\n active = true;\n generation++;\n // Native callers can emit developer events without browser observers.\n if (typeof window.addEventListener !== \"function\") return;\n // One per session: fields seen and values typed are forgotten on stop().\n const redact = createRedactor();\n try {\n uninstalls.push(redact.watch());\n if (options.capture?.clicks !== false) {\n uninstalls.push(\n installClickCapture({ emit: capture, getRoute, redact }),\n );\n }\n if (options.capture?.navigation !== false) {\n uninstalls.push(installNavigationCapture({ emit: capture, redact }));\n }\n if (options.capture?.inputs !== false) {\n const inputs = installInputCapture({ emit: capture, redact });\n flushInputs = inputs.flush;\n uninstalls.push(inputs);\n }\n if (options.capture?.network !== false) {\n uninstalls.push(\n installNetworkCapture({\n emit: capture,\n ignoreUrls: options.ignoreUrls ?? [],\n redact,\n }),\n );\n }\n record(\"page\", {\n route: getRoute(),\n url: redact.url(location.href),\n title: document.title,\n referrer: redact.url(document.referrer),\n });\n } catch {\n stop();\n report(\"CAPTURE_FAILED\", \"Browser capture could not start.\");\n }\n },\n stop,\n emit(name, value) {\n if (!active) return;\n if (RESERVED_NAMES.has(name)) {\n report(\n \"INVALID_EVENT\",\n \"Use a developer event name, not a reserved capture name.\",\n );\n return;\n }\n record(name, value);\n },\n };\n}\n"],"mappings":";;;;AACA,MAAa,uBAAuB;CAClC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;;AAKD,MAAa,WAAW;;;;ACXxB,MAAM,aACJ;AAIF,MAAM,kBACJ;AAEF,MAAM,WACJ;AACF,MAAM,wBAAwB;AAE9B,MAAM,QAAQ;AAEd,MAAM,SAAS;AAKf,MAAM,OACJ;AAGF,MAAM,WAAW;;AAEjB,MAAa,aAAa,KAAK;AAC/B,MAAM,oBAAoB;AAE1B,MAAa,mBAAmB,QAAgB;CAC9C,MAAM,QAAQ,IAAI,IACf,QAAQ,oBAAoB,GAAG,QAC9B,OAAO,aAAa,SAAS,KAAK,GAAG,CAAC,CACvC,CACA,QAAQ,uBAAuB,MAAM,CACrC,aAAa,CACb,MAAM,YAAY,CAClB,OAAO,QAAQ,CACf,KAAK,IAAI,CAAC;AACb,QACE,CAAC,SAAS,KAAK,MAAM,KACpB,WAAW,KAAK,MAAM,QAAQ,MAAM,GAAG,CAAC,IAAI,gBAAgB,KAAK,MAAM;;AAI5E,MAAM,UAAU,SAAiB;AAC/B,KAAI;AACF,SAAO,KAAK,MAAM,IAAI,KAAK,GAAG;SACxB;AACN,SAAO;;;;AAKX,MAAM,cAAc,MAAc,UAAkB;CAClD,IAAI,QAAQ;AACZ,MAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,KAAK;EACxC,MAAM,OAAO,KAAK;AAClB,MAAI,SAAS,MAAK;AAChB,UAAO,YAAY;AACnB,UAAO,KAAK,KAAK;AACjB,OAAI,OAAO,YAAY;aACd,SAAS,OAAO,SAAS,KAAK;AACvC;AACA;aACS,SAAS,OAAO,SAAS,KAClC;OAAI,EAAE,QAAQ,EAAG,QAAO;SACnB;AACL,OAAI,CAAC,UAAU,SAAS,OAAO,KAAK,KAAK,KAAK,EAAG,QAAO;AACxD;;AAEF,MAAI,CAAC,MAAO,QAAO,IAAI;;AAEzB,QAAO,KAAK;;;;;;;AAQd,MAAM,cAAc,MAAc,QAAQ,MAAc;CACtD,MAAM,QAAQ,IAAI,OAAO,MAAM;CAC/B,IAAI,UAAU;CACd,MAAM,SAAS,SAAiB;EAC9B,MAAM,UAAU,KAAK,QAAQ,UAAU,KAAK;AAC5C,SAAO,QAAQ,QACb,OACC,MAAM,KAAa,WAAmB,OAAe,WAAmB;AACvE,OAAI,CAAC,gBAAgB,IAAI,CAAE,QAAO;AAElC,OAAI,SAAS,KAAK,WAAW,QAAQ,QAAQ;AAC3C,cAAU;AACV,QAAI,CAAC,MAAO,QAAO;;AAErB,UAAO,MAAM,YAAY;IAE5B;;CAEH,IAAI,MAAM;CACV,IAAI,OAAO;CACX,IAAI;AACJ,QAAQ,QAAQ,MAAM,KAAK,KAAK,EAAG;EACjC,MAAM,CAAC,KAAK,MAAM,QAAQ,SAAS;AACnC,YAAU;AACV,SAAO,MAAM,KAAK,MAAM,MAAM,MAAM,MAAM,CAAC;EAC3C,IAAI,MAAM,MAAM;EAChB,IAAI,cAAc;EAClB,MAAM,QAAQ,KAAM,SAAS,KAAK,GAAG,OAAO,KAAM,GAAG;AACrD,MAAI,OACF;OAAI,gBAAgB,MAAM,EAAE;IAC1B,MAAM,WAAW,WAAW,MAAM,IAAI;AAEtC,QAAI,CAAC,uBAAuB,KAAK,KAAK,MAAM,KAAK,SAAS,CAAC,EAAE;AAC3D,oBAAe,IAAI,SAAS;AAC5B,WAAM;;;aAGD,QAAS,eAAc,IAAI,SAAS;WACtC,QAAQ,KAAK,QAAQ,KAAK,MAAM,EAAE;GACzC,MAAM,WAAW,WAAW,OAAO,QAAQ,EAAE;AAC7C,OAAI,aAAa,MACf,eAAc,KAAK,UAAU,SAAS,CAAC,MACrC,GACA,SAAS,SAAY,GACtB;;AAEL,SAAO;AACP,SAAO,MAAM,YAAY;;AAE3B,QAAO,MAAM,MAAM,KAAK,MAAM,KAAK,CAAC;;;AAItC,SAAgB,iBAAiB;CAC/B,MAAM,uBAAO,IAAI,SAAkB;CAEnC,MAAM,6BAAa,IAAI,KAAsB;CAC7C,IAAI;CAEJ,MAAM,WAAW,YAA8B;AAC7C,OAAK,MAAM,UAAU,QACnB,KAAI,OAAO,UAAU,aAAa,KAAK,WACrC,MAAK,IAAI,OAAO,OAAkB;;;;;;CAOxC,MAAM,SAAS,MAAc,cAA0C;AACrE,OAAK,MAAM,SAAS,CAAC,GAAG,WAAW,QAAQ,CAAC,CAAC,MAC1C,GAAG,MAAM,EAAE,SAAS,EAAE,OACxB,CACC,MAAK,MAAM,QAAQ;GACjB;GACA,KAAK,UAAU,MAAM,CAAC,MAAM,GAAG,GAAG;GAClC,mBAAmB,MAAM;GACzB,IAAI,gBAAgB,EAAE,GAAG,OAAO,CAAC,CAAC,UAAU,CAAC,MAAM,EAAE;GACrD,GAAG,CAAC,YAAY,MAAM,IAAI,GAAG,CAAC,QAAQ,WAAW,OAAO,UAAU,EAAE;GACrE,CACC,QAAO,QAAQ,KAAK,KAAK,GAErB,KAAK,QAAQ,OAAO,WAAW,KAAK,UAAU,IAAI,EAAE,SAAS,GAC7D,KAAK,MAAM,KAAK,CAAC,KAAK,SAAS;AACvC,SAAO;;;CAIT,MAAM,cAAc,YAAqB;AACvC,MAAI,SAAU,SAAQ,SAAS,aAAa,CAAC;AAC7C,MACE,EAAE,mBAAmB,qBACrB,EACE,QAAQ,SAAS,cACjB,KAAK,IAAI,QAAQ,IACjB,gBAAgB,QAAQ,KAAK,IAC7B,sBAAsB,KAAK,QAAQ,aAAa,eAAe,IAAI,GAAG,EAGxE,QAAO;AACT,OAAK,IAAI,QAAQ;AACjB,MAAI,QAAQ,MAAM,UAAU,GAAG;AAC7B,cAAW,OAAO,QAAQ;AAC1B,cAAW,IAAI,SAAS,QAAQ,MAAM;AACtC,OAAI,WAAW,OAAO,kBACpB,YAAW,OAAO,WAAW,MAAM,CAAC,MAAM,CAAC,MAAO;;AAEtD,SAAO;;AAGT,QAAO;EACL;EACA;EAKA,QAAQ;AAGN,OAAI;AACF,SAAK,MAAM,SAAS,SAAS,iBAAiB,uBAAuB,CACnE,MAAK,IAAI,MAAM;WACX;GAGR,IAAI;AACJ,OAAI;AACF,cAAU,IAAI,iBAAiB,QAAQ;AACvC,YAAQ,QAAQ,UAAU;KACxB,SAAS;KACT,iBAAiB,CAAC,OAAO;KACzB,mBAAmB;KACpB,CAAC;AACF,eAAW;WACL;AACN,cAAU;;GAEZ,MAAM,YAAY,UAAiB;AACjC,QAAI;KACF,MAAM,CAAC,UAAU,MAAM,cAAc;AACrC,SAAI,kBAAkB,QAAS,YAAW,OAAO;YAC3C;;AAIV,QAAK,MAAM,QAAQ,CAAC,SAAS,SAAS,CACpC,QAAO,iBAAiB,MAAM,UAAU,KAAK;AAC/C,gBAAa;AACX,aAAS,YAAY;AACrB,QAAI,YAAY,UAAa,aAAa,QAAS,YAAW;AAC9D,SAAK,MAAM,QAAQ,CAAC,SAAS,SAAS,CACpC,QAAO,oBAAoB,MAAM,UAAU,KAAK;;;EAGtD,QAAQ,KAAa,UACnB,gBAAgB,IAAI,GAAG,WAAW,MAAM,WAAW,MAAM,CAAC;EAE5D,KAAK,MAAc;AACjB,OAAI;AACF,WAAO,MAAM,WAAW,KAAK,MAAM,GAAG,WAAW,CAAC,CAAC;WAC7C;AACN;;;EAIJ,IAAI,OAAe;AACjB,OAAI;AACF,WAAO,MAAM,MAAM,QAAQ,WAAW,SAAS,WAAW,KAAK,CAAC,CAAC;WAC3D;AACN,WAAO;;;EAGZ;;;;;AC7PH,MAAM,cACJ;AACF,MAAM,uBAAuB;AAG7B,MAAM,kBAAkB;;AAGxB,SAAS,oBAAoB,IAAa,UAAkB;CAC1D,IAAI,UAA0B;AAC9B,QAAO,YAAY,MAAM;EACvB,MAAM,QAAQ,QAAQ,QAAQ,SAAS;AACvC,MAAI,UAAU,KAAM,QAAO;EAC3B,MAAM,OAAO,QAAQ,aAAa;AAClC,YAAU,gBAAgB,aAAa,KAAK,OAAO;;AAErD,QAAO;;;;;;AAkBT,SAAgB,eAAe,IAAa,QAAkB;AAC5D,KAAI,oBAAoB,IAAI,2BAA2B,KAAK,KAAM,QAAO;CACzE,MAAM,UAAU,GAAG,QAAQ,YAAY,IAAI;CAE3C,MAAM,WAAW,OAAO,WAAW,QAAQ;AAsB3C,QArB6B;EAC3B;EACA,QAAQ;GACN,KAAK,QAAQ;GACb,MAAM,QAAQ,aAAa,OAAO;GAClC,QAAQ,QAAQ,aAAa,yBAAyB;GACtD,OAAO,QAAQ,eAAe,IAAI,MAAM,GAAG,gBAAgB;GAC3D,YAAY,OAAO,YACjB,MAAM,KAAK,QAAQ,aAAa,EAAE,MAAM,YAAY,CAClD,MAGC,YAAY,SAAS,WACtB,gBAAgB,KAAK,QAAQ,UAAU,GAAG,CAAC,GACvC,WACA,OAAO,IAAI,MAAM,CACtB,CAAC,CACH;GACD,GAAG,iBAAiB,SAAS,OAAO;GACrC;EACF;;AAIH,MAAM,mBAAmB;AAEzB,MAAM,iBAAiB;AACvB,MAAM,iBAAiB,IAAI;AAG3B,MAAM,eACJ;AACF,MAAM,kBAAkB;AACxB,MAAM,aAAa,GAAG,gBAAgB;AACtC,MAAM,WAAW,4BAA4B;AAG7C,MAAM,UAAU;AAChB,MAAM,YAAY,GAAG,QAAQ;AAC7B,MAAM,UAAU;AAEhB,MAAM,WACJ;AACF,MAAM,gBAAgB;AAEtB,MAAM,iBACJ;AAGF,MAAM,YAAY;AAIlB,MAAM,WACJ;AACF,MAAM,QACJ;AAGF,MAAM,MACJ;AACF,MAAM,YAAY;AAElB,MAAM,UAAU,aACb,QAAQ,aAAa,OAAO,IAAI,IAAI,MAAM,CAAC,MAAM,MAAM,CAAC,GAAI,aAAa;AAE5E,MAAM,UAAU,SAAkB,UAC/B,QAAQ,aAAa,KAAK,IAAI,IAAI,MAAM,MAAM,CAAC,OAAO,QAAQ;AAEjE,MAAM,UAAU,YAAqB;CACnC,MAAM,OAAO,QAAQ,aAAa;AAClC,QAAO,gBAAgB,YAAY,gBAAgB,aAAa,OAAO;;;AAIzE,MAAM,aAAa,YAAqB;CACtC,MAAM,OAAO,OAAO,QAAQ;AAC5B,QAAO,OACH,aAAa,KAAK,KAAK,GACvB,QAAQ,QAAQ,2BAA2B;;;AAIjD,MAAM,cAAc,YAClB,mBAAmB,mBACf,CAAC,eAAe,KAAK,QAAQ,KAAK,GAClC,QAAQ,QAAQ,mBAAmB,IACnC,QAAQ,QAAQ,WAAW,KAAK;;;;;;AAOtC,SAAS,UAAU,SAAkB;AACnC,KAAI,QAAQ,cAAc,MAAO,QAAO,QAAQ,aAAa,MAAM;AACnE,KAAI,QAAQ,cAAc,SAAS,OAAO,QAAQ,KAAK,MACrD,QAAO;CACT,MAAM,QAAQ,MAAM,KAAK,QAAQ,SAAS,CAAC,MACxC,UAAU,MAAM,cAAc,WAAW,MAAM,sBAAsB,EACvE;AACD,QAAO,QAAQ,aAAa,aAAa,IAAI,OAAO,eAAe;;;;;;;;;;;AAYrE,SAAS,SAAS,MAAe,QAAiB;AAChD,KAAI,WAAW,KAAK,CAAE,QAAO;CAC7B,IAAI,OAAO;CACX,IAAI,QAAQ;CACZ,IAAI,aAAa;CAEjB,IAAI;CACJ,MAAM,0BAAU,IAAI,KAAsB;CAC1C,MAAM,OAAO,SAAkB,QAAQ,OAAO,MAAM,QAAQ,QAAQ,IAAI;CAGxE,MAAM,QAAQ,UAAgB;AAC5B,MAAI,CAAC,OAAQ,QAAO;AACpB,OACE,IAAI,QAAQ,OAAO,SAAS,MAAM,YAClC,UAAU,QAAQ,WAAW,MAC7B,QAAQ,QAAQ,SAAS,OAAO,YAChC;GACA,MAAM,WAAW,QAAQ,IAAI,OAAO,oBAAI,IAAI,KAAW;AACvD,WAAQ,IAAI,QAAQ,SAAS;AAC7B,OAAI,SAAS,IAAI,MAAM,CAAE;AACzB,OAAI,SAAS,IAAI,MAAM,CAAC,OAAO,EAAG,QAAO;;AAE3C,SAAO;;CAET,IAAI,OAAoB,KAAK;AAC7B,QAAO,SAAS,MAAM;AACpB,MAAI,EAAE,QAAQ,kBAAkB,KAAK,SAAS,gBAAgB;AAC5D,OAAI,OAAQ,QAAO;AACnB;;EAEF,IAAI,UAAU;AACd,MAAI,gBAAgB,MAAM;GACxB,MAAM,SAAS,KAAK;GAEpB,IAAI,SAAS;AACb,UAAO,WAAW,QAAQ,SAAS,KAAK,OAAO,UAAU,CACvD,UAAS,OAAO;AAClB,OACE,KAAK,KAAK,KAAK,KAAK,KACnB,CAAC,KAAK,OAAO,IACX,UAAU,OAAO,cAAc,UAAU,QAAQ,YAAY,QAEhE,QAAO;AACT,OAAI,KAAK,KAAK;aACL,gBAAgB,SAAS;AAClC,OAAI,KAAK,QAAQ,SAAS,CAAE,QAAO;AACnC,OACE,UACA,KAAK,QAAQ,UAAU,IACvB,EAAE,KAAK,cAAc,aAAa,KAAK,QAAQ,QAAQ,MACtD,KAAK,cAAc,OAAO,EAAE,aAAa,GAE1C,QAAO;GACT,MAAM,OAAO,KAAK,QAAQ,QAAQ,GAAG,OAAO,UAAU,KAAK;AAC3D,OAAI,MAAM;AACR,QAAI,CAAC,KAAK,KAAK,CAAE,QAAO;AACxB,QAAI,IAAI,KAAK,GAAG;cACP,SAAS,QAAW;AAC7B,QAAI,CAAC,SAAS,KAAK,KAAK,UAAU,CAAE,KAAI,IAAI;AAC5C,cAAU;;;AAGd,MAAI,WAAW,KAAK,eAAe,KAAM,QAAO,KAAK;OAChD;AACH,UAAO,SAAS,QAAQ,KAAK,gBAAgB,MAAM;AACjD,WAAO,KAAK;AACZ,QAAI,CAAC,SAAS,KAAM,KAAiB,UAAU,CAAE,KAAI,IAAI;;AAE3D,UAAO,SAAS,OAAO,OAAO,KAAK;;;AAGvC,QAAO;;;AAIT,MAAM,gBAAgB,aACnB,QAAQ,cAAc,YACrB,sBAAsB,KAAK,OAAO,QAAQ,CAAC,KAC7C,iBAAiB,QAAQ;;AAG3B,SAAS,iBAAiB,SAAkB;AAC1C,KAAI,QAAQ,QAAQ,6BAA6B,KAAK,KAAM,QAAO;CACnE,MAAM,OAAO,OAAO,QAAQ;AAC5B,QACE,SAAS,QACT,MAAM,KACJ,KAAK,iBAAiB,wCAAwC,CAC/D,CAAC,MAAM,UACN,CAAC,GAAG,OAAO,OAAO,gBAAgB,EAAE,GAAG,OAAO,OAAO,YAAY,CAAC,CAAC,MAChE,OAAO,KAAK,eAAe,GAAG,EAAE,SAAS,QAAQ,CACnD,CACF;;AAIL,SAAS,WAAW,SAAkB;CACpC,MAAM,OAAO,OAAO,QAAQ;AAC5B,QAAO,SAAS,OACZ,OACA,OAAO,SAAS,kBAAkB,CAC/B,KAAK,OAAO;EACX,MAAM,aAAa,KAAK,eAAe,GAAG;AAC1C,SAAO,eAAe,QAAQ,iBAAiB,WAAW,GACtD,KACA,SAAS,YAAY,MAAM;GAC/B,CACD,KAAK,IAAI;;;AAIlB,MAAM,aAAa,SACjB,KAAK,QAAQ,WAAW,GAAG,CAAC,QAAQ,QAAQ,IAAI,CAAC,MAAM;AAEzD,SAAS,YAAY,SAAkB;CACrC,MAAM,OAAO,SAAS,SAAS,KAAK;AACpC,QAAO,MAAM,KAAK,UAAU,KAAK,CAAC,CAAC,SAAS,mBAAmB,KAAK;;;AAItE,SAAS,WAAW,KAAa,QAAkB;CACjD,MAAM,QAAQ,MAAM,KAGlB,OACG,MACC,UAAU,OAAO,KAAK,IAAI,QAAQ,WAAW,GAAG,CAAC,IAAI,GAAG,EACxD,UACD,CACA,QAAQ,UAAU,QAAQ,CAC1B,QAAQ,OAAO,UAAU,CACzB,QAAQ,KAAK,KAAK,WAAW,CAC7B,QAAQ,YAAY,QAAS,KAAK,KAAK,IAAI,GAAG,WAAW,IAAK,CAClE;AACD,QAAO,MAAM,SAAS,mBAClB,GAAG,MACA,MAAM,GAAG,mBAAmB,EAAE,CAC9B,KAAK,GAAG,CACR,SAAS,CAAC,KACb,MAAM,KAAK,GAAG;;AAGpB,SAAS,WAAW,SAAkC,QAAkB;AACtE,MAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,QAAQ,WAAW,QAAQ,IAAI,IAAI,OAAO;AAChD,MAAI,MAAO,QAAO;;;;;;;;;;;;AActB,SAAS,WAAW,SAAkB,QAAsC;CAC1E,MAAM,SAAS,QAAQ,QAAQ,2CAA2C;AAC1E,KAAI,WAAW,QAAQ,aAAa,OAAO,CAAE,QAAO;CAEpD,MAAM,QAAQ,OACN,WAAW,QAAQ,QACnB,QAAQ,aAAa,aAAa,CACzC;AACD,KACE,cAAc,KAAK,QAAQ,UAAU,IACrC,4BAA4B,KAAK,OAAO,QAAQ,CAAC,EACjD;EACA,MAAM,MAAM,WACV,CAAC,GAAG,aAAa,UAAU,QAAQ,IAAI,KAAK,EAC5C,OACD;AACD,MAAI,QAAQ,OAAW,QAAO;EAE9B,IAAI,UAAU,QAAQ;AACtB,SAAO,YAAY,QAAQ,CAAC,UAAU,QAAQ,CAC5C,WAAU,QAAQ;AACpB,SAAO,YAAY,OAAO,SAAY,WAAW,SAAS,OAAO;;CAEnE,MAAM,UAAU,UAAU,QAAQ,IAAI,CAAC,WAAW,QAAQ;CAC1D,MAAM,eACJ,mBAAmB,oBAAoB,cAAc,KAAK,QAAQ,KAAK;AACzE,QAAO,WACL;EACE,GAAG;QAGD,eACI,QAAQ,SAAS,UACf,QAAQ,aAAa,MAAM,GAC3B,QAAQ,QACV;QACC,WAAW,eAAe,QAAQ,aAAa,QAAQ,GAAG;QAC1D,UAAU,YAAY,QAAQ,GAAG;EACzC,EACD,OACD;;;AAIH,SAAgB,iBAAiB,SAAkB,QAAkB;AACnE,KAAI,mBAAmB,iBACrB,QAAO;EACL,OAAO,OAAO,WAAW,QAAQ,GAAG,WAAW,QAAQ;EACvD,GAAI,CAAC,YAAY,QAAQ,CAAC,SAAS,QAAQ,KAAK,GAC5C,EAAE,SAAS,QAAQ,SAAS,GAC5B,EAAE;EACN,GAAI,QAAQ,SAAS,SACjB,EACE,OAAO,MAAM,KAAK,QAAQ,SAAS,EAAE,GAAG,UAAU;GAChD,MAAM,KAAK;GACX,MAAM,KAAK;GACX,MAAM,KAAK;GACX,cAAc,KAAK;GACpB,EAAE,EACJ,GACD,EAAE;EACP;AAEH,KAAI,mBAAmB,kBACrB,QAAO;EACL,OAAO,QAAQ;EACf,gBAAgB,MAAM,KACpB,QAAQ,kBACP,WAAW,OAAO,MACpB;EACF;AAEH,KAAI,mBAAmB,oBAAqB,QAAO,EAAE,OAAO,QAAQ,OAAO;AAC3E,KAAI,QAAQ,QAAQ,gBAAgB,CAClC,QAAO,EAAE,OAAO,QAAQ,eAAe,IAAI;AAE7C,QAAO,EAAE;;AAGX,SAAS,aAAa,OAAc;CAClC,MAAM,CAAC,SAAS,MAAM,cAAc;AACpC,QAAO,iBAAiB,UAAU,QAAQ;;AAG5C,SAAS,iBAAiB,IAAa;AACrC,QAAO,GAAG,cAAc,UAAU,GAAG,cAAc,UAAU,CAAC,GAAG;;AAGnE,SAAS,OAAO,QAAuB,OAAmB;AACxD,QACE,KAAK,IAAI,OAAO,IAAI,MAAM,QAAQ,IAAI,wBACtC,KAAK,IAAI,OAAO,IAAI,MAAM,QAAQ,IAAI;;;;;;;AAS1C,SAAgB,oBAAoB,QAOjC;CACD,MAAM,EACJ,MACA,UACA,QACA,aAAa,UAAU,MAAM,WAC7B,SAAS,gBAAgB,KACvB;CACJ,IAAI,SAA+B;CAGnC,MAAM,iBAAiB,OAAgB;EACrC,MAAM,YAAY,eAAe,IAAI,OAAO;AAC5C,MAAI,cAAc,KAAM,QAAO;AAC/B,MAAI;GACF,MAAM,QAAQ,WAAW,UAAU,SAAS,OAAO;AACnD,OAAI,UAAU,OAAW,WAAU,OAAO,QAAQ;UAC5C;AAGR,SAAO;;CAGT,MAAM,iBAAiB,UAAwB;AAC7C,MAAI;AACF,OAAI,MAAM,cAAc,MAAO;GAC/B,MAAM,KAAK,aAAa,MAAM;AAC9B,YAAS;IACP,WAAW,OAAO,OAAO,OAAO,cAAc,GAAG;IACjD,GAAG,MAAM;IACT,GAAG,MAAM;IACV;UACK;AACN,YAAS;;;CAIb,MAAM,WAAW,UAAsB;EACrC,MAAM,gBAAgB;AACtB,WAAS;AACT,MAAI;AACF,OAAI,CAAC,UAAU,MAAM,CAAE;GACvB,MAAM,KAAK,aAAa,MAAM;AAC9B,OAAI,OAAO,KAAM;GAKjB,MAAM,YAHJ,kBAAkB,QAClB,iBAAiB,GAAG,IACpB,OAAO,eAAe,MAAM,GAE1B,cAAc,YACd,cAAc,GAAG;AACrB,OAAI,cAAc,KAAM;GACxB,MAAM,QAAQ,MAAM,WAAW,IAAI,aAAa;AAChD,QAAK,SAAS;IACZ,QAAQ;KAAE,GAAG,UAAU;KAAQ;KAAO;IACtC,OAAO,UAAU;IACjB,KAAK,OAAO,IAAI,SAAS,KAAK;IAC9B,GAAG,SAAS,UAAU,QAAQ;IAC/B,CAAC;UACI;;AAKV,QAAO,iBAAiB,eAAe,eAAe;EACpD,SAAS;EACT,SAAS;EACV,CAAC;AACF,QAAO,iBAAiB,SAAS,SAAS;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AAC3E,cAAa;AACX,SAAO,oBAAoB,eAAe,eAAe,EAAE,SAAS,MAAM,CAAC;AAC3E,SAAO,oBAAoB,SAAS,SAAS,EAAE,SAAS,MAAM,CAAC;;;;;;ACzenE,MAAM,mBAAmB;AACzB,MAAM,mBAAmB,IAAI,IAAI;CAC/B;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;;AAcF,SAAgB,oBAAoB,QAIjC;CACD,MAAM,EACJ,MACA,aAAa,UAAiB,MAAM,WACpC,SAAS,gBAAgB,KACvB;CACJ,IAAI,SAAS;CACb,MAAM,0BAAU,IAAI,KAAoB;CAGxC,MAAM,6BAAa,IAAI,SAA0B;CACjD,MAAM,4BAAY,IAAI,SAA2B;CAEjD,MAAM,UAAU,YAAqB;AACnC,eAAa,QAAQ,IAAI,QAAQ,EAAE,MAAM;AACzC,UAAQ,OAAO,QAAQ;;CAEzB,MAAM,WAAW,SAAkB,SAAe;AAChD,MAAI;AAGF,OAAI,CAAC,UAAU,eAAe,SAAS,OAAO,KAAK,KAAM;AAGzD,OAAI,OAAO,WAAW,QAAQ,IAAI,KAAK,MAAM,UAAU,SAAU;AACjE,OAAI,KAAK,QAAQ,cAAc,QAC7B,YAAW,IAAI,SAAS,KAAK,UAAU,KAAK,MAAM,CAAC;AAErD,QAAK,QAAQ,MAAM,OAAO,IAAI,KAAK,QAAQ,IAAI;AAC/C,QAAK,QAAQ,OAAO,OAAO,OAAO,IAAI,KAAK,QAAQ,OAAO,KAAK;AAC/D,QAAK,QAAQ,OAAO,aAAa,OAAO,YACtC,OAAO,QAAQ,KAAK,QAAQ,OAAO,WAAW,CAAC,KAAK,CAAC,KAAK,WAAW,CACnE,KACA,OAAO,IAAI,MAAM,CAClB,CAAC,CACH;AACD,QAAK,SAAS,KAAK,QAAQ;UACrB;;CAIV,MAAM,cAAc;EAElB,MAAM,QAAQ,CAAC,GAAG,QAAQ;AAC1B,OAAK,MAAM,CAAC,YAAY,MAAO,QAAO,QAAQ;AAC9C,OAAK,MAAM,CAAC,SAAS,SAAS,MAAO,SAAQ,SAAS,KAAK;;CAE7D,MAAM,WAAW,SAAkB,WAAmB,WAAoB;AACxE,MAAI,QAAQ,OAAO,KAAK,CAAC,QAAQ,IAAI,QAAQ,CAAE,QAAO;AACtD,MAAI,CAAC,OAAQ;EACb,MAAM,YAAY,eAAe,SAAS,OAAO;AACjD,MAAI,cAAc,MAAM;AACtB,UAAO,QAAQ;AACf;;EAEF,MAAM,QAAQ,iBAAiB,SAAS,OAAO;AAC/C,MAAI,EAAE,WAAW,OAAQ;EACzB,MAAM,OACJ,mBAAmB,uBAClB,mBAAmB,oBAClB,iBAAiB,IAAI,QAAQ,KAAK,IACpC,QAAQ,QAAQ,qDAAmD;AACrE,MAAI,CAAC,KAAM,QAAO;EAClB,MAAM,YAAY,KAAK,UAAU,MAAM;EACvC,MAAM,OACJ,cAAc,aACb,cAAc,KAAK,UAAU,QAAQ,IAAI,QAAQ,EAAE,MAAM,IACxD,cAAc,WAAW,IAAI,QAAQ;AACzC,aAAW,OAAO,QAAQ;AAC1B,MAAI,MAAM;AACR,UAAO;AACP,cAAW,OAAO,QAAQ;AAC1B;;AAEF,SAAO,QAAQ;EACf,MAAM,OAAa;GACjB;GACA,SAAS;IACP;IACA,QAAQ;KAAE,GAAG,UAAU;KAAQ,GAAG;KAAO;IACzC,KAAK,OAAO,IAAI,SAAS,KAAK;IAC9B,OAAO,SAAS;IACjB;GACF;AACD,MAAI,CAAC,MAAM;AACT,WAAQ,SAAS,KAAK;AACtB;;AAEF,MAAI,OACF,MAAK,QAAQ,iBAAiB;AAC5B,UAAO,QAAQ;AACf,WAAQ,SAAS,KAAK;KACrB,iBAAiB;AAEtB,UAAQ,IAAI,SAAS,KAAK;;CAE5B,MAAM,UAAU,UAAiB;AAC/B,MAAI;AACF,OAAI,CAAC,UAAU,CAAC,UAAU,MAAM,CAAE;GAClC,MAAM,CAAC,WAAW,MAAM,cAAc;AACtC,OAAI,EAAE,mBAAmB,SAAU;AACnC,OAAI,MAAM,SAAS,oBAAoB;AACrC,cAAU,IAAI,SAAS,KAAK;AAC5B,iBAAa,QAAQ,IAAI,QAAQ,EAAE,MAAM;AACzC;;AAEF,OAAI,MAAM,SAAS,iBAAkB,WAAU,IAAI,SAAS,MAAM;GAGlE,MAAM,gBACJ,UAAU,IAAI,QAAQ,KAAK,QAC1B,UAAU,IAAI,QAAQ,KAAK,SAC1B,iBAAiB,SACjB,MAAM,gBAAgB;AAC1B,WACE,SACA,MAAM,SAAS,mBAAmB,UAAU,MAAM,MAClD,CAAC,cACF;AACD,OAAI,MAAM,SAAS,SAAU,QAAO;UAC9B;;CAIV,MAAM,YAAY,UAAiB;AACjC,MAAI,MAAM,SAAS,cAAc,CAAC,UAAU,MAAM,CAAE;AACpD,MAAI,MAAM,SAAS,WAAW;GAC5B,MAAM,CAAC,WAAW,MAAM,cAAc;AAGtC,OACE,EAAE,iBAAiB,kBACnB,MAAM,QAAQ,WACd,MAAM,eACN,MAAM,YAAY,OACjB,mBAAmB,WAAW,UAAU,IAAI,QAAQ,KAAK,KAE1D;;AAEJ,SAAO;;CAET,MAAM,YAAY;EAAC;EAAS;EAAU;EAAoB;EAAiB;CAC3E,MAAM,cAAc;EAAC;EAAS;EAAU;EAAY;EAAY;EAAU;AAC1E,MAAK,MAAM,QAAQ,UACjB,QAAO,iBAAiB,MAAM,QAAQ;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AACzE,MAAK,MAAM,QAAQ,YACjB,QAAO,iBAAiB,MAAM,UAAU;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;CAC3E,MAAM,kBAAkB;AACtB,WAAS;AACT,OAAK,MAAM,WAAW,QAAQ,MAAM,CAAE,QAAO,QAAQ;AACrD,OAAK,MAAM,QAAQ,UACjB,QAAO,oBAAoB,MAAM,QAAQ,KAAK;AAChD,OAAK,MAAM,QAAQ,YACjB,QAAO,oBAAoB,MAAM,UAAU,KAAK;;AAEpD,QAAO,OAAO,OAAO,WAAW,EAAE,OAAO,CAAC;;;;;ACzK5C,SAAS,mBAAmB,OAA2C;AACrE,QACE,OAAO,UAAU,YACjB,UAAU,QACV,sBAAsB,SACtB,yBAAyB;;AAI7B,SAAS,gBAAgB;CACvB,MAAM,aAAsB,QAAQ,IAAI,QAAQ,aAAa;AAC7D,QAAO,mBAAmB,WAAW,GAAG,aAAa;;AAGvD,SAAS,iBAAiB,OAAgC;AACxD,SAAQ,OAAR;EACE,KAAK;EACL,KAAK;EACL,KAAK,SACH,QAAO;EACT,QACE,QAAO;;;;;;;;;AAUb,SAAgB,yBAAyB,QAItC;CACD,MAAM,EAAE,MAAM,SAAS,gBAAgB,KAAK;CAC5C,IAAI,UAAU,SAAS;CAEvB,MAAM,SAAS,mBAAmC;AAChD,MAAI;GACF,MAAM,OAAO,SAAS;AACtB,OAAI,SAAS,QAAS;AACtB,QAAK,cAAc;IACjB,MAAM,OAAO,IAAI,QAAQ;IACzB,IAAI,OAAO,IAAI,KAAK;IACpB;IACD,CAAC;AACF,aAAU;UACJ;;CAKV,MAAM,aAAa,eAAe;AAClC,KAAI,eAAe,MAAM;EACvB,MAAM,iBAAiB,UACrB,MAAM,iBAAiB,QAAQ,IAAI,OAAO,iBAAiB,CAAC,CAAC;AAC/D,aAAW,iBAAiB,sBAAsB,cAAc;AAChE,eACE,WAAW,oBAAoB,sBAAsB,cAAc;;AAGvE,QAAO,uBAAuB,MAAM;;AAGtC,SAAS,uBACP,OACA;CACA,IAAI,SAAS;CACb,MAAM,WAA2B,EAAE;CAEnC,MAAM,SAAS,QAAiB,WAA0B;EACxD,MAAM,WAAW,OAAO;EACxB,MAAM,UAAkC,SAAyB,GAAG,MAAM;GACxE,MAAM,SAAS,SAAS,MAAM,MAAM,KAAK;AACzC,OAAI,OAAQ,OAAM,WAAW,cAAc,SAAS,UAAU;AAC9D,UAAO;;AAET,SAAO,UAAU;AACjB,WAAS,WAAW;AAElB,OAAI,OAAO,YAAY,QAAS,QAAO,UAAU;IACjD;;AAIJ,MAAK,MAAM,UADsB,CAAC,aAAa,eAAe,EAChC;AAC5B,QAAM,QAAQ,WAAW,OAAO;AAChC,MAAI,OAAO,UAAU,eAAe,KAAK,SAAS,OAAO,CACvD,OAAM,SAAS,OAAO;;CAG1B,MAAM,mBAAmB,MAAM,WAAW;AAC1C,QAAO,iBAAiB,YAAY,WAAW;AAC/C,QAAO,iBAAiB,cAAc,WAAW;AAEjD,cAAa;AACX,WAAS;AACT,SAAO,oBAAoB,YAAY,WAAW;AAClD,SAAO,oBAAoB,cAAc,WAAW;AACpD,OAAK,MAAM,WAAW,SAAU,UAAS;;;;;;ACtG7C,MAAM,aAAa;AACnB,MAAM,kBAAkB;AACxB,MAAM,UAAU,IAAI,aAAa;AACjC,MAAM,yBAAuC;CAC3C,QAAQ;CACR,QAAQ;CACT;;AAID,SAAS,SACP,OACA,QACA,aACA,QACA,QACc;CACd,IAAI;AACJ,KAAI,CAAC,SAAS,YAAY,CACxB,KAAI;AAGF,SAAO,IAAI,YAAY,SAAS,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC,OAAO,OAAO,EACrE,QAAQ,WAAW,YACpB,CAAC;SACI;AAGV,KAAI,SAAS,QAAW;AACtB,SAAO,OAAO,KAAK;AACnB,MAAI,SAAS,OAAW,QAAO,iBAAiB;AAEhD,MAAI,KAAK,SAAS,KAAK,EAAE;AACvB,WAAQ,QAAQ,OAAO,KAAK;AAC5B,UAAO;;;CAGX,MAAM,SAAS,SAAS;AAExB,UAAS,KAAK,OAAO,aAAa,GAAG,MAAM,SAAS,GAAG,WAAW,CAAC,CAAC;CACpE,MAAM,SAAuB;EAC3B,QAAQ,WAAW,cAAc,MAAM,WAAW,IAAI,UAAU;EAChE;EACA,UAAU,SAAS,WAAW;EAC9B,GAAI,SAAS,EAAE,QAAQ,GAAG,EAAE;EAC7B;AACD,KAAI,QAAQ,OAAO,KAAK,UAAU,OAAO,CAAC,CAAC,UAAU,WACnD,QAAO;AACT,QAAO,SAAS,WAAW,aAAa,cAAc;AACtD,QAAO,YAAY;CAEnB,IAAI,MAAM;CACV,IAAI,OAAO,KAAK;AAChB,QAAO,MAAM,MAAM;EACjB,MAAM,SAAS,KAAK,MAAM,MAAM,QAAQ,EAAE;AAC1C,SAAO,OAAO,KAAK,MAAM,GAAG,OAAO;AACnC,MAAI,QAAQ,OAAO,KAAK,UAAU,OAAO,CAAC,CAAC,UAAU,WACnD,OAAM;MACH,QAAO,SAAS;;AAEvB,KAAI,OACF,QAAO,MAAM;UACN,MAAM,KAAK,kBAAkB,KAAK,KAAK,MAAM,GAAI,CAAE;AAC5D,QAAO,OAAO,KAAK,MAAM,GAAG,IAAI;AAChC,QAAO;;AAGT,SAAS,aACP,OACA,QACA,SAAiC,YACjC,QACc;AACd,QAAO,SACL,QAAQ,OAAO,MAAM,MAAM,GAAG,WAAW,CAAC,EAC1C,MAAM,SAAS,cAAc,WAAW,aAAa,cAAc,QACnE,cACA,QACA,OACD;;AAGH,SAAS,SAAS,aAAqB;AACrC,QACE,gBAAgB,MAChB,CAAC,oFAAoF,KACnF,YACD;;;AAKL,SAAgB,kBAAkB,QAAkB;CAClD,MAAM,gCAAgB,IAAI,KAAiB;CAC3C,IAAI,SAAS;CACb,MAAM,QACJ,QACA,cAAc,OACY;AAC1B,MAAI,CAAC,OAAQ,QAAO,QAAQ,QAAQ,EAAE,QAAQ,SAAS,CAAC;AACxD,MAAI,CAAC,OACH,QAAO,QAAQ,QAAQ;GACrB,QAAQ;GACR,QAAQ;GACT,CAAC;EACJ,IAAI;AACJ,MAAI;AACF,YAAS,OAAO,WAAW;UACrB;AACN,UAAO,QAAQ,QAAQ;IAAE,QAAQ;IAAe,QAAQ;IAAe,CAAC;;AAE1E,SAAO,IAAI,SAAS,YAAY;GAC9B,MAAM,QAAQ,IAAI,WAAW,WAAW;GACxC,IAAI,SAAS;GACb,IAAI,WAAW;GACf,MAAM,UAAU,QAAgC,WAAoB;AAClE,QAAI,SAAU;AACd,eAAW;AACX,iBAAa,MAAM;AACnB,kBAAc,OAAO,OAAO;AAC5B,YACE,SACE,MAAM,SAAS,GAAG,OAAO,EACzB,QACA,aACA,OAAO,MACP,OACD,CACF;AAED,IAAK,OAAO,QAAQ,CAAC,YAAY,GAAG;;GAEtC,MAAM,eAAe,OAAO,eAAe,kBAAkB;GAC7D,MAAM,QAAQ,iBACN,OAAO,WAAW,oBAAoB,EAC5C,gBACD;AACD,iBAAc,IAAI,OAAO;AACzB,IAAM,YAAY;AAChB,QAAI;AACF,cAAS;MACP,MAAM,QAAQ,MAAM,OAAO,MAAM;AACjC,UAAI,SAAU;AACd,UAAI,MAAM,MAAM;AACd,cAAO,WAAW;AAClB;;MAEF,MAAM,OAAO,MAAM,MAAM,SAAS,GAAG,aAAa,OAAO;AACzD,YAAM,IAAI,MAAM,OAAO;AACvB,gBAAU,KAAK;AACf,UAAI,WAAW,YAAY;AACzB,cAAO,YAAY;AACnB;;;YAGE;AACN,YAAO,eAAe,mBAAmB;cACjC;AACR,SAAI;AACF,aAAO,aAAa;aACd;;OAIR;IACJ;;CAEJ,MAAM,QACJ,OACA,cAAc,OACY;AAC1B,MAAI;AACF,OAAI,SAAS,KAAM,QAAO,QAAQ,QAAQ,EAAE,QAAQ,SAAS,CAAC;AAC9D,OAAI,iBAAiB,gBAAiB,SAAQ,MAAM,UAAU;AAC9D,OAAI,OAAO,UAAU,SACnB,QAAO,QAAQ,QAAQ,aAAa,OAAO,OAAO,KAAK,CAAC;AAC1D,OACE,OAAO,mBAAmB,eAC1B,iBAAiB,eAGjB,QAAO,QAAQ,QAAQ;IACrB,QAAQ;IACR,QAAQ;IACT,CAAC;AAEJ,OAAI,iBAAiB,UAAU;IAC7B,MAAM,OAAO,IAAI,UAAU;AAC3B,UAAM,SAAS,OAAO,QACpB,KAAK,OACH,KACA,OAAO,UAAU,WAAW,OAAO,MAAM,KAAK,MAAM,GAAG,MACxD,CACF;AACD,YAAQ;;GAEV,MAAM,WAAW,IAAI,SAAS,MAAM;AACpC,UAAO,KACL,SAAS,MACT,eAAe,SAAS,QAAQ,IAAI,eAAe,IAAI,GACxD;UACK;AACN,UAAO,QAAQ,QAAQ;IACrB,QAAQ;IACR,QAAQ;IACT,CAAC;;;AAGN,QAAO;EACL;EACA;EACA,OAAO,OAAe,QAAiC,WACrD,aAAa,OAAO,OAAO,MAAM,QAAQ,OAAO;EAClD,OAAO;AACL,YAAS;AACT,QAAK,MAAM,UAAU,cAAe,SAAQ;;EAE/C;;;;;ACzNH,MAAM,kBAAkB,QAAQ,QAAsB,EAAE,QAAQ,SAAS,CAAC;AAC1E,MAAM,mBAAmB,WACvB,QAAQ,QAAsB;CAAE,QAAQ;CAAe;CAAQ,CAAC;AAClE,MAAM,cAAc,QAAgB,IAAI,IAAI,KAAK,SAAS,KAAK,CAAC;AAKhE,MAAM,qBAAqB,IAAI,IAAI;CACjC;CACA;CACA;CACD,CAAC;AACF,MAAM,eAAe,MAAc,UACjC,mBAAmB,IAAI,KAAK,aAAa,CAAC,IAAI,gBAAgB,KAAK,GAC/D,WACA;AACN,MAAM,gBAAgB,YACpB,OAAO,YACL,MAAM,KAAK,QAAQ,SAAS,GAAG,CAAC,MAAM,WAAW,CAC/C,MACA,YAAY,MAAM,MAAM,CACzB,CAAC,CACH;AAGH,SAAS,aAAa,OAAgB;AACpC,QACE,OAAO,UAAU,YACjB,UAAU,QACV,UAAU,SACV,MAAM,SAAS;;;AAKnB,SAAgB,sBAAsB,QAKnC;CACD,IAAI,SAAS;CACb,MAAM,EAAE,SAAS,gBAAgB,KAAK;CACtC,MAAM,SAAS,kBAAkB,OAAO;CACxC,MAAM,aAAa,OAAO,WAAW,KAAK,YACxC,OAAO,YAAY,WACf,WAAW,QAAQ,GACnB,IAAI,OAAO,QAAQ,QAAQ,QAAQ,MAAM,CAC9C;CACD,MAAM,aAAa,QACjB,WAAW,MAAM,YAAY;AAC3B,MAAI,OAAO,YAAY,SAAU,QAAO,IAAI,WAAW,QAAQ;AAC/D,UAAQ,YAAY;AACpB,SAAO,QAAQ,KAAK,IAAI;GACxB;CAEJ,MAAM,UACJ,WACA,SACA,aACA,WACA,QACA,SACA,aACG;EACH,MAAM,aAAa,KAAK,MAAM,YAAY,KAAK,GAAG,UAAU;EAC5D,MAAM,cAAc,KAAK,KAAK;AAC9B,EAAK,QAAQ,IAAI,CACf,aACA,UAAU,QAAQ,gBAAgB,cAAc,CACjD,CAAC,CACC,MAAM,CAAC,MAAM,cAAc;AAC1B,OAAI,CAAC,OAAQ;GACb,MAAM,OAAO,OAAO,IAAI,QAAQ,IAAI;GACpC,MAAM,MAAM,IAAI,IAAI,KAAK;AACzB,UAAO,KAAK,WAAW;IACrB;IACA,QAAQ,QAAQ;IAChB,KAAK;IACL,QAAQ,IAAI;IACZ,OAAO,IAAI;IACX;IACA;IACA;IACA;IACA,SAAS;KAAE,SAAS,QAAQ;KAAS,MAAM;KAAM;IACjD,UAAU;KACR,GAAI,UAAU,MAAM,EAAE,KAAK,OAAO,IAAI,SAAS,IAAI,EAAE,GAAG,EAAE;KAC1D,SAAS,UAAU,WAAW,EAAE;KAChC,MAAM;KACP;IACF,CAAC;IACF,CACD,YAAY,GAEX;;CAGN,MAAM,gBAAgB,WAAW;CACjC,MAAM,gBAA8B,OAAO,SAAS;AAClD,MAAI,CAAC,OAAQ,QAAO,cAAc,KAAK,YAAY,OAAO,KAAK;EAC/D,IAAI;EACJ,IAAI;AACJ,MAAI;GACF,MAAM,SACJ,OAAO,YAAY,eAAe,iBAAiB,UAC/C,QACA;AACN,aAAU;IACR,KAAK,WAAW,SAAS,OAAO,MAAM,OAAO,MAAM,CAAC;IACpD,SAAS,MAAM,UAAU,QAAQ,UAAU,OAAO,aAAa;IAC/D,SAAS,aAAa,IAAI,QAAQ,MAAM,WAAW,QAAQ,QAAQ,CAAC;IACrE;AACD,aAAU,UAAU,QAAQ,IAAI;UAC1B;AACN,UAAO,cAAc,KAAK,YAAY,OAAO,KAAK;;AAEpD,MAAI,QAAS,QAAO,cAAc,KAAK,YAAY,OAAO,KAAK;EAC/D,MAAM,YAAY,YAAY,KAAK;EACnC,IAAI;AACJ,MAAI;AACF,iBACE,MAAM,QAAQ,OACV,OAAO,KAAK,KAAK,MAAM,QAAQ,QAAQ,gBAAgB,GACvD,OAAO,YAAY,eAAe,iBAAiB,UACjD,OAAO,KAAK,MAAM,OAAO,CAAC,MAAM,QAAQ,QAAQ,gBAAgB,GAChE,WAAW;UACb;AACN,iBAAc,gBAAgB,uBAAuB;;EAEvD,MAAM,UAAU,UACd,OACE,SACA,SACA,aACA,WACA,MACA,aAAa,MAAM,GAAG,YAAY,QACnC;EACH,IAAI;AACJ,MAAI;AACF,aAAU,cAAc,KAAK,YAAY,OAAO,KAAK;WAC9C,OAAO;AACd,UAAO,MAAM;AACb,SAAM;;AAER,EAAK,QACF,MAAM,aAAa;AAClB,OAAI,CAAC,OAAQ;GACb,IAAI;AACJ,OAAI;AACF,mBACE,SAAS,SAAS,YAAY,SAAS,SAAS,mBAC5C,gBAAgB,kBAAkB,GAClC,OAAO,KACL,SAAS,OAAO,CAAC,MACjB,SAAS,QAAQ,IAAI,eAAe,IAAI,GACzC;WACD;AACN,mBAAe,gBAAgB,wBAAwB;;AAEzD,UACE,SACA,SACA,aACA,WACA,SAAS,QACT,MACA;IACE,KAAK,SAAS;IACd,SAAS,aAAa,SAAS,QAAQ;IACvC,MAAM;IACP,CACF;KACA,OAAO,CACT,YAAY,GAAG;AAElB,SAAO;;AAET,KAAI,OAAO,kBAAkB,WAAY,YAAW,QAAQ;CAC5D,MAAM,eAAe,SAAS,WAAW,QAAQ,OAAO;AACxD,cAAa;AACX,WAAS;AACT,SAAO,MAAM;AACb,MAAI,WAAW,UAAU,aAAc,YAAW,QAAQ;AAC1D,gBAAc;;;AAclB,SAAS,SACP,WACA,QACA,QACA;AACA,KAAI,OAAO,mBAAmB,WAAY,cAAa;CACvD,MAAM,QAAQ,eAAe;CAC7B,MAAM,eAAe,MAAM;CAC3B,MAAM,eAAe,MAAM;CAC3B,MAAM,oBAAoB,MAAM;CAChC,MAAM,2BAAW,IAAI,SAAuC;CAC5D,MAAM,4BAAY,IAAI,KAAiB;CAGvC,MAAM,2BAAW,IAAI,SAAqC;CAC1D,IAAI,SAAS;CACb,MAAM,cAAc,SAElB,QACA,KACA,GAAG,MACH;AACA,WAAS,IAAI,KAAK,IAAI;EACtB,MAAM,SAAS,QAAQ,MAAM,cAAc,MAAM;GAAC;GAAQ;GAAK,GAAG;GAAK,CAAC;AACxE,MAAI;AACF,YAAS,IAAI,MAAM;IACjB,KAAK,WAAW,OAAO,IAAI,CAAC;IAC5B,QAAQ,OAAO,aAAa;IAC5B,SAAS,EAAE;IACZ,CAAC;UACI;AACN,YAAS,OAAO,KAAK;;AAEvB,SAAO;;CAET,MAAM,mBAAmB,SAEvB,MACA,OACA;EACA,MAAM,SAAS,kBAAkB,KAAK,MAAM,MAAM,MAAM;EACxD,MAAM,UAAU,SAAS,IAAI,KAAK;AAClC,MAAI,UAAU,QACZ,KAAI;GACF,MAAM,UAAU,IAAI,QAAQ,QAAQ,QAAQ;AAC5C,WAAQ,OAAO,MAAM,MAAM;AAC3B,WAAQ,UAAU,aAAa,QAAQ;UACjC;AAIV,SAAO;;CAET,MAAM,cAAc,SAElB,MACA;EACA,MAAM,UAAU,SAAS,IAAI,KAAK;AAClC,MAAI,CAAC,UAAU,CAAC,WAAW,UAAU,QAAQ,IAAI,CAC/C,QAAO,aAAa,KAAK,MAAM,KAAK;EACtC,MAAM,YAAY,YAAY,KAAK;EACnC,IAAI;AACJ,MAAI;AACF,iBACE,OAAO,aAAa,eAAe,gBAAgB,WAC/C,QAAQ,QACN,OAAO,KAAK,IAAI,eAAe,CAAC,kBAAkB,KAAK,CAAC,CACzD,GACD,OAAO,KACL,MACA,QAAQ,QAAQ,gBACjB;UACD;AACN,iBAAc,gBAAgB,2BAA2B;;EAE3D,IAAI,UAAU;EACd,MAAM,gBAAgB;AACpB,aAAU;;EAEZ,MAAM,gBAAgB;AACpB,QAAK,oBAAoB,SAAS,QAAQ;AAC1C,QAAK,oBAAoB,WAAW,MAAM;AAC1C,aAAU,OAAO,QAAQ;AACzB,OAAI,SAAS,IAAI,KAAK,KAAK,QAAS,UAAS,OAAO,KAAK;;EAE3D,MAAM,cAAc;AAClB,YAAS;AACT,OAAI,CAAC,OAAQ;GACb,MAAM,UAAU,UAAU,YAAY,KAAK,WAAW,IAAI,UAAU;GACpE,MAAM,UAAkC,EAAE;AAC1C,OAAI;AACF,SAAK,MAAM,QAAQ,KAAK,uBAAuB,CAAC,MAAM,QAAQ,EAAE;KAC9D,MAAM,QAAQ,KAAK,QAAQ,IAAI;AAC/B,SAAI,QAAQ,GAAG;MACb,MAAM,OAAO,KAAK,MAAM,GAAG,MAAM,CAAC,aAAa;AAC/C,cAAQ,QAAQ,YAAY,MAAM,KAAK,MAAM,QAAQ,EAAE,CAAC,MAAM,CAAC;;;WAG7D;GAGR,IAAI;AACJ,OAAI;AACF,QAAI,KAAK,iBAAiB,MAAM,KAAK,iBAAiB,OACpD,gBAAe,QAAQ,QACrB,OAAO,KACL,KAAK,cACL,YAAY,OAAO,aAAa,eAChC,YAAY,OAAO,SAAY,WAAW,UAC3C,CACF;aACQ,KAAK,iBAAiB,OAC/B,gBAAe,QAAQ,QACrB,OAAO,KAAK,KAAK,UAAU,KAAK,SAAS,CAAC,CAC3C;aACM,KAAK,iBAAiB,WAC7B,gBAAe,KAAK,cAChB,QAAQ,QACN,OAAO,KACL,IAAI,eAAe,CAAC,kBAAkB,KAAK,YAAY,CACxD,CACF,GACD,gBAAgB,iBAAiB;QAErC,gBAAe,OAAO,KACpB,KAAK,UACL,QAAQ,mBAAmB,2BAC5B;WACG;AACN,mBAAe,gBAAgB,4BAA4B;;AAE7D,UACE,OACA,SACA,aACA,WACA,YAAY,OAAO,KAAK,SAAS,MACjC,SACA;IACE,KAAK,KAAK;IACV;IACA,MAAM;IACP,CACF;;AAEH,OAAK,iBAAiB,SAAS,QAAQ;AACvC,OAAK,iBAAiB,WAAW,MAAM;AACvC,YAAU,IAAI,QAAQ;AACtB,WAAS,IAAI,MAAM,QAAQ;AAC3B,MAAI;AACF,UAAO,aAAa,KAAK,MAAM,KAAK;WAC7B,OAAO;AACd,YAAS;AACT,UACE,OACA,SACA,aACA,WACA,MACA,aAAa,MAAM,GAAG,YAAY,QACnC;AACD,SAAM;;;AAGV,OAAM,OAAO;AACb,OAAM,OAAO;AACb,OAAM,mBAAmB;AACzB,cAAa;AACX,WAAS;AACT,OAAK,MAAM,WAAW,UAAW,UAAS;AAC1C,MAAI,MAAM,SAAS,YAAa,OAAM,OAAO;AAC7C,MAAI,MAAM,SAAS,YAAa,OAAM,OAAO;AAC7C,MAAI,MAAM,qBAAqB,iBAC7B,OAAM,mBAAmB;;;;;;;ACtY/B,SAAgB,QAAQ,UAAkB,aAAuB,EAAE,EAAE;AACnE,QAAO;;;AAIT,SAAgB,iBACd,KACA,aAAuB,EAAE,EACzB,OAAe,WAAW,UAAU,QAAQ,qBAC5C;CACA,MAAM,SAAS,IAAI,IAAI,KAAK,KAAK;AACjC,QAAO;EAAE,KAAK,OAAO;EAAM,QAAQ,OAAO;EAAQ,OAAO,OAAO;EAAU;;;;;ACM5E,MAAM,aAAa;AACnB,MAAM,oBAAoB;AAC1B,MAAM,WAAW,IAAI,IAAY,qBAAqB;;;;;;;;;;AAqBtD,SAAgB,gBAAgB,SAA2B;CACzD,MAAM,EAAE,MAAM,QAAQ,YAAY,WAAW;CAC7C,MAAM,UAAU;EACd,QAAQ;EACR,YAAY;EACZ,SAAS;EACT,QAAQ;EACR,GAAG,QAAQ;EACZ;CACD,IAAI,UAA0B;CAC9B,IAAI,MAAM;CAEV,MAAM,iBAAiB,QAAQ,SAAS,UAAU,OAAO;CAEzD,MAAM,SAAS,gBAAkC;EAC/C,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,MAAI,QAAQ,MAAM,WAAW,KAAK,QAAQ,YAAY,EAAG;EACzD,MAAM,SAAS,QAAQ,MAAM,OAAO,EAAE;EACtC,MAAM,UAAU,QAAQ;AACxB,UAAQ,UAAU;EAClB,MAAM,QAAuB;GAC3B,cAAc,QAAQ;GACtB,sBAAsB,QAAQ;GAC9B;GACA;GACD;EACD,MAAM,kBAAkB;AAEtB,OAAI,YAAY,QAAS,SAAQ,WAAW,OAAO,SAAS;;AAE9D,MAAI;AACF,WAAQ,QAAQ,KAAK,OAAO,YAAY,CAAC,CAAC,MAAM,UAAU;UACpD;AACN,cAAW;;;CAIf,MAAM,UAAU,MAAc,UAAmC;EAC/D,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,MAAI,SAAS,WAAW,SAAS,UAAW,SAAQ,eAAe;AACnE,MAAI,YAAY,QAAS;AACzB,MAAI;AACF,UAAO;GACP,MAAM,QAAuB;IAC3B,MAAM;IACN;IACA,OAAO;KAAE,GAAG;KAAO;KAAK;IACxB,WAAW,KAAK,KAAK;IACtB;GACD,MAAM,OAAO,eAAe,SAAY,QAAQ,WAAW,MAAM;AACjE,OAAI,SAAS,QAAQ,YAAY,QAAS;AAC1C,WAAQ,MAAM,KAAK,KAAK;AACxB,OAAI,QAAQ,MAAM,UAAU,WAAY,QAAO;UACzC;;CAKV,MAAM,SAAS,EAAE,cAAc,2BAAyC;AACtE,MAAI,YAAY,MAAM;AACpB,OAAI,QAAQ,iBAAiB,aAC3B,SAAQ,KACN,sCAAsC,QAAQ,aAAa,yCAAyC,aAAa,IAClH;AAEH;;AAEF,MACE,OAAO,WAAW,eAClB,OAAO,OAAO,qBAAqB,WAEnC;EACF,MAAM,UAAmB;GACvB;GACA;GACA,OAAO,EAAE;GACT,SAAS;GACT,YAAY,EAAE;GACd,OAAO;GACR;AACD,YAAU;EACV,MAAM,iBAAgC,MAAM,UAAU;AACpD,OAAI,YAAY,QAAS,QAAO,MAAM,MAAM;;EAG9C,MAAM,SAAS,gBAAgB;AAC/B,UAAQ,WAAW,KAAK,OAAO,OAAO,CAAC;EACvC,MAAM,aAAa,CACjB,GAAI,QAAQ,cAAc,EAAE,EAC5B,GAAI,KAAK,QAAQ,SAAY,EAAE,GAAG,CAAC,KAAK,IAAI,CAC7C;AACD,MAAI,QAAQ,OACV,SAAQ,WAAW,KACjB,oBAAoB;GAAE,MAAM;GAAe;GAAU;GAAQ;GAAQ,CAAC,CACvE;AACH,MAAI,QAAQ,WACV,SAAQ,WAAW,KACjB,yBAAyB;GAAE,MAAM;GAAe;GAAQ;GAAQ,CAAC,CAClE;AACH,MAAI,QAAQ,QACV,SAAQ,WAAW,KACjB,sBAAsB;GACpB,MAAM;GACN;GACA;GACA;GACD,CAAC,CACH;AACH,MAAI,QAAQ,QAAQ;GAClB,MAAM,SAAS,oBAAoB;IAAE,MAAM;IAAe;IAAQ,CAAC;AACnE,WAAQ,cAAc,OAAO;AAC7B,WAAQ,WAAW,KAAK,OAAO;;EAEjC,MAAM,mBAAmB,MAAM,EAAE,QAAQ,MAAM,CAAC;AAChD,SAAO,iBAAiB,YAAY,WAAW;AAC/C,UAAQ,WAAW,WACjB,OAAO,oBAAoB,YAAY,WAAW,CACnD;AACD,UAAQ,QAAQ,kBAAkB,OAAO,EAAE,kBAAkB;AAC7D,SAAO,QAAQ;GACb,OAAO,UAAU;GACjB,KAAK,OAAO,IAAI,SAAS,KAAK;GAC9B,OAAO,SAAS;GAChB,UAAU,OAAO,IAAI,SAAS,SAAS;GACxC,CAAC;;CAGJ,MAAM,aAAa;EACjB,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,UAAQ,eAAe;AACvB,MAAI,YAAY,QAAS;AACzB,gBAAc,QAAQ,MAAM;AAC5B,OAAK,MAAM,aAAa,QAAQ,WAAY,YAAW;AACvD,SAAO;AACP,YAAU;;CAGZ,MAAM,QAAQ,MAAc,UAAmC;AAC7D,MAAI,SAAS,IAAI,KAAK,EAAE;AACtB,WAAQ,KACN,2BAA2B,KAAK,yEACjC;AACD;;AAEF,SAAO,MAAM,MAAM;;CAGrB,MAAM,SAAS,MAAwB,UACrC,OAAO,MAAM,MAAM;AAWrB,QAT6B;EAC3B;EACA;EACA;EACA;EACA,IAAI,eAAe;AACjB,UAAO,SAAS,gBAAgB;;EAEnC;;;;;;;;;;;;;;AC/LH,SAAgB,SACd,KACA,OAA6C,EAAE,EAC/C;CACA,MAAM,OAAO,OAAO,OAAsB,UAA2B,EAAE,KAAK;EAC1E,MAAM,OAAO,KAAK,UAAU,MAAM;AAGlC,MADE,QAAQ,WAAW,QAAQ,OAAO,UAAU,eAAe,YAC9C;AAKb,OAAI,CAJW,UAAU,WACvB,KACA,IAAI,KAAK,CAAC,KAAK,EAAE,EAAE,MAAM,oBAAoB,CAAC,CAC/C,CACY,OAAM,IAAI,MAAM,+BAA+B;AAC5D;;EAEF,MAAM,WAAW,MAAM,MAAM,KAAK;GAChC,QAAQ;GACR,WAAW;GACX,SAAS;IAAE,gBAAgB;IAAoB,GAAG,KAAK;IAAS;GAChE;GACD,CAAC;AACF,MAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,uBAAuB,SAAS,SAAS;;AAG7E,QAD2B,OAAO,OAAO,MAAM,EAAE,KAAK,CAAC;;;;;ACrBzD,MAAa,6BAA6B,KAAK;AAC/C,MAAM,iBAAiB,IAAI,IAAY,qBAAqB;AAE5D,SAAS,OACP,OACA,4BAAY,IAAI,KAAa,EACT;AACpB,KAAI,OAAO,UAAU,SAAU,QAAO,CAAC,MAAM,SAAS,KAAK;AAC3D,KAAI,UAAU,QAAQ,OAAO,UAAU,UAAW,QAAO;AACzD,KAAI,OAAO,UAAU,SAAU,QAAO,OAAO,SAAS,MAAM;AAC5D,KAAI,OAAO,UAAU,YAAY,UAAU,IAAI,MAAM,CAAE,QAAO;AAC9D,KACE,CAAC,MAAM,QAAQ,MAAM,IACrB,OAAO,eAAe,MAAM,KAAK,OAAO,aACxC,OAAO,eAAe,MAAM,KAAK,KAEjC,QAAO;AACT,WAAU,IAAI,MAAM;AACpB,KACE,CAAC,MAAM,QAAQ,MAAM,IACrB,OAAO,KAAK,MAAM,CAAC,MAAM,QAAQ,IAAI,SAAS,KAAK,CAAC,CAEpD,QAAO;CACT,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,QAAQ,OAAO,OAAO,MAAM;AAClE,MAAK,MAAM,QAAQ,OACjB,KAAI,CAAC,OAAO,MAAM,UAAU,CAAE,QAAO;AAEvC,WAAU,OAAO,MAAM;AACvB,QAAO;;;;;;AAOT,SAAgB,0BACd,SAGqB;CACrB,IAAI,SAAS;CACb,IAAI,aAAa;CACjB,IAAI,aAA6B,EAAE;CACnC,IAAI;CAEJ,MAAM,UAAU,MAAc,YAAoB;AAChD,MAAI;AACF,WAAQ,UAAU;IAAE;IAAM;IAAS,CAAC;UAC9B;;CAKV,MAAM,iBAAiB,QAAQ,SAAS,SAAS;CAEjD,MAAM,UAAU,MAAc,UAAqB;AACjD,MAAI,CAAC,OAAQ;EACb,MAAM,UAAU;AAChB,MAAI,SAAS,WAAW,SAAS,UAAW,gBAAe;AAC3D,MAAI,CAAC,UAAU,eAAe,QAAS;EACvC,IAAI;AACJ,MAAI;GACF,MAAM,QAAyB;IAC7B,MAAM;IACN;IACA,WAAW,KAAK,KAAK;IACrB;IACD;GACD,MAAM,OACJ,QAAQ,eAAe,SAAY,QAAQ,QAAQ,WAAW,MAAM;AACtE,OAAI,SAAS,QAAQ,CAAC,UAAU,eAAe,QAAS;AACxD,OACE,KAAK,SAAS,YACd,OAAO,KAAK,SAAS,YACrB,CAAC,KAAK,KAAK,MAAM,IACjB,KAAK,KAAK,SAAS,OACnB,KAAK,KAAK,SAAS,KAAK,IACxB,CAAC,OAAO,SAAS,KAAK,UAAU,IAChC,KAAK,aAAa,KAClB,KAAK,aAAa,kBAClB,CAAC,OAAO,KAAK,MAAM,EACnB;AACA,WACE,iBACA,4DACD;AACD;;GAGF,MAAM,aAAa,KAAK,UAAU;IAChC,MAAM,KAAK;IACX,MAAM,KAAK;IACX,WAAW,KAAK;IAChB,OAAO,KAAK;IACb,CAAC;AACF,OACE,IAAI,aAAa,CAAC,OAAO,WAAW,CAAC,aACrC,4BACA;AACA,WAAO,mBAAmB,uCAAuC;AACjE;;AAEF,aAAU,KAAK,MAAM,WAAW;UAC1B;AACN,UAAO,iBAAiB,6CAA6C;AACrE;;AAEF,MAAI;AACF,WAAQ,KAAK,QAAQ;UACf;AACN,UACE,uBACA,wDACD;;;CAIL,MAAM,WAAW,MAAc,UAAmC;AAEhE,SAAO,MAAM,MAAmB;;CAGlC,MAAM,aAAa;EACjB,MAAM,UAAU;EAChB,MAAM,QAAQ;AACd,gBAAc;AACd,WAAS;AACT,MAAI,eAAe,QAAS;AAC5B;AACA,WAAS;EACT,MAAM,YAAY;AAClB,eAAa,EAAE;AACf,OAAK,MAAM,aAAa,UAAW,YAAW;;AAGhD,QAAO;EACL,QAAQ;AACN,OAAI,UAAU,OAAO,WAAW,YAAa;AAC7C,YAAS;AACT;AAEA,OAAI,OAAO,OAAO,qBAAqB,WAAY;GAEnD,MAAM,SAAS,gBAAgB;AAC/B,OAAI;AACF,eAAW,KAAK,OAAO,OAAO,CAAC;AAC/B,QAAI,QAAQ,SAAS,WAAW,MAC9B,YAAW,KACT,oBAAoB;KAAE,MAAM;KAAS;KAAU;KAAQ,CAAC,CACzD;AAEH,QAAI,QAAQ,SAAS,eAAe,MAClC,YAAW,KAAK,yBAAyB;KAAE,MAAM;KAAS;KAAQ,CAAC,CAAC;AAEtE,QAAI,QAAQ,SAAS,WAAW,OAAO;KACrC,MAAM,SAAS,oBAAoB;MAAE,MAAM;MAAS;MAAQ,CAAC;AAC7D,mBAAc,OAAO;AACrB,gBAAW,KAAK,OAAO;;AAEzB,QAAI,QAAQ,SAAS,YAAY,MAC/B,YAAW,KACT,sBAAsB;KACpB,MAAM;KACN,YAAY,QAAQ,cAAc,EAAE;KACpC;KACD,CAAC,CACH;AAEH,WAAO,QAAQ;KACb,OAAO,UAAU;KACjB,KAAK,OAAO,IAAI,SAAS,KAAK;KAC9B,OAAO,SAAS;KAChB,UAAU,OAAO,IAAI,SAAS,SAAS;KACxC,CAAC;WACI;AACN,UAAM;AACN,WAAO,kBAAkB,mCAAmC;;;EAGhE;EACA,KAAK,MAAM,OAAO;AAChB,OAAI,CAAC,OAAQ;AACb,OAAI,eAAe,IAAI,KAAK,EAAE;AAC5B,WACE,iBACA,2DACD;AACD;;AAEF,UAAO,MAAM,MAAM;;EAEtB"} +\ No newline at end of file +diff --git a/dist/index.d.cts b/dist/index.d.cts +index 972cf64d52722d12dd2163df1cbe48875d964f21..122bdb4b743ed8454fb3e892d3e719258fcfa798 100644 +--- a/dist/index.d.cts ++++ b/dist/index.d.cts +@@ -30,6 +30,12 @@ interface ClickTarget { + role: string | null; + action: string | null; + text: string; ++ /** ++ * Short, cleaned name of the clicked control (click events only). Comes from ++ * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title`, or a ++ * control's own text. Omitted when no safe name exists. ++ */ ++ label?: string; + attributes: Record<string, string>; + value?: string; + checked?: boolean; +diff --git a/dist/index.d.cts.map b/dist/index.d.cts.map +index ba3d1de09e9e959a65cab0e750f9e7c81b5162ca..454444fae4fde95cee682bc096dd8480464fe3b6 100644 +--- a/dist/index.d.cts.map ++++ b/dist/index.d.cts.map +@@ -1 +1 @@ +-{"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/collector.ts","../src/http-sink.ts","../src/routes.ts","../src/trajectory-types.ts","../src/trajectory-collector.ts"],"mappings":";;cACa,oBAAA;AAAA,KAYD,gBAAA,WAA2B,oBAAA;;UAMtB,aAAA,KAAkB,MAAA;EACjC,IAAA;EACA,IAAA;EACA,KAAA,EAAO,CAAA;EACP,SAAA;AAAA;;UAIe,aAAA;EACf,YAAA;EACA,oBAAA;EACA,MAAA,EAAQ,aAAA;EAVR;EAYA,OAAA;AAAA;AAAA,UAGe,eAAA;EAZf;EAcA,MAAA;AAAA;AAVF;AAAA,KAcY,YAAA,KACV,KAAA,EAAO,aAAA,EACP,OAAA,GAAU,eAAA,YACA,OAAA;EAAA,SAA4B,GAAA;AAAA;AAAA,UAEvB,WAAA;EACf,GAAA;EACA,IAAA;EACA,MAAA;EACA,IAAA;EACA,UAAA,EAAY,MAAA;EACZ,KAAA;EACA,OAAA;EACA,cAAA;EACA,KAAA;IAAU,IAAA;IAAc,IAAA;IAAc,IAAA;IAAc,YAAA;EAAA;EACpD,KAAA;AAAA;;KAIU,QAAA,IAAY,MAAA,EAAQ,OAAA,KAAY,MAAA;AAAA,UAI3B,cAAA;EACf,MAAA;EACA,UAAA;EACA,OAAA;EACA,MAAA;AAAA;AAAA,UAGe,gBAAA;EACf,IAAA,EAAM,YAAA;EA1BS;EA4Bf,MAAA;;EAEA,UAAA,aAAuB,MAAA;EA7BvB;EA+BA,OAAA,GAAU,cAAA;EA7BV;EA+BA,UAAA,IAAc,KAAA,EAAO,aAAA,KAAkB,aAAA;EACvC,MAAA,GAAS,QAAA;AAAA;AAAA,UAGM,YAAA;EACf,YAAA;EACA,oBAAA;AAAA;AAAA,UAGe,SAAA;EACf,KAAA,CAAM,OAAA,EAAS,YAAA;EACf,IAAA;EApCoD;EAsCpD,IAAA,CAAK,IAAA,UAAc,KAAA,EAAO,MAAA;EArCrB;EAuCL,KAAA,CAAM,IAAA,EAAM,gBAAA,EAAkB,KAAA,EAAO,MAAA;EAAA,SAC5B,YAAA;AAAA;;;AA/FX;;;;;AAYA;;;;AAZA,iBCuCgB,eAAA,CAAgB,OAAA,EAAS,gBAAA,GAAgB,SAAA;;;ADvCzD;;;;;AAYA;;;;AAZA,iBEUgB,QAAA,CACd,GAAA,UACA,IAAA;EAAQ,OAAA,GAAU,MAAA;AAAA,IAA6B,YAAA;;;;iBCZjC,OAAA,CAAQ,QAAA,UAAkB,UAAA;;iBAK1B,gBAAA,CACd,GAAA,UACA,UAAA,aACA,IAAA;;;;;;;AHRF;AAAA,KIEY,SAAA,sCAKR,SAAA;EAAA,CACG,GAAA,WAAc,SAAA;AAAA;;UAGJ,eAAA,KAAoB,SAAA;EACnC,IAAA;EACA,IAAA;EACA,SAAA;EACA,KAAA,EAAO,CAAA;AAAA;AAAA,KAGG,WAAA;EACN,MAAA;EAAmB,YAAA;AAAA;EACnB,MAAA;EAAiB,IAAA;AAAA;AAAA,UAEN,eAAA;EACf,SAAA;EACA,QAAA;IAAY,SAAA;IAAmB,KAAA;EAAA;AAAA;AAAA,UAGhB,eAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,wBAAA;EJHP;EIKR,MAAA;EACA,OAAA,GAAU,cAAA;EJJH;EIMP,UAAA,aAAuB,MAAA;EJHO;EIK9B,UAAA,IAAc,KAAA,EAAO,eAAA,KAAoB,eAAA;EJHzC;EIKA,OAAA,IAAW,KAAA,EAAO,eAAA;AAAA;AAAA,UAGH,mBAAA;EACf,KAAA;EACA,IAAA;EACA,IAAA,CAAK,IAAA,UAAc,KAAA,EAAO,SAAA;AAAA;;;cClCf,0BAAA;;;;;iBAmCG,yBAAA,CACd,OAAA,EAAS,wBAAA;EACP,IAAA,GAAO,KAAA,EAAO,eAAA;AAAA,IAEf,mBAAA"} +\ No newline at end of file ++{"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/collector.ts","../src/http-sink.ts","../src/routes.ts","../src/trajectory-types.ts","../src/trajectory-collector.ts"],"mappings":";;cACa,oBAAA;AAAA,KAYD,gBAAA,WAA2B,oBAAA;;UAMtB,aAAA,KAAkB,MAAA;EACjC,IAAA;EACA,IAAA;EACA,KAAA,EAAO,CAAA;EACP,SAAA;AAAA;;UAIe,aAAA;EACf,YAAA;EACA,oBAAA;EACA,MAAA,EAAQ,aAAA;EAVR;EAYA,OAAA;AAAA;AAAA,UAGe,eAAA;EAZf;EAcA,MAAA;AAAA;AAVF;AAAA,KAcY,YAAA,KACV,KAAA,EAAO,aAAA,EACP,OAAA,GAAU,eAAA,YACA,OAAA;EAAA,SAA4B,GAAA;AAAA;AAAA,UAEvB,WAAA;EACf,GAAA;EACA,IAAA;EACA,MAAA;EACA,IAAA;EAlBO;;AAGT;;;EAqBE,KAAA;EACA,UAAA,EAAY,MAAA;EACZ,KAAA;EACA,OAAA;EACA,cAAA;EACA,KAAA;IAAU,IAAA;IAAc,IAAA;IAAc,IAAA;IAAc,YAAA;EAAA;EACpD,KAAA;AAAA;;KAIU,QAAA,IAAY,MAAA,EAAQ,OAAA,KAAY,MAAA;AAAA,UAI3B,cAAA;EACf,MAAA;EACA,UAAA;EACA,OAAA;EACA,MAAA;AAAA;AAAA,UAGe,gBAAA;EACf,IAAA,EAAM,YAAA;EA7BN;EA+BA,MAAA;EAxBA;EA0BA,UAAA,aAAuB,MAAA;EAzBX;EA2BZ,OAAA,GAAU,cAAA;EAzBV;EA2BA,UAAA,IAAc,KAAA,EAAO,aAAA,KAAkB,aAAA;EACvC,MAAA,GAAS,QAAA;AAAA;AAAA,UAGM,YAAA;EACf,YAAA;EACA,oBAAA;AAAA;AAAA,UAGe,SAAA;EACf,KAAA,CAAM,OAAA,EAAS,YAAA;EACf,IAAA;EA/BkB;EAiClB,IAAA,CAAK,IAAA,UAAc,KAAA,EAAO,MAAA;EAjCsB;EAmChD,KAAA,CAAM,IAAA,EAAM,gBAAA,EAAkB,KAAA,EAAO,MAAA;EAAA,SAC5B,YAAA;AAAA;;;AArGX;;;;;AAYA;;;;AAZA,iBCwCgB,eAAA,CAAgB,OAAA,EAAS,gBAAA,GAAgB,SAAA;;;ADxCzD;;;;;AAYA;;;;AAZA,iBEUgB,QAAA,CACd,GAAA,UACA,IAAA;EAAQ,OAAA,GAAU,MAAA;AAAA,IAA6B,YAAA;;;;iBCZjC,OAAA,CAAQ,QAAA,UAAkB,UAAA;;iBAK1B,gBAAA,CACd,GAAA,UACA,UAAA,aACA,IAAA;;;;;;;AHRF;AAAA,KIEY,SAAA,sCAKR,SAAA;EAAA,CACG,GAAA,WAAc,SAAA;AAAA;;UAGJ,eAAA,KAAoB,SAAA;EACnC,IAAA;EACA,IAAA;EACA,SAAA;EACA,KAAA,EAAO,CAAA;AAAA;AAAA,KAGG,WAAA;EACN,MAAA;EAAmB,YAAA;AAAA;EACnB,MAAA;EAAiB,IAAA;AAAA;AAAA,UAEN,eAAA;EACf,SAAA;EACA,QAAA;IAAY,SAAA;IAAmB,KAAA;EAAA;AAAA;AAAA,UAGhB,eAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,wBAAA;EJHP;EIKR,MAAA;EACA,OAAA,GAAU,cAAA;EJJH;EIMP,UAAA,aAAuB,MAAA;EJHO;EIK9B,UAAA,IAAc,KAAA,EAAO,eAAA,KAAoB,eAAA;EJHzC;EIKA,OAAA,IAAW,KAAA,EAAO,eAAA;AAAA;AAAA,UAGH,mBAAA;EACf,KAAA;EACA,IAAA;EACA,IAAA,CAAK,IAAA,UAAc,KAAA,EAAO,SAAA;AAAA;;;cClCf,0BAAA;;;;;iBAmCG,yBAAA,CACd,OAAA,EAAS,wBAAA;EACP,IAAA,GAAO,KAAA,EAAO,eAAA;AAAA,IAEf,mBAAA"} +\ No newline at end of file +diff --git a/dist/index.d.mts b/dist/index.d.mts +index 4aa0c8cd472fa803ffdb77205a15f6c55a9a03c6..169d7563e6c03cd6d2f90c0bcc5c2cce91fb9dd4 100644 +--- a/dist/index.d.mts ++++ b/dist/index.d.mts +@@ -30,6 +30,12 @@ interface ClickTarget { + role: string | null; + action: string | null; + text: string; ++ /** ++ * Short, cleaned name of the clicked control (click events only). Comes from ++ * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title`, or a ++ * control's own text. Omitted when no safe name exists. ++ */ ++ label?: string; + attributes: Record<string, string>; + value?: string; + checked?: boolean; +diff --git a/dist/index.d.mts.map b/dist/index.d.mts.map +index cfe28df720a125275c0cfb4f169aea9bcee94fd5..aa5be870738b05c7e91071d81e1816cc83422c6b 100644 +--- a/dist/index.d.mts.map ++++ b/dist/index.d.mts.map +@@ -1 +1 @@ +-{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/collector.ts","../src/http-sink.ts","../src/routes.ts","../src/trajectory-types.ts","../src/trajectory-collector.ts"],"mappings":";;cACa,oBAAA;AAAA,KAYD,gBAAA,WAA2B,oBAAA;;UAMtB,aAAA,KAAkB,MAAA;EACjC,IAAA;EACA,IAAA;EACA,KAAA,EAAO,CAAA;EACP,SAAA;AAAA;;UAIe,aAAA;EACf,YAAA;EACA,oBAAA;EACA,MAAA,EAAQ,aAAA;EAVR;EAYA,OAAA;AAAA;AAAA,UAGe,eAAA;EAZf;EAcA,MAAA;AAAA;AAVF;AAAA,KAcY,YAAA,KACV,KAAA,EAAO,aAAA,EACP,OAAA,GAAU,eAAA,YACA,OAAA;EAAA,SAA4B,GAAA;AAAA;AAAA,UAEvB,WAAA;EACf,GAAA;EACA,IAAA;EACA,MAAA;EACA,IAAA;EACA,UAAA,EAAY,MAAA;EACZ,KAAA;EACA,OAAA;EACA,cAAA;EACA,KAAA;IAAU,IAAA;IAAc,IAAA;IAAc,IAAA;IAAc,YAAA;EAAA;EACpD,KAAA;AAAA;;KAIU,QAAA,IAAY,MAAA,EAAQ,OAAA,KAAY,MAAA;AAAA,UAI3B,cAAA;EACf,MAAA;EACA,UAAA;EACA,OAAA;EACA,MAAA;AAAA;AAAA,UAGe,gBAAA;EACf,IAAA,EAAM,YAAA;EA1BS;EA4Bf,MAAA;;EAEA,UAAA,aAAuB,MAAA;EA7BvB;EA+BA,OAAA,GAAU,cAAA;EA7BV;EA+BA,UAAA,IAAc,KAAA,EAAO,aAAA,KAAkB,aAAA;EACvC,MAAA,GAAS,QAAA;AAAA;AAAA,UAGM,YAAA;EACf,YAAA;EACA,oBAAA;AAAA;AAAA,UAGe,SAAA;EACf,KAAA,CAAM,OAAA,EAAS,YAAA;EACf,IAAA;EApCoD;EAsCpD,IAAA,CAAK,IAAA,UAAc,KAAA,EAAO,MAAA;EArCrB;EAuCL,KAAA,CAAM,IAAA,EAAM,gBAAA,EAAkB,KAAA,EAAO,MAAA;EAAA,SAC5B,YAAA;AAAA;;;AA/FX;;;;;AAYA;;;;AAZA,iBCuCgB,eAAA,CAAgB,OAAA,EAAS,gBAAA,GAAgB,SAAA;;;ADvCzD;;;;;AAYA;;;;AAZA,iBEUgB,QAAA,CACd,GAAA,UACA,IAAA;EAAQ,OAAA,GAAU,MAAA;AAAA,IAA6B,YAAA;;;;iBCZjC,OAAA,CAAQ,QAAA,UAAkB,UAAA;;iBAK1B,gBAAA,CACd,GAAA,UACA,UAAA,aACA,IAAA;;;;;;;AHRF;AAAA,KIEY,SAAA,sCAKR,SAAA;EAAA,CACG,GAAA,WAAc,SAAA;AAAA;;UAGJ,eAAA,KAAoB,SAAA;EACnC,IAAA;EACA,IAAA;EACA,SAAA;EACA,KAAA,EAAO,CAAA;AAAA;AAAA,KAGG,WAAA;EACN,MAAA;EAAmB,YAAA;AAAA;EACnB,MAAA;EAAiB,IAAA;AAAA;AAAA,UAEN,eAAA;EACf,SAAA;EACA,QAAA;IAAY,SAAA;IAAmB,KAAA;EAAA;AAAA;AAAA,UAGhB,eAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,wBAAA;EJHP;EIKR,MAAA;EACA,OAAA,GAAU,cAAA;EJJH;EIMP,UAAA,aAAuB,MAAA;EJHO;EIK9B,UAAA,IAAc,KAAA,EAAO,eAAA,KAAoB,eAAA;EJHzC;EIKA,OAAA,IAAW,KAAA,EAAO,eAAA;AAAA;AAAA,UAGH,mBAAA;EACf,KAAA;EACA,IAAA;EACA,IAAA,CAAK,IAAA,UAAc,KAAA,EAAO,SAAA;AAAA;;;cClCf,0BAAA;;;;;iBAmCG,yBAAA,CACd,OAAA,EAAS,wBAAA;EACP,IAAA,GAAO,KAAA,EAAO,eAAA;AAAA,IAEf,mBAAA"} +\ No newline at end of file ++{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/collector.ts","../src/http-sink.ts","../src/routes.ts","../src/trajectory-types.ts","../src/trajectory-collector.ts"],"mappings":";;cACa,oBAAA;AAAA,KAYD,gBAAA,WAA2B,oBAAA;;UAMtB,aAAA,KAAkB,MAAA;EACjC,IAAA;EACA,IAAA;EACA,KAAA,EAAO,CAAA;EACP,SAAA;AAAA;;UAIe,aAAA;EACf,YAAA;EACA,oBAAA;EACA,MAAA,EAAQ,aAAA;EAVR;EAYA,OAAA;AAAA;AAAA,UAGe,eAAA;EAZf;EAcA,MAAA;AAAA;AAVF;AAAA,KAcY,YAAA,KACV,KAAA,EAAO,aAAA,EACP,OAAA,GAAU,eAAA,YACA,OAAA;EAAA,SAA4B,GAAA;AAAA;AAAA,UAEvB,WAAA;EACf,GAAA;EACA,IAAA;EACA,MAAA;EACA,IAAA;EAlBO;;AAGT;;;EAqBE,KAAA;EACA,UAAA,EAAY,MAAA;EACZ,KAAA;EACA,OAAA;EACA,cAAA;EACA,KAAA;IAAU,IAAA;IAAc,IAAA;IAAc,IAAA;IAAc,YAAA;EAAA;EACpD,KAAA;AAAA;;KAIU,QAAA,IAAY,MAAA,EAAQ,OAAA,KAAY,MAAA;AAAA,UAI3B,cAAA;EACf,MAAA;EACA,UAAA;EACA,OAAA;EACA,MAAA;AAAA;AAAA,UAGe,gBAAA;EACf,IAAA,EAAM,YAAA;EA7BN;EA+BA,MAAA;EAxBA;EA0BA,UAAA,aAAuB,MAAA;EAzBX;EA2BZ,OAAA,GAAU,cAAA;EAzBV;EA2BA,UAAA,IAAc,KAAA,EAAO,aAAA,KAAkB,aAAA;EACvC,MAAA,GAAS,QAAA;AAAA;AAAA,UAGM,YAAA;EACf,YAAA;EACA,oBAAA;AAAA;AAAA,UAGe,SAAA;EACf,KAAA,CAAM,OAAA,EAAS,YAAA;EACf,IAAA;EA/BkB;EAiClB,IAAA,CAAK,IAAA,UAAc,KAAA,EAAO,MAAA;EAjCsB;EAmChD,KAAA,CAAM,IAAA,EAAM,gBAAA,EAAkB,KAAA,EAAO,MAAA;EAAA,SAC5B,YAAA;AAAA;;;AArGX;;;;;AAYA;;;;AAZA,iBCwCgB,eAAA,CAAgB,OAAA,EAAS,gBAAA,GAAgB,SAAA;;;ADxCzD;;;;;AAYA;;;;AAZA,iBEUgB,QAAA,CACd,GAAA,UACA,IAAA;EAAQ,OAAA,GAAU,MAAA;AAAA,IAA6B,YAAA;;;;iBCZjC,OAAA,CAAQ,QAAA,UAAkB,UAAA;;iBAK1B,gBAAA,CACd,GAAA,UACA,UAAA,aACA,IAAA;;;;;;;AHRF;AAAA,KIEY,SAAA,sCAKR,SAAA;EAAA,CACG,GAAA,WAAc,SAAA;AAAA;;UAGJ,eAAA,KAAoB,SAAA;EACnC,IAAA;EACA,IAAA;EACA,SAAA;EACA,KAAA,EAAO,CAAA;AAAA;AAAA,KAGG,WAAA;EACN,MAAA;EAAmB,YAAA;AAAA;EACnB,MAAA;EAAiB,IAAA;AAAA;AAAA,UAEN,eAAA;EACf,SAAA;EACA,QAAA;IAAY,SAAA;IAAmB,KAAA;EAAA;AAAA;AAAA,UAGhB,eAAA;EACf,IAAA;EACA,OAAA;AAAA;AAAA,UAGe,wBAAA;EJHP;EIKR,MAAA;EACA,OAAA,GAAU,cAAA;EJJH;EIMP,UAAA,aAAuB,MAAA;EJHO;EIK9B,UAAA,IAAc,KAAA,EAAO,eAAA,KAAoB,eAAA;EJHzC;EIKA,OAAA,IAAW,KAAA,EAAO,eAAA;AAAA;AAAA,UAGH,mBAAA;EACf,KAAA;EACA,IAAA;EACA,IAAA,CAAK,IAAA,UAAc,KAAA,EAAO,SAAA;AAAA;;;cClCf,0BAAA;;;;;iBAmCG,yBAAA,CACd,OAAA,EAAS,wBAAA;EACP,IAAA,GAAO,KAAA,EAAO,eAAA;AAAA,IAEf,mBAAA"} +\ No newline at end of file +diff --git a/dist/index.mjs b/dist/index.mjs +index fec482c7cff5d3e246bfc934203d334ee5725866..21a9c3c3ac67562bd8caa482c848c5b5bbe1052d 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -115,13 +115,17 @@ function createRedactor() { + const toggled = (records) => { + for (const record of records) if (record.oldValue?.toLowerCase() === "password") seen.add(record.target); + }; +- /** Replaces remembered values, raw and in their JSON and URL encodings. */ +- const scrub = (text) => { ++ /** ++ * Replaces remembered values, raw and in their JSON and URL encodings, plus ++ * `normalize(value)` when given and at least 4 characters long. ++ */ ++ const scrub = (text, normalize) => { + for (const value of [...remembered.values()].sort((a, b) => b.length - a.length)) for (const form of [ + value, + JSON.stringify(value).slice(1, -1), + encodeURIComponent(value), +- new URLSearchParams({ v: value }).toString().slice(2) ++ new URLSearchParams({ v: value }).toString().slice(2), ++ ...[normalize?.(value) ?? ""].filter((normal) => normal.length >= 4) + ]) text = /^\d+$/.test(form) ? text.replace(RegExp(`(?<!\\d)${form}(?!\\d)`, "g"), REDACTED) : text.split(form).join(REDACTED); + return text; + }; +@@ -139,6 +143,7 @@ function createRedactor() { + }; + return { + isPassword, ++ scrub, + watch() { + try { + for (const input of document.querySelectorAll("input[type=password]")) seen.add(input); +@@ -222,6 +227,172 @@ function describeTarget(el, redact) { + } + }; + } ++const MAX_LABEL_LENGTH = 80; ++const MAX_WALK_NODES = 1e3; ++const MAX_WALK_CHARS = 4 * MAX_LABEL_LENGTH; ++const CONTROL_ROLE = /^(button|link|tab|menuitem(checkbox|radio)?|option|checkbox|switch|radio|treeitem)$/; ++const CONTENTEDITABLE = "[contenteditable]:not([contenteditable=\"false\"])"; ++const TEXT_ENTRY = `${CONTENTEDITABLE}, [role~=textbox], [role~=searchbox], [role~=combobox]`; ++const EDITABLE = `input, textarea, select, ${TEXT_ENTRY}`; ++const HEADING = "h1, h2, h3, h4, h5, h6, [role~=heading]"; ++const STRUCTURE = `${HEADING}, p, ul, ol, li, dl, table, article, section, blockquote`; ++const SKIPPED = "script, style, template, [hidden], [aria-hidden=\"true\"]"; ++const PHRASING = /^(b|i|u|em|strong|mark|small|sub|sup|abbr|code|kbd|s|q|cite|dfn|var|time|data|bdi|bdo|wbr|span)$/; ++const CAPTION_INPUT = /^(submit|button|reset|image)$/; ++const NON_TEXT_INPUT = /^(submit|button|reset|image|checkbox|radio|range|color|file|hidden)$/; ++const IGNORABLE = /\p{Default_Ignorable_Code_Point}/gu; ++const URL_LIKE = /(?:\b[a-z][\w+.-]{0,31}:\/\/|\bwww\.)[^\s<>"']*[^\s<>"'.,;:!?)]/gi; ++const EMAIL = /[\p{L}\p{N}_.%+-]{1,64}@[\p{L}\p{N}-]{1,63}(?:\.[\p{L}\p{N}-]{1,63})+/gu; ++const JWT = /(^|[^\w-])(?:eyJ[\w-]*\.[\w-]*\.[\w-]*|[\w-]{16,}\.[\w-]{16,}\.[\w-]{16,}|(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,})/g; ++const TOKEN_RUN = /[\w+/=-]{24,}/g; ++const roleOf = (element) => (element.getAttribute("role") ?? "").trim().split(/\s+/)[0].toLowerCase(); ++const idRefs = (element, name) => (element.getAttribute(name) ?? "").split(/\s+/).filter(Boolean); ++const rootOf = (element) => { ++ const root = element.getRootNode(); ++ return root instanceof Document || root instanceof ShadowRoot ? root : null; ++}; ++/** Buttons, links with `href`, `summary`, and elements with a control role. */ ++const isControl = (element) => { ++ const role = roleOf(element); ++ return role ? CONTROL_ROLE.test(role) : element.matches("button, a[href], summary"); ++}; ++/** Text-like inputs, text areas, selects, and anything inside editable content or a text-entry role. */ ++const holdsValue = (element) => element instanceof HTMLInputElement ? !NON_TEXT_INPUT.test(element.type) : element.matches("textarea, select") || element.closest(TEXT_ENTRY) !== null; ++/** ++* The name an image contributes: `alt` of an `img`, or `aria-label` (else a ++* plain `<title>`) of an `svg` or `role="img"` element. `undefined` when the ++* element is not an image. ++*/ ++function imageName(element) { ++ if (element.localName === "img") return element.getAttribute("alt"); ++ if (element.localName !== "svg" && roleOf(element) !== "img") return void 0; ++ const title = Array.from(element.children).find((child) => child.localName === "title" && child.childElementCount === 0); ++ return element.getAttribute("aria-label") ?? title?.textContent ?? null; ++} ++/** ++* Rendered text. Inline formatting joins words; other elements separate them. ++* Skips hidden subtrees, uses image names, and does ++* not walk into images. Yields nothing for editable content or editable ++* descendants. In `strict` mode (a control's own text) it also yields nothing ++* past the walk bounds, for card-like structure, and for text that looks like ++* separate fields: two `div`s that directly hold text, or three sibling ++* elements that each carry text. ++*/ ++function safeText(root, strict) { ++ if (holdsValue(root)) return ""; ++ let text = ""; ++ let nodes = 0; ++ let paragraphs = 0; ++ let div; ++ const bearers = /* @__PURE__ */ new Map(); ++ const add = (part) => text = (text + part).replace(/\s+/g, " "); ++ const bear = (start) => { ++ if (!strict) return true; ++ for (let child = start, parent = start.parentNode; child !== root && parent !== null; child = parent, parent = parent.parentNode) { ++ const siblings = bearers.get(parent) ?? /* @__PURE__ */ new Set(); ++ bearers.set(parent, siblings); ++ if (siblings.has(child)) break; ++ if (siblings.add(child).size > 2) return false; ++ } ++ return true; ++ }; ++ let node = root.firstChild; ++ while (node !== null) { ++ if (++nodes > MAX_WALK_NODES || text.length > MAX_WALK_CHARS) { ++ if (strict) return ""; ++ break; ++ } ++ let descend = false; ++ if (node instanceof Text) { ++ const parent = node.parentNode; ++ let holder = parent; ++ while (holder !== root && PHRASING.test(holder.localName)) holder = holder.parentElement; ++ if (/\S/.test(node.data) && (!bear(parent) || strict && holder.localName === "div" && (div ??= holder) !== holder)) return ""; ++ add(node.data); ++ } else if (node instanceof Element) { ++ if (node.matches(EDITABLE)) return ""; ++ if (strict && node.matches(STRUCTURE) && !(root.localName === "summary" && node.matches(HEADING)) && (node.localName !== "p" || ++paragraphs > 1)) return ""; ++ const name = node.matches(SKIPPED) ? null : imageName(node); ++ if (name) { ++ if (!bear(node)) return ""; ++ add(` ${name} `); ++ } else if (name === void 0) { ++ if (!PHRASING.test(node.localName)) add(" "); ++ descend = true; ++ } ++ } ++ if (descend && node.firstChild !== null) node = node.firstChild; ++ else { ++ while (node !== root && node.nextSibling === null) { ++ node = node.parentNode; ++ if (!PHRASING.test(node.localName)) add(" "); ++ } ++ node = node === root ? null : node.nextSibling; ++ } ++ } ++ return text; ++} ++/** An option or tree item of an autocomplete or search popup, whose text usually repeats what the user typed. */ ++const isSuggestion = (element) => (element.localName === "option" || /^(option|treeitem)$/.test(roleOf(element))) && inSuggestionList(element); ++/** Whether `element` is, or is inside, a combobox, a datalist, or the popup a combobox or autocomplete field controls or owns. */ ++function inSuggestionList(element) { ++ if (element.closest("[role~=combobox], datalist") !== null) return true; ++ const root = rootOf(element); ++ return root !== null && Array.from(root.querySelectorAll("[role~=combobox], [aria-autocomplete]")).some((owner) => [...idRefs(owner, "aria-controls"), ...idRefs(owner, "aria-owns")].some((id) => root.getElementById(id)?.contains(element))); ++} ++function labelledBy(element) { ++ const root = rootOf(element); ++ return root === null ? null : idRefs(element, "aria-labelledby").map((id) => { ++ const referenced = root.getElementById(id); ++ return referenced === null || inSuggestionList(referenced) ? "" : safeText(referenced, false); ++ }).join(" "); ++} ++/** A control's own text, unless it is longer than a label: then it is content, not a name. */ ++const normalize = (text) => text.replace(IGNORABLE, "").replace(/\s+/g, " ").trim(); ++function contentText(element) { ++ const text = safeText(element, true); ++ return Array.from(normalize(text)).length > MAX_LABEL_LENGTH ? "" : text; ++} ++/** Removes invisible characters, credentials, emails, URLs, and tokens, then caps the length. */ ++function cleanLabel(raw, redact) { ++ const chars = Array.from(redact.scrub(normalize(redact.body(raw.replace(IGNORABLE, "")) ?? ""), normalize).replace(URL_LIKE, "[url]").replace(EMAIL, "[email]").replace(JWT, `$1${REDACTED}`).replace(TOKEN_RUN, (run) => /\d/.test(run) ? REDACTED : run)); ++ return chars.length > MAX_LABEL_LENGTH ? `${chars.slice(0, MAX_LABEL_LENGTH - 1).join("").trimEnd()}…` : chars.join(""); ++} ++function firstLabel(sources, redact) { ++ for (const source of sources) { ++ const label = cleanLabel(source() ?? "", redact); ++ if (label) return label; ++ } ++} ++/** ++* A short name for the clicked control, from the first source that yields one: ++* `aria-labelledby`, `aria-label`, an input caption or `alt`, `title` (controls ++* only), then the text of a control named by its content. Containers, cards, ++* and editable content never contribute their text. An autocomplete suggestion, ++* or anything inside one, gets no label from any source; `data-copilotkit-action` ++* names it instead. An icon without its own name takes the name of the control ++* around it. ++*/ ++function clickLabel(element, redact) { ++ const option = element.closest("option, [role~=option], [role~=treeitem]"); ++ if (option !== null && isSuggestion(option)) return void 0; ++ const named = [() => labelledBy(element), () => element.getAttribute("aria-label")]; ++ if (/^(img|svg)$/.test(element.localName) || /^(img|presentation|none)$/.test(roleOf(element))) { ++ const own = firstLabel([...named, () => imageName(element) ?? null], redact); ++ if (own !== void 0) return own; ++ let control = element.parentElement; ++ while (control !== null && !isControl(control)) control = control.parentElement; ++ return control === null ? void 0 : clickLabel(control, redact); ++ } ++ const control = isControl(element) && !holdsValue(element); ++ const captionInput = element instanceof HTMLInputElement && CAPTION_INPUT.test(element.type); ++ return firstLabel([ ++ ...named, ++ () => captionInput ? element.type === "image" ? element.getAttribute("alt") : element.value : null, ++ () => control || captionInput ? element.getAttribute("title") : null, ++ () => control ? contentText(element) : null ++ ], redact); ++} + /** Snapshot live control properties; HTML attributes do not reflect edited values. */ + function readControlValue(element, redact) { + if (element instanceof HTMLInputElement) return { +@@ -239,7 +410,7 @@ function readControlValue(element, redact) { + selectedValues: Array.from(element.selectedOptions, (option) => option.value) + }; + if (element instanceof HTMLTextAreaElement) return { value: element.value }; +- if (element.matches("[contenteditable]:not([contenteditable=\"false\"])")) return { value: element.textContent ?? "" }; ++ if (element.matches(CONTENTEDITABLE)) return { value: element.textContent ?? "" }; + return {}; + } + function eventElement(event) { +@@ -260,12 +431,21 @@ function isNear(origin, event) { + function installClickCapture(params) { + const { emit, getRoute, enrich, isTrusted = (event) => event.isTrusted, redact = createRedactor() } = params; + let origin = null; ++ const describeClick = (el) => { ++ const described = describeTarget(el, redact); ++ if (described === null) return null; ++ try { ++ const label = clickLabel(described.element, redact); ++ if (label !== void 0) described.target.label = label; ++ } catch {} ++ return described; ++ }; + const onPointerDown = (event) => { + try { + if (event.isPrimary === false) return; + const el = eventElement(event); + origin = { +- described: el === null ? null : describeTarget(el, redact), ++ described: el === null ? null : describeClick(el), + x: event.clientX, + y: event.clientY + }; +@@ -280,7 +460,7 @@ function installClickCapture(params) { + if (!isTrusted(event)) return; + const el = eventElement(event); + if (el === null) return; +- const described = pointerOrigin !== null && isRootOrDetached(el) && isNear(pointerOrigin, event) ? pointerOrigin.described : describeTarget(el, redact); ++ const described = pointerOrigin !== null && isRootOrDetached(el) && isNear(pointerOrigin, event) ? pointerOrigin.described : describeClick(el); + if (described === null) return; + const input = event.detail === 0 ? "keyboard" : "pointer"; + emit("click", { +@@ -310,41 +490,138 @@ function installClickCapture(params) { + + //#endregion + //#region src/inputs.ts +-/** Records user edits without changing the control or interfering with its listeners. */ ++const TEXT_DEBOUNCE_MS = 300; ++const TEXT_INPUT_TYPES = new Set([ ++ "text", ++ "search", ++ "tel", ++ "url", ++ "email", ++ "password", ++ "number" ++]); ++/** Records settled user edits without interfering with the host app's controls. */ + function installInputCapture(params) { + const { emit, isTrusted = (event) => event.isTrusted, redact = createRedactor() } = params; +- const onInput = (event) => { ++ let active = true; ++ const pending = /* @__PURE__ */ new Map(); ++ const changeEcho = /* @__PURE__ */ new WeakMap(); ++ const composing = /* @__PURE__ */ new WeakMap(); ++ const cancel = (element) => { ++ clearTimeout(pending.get(element)?.timer); ++ pending.delete(element); ++ }; ++ const deliver = (element, edit) => { + try { +- if (!isTrusted(event)) return; +- const [element] = event.composedPath(); +- if (!(element instanceof Element)) return; +- const described = describeTarget(element, redact); +- if (described === null) return; +- const value = readControlValue(element, redact); +- if (!("value" in value)) return; +- emit("input", { +- eventType: event.type, ++ if (!active || describeTarget(element, redact) === null) return; ++ if (redact.isPassword(element) && edit.state.value !== REDACTED) return; ++ if (edit.payload.eventType === "input") changeEcho.set(element, JSON.stringify(edit.state)); ++ edit.payload.url = redact.url(edit.payload.url); ++ edit.payload.target.text = redact.url(edit.payload.target.text); ++ edit.payload.target.attributes = Object.fromEntries(Object.entries(edit.payload.target.attributes).map(([key, value]) => [key, redact.url(value)])); ++ emit("input", edit.payload); ++ } catch {} ++ }; ++ const flush = () => { ++ const edits = [...pending]; ++ for (const [element] of edits) cancel(element); ++ for (const [element, edit] of edits) deliver(element, edit); ++ }; ++ const observe = (element, eventType, settle) => { ++ if (pending.size > 0 && !pending.has(element)) flush(); ++ if (!active) return; ++ const described = describeTarget(element, redact); ++ if (described === null) { ++ cancel(element); ++ return; ++ } ++ const state = readControlValue(element, redact); ++ if (!("value" in state)) return; ++ const text = element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement && TEXT_INPUT_TYPES.has(element.type) || element.matches("[contenteditable]:not([contenteditable=\"false\"])"); ++ if (!text) flush(); ++ const signature = JSON.stringify(state); ++ const echo = eventType === "change" && (signature === JSON.stringify(pending.get(element)?.state) || signature === changeEcho.get(element)); ++ changeEcho.delete(element); ++ if (echo) { ++ flush(); ++ changeEcho.delete(element); ++ return; ++ } ++ cancel(element); ++ const edit = { ++ state, ++ payload: { ++ eventType, + target: { + ...described.target, +- ...value ++ ...state + }, + url: redact.url(location.href), + route: location.pathname +- }); ++ } ++ }; ++ if (!text) { ++ deliver(element, edit); ++ return; ++ } ++ if (settle) edit.timer = setTimeout(() => { ++ cancel(element); ++ deliver(element, edit); ++ }, TEXT_DEBOUNCE_MS); ++ pending.set(element, edit); ++ }; ++ const onEdit = (event) => { ++ try { ++ if (!active || !isTrusted(event)) return; ++ const [element] = event.composedPath(); ++ if (!(element instanceof Element)) return; ++ if (event.type === "compositionstart") { ++ composing.set(element, true); ++ clearTimeout(pending.get(element)?.timer); ++ return; ++ } ++ if (event.type === "compositionend") composing.set(element, false); ++ const inComposition = composing.get(element) === true || composing.get(element) !== false && "isComposing" in event && event.isComposing === true; ++ observe(element, event.type === "compositionend" ? "input" : event.type, !inComposition); ++ if (event.type === "change") flush(); + } catch {} + }; +- window.addEventListener("input", onInput, { ++ const onAction = (event) => { ++ if (event.type !== "pagehide" && !isTrusted(event)) return; ++ if (event.type === "keydown") { ++ const [element] = event.composedPath(); ++ if (!(event instanceof KeyboardEvent) || event.key !== "Enter" || event.isComposing || event.keyCode === 229 || element instanceof Element && composing.get(element) === true) return; ++ } ++ flush(); ++ }; ++ const editTypes = [ ++ "input", ++ "change", ++ "compositionstart", ++ "compositionend" ++ ]; ++ const actionTypes = [ ++ "click", ++ "submit", ++ "focusout", ++ "pagehide", ++ "keydown" ++ ]; ++ for (const type of editTypes) window.addEventListener(type, onEdit, { + capture: true, + passive: true + }); +- window.addEventListener("change", onInput, { ++ for (const type of actionTypes) window.addEventListener(type, onAction, { + capture: true, + passive: true + }); +- return () => { +- window.removeEventListener("input", onInput, { capture: true }); +- window.removeEventListener("change", onInput, { capture: true }); ++ const uninstall = () => { ++ active = false; ++ for (const element of pending.keys()) cancel(element); ++ for (const type of editTypes) window.removeEventListener(type, onEdit, true); ++ for (const type of actionTypes) window.removeEventListener(type, onAction, true); + }; ++ return Object.assign(uninstall, { flush }); + } + + //#endregion +@@ -864,6 +1141,8 @@ function createCollector(options) { + const record = (name, value) => { + const current = session; + if (current === null) return; ++ if (name !== "input" && name !== "network") current.flushInputs?.(); ++ if (session !== current) return; + try { + seq += 1; + const event = { +@@ -876,7 +1155,7 @@ function createCollector(options) { + timestamp: Date.now() + }; + const kept = beforeSend === void 0 ? event : beforeSend(event); +- if (kept === null) return; ++ if (kept === null || session !== current) return; + current.queue.push(kept); + if (current.queue.length >= FLUSH_SIZE) flush(); + } catch {} +@@ -919,10 +1198,14 @@ function createCollector(options) { + ignoreUrls, + redact + })); +- if (capture.inputs) current.uninstalls.push(installInputCapture({ +- emit: recordCurrent, +- redact +- })); ++ if (capture.inputs) { ++ const inputs = installInputCapture({ ++ emit: recordCurrent, ++ redact ++ }); ++ current.flushInputs = inputs.flush; ++ current.uninstalls.push(inputs); ++ } + const onPageHide = () => flush({ beacon: true }); + window.addEventListener("pagehide", onPageHide); + current.uninstalls.push(() => window.removeEventListener("pagehide", onPageHide)); +@@ -937,6 +1220,8 @@ function createCollector(options) { + const stop = () => { + const current = session; + if (current === null) return; ++ current.flushInputs?.(); ++ if (session !== current) return; + clearInterval(current.timer); + for (const uninstall of current.uninstalls) uninstall(); + flush(); +@@ -1016,7 +1301,9 @@ function isJson(value, ancestors = /* @__PURE__ */ new Set()) { + */ + function createTrajectoryCollector(options) { + let active = false; ++ let generation = 0; + let uninstalls = []; ++ let flushInputs; + const report = (code, message) => { + try { + options.onError?.({ +@@ -1028,6 +1315,9 @@ function createTrajectoryCollector(options) { + const getRoute = () => toRoute(location.pathname); + const record = (name, value) => { + if (!active) return; ++ const current = generation; ++ if (name !== "input" && name !== "network") flushInputs?.(); ++ if (!active || generation !== current) return; + let payload; + try { + const event = { +@@ -1037,7 +1327,7 @@ function createTrajectoryCollector(options) { + value + }; + const kept = options.beforeSend === void 0 ? event : options.beforeSend(event); +- if (kept === null || !active) return; ++ if (kept === null || !active || generation !== current) return; + if (kept.type !== "CUSTOM" || typeof kept.name !== "string" || !kept.name.trim() || kept.name.length > 200 || kept.name.includes("\0") || !Number.isFinite(kept.timestamp) || kept.timestamp <= 0 || kept.timestamp >= 0xe677d21fdc00 || !isJson(kept.value)) { + report("INVALID_EVENT", "The event must contain a name, timestamp, and JSON value."); + return; +@@ -1067,6 +1357,12 @@ function createTrajectoryCollector(options) { + record(name, value); + }; + const stop = () => { ++ const current = generation; ++ const flush = flushInputs; ++ flushInputs = void 0; ++ flush?.(); ++ if (generation !== current) return; ++ generation++; + active = false; + const installed = uninstalls; + uninstalls = []; +@@ -1076,6 +1372,7 @@ function createTrajectoryCollector(options) { + start() { + if (active || typeof window === "undefined") return; + active = true; ++ generation++; + if (typeof window.addEventListener !== "function") return; + const redact = createRedactor(); + try { +@@ -1089,10 +1386,14 @@ function createTrajectoryCollector(options) { + emit: capture, + redact + })); +- if (options.capture?.inputs !== false) uninstalls.push(installInputCapture({ +- emit: capture, +- redact +- })); ++ if (options.capture?.inputs !== false) { ++ const inputs = installInputCapture({ ++ emit: capture, ++ redact ++ }); ++ flushInputs = inputs.flush; ++ uninstalls.push(inputs); ++ } + if (options.capture?.network !== false) uninstalls.push(installNetworkCapture({ + emit: capture, + ignoreUrls: options.ignoreUrls ?? [], +diff --git a/dist/index.mjs.map b/dist/index.mjs.map +index 547d4dfa2be4a2dbe783c5cc11a8e1bacc2c864a..813c85449bdd55717de995b2880bdea9cd64af60 100644 +--- a/dist/index.mjs.map ++++ b/dist/index.mjs.map +@@ -1 +1 @@ +-{"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/redact.ts","../src/clicks.ts","../src/inputs.ts","../src/navigation.ts","../src/network-body.ts","../src/network.ts","../src/routes.ts","../src/collector.ts","../src/http-sink.ts","../src/trajectory-collector.ts"],"sourcesContent":["/** Event names the collector and CopilotKit Core emit. `emit()` rejects them. */\nexport const BUILT_IN_EVENT_NAMES = [\n \"page\",\n \"navigation\",\n \"click\",\n \"input\",\n \"network\",\n \"thread.linked\",\n \"agent.run\",\n \"agent.message\",\n \"tool.call\",\n] as const;\n\nexport type BuiltInEventName = (typeof BUILT_IN_EVENT_NAMES)[number];\n\n/** Replaces values that never leave the browser, even with full capture: passwords and credentials. */\nexport const REDACTED = \"[redacted]\";\n\n/** One captured interaction, shaped as an AG-UI `CUSTOM` event. */\nexport interface LearningEvent<V = Record<string, unknown>> {\n type: \"CUSTOM\";\n name: string;\n value: V;\n timestamp: number;\n}\n\n/** What a sink receives. The Trajectory scope sits on the batch, not on each event. */\nexport interface LearningBatch {\n trajectoryId: string;\n learningContainerIds?: string[];\n events: LearningEvent[];\n /** Events lost since the previous batch (queue overflow or a failed send). */\n dropped: number;\n}\n\nexport interface SinkSendOptions {\n /** True when the page is going away; use a transport that survives unload. */\n beacon?: boolean;\n}\n\n/** Receives batches. `url`, when set, is never captured as a network event. */\nexport type LearningSink = ((\n batch: LearningBatch,\n options?: SinkSendOptions,\n) => void | Promise<void>) & { readonly url?: string };\n\nexport interface ClickTarget {\n tag: string;\n role: string | null;\n action: string | null;\n text: string;\n attributes: Record<string, string>;\n value?: string;\n checked?: boolean;\n selectedValues?: string[];\n files?: { name: string; type: string; size: number; lastModified: number }[];\n input: \"pointer\" | \"keyboard\";\n}\n\n/** Adds fields to a click event, from the element the user clicked. */\nexport type EnrichFn = (target: Element) => Record<string, unknown> | undefined;\n\nexport type Emit = (name: string, value: Record<string, unknown>) => void;\n\nexport interface CaptureOptions {\n clicks?: boolean;\n navigation?: boolean;\n network?: boolean;\n inputs?: boolean;\n}\n\nexport interface CollectorOptions {\n sink: LearningSink;\n /** @deprecated Capture retains raw URLs; route templates no longer transform events. */\n routes?: string[];\n /** URLs (prefix match on the absolute URL) or patterns that network capture skips. */\n ignoreUrls?: (string | RegExp)[];\n /** Turns capture modules on or off. All are on by default. */\n capture?: CaptureOptions;\n /** Runs last, in the browser. Return the event to send, or `null` to drop it. */\n beforeSend?: (event: LearningEvent) => LearningEvent | null;\n enrich?: EnrichFn;\n}\n\nexport interface StartOptions {\n trajectoryId: string;\n learningContainerIds?: string[];\n}\n\nexport interface Collector {\n start(options: StartOptions): void;\n stop(): void;\n /** Developer events. Built-in names warn and are ignored. */\n emit(name: string, value: Record<string, unknown>): void;\n /** @internal For @copilotkit/core only: emits built-in names. */\n ɵemit(name: BuiltInEventName, value: Record<string, unknown>): void;\n readonly trajectoryId: string | null;\n}\n","import { REDACTED } from \"./types\";\n\n// Passwords and unambiguous credentials never leave the browser, even with full\n// capture. Keys are split into lowercase words (`accessToken`, `access_token`,\n// `Access-Token` all become \"access token\"). These match anywhere in the joined words:\nconst CREDENTIAL =\n /password|passwd|passphrase|passcode|credential|api(key|token)|privatekey|subscriptionkey|(access|refresh|auth|session)token|clientsecret/;\n// These match only as whole words (`secret` but not `secretary`, `id token` but\n// not `invalid token`), as the last word (`Set-Cookie` but not `cookieConsent`),\n// or as the entire key.\nconst CREDENTIAL_WORD =\n / (secret|id ?token)s? | (authorization|cookies?|jwt|bearer ?token|[cx]srf ?token) $|^ (pwd|pass|token|otp) $/;\n// Metadata about a credential is not the credential: `passwordExpiresAt`, `credentialId`.\nconst METADATA =\n / (ids?|at|in|expires?|expiry|length|count|enabled|required|policy|hint|type) $/;\nconst PASSWORD_AUTOCOMPLETE = /(current|new)-password|one-time-code/;\n// A JSON string, possibly cut off, with the `:` that makes it a key.\nconst TOKEN = /\"((?:\\\\[\\s\\S]|[^\"\\\\])*)(\"?)(\\s*:\\s*)?/g;\n// A lone trailing backslash belongs to a cut-off string.\nconst STRING = /\"(?:\\\\[\\s\\S]?|[^\"\\\\])*\"?/y;\n// `key=value` and `key: value` outside JSON strings: forms, query strings,\n// GraphQL, YAML. A `:` needs a space or end after it, so `host:port` is no pair.\n// The key length bound keeps scanning long unbroken text linear.\n// An auth scheme (`Bearer x`) belongs to the value.\nconst PAIR =\n /([\\w$.%+[\\]-]{1,64})(\\s*(?:=|:(?=\\s|$))\\s*)((?:(?:basic|bearer|digest|token) +)?[^\\s&,;)}\\]]*)/gi;\n// `scheme://user:pass@` anywhere in text; up to the last `@` before the host.\n// The scheme length bound keeps scanning long words linear.\nconst USERINFO = /([a-z][\\w+.-]{0,31}:\\/\\/)[^\\s/?#\"]*@/gi;\n/** Text beyond this is neither redacted nor kept; snapshots keep 4 KiB of the result. */\nexport const TEXT_LIMIT = 16 * 1024;\nconst REMEMBERED_VALUES = 20;\n\nexport const isCredentialKey = (key: string) => {\n const words = ` ${key\n .replace(/%([\\da-f]{2})/gi, (_, hex: string) =>\n String.fromCharCode(parseInt(hex, 16)),\n )\n .replace(/([a-z\\d])(?=[A-Z])/g, \"$1 \")\n .toLowerCase()\n .split(/[^a-z\\d]+/)\n .filter(Boolean)\n .join(\" \")} `;\n return (\n !METADATA.test(words) &&\n (CREDENTIAL.test(words.replace(/ /g, \"\")) || CREDENTIAL_WORD.test(words))\n );\n};\n\nconst decode = (body: string) => {\n try {\n return JSON.parse(`\"${body}\"`) as string;\n } catch {\n return body;\n }\n};\n\n/** The end of the JSON value at `start`: a string, a balanced object or array, or a bare literal. */\nconst endOfValue = (text: string, start: number) => {\n let depth = 0;\n for (let i = start; i < text.length; i++) {\n const char = text[i]!;\n if (char === '\"') {\n STRING.lastIndex = i;\n STRING.test(text);\n i = STRING.lastIndex - 1;\n } else if (char === \"{\" || char === \"[\") {\n depth++;\n continue;\n } else if (char === \"}\" || char === \"]\") {\n if (--depth < 0) return i;\n } else {\n if (!depth && (char === \",\" || /\\s/.test(char))) return i;\n continue;\n }\n if (!depth) return i + 1;\n }\n return text.length;\n};\n\n/**\n * Redacts credential values in JSON (valid or cut off) and in key/value text,\n * and recurses into string values that hold JSON, forms, or URLs. Unchanged\n * parts keep their exact original text.\n */\nconst redactText = (text: string, depth = 0): string => {\n const token = new RegExp(TOKEN);\n let pending = false;\n const pairs = (part: string) => {\n const segment = part.replace(USERINFO, \"$1\");\n return segment.replace(\n PAIR,\n (pair, key: string, separator: string, value: string, offset: number) => {\n if (!isCredentialKey(key)) return pair;\n // The value may continue in a following string: `password:\"x\"`.\n if (offset + pair.length === segment.length) {\n pending = true;\n if (!value) return pair;\n }\n return key + separator + REDACTED;\n },\n );\n };\n let out = \"\";\n let last = 0;\n let match: RegExpExecArray | null;\n while ((match = token.exec(text))) {\n const [raw, body, closed, colon] = match;\n pending = false;\n out += pairs(text.slice(last, match.index));\n let end = token.lastIndex;\n let replacement = raw;\n const value = body!.includes(\"\\\\\") ? decode(body!) : body!;\n if (colon) {\n if (isCredentialKey(value)) {\n const valueEnd = endOfValue(text, end);\n // Booleans and null say whether a credential exists, not what it is.\n if (!/^(true|false|null|)$/.test(text.slice(end, valueEnd))) {\n replacement += `\"${REDACTED}\"`;\n end = valueEnd;\n }\n }\n } else if (pending) replacement = `\"${REDACTED}\"`;\n else if (depth < 4 && /[=:\"]/.test(value)) {\n const redacted = redactText(value, depth + 1);\n if (redacted !== value)\n replacement = JSON.stringify(redacted).slice(\n 0,\n closed ? undefined : -1,\n );\n }\n out += replacement;\n last = token.lastIndex = end;\n }\n return out + pairs(text.slice(last));\n};\n\n/** Per-session redaction state: password-like fields and the values typed into them. */\nexport function createRedactor() {\n const seen = new WeakSet<Element>();\n // Latest value per field, so each keystroke replaces the previous prefix.\n const remembered = new Map<Element, string>();\n let observer: MutationObserver | undefined;\n\n const toggled = (records: MutationRecord[]) => {\n for (const record of records)\n if (record.oldValue?.toLowerCase() === \"password\")\n seen.add(record.target as Element);\n };\n\n /** Replaces remembered values, raw and in their JSON and URL encodings. */\n const scrub = (text: string) => {\n for (const value of [...remembered.values()].sort(\n (a, b) => b.length - a.length,\n ))\n for (const form of [\n value,\n JSON.stringify(value).slice(1, -1),\n encodeURIComponent(value),\n new URLSearchParams({ v: value }).toString().slice(2),\n ])\n text = /^\\d+$/.test(form)\n ? // One-time codes must not match inside longer numbers.\n text.replace(RegExp(`(?<!\\\\d)${form}(?!\\\\d)`, \"g\"), REDACTED)\n : text.split(form).join(REDACTED);\n return text;\n };\n\n /** True for current, former (show-password toggles), and autocomplete password fields. */\n const isPassword = (element: Element) => {\n if (observer) toggled(observer.takeRecords());\n if (\n !(element instanceof HTMLInputElement) ||\n !(\n element.type === \"password\" ||\n seen.has(element) ||\n isCredentialKey(element.name) ||\n PASSWORD_AUTOCOMPLETE.test(element.getAttribute(\"autocomplete\") ?? \"\")\n )\n )\n return false;\n seen.add(element);\n if (element.value.length >= 4) {\n remembered.delete(element);\n remembered.set(element, element.value);\n if (remembered.size > REMEMBERED_VALUES)\n remembered.delete(remembered.keys().next().value!);\n }\n return true;\n };\n\n return {\n isPassword,\n /**\n * Tracks password fields for the session: existing ones, `type` toggles away\n * from \"password\", and typed values even when input capture is off.\n */\n watch() {\n // Each part is optional: a partial DOM (tests, embedded webviews, hardened\n // pages) must not stop capture from starting or break the host app.\n try {\n for (const input of document.querySelectorAll(\"input[type=password]\"))\n seen.add(input);\n } catch {\n // Fields are still caught on first sight by isPassword.\n }\n let current: MutationObserver | undefined;\n try {\n current = new MutationObserver(toggled);\n current.observe(document, {\n subtree: true,\n attributeFilter: [\"type\"],\n attributeOldValue: true,\n });\n observer = current;\n } catch {\n current = undefined;\n }\n const remember = (event: Event) => {\n try {\n const [target] = event.composedPath();\n if (target instanceof Element) isPassword(target);\n } catch {\n // Capture must never break an edit in the host app.\n }\n };\n for (const type of [\"input\", \"change\"])\n window.addEventListener(type, remember, true);\n return () => {\n current?.disconnect();\n if (current !== undefined && observer === current) observer = undefined;\n for (const type of [\"input\", \"change\"])\n window.removeEventListener(type, remember, true);\n };\n },\n field: (key: string, value: string) =>\n isCredentialKey(key) ? REDACTED : scrub(redactText(value)),\n /** Returns the redacted body text, or undefined if redaction failed. */\n body(text: string) {\n try {\n return scrub(redactText(text.slice(0, TEXT_LIMIT)));\n } catch {\n return undefined;\n }\n },\n /** Drops userinfo and redacts credential values; also applied to element attributes. */\n url(value: string) {\n try {\n return scrub(value.replace(/[^#]+/g, (part) => redactText(part)));\n } catch {\n return REDACTED;\n }\n },\n };\n}\n\nexport type Redactor = ReturnType<typeof createRedactor>;\n","import { createRedactor, isCredentialKey } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { ClickTarget, Emit, EnrichFn } from \"./types\";\n\nconst INTERACTIVE =\n 'button, a[href], input, select, textarea, label, summary, [role], [data-copilotkit-action], [contenteditable]:not([contenteditable=\"false\"])';\nconst RECOVERY_DISTANCE_PX = 5;\n// `[role]` also matches containers such as role=\"main\"; cap their text so one\n// click cannot exceed the 16 KiB event limit and be dropped.\nconst MAX_TEXT_LENGTH = 1000;\n\n/** `closest()` that continues through shadow roots to their hosts. */\nfunction closestAcrossShadow(el: Element, selector: string) {\n let current: Element | null = el;\n while (current !== null) {\n const match = current.closest(selector);\n if (match !== null) return match;\n const root = current.getRootNode();\n current = root instanceof ShadowRoot ? root.host : null;\n }\n return null;\n}\n\ninterface Described {\n element: Element;\n target: Omit<ClickTarget, \"input\">;\n}\n\ninterface PointerOrigin {\n described: Described | null;\n x: number;\n y: number;\n}\n\n/**\n * Finds the nearest interactive element and retains its text, attributes, and value.\n * The explicit `data-copilotkit-ignore` opt-out still applies.\n */\nexport function describeTarget(el: Element, redact: Redactor) {\n if (closestAcrossShadow(el, \"[data-copilotkit-ignore]\") !== null) return null;\n const element = el.closest(INTERACTIVE) ?? el;\n // React and server markup can mirror the password into the value attribute.\n const password = redact.isPassword(element);\n const described: Described = {\n element,\n target: {\n tag: element.localName,\n role: element.getAttribute(\"role\"),\n action: element.getAttribute(\"data-copilotkit-action\"),\n text: (element.textContent ?? \"\").slice(0, MAX_TEXT_LENGTH),\n attributes: Object.fromEntries(\n Array.from(element.attributes, ({ name, value }) => [\n name,\n // Credential-named attributes (`data-api-key`) are redacted whole;\n // URL-valued ones (`href`, `action`, …) can carry credentials too.\n (password && name === \"value\") ||\n isCredentialKey(name.replace(/^data-/, \"\"))\n ? REDACTED\n : redact.url(value),\n ]),\n ),\n ...readControlValue(element, redact),\n },\n };\n return described;\n}\n\n/** Snapshot live control properties; HTML attributes do not reflect edited values. */\nexport function readControlValue(element: Element, redact: Redactor) {\n if (element instanceof HTMLInputElement) {\n return {\n value: redact.isPassword(element) ? REDACTED : element.value,\n ...([\"checkbox\", \"radio\"].includes(element.type)\n ? { checked: element.checked }\n : {}),\n ...(element.type === \"file\"\n ? {\n files: Array.from(element.files ?? [], (file) => ({\n name: file.name,\n type: file.type,\n size: file.size,\n lastModified: file.lastModified,\n })),\n }\n : {}),\n };\n }\n if (element instanceof HTMLSelectElement) {\n return {\n value: element.value,\n selectedValues: Array.from(\n element.selectedOptions,\n (option) => option.value,\n ),\n };\n }\n if (element instanceof HTMLTextAreaElement) return { value: element.value };\n if (element.matches('[contenteditable]:not([contenteditable=\"false\"])')) {\n return { value: element.textContent ?? \"\" };\n }\n return {};\n}\n\nfunction eventElement(event: Event) {\n const [first] = event.composedPath();\n return first instanceof Element ? first : null;\n}\n\nfunction isRootOrDetached(el: Element) {\n return el.localName === \"body\" || el.localName === \"html\" || !el.isConnected;\n}\n\nfunction isNear(origin: PointerOrigin, event: MouseEvent) {\n return (\n Math.abs(origin.x - event.clientX) <= RECOVERY_DISTANCE_PX &&\n Math.abs(origin.y - event.clientY) <= RECOVERY_DISTANCE_PX\n );\n}\n\n/**\n * Listens for clicks in the capture phase on `window`, so handlers that stop\n * propagation do not hide them. The target is described at `pointerdown`,\n * because a re-render before `click` can move the click to `<body>`.\n */\nexport function installClickCapture(params: {\n emit: Emit;\n getRoute: () => string;\n enrich?: EnrichFn;\n // ponytail: test seam only. jsdom cannot create trusted events; the collector never passes this.\n isTrusted?: (event: Event) => boolean;\n redact?: Redactor;\n}) {\n const {\n emit,\n getRoute,\n enrich,\n isTrusted = (event) => event.isTrusted,\n redact = createRedactor(),\n } = params;\n let origin: PointerOrigin | null = null;\n\n const onPointerDown = (event: PointerEvent) => {\n try {\n if (event.isPrimary === false) return;\n const el = eventElement(event);\n origin = {\n described: el === null ? null : describeTarget(el, redact),\n x: event.clientX,\n y: event.clientY,\n };\n } catch {\n origin = null;\n }\n };\n\n const onClick = (event: MouseEvent) => {\n const pointerOrigin = origin;\n origin = null;\n try {\n if (!isTrusted(event)) return;\n const el = eventElement(event);\n if (el === null) return;\n const canRecover =\n pointerOrigin !== null &&\n isRootOrDetached(el) &&\n isNear(pointerOrigin, event);\n const described = canRecover\n ? pointerOrigin.described\n : describeTarget(el, redact);\n if (described === null) return;\n const input = event.detail === 0 ? \"keyboard\" : \"pointer\";\n emit(\"click\", {\n target: { ...described.target, input },\n route: getRoute(),\n url: redact.url(location.href),\n ...enrich?.(described.element),\n });\n } catch {\n // Capture must never break the host app's click.\n }\n };\n\n window.addEventListener(\"pointerdown\", onPointerDown, {\n capture: true,\n passive: true,\n });\n window.addEventListener(\"click\", onClick, { capture: true, passive: true });\n return () => {\n window.removeEventListener(\"pointerdown\", onPointerDown, { capture: true });\n window.removeEventListener(\"click\", onClick, { capture: true });\n };\n}\n","import { describeTarget, readControlValue } from \"./clicks\";\nimport { createRedactor } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport type { Emit } from \"./types\";\n\n/** Records user edits without changing the control or interfering with its listeners. */\nexport function installInputCapture(params: {\n emit: Emit;\n isTrusted?: (event: Event) => boolean;\n redact?: Redactor;\n}) {\n const {\n emit,\n isTrusted = (event: Event) => event.isTrusted,\n redact = createRedactor(),\n } = params;\n const onInput = (event: Event) => {\n try {\n if (!isTrusted(event)) return;\n const [element] = event.composedPath();\n if (!(element instanceof Element)) return;\n const described = describeTarget(element, redact);\n if (described === null) return;\n const value = readControlValue(element, redact);\n if (!(\"value\" in value)) return;\n emit(\"input\", {\n eventType: event.type,\n target: { ...described.target, ...value },\n url: redact.url(location.href),\n route: location.pathname,\n });\n } catch {\n // Capture must never break an edit in the host app.\n }\n };\n window.addEventListener(\"input\", onInput, { capture: true, passive: true });\n window.addEventListener(\"change\", onInput, { capture: true, passive: true });\n return () => {\n window.removeEventListener(\"input\", onInput, { capture: true });\n window.removeEventListener(\"change\", onInput, { capture: true });\n };\n}\n","import { createRedactor } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport type { Emit } from \"./types\";\n\ntype NavigationType = \"push\" | \"replace\" | \"traverse\" | \"reload\";\ntype HistoryMethod = \"pushState\" | \"replaceState\";\n\n/** The part of the Navigation API this module uses (not in TypeScript's DOM lib yet). */\ninterface NavigationTarget {\n addEventListener(\n type: \"currententrychange\",\n listener: (event: Event) => void,\n ): void;\n removeEventListener(\n type: \"currententrychange\",\n listener: (event: Event) => void,\n ): void;\n}\n\nfunction isNavigationTarget(value: unknown): value is NavigationTarget {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"addEventListener\" in value &&\n \"removeEventListener\" in value\n );\n}\n\nfunction getNavigation() {\n const navigation: unknown = Reflect.get(window, \"navigation\");\n return isNavigationTarget(navigation) ? navigation : null;\n}\n\nfunction toNavigationType(value: unknown): NavigationType {\n switch (value) {\n case \"replace\":\n case \"traverse\":\n case \"reload\":\n return value;\n default:\n return \"push\";\n }\n}\n\n/**\n * Emits `navigation` when the full URL changes in a single-page app.\n * Uses the Navigation API when it exists. Otherwise patches `pushState` and\n * `replaceState` on the prototype and on the instance, because some routers\n * (Next.js) wrap the instance method.\n */\nexport function installNavigationCapture(params: {\n emit: Emit;\n routes?: string[];\n redact?: Redactor;\n}) {\n const { emit, redact = createRedactor() } = params;\n let current = location.href;\n\n const check = (navigationType: NavigationType) => {\n try {\n const next = location.href;\n if (next === current) return;\n emit(\"navigation\", {\n from: redact.url(current),\n to: redact.url(next),\n navigationType,\n });\n current = next;\n } catch {\n // Capture must never break navigation.\n }\n };\n\n const navigation = getNavigation();\n if (navigation !== null) {\n const onEntryChange = (event: Event) =>\n check(toNavigationType(Reflect.get(event, \"navigationType\")));\n navigation.addEventListener(\"currententrychange\", onEntryChange);\n return () =>\n navigation.removeEventListener(\"currententrychange\", onEntryChange);\n }\n\n return installHistoryFallback(check);\n}\n\nfunction installHistoryFallback(\n check: (navigationType: NavigationType) => void,\n) {\n let active = true;\n const restores: (() => void)[] = [];\n\n const patch = (target: History, method: HistoryMethod) => {\n const original = target[method];\n const wrapper: History[HistoryMethod] = function (this: History, ...args) {\n const result = original.apply(this, args);\n if (active) check(method === \"pushState\" ? \"push\" : \"replace\");\n return result;\n };\n target[method] = wrapper;\n restores.push(() => {\n // Restore only if no one wrapped it after us; otherwise our wrapper stays as a pass-through.\n if (target[method] === wrapper) target[method] = original;\n });\n };\n\n const methods: HistoryMethod[] = [\"pushState\", \"replaceState\"];\n for (const method of methods) {\n patch(History.prototype, method);\n if (Object.prototype.hasOwnProperty.call(history, method))\n patch(history, method);\n }\n\n const onPopState = () => check(\"traverse\");\n window.addEventListener(\"popstate\", onPopState);\n window.addEventListener(\"hashchange\", onPopState);\n\n return () => {\n active = false;\n window.removeEventListener(\"popstate\", onPopState);\n window.removeEventListener(\"hashchange\", onPopState);\n for (const restore of restores) restore();\n };\n}\n","import { TEXT_LIMIT } from \"./redact\";\nimport type { Redactor } from \"./redact\";\n\n/** A bounded observation of a body; never the stream returned to the app. */\nexport interface BodySnapshot {\n status:\n | \"complete\"\n | \"empty\"\n | \"truncated\"\n | \"unavailable\"\n | \"interrupted\"\n | \"timeout\";\n text?: string;\n encoding?: \"utf-8\" | \"base64\";\n truncated?: true;\n reason?: string;\n}\n\nconst BODY_BYTES = 4096;\nconst READ_TIMEOUT_MS = 1000;\nconst encoder = new TextEncoder();\nconst redactionFailed = (): BodySnapshot => ({\n status: \"unavailable\",\n reason: \"redaction-failed\",\n});\ntype RedactText = (text: string) => string | undefined;\n\n/** Redacts up to TEXT_LIMIT bytes of text first, then cuts the result to BODY_BYTES. */\nfunction snapshot(\n bytes: Uint8Array,\n status: BodySnapshot[\"status\"],\n contentType: string,\n redact: RedactText,\n reason?: string,\n): BodySnapshot {\n let text: string | undefined;\n if (!isBinary(contentType))\n try {\n // Declared text decodes lossily so it is always redacted; untyped bytes\n // that are not UTF-8 are treated as binary.\n text = new TextDecoder(\"utf-8\", { fatal: !contentType }).decode(bytes, {\n stream: status !== \"complete\",\n });\n } catch {\n /* Not UTF-8: keep the bytes as base64. */\n }\n if (text !== undefined) {\n text = redact(text);\n if (text === undefined) return redactionFailed();\n // PostgreSQL jsonb rejects NUL in strings; base64 retains the redacted bytes.\n if (text.includes(\"\\0\")) {\n bytes = encoder.encode(text);\n text = undefined;\n }\n }\n const binary = text === undefined;\n // Binary bodies are not inspected; only their first bytes are kept.\n text ??= btoa(String.fromCharCode(...bytes.subarray(0, BODY_BYTES)));\n const result: BodySnapshot = {\n status: status === \"complete\" && bytes.length === 0 ? \"empty\" : status,\n text,\n encoding: binary ? \"base64\" : \"utf-8\",\n ...(reason ? { reason } : {}),\n };\n if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)\n return result;\n result.status = status === \"complete\" ? \"truncated\" : status;\n result.truncated = true;\n // Bound the serialized field too: quotes/control characters expand in JSON.\n let low = 0;\n let high = text.length;\n while (low < high) {\n const middle = Math.ceil((low + high) / 2);\n result.text = text.slice(0, middle);\n if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)\n low = middle;\n else high = middle - 1;\n }\n if (binary)\n low -= low % 4; // Keep complete base64 groups.\n else if (low > 0 && /[\\uD800-\\uDBFF]/.test(text[low - 1]!)) low--;\n result.text = text.slice(0, low);\n return result;\n}\n\nfunction snapshotText(\n value: string,\n redact: RedactText,\n status: BodySnapshot[\"status\"] = \"complete\",\n reason?: string,\n): BodySnapshot {\n return snapshot(\n encoder.encode(value.slice(0, TEXT_LIMIT)),\n value.length > TEXT_LIMIT && status === \"complete\" ? \"truncated\" : status,\n \"text/plain\",\n redact,\n reason,\n );\n}\n\nfunction isBinary(contentType: string) {\n return (\n contentType !== \"\" &&\n !/(^text\\/|json|xml|javascript|graphql|x-www-form-urlencoded|multipart\\/form-data)/i.test(\n contentType,\n )\n );\n}\n\n/** Cancel only cloned/synthetic bodies. Cancelling a tee may wait for the app's branch. */\nexport function createBodyCapture(redact: Redactor) {\n const cancellations = new Set<() => void>();\n let active = true;\n const read = (\n stream: ReadableStream<Uint8Array> | null,\n contentType = \"\",\n ): Promise<BodySnapshot> => {\n if (!stream) return Promise.resolve({ status: \"empty\" });\n if (!active)\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"capture-stopped\",\n });\n let reader: ReadableStreamDefaultReader<Uint8Array>;\n try {\n reader = stream.getReader();\n } catch {\n return Promise.resolve({ status: \"unavailable\", reason: \"body-locked\" });\n }\n return new Promise((resolve) => {\n const bytes = new Uint8Array(TEXT_LIMIT);\n let length = 0;\n let finished = false;\n const finish = (status: BodySnapshot[\"status\"], reason?: string) => {\n if (finished) return;\n finished = true;\n clearTimeout(timer);\n cancellations.delete(cancel);\n resolve(\n snapshot(\n bytes.subarray(0, length),\n status,\n contentType,\n redact.body,\n reason,\n ),\n );\n // Do not await cancellation: a cloned fetch stream can share a live SSE source.\n void reader.cancel().catch(() => {});\n };\n const cancel = () => finish(\"interrupted\", \"capture-stopped\");\n const timer = setTimeout(\n () => finish(\"timeout\", \"body-read-timeout\"),\n READ_TIMEOUT_MS,\n );\n cancellations.add(cancel);\n void (async () => {\n try {\n for (;;) {\n const chunk = await reader.read();\n if (finished) break;\n if (chunk.done) {\n finish(\"complete\");\n break;\n }\n const kept = chunk.value.subarray(0, TEXT_LIMIT - length);\n bytes.set(kept, length);\n length += kept.length;\n if (length === TEXT_LIMIT) {\n finish(\"truncated\");\n break;\n }\n }\n } catch {\n finish(\"interrupted\", \"body-read-failed\");\n } finally {\n try {\n reader.releaseLock();\n } catch {\n /* A cancelled read may still be settling. */\n }\n }\n })();\n });\n };\n const body = (\n value: BodyInit | null | undefined,\n contentType = \"\",\n ): Promise<BodySnapshot> => {\n try {\n if (value == null) return Promise.resolve({ status: \"empty\" });\n if (value instanceof URLSearchParams) value = value.toString();\n if (typeof value === \"string\")\n return Promise.resolve(snapshotText(value, redact.body));\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n // A caller-owned init.body cannot be teed without locking/replacing it.\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"caller-owned-stream\",\n });\n }\n if (value instanceof FormData) {\n const form = new FormData();\n value.forEach((field, key) =>\n form.append(\n key,\n typeof field === \"string\" ? redact.field(key, field) : field,\n ),\n );\n value = form;\n }\n const response = new Response(value);\n return read(\n response.body,\n contentType || response.headers.get(\"content-type\") || \"\",\n );\n } catch {\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"body-not-readable\",\n });\n }\n };\n return {\n read,\n body,\n text: (value: string, status?: BodySnapshot[\"status\"], reason?: string) =>\n snapshotText(value, redact.body, status, reason),\n stop() {\n active = false;\n for (const cancel of cancellations) cancel();\n },\n };\n}\n","import { createBodyCapture } from \"./network-body\";\nimport type { BodySnapshot } from \"./network-body\";\nimport { createRedactor, isCredentialKey } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { Emit } from \"./types\";\n\ntype Outcome = \"ok\" | \"error\" | \"aborted\";\ninterface RequestParts {\n url: string;\n method: string;\n headers: Record<string, string>;\n}\ninterface ResponseParts {\n url?: string;\n headers: Record<string, string>;\n body: Promise<BodySnapshot>;\n}\nconst emptyBody = () => Promise.resolve<BodySnapshot>({ status: \"empty\" });\nconst unavailableBody = (reason: string) =>\n Promise.resolve<BodySnapshot>({ status: \"unavailable\", reason });\nconst toAbsolute = (url: string) => new URL(url, location.href).href;\n// Credentials carry no product signal and must never leave the browser, even\n// with full capture. The header name stays, so the event still shows it was sent.\n// Names the credential-key matcher misses; it covers `authorization`, `cookie`,\n// `x-api-key`, `x-csrf-token`, and similar.\nconst CREDENTIAL_HEADERS = new Set([\n \"x-amz-security-token\",\n \"private-token\",\n \"x-token\",\n]);\nconst headerValue = (name: string, value: string) =>\n CREDENTIAL_HEADERS.has(name.toLowerCase()) || isCredentialKey(name)\n ? REDACTED\n : value;\nconst headerValues = (headers: Headers) =>\n Object.fromEntries(\n Array.from(headers.entries(), ([name, value]) => [\n name,\n headerValue(name, value),\n ]),\n );\n\n// Checks the name, not instanceof: errors can come from another realm.\nfunction isAbortError(error: unknown) {\n return (\n typeof error === \"object\" &&\n error !== null &&\n \"name\" in error &&\n error.name === \"AbortError\"\n );\n}\n\n/** Records browser-visible request/response data without consuming the app's bodies. */\nexport function installNetworkCapture(params: {\n emit: Emit;\n routes?: string[];\n ignoreUrls: (string | RegExp)[];\n redact?: Redactor;\n}) {\n let active = true;\n const { redact = createRedactor() } = params;\n const bodies = createBodyCapture(redact);\n const ignoreUrls = params.ignoreUrls.map((pattern) =>\n typeof pattern === \"string\"\n ? toAbsolute(pattern)\n : new RegExp(pattern.source, pattern.flags),\n );\n const isIgnored = (url: string) =>\n ignoreUrls.some((pattern) => {\n if (typeof pattern === \"string\") return url.startsWith(pattern);\n pattern.lastIndex = 0;\n return pattern.test(url);\n });\n\n const record: RecordFn = (\n transport,\n request,\n requestBody,\n startedAt,\n status,\n outcome,\n response,\n ) => {\n const durationMs = Math.round(performance.now() - startedAt);\n const completedAt = Date.now();\n void Promise.all([\n requestBody,\n response?.body ?? unavailableBody(\"no-response\"),\n ])\n .then(([sent, received]) => {\n if (!active) return;\n const href = redact.url(request.url);\n const url = new URL(href);\n params.emit(\"network\", {\n transport,\n method: request.method,\n url: href,\n origin: url.origin,\n route: url.pathname,\n status,\n durationMs,\n completedAt,\n outcome,\n request: { headers: request.headers, body: sent },\n response: {\n ...(response?.url ? { url: redact.url(response.url) } : {}),\n headers: response?.headers ?? {},\n body: received,\n },\n });\n })\n .catch(() => {\n /* Capture must never affect the host request. */\n });\n };\n\n const originalFetch = globalThis.fetch;\n const wrappedFetch: typeof fetch = (input, init) => {\n if (!active) return originalFetch.call(globalThis, input, init);\n let request: RequestParts;\n let ignored: boolean;\n try {\n const source =\n typeof Request !== \"undefined\" && input instanceof Request\n ? input\n : undefined;\n request = {\n url: toAbsolute(source ? source.url : String(input)),\n method: (init?.method ?? source?.method ?? \"GET\").toUpperCase(),\n headers: headerValues(new Headers(init?.headers ?? source?.headers)),\n };\n ignored = isIgnored(request.url);\n } catch {\n return originalFetch.call(globalThis, input, init);\n }\n if (ignored) return originalFetch.call(globalThis, input, init);\n const startedAt = performance.now();\n let requestBody: Promise<BodySnapshot>;\n try {\n requestBody =\n init?.body != null\n ? bodies.body(init.body, request.headers[\"content-type\"])\n : typeof Request !== \"undefined\" && input instanceof Request\n ? bodies.read(input.clone().body, request.headers[\"content-type\"])\n : emptyBody();\n } catch {\n requestBody = unavailableBody(\"request-clone-failed\");\n }\n const failed = (error: unknown) =>\n record(\n \"fetch\",\n request,\n requestBody,\n startedAt,\n null,\n isAbortError(error) ? \"aborted\" : \"error\",\n );\n let promise: Promise<Response>;\n try {\n promise = originalFetch.call(globalThis, input, init);\n } catch (error) {\n failed(error);\n throw error;\n }\n void promise\n .then((response) => {\n if (!active) return;\n let responseBody: Promise<BodySnapshot>;\n try {\n responseBody =\n response.type === \"opaque\" || response.type === \"opaqueredirect\"\n ? unavailableBody(\"opaque-response\")\n : bodies.read(\n response.clone().body,\n response.headers.get(\"content-type\") ?? \"\",\n );\n } catch {\n responseBody = unavailableBody(\"response-clone-failed\");\n }\n record(\n \"fetch\",\n request,\n requestBody,\n startedAt,\n response.status,\n \"ok\",\n {\n url: response.url,\n headers: headerValues(response.headers),\n body: responseBody,\n },\n );\n }, failed)\n .catch(() => {});\n // Return the identical native Promise, Response, and rejection to the caller.\n return promise;\n };\n if (typeof originalFetch === \"function\") globalThis.fetch = wrappedFetch;\n const uninstallXhr = patchXhr(isIgnored, record, bodies);\n return () => {\n active = false;\n bodies.stop();\n if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;\n uninstallXhr();\n };\n}\n\ntype RecordFn = (\n transport: \"fetch\" | \"xhr\",\n request: RequestParts,\n requestBody: Promise<BodySnapshot>,\n startedAt: number,\n status: number | null,\n outcome: Outcome,\n response?: ResponseParts,\n) => void;\n\nfunction patchXhr(\n isIgnored: (url: string) => boolean,\n record: RecordFn,\n bodies: ReturnType<typeof createBodyCapture>,\n) {\n if (typeof XMLHttpRequest !== \"function\") return () => {};\n const proto = XMLHttpRequest.prototype;\n const originalOpen = proto.open;\n const originalSend = proto.send;\n const originalSetHeader = proto.setRequestHeader;\n const requests = new WeakMap<XMLHttpRequest, RequestParts>();\n const listeners = new Set<() => void>();\n // open() on an in-flight XHR ends it without `loadend`; its listeners must not\n // report the next request with the old request's details.\n const inFlight = new WeakMap<XMLHttpRequest, () => void>();\n let active = true;\n const wrappedOpen = function (\n this: XMLHttpRequest,\n method: string,\n url: string | URL,\n ...rest: unknown[]\n ) {\n inFlight.get(this)?.();\n const result = Reflect.apply(originalOpen, this, [method, url, ...rest]);\n try {\n requests.set(this, {\n url: toAbsolute(String(url)),\n method: method.toUpperCase(),\n headers: {},\n });\n } catch {\n requests.delete(this);\n }\n return result;\n };\n const wrappedSetHeader = function (\n this: XMLHttpRequest,\n name: string,\n value: string,\n ) {\n const result = originalSetHeader.call(this, name, value);\n const request = requests.get(this);\n if (active && request) {\n try {\n const headers = new Headers(request.headers);\n headers.append(name, value);\n request.headers = headerValues(headers);\n } catch {\n /* Preserve successful native calls even if observation fails. */\n }\n }\n return result;\n };\n const wrappedSend = function (\n this: XMLHttpRequest,\n body?: Document | XMLHttpRequestBodyInit | null,\n ) {\n const request = requests.get(this);\n if (!active || !request || isIgnored(request.url))\n return originalSend.call(this, body);\n const startedAt = performance.now();\n let requestBody: Promise<BodySnapshot>;\n try {\n requestBody =\n typeof Document !== \"undefined\" && body instanceof Document\n ? Promise.resolve(\n bodies.text(new XMLSerializer().serializeToString(body)),\n )\n : bodies.body(\n body as BodyInit | null | undefined,\n request.headers[\"content-type\"],\n );\n } catch {\n requestBody = unavailableBody(\"request-body-unavailable\");\n }\n let aborted = false;\n const onAbort = () => {\n aborted = true;\n };\n const cleanup = () => {\n this.removeEventListener(\"abort\", onAbort);\n this.removeEventListener(\"loadend\", onEnd);\n listeners.delete(cleanup);\n if (inFlight.get(this) === cleanup) inFlight.delete(this);\n };\n const onEnd = () => {\n cleanup();\n if (!active) return;\n const outcome = aborted ? \"aborted\" : this.status === 0 ? \"error\" : \"ok\";\n const headers: Record<string, string> = {};\n try {\n for (const line of this.getAllResponseHeaders().split(/\\r?\\n/)) {\n const split = line.indexOf(\":\");\n if (split > 0) {\n const name = line.slice(0, split).toLowerCase();\n headers[name] = headerValue(name, line.slice(split + 1).trim());\n }\n }\n } catch {\n /* Cross-origin or failed requests can hide response headers. */\n }\n let responseBody: Promise<BodySnapshot>;\n try {\n if (this.responseType === \"\" || this.responseType === \"text\") {\n responseBody = Promise.resolve(\n bodies.text(\n this.responseText,\n outcome === \"ok\" ? \"complete\" : \"interrupted\",\n outcome === \"ok\" ? undefined : `request-${outcome}`,\n ),\n );\n } else if (this.responseType === \"json\")\n responseBody = Promise.resolve(\n bodies.text(JSON.stringify(this.response)),\n );\n else if (this.responseType === \"document\")\n responseBody = this.responseXML\n ? Promise.resolve(\n bodies.text(\n new XMLSerializer().serializeToString(this.responseXML),\n ),\n )\n : unavailableBody(\"empty-document\");\n else\n responseBody = bodies.body(\n this.response as BodyInit,\n headers[\"content-type\"] ?? \"application/octet-stream\",\n );\n } catch {\n responseBody = unavailableBody(\"response-body-unavailable\");\n }\n record(\n \"xhr\",\n request,\n requestBody,\n startedAt,\n outcome === \"ok\" ? this.status : null,\n outcome,\n {\n url: this.responseURL,\n headers,\n body: responseBody,\n },\n );\n };\n this.addEventListener(\"abort\", onAbort);\n this.addEventListener(\"loadend\", onEnd);\n listeners.add(cleanup);\n inFlight.set(this, cleanup);\n try {\n return originalSend.call(this, body);\n } catch (error) {\n cleanup();\n record(\n \"xhr\",\n request,\n requestBody,\n startedAt,\n null,\n isAbortError(error) ? \"aborted\" : \"error\",\n );\n throw error;\n }\n };\n proto.open = wrappedOpen;\n proto.send = wrappedSend;\n proto.setRequestHeader = wrappedSetHeader;\n return () => {\n active = false;\n for (const cleanup of listeners) cleanup();\n if (proto.open === wrappedOpen) proto.open = originalOpen;\n if (proto.send === wrappedSend) proto.send = originalSend;\n if (proto.setRequestHeader === wrappedSetHeader)\n proto.setRequestHeader = originalSetHeader;\n };\n}\n","/** Returns the original pathname. Capture no longer masks or categorizes routes. */\nexport function toRoute(pathname: string, _templates: string[] = []) {\n return pathname;\n}\n\n/** Resolves a URL without removing path segments, query parameters, or fragments. */\nexport function toOriginAndRoute(\n url: string,\n _templates: string[] = [],\n base: string = globalThis.location?.href ?? \"http://localhost/\",\n) {\n const parsed = new URL(url, base);\n return { url: parsed.href, origin: parsed.origin, route: parsed.pathname };\n}\n","import { installClickCapture } from \"./clicks\";\nimport { installInputCapture } from \"./inputs\";\nimport { installNavigationCapture } from \"./navigation\";\nimport { installNetworkCapture } from \"./network\";\nimport { createRedactor } from \"./redact\";\nimport { toRoute } from \"./routes\";\nimport { BUILT_IN_EVENT_NAMES } from \"./types\";\nimport type {\n BuiltInEventName,\n Collector,\n CollectorOptions,\n LearningBatch,\n LearningEvent,\n SinkSendOptions,\n StartOptions,\n} from \"./types\";\n\n// ponytail: flushing at 50 also bounds the queue at 50; add a separate cap only if sends become async-buffered.\nconst FLUSH_SIZE = 50;\nconst FLUSH_INTERVAL_MS = 2000;\nconst BUILT_IN = new Set<string>(BUILT_IN_EVENT_NAMES);\n\ninterface Session {\n trajectoryId: string;\n learningContainerIds: string[] | undefined;\n queue: LearningEvent[];\n dropped: number;\n uninstalls: (() => void)[];\n timer: ReturnType<typeof setInterval> | undefined;\n}\n\n/**\n * Creates a collector. Nothing is installed until `start()`: no listeners,\n * no patches, no timers, no buffer. `stop()` removes all of them and sends\n * queued events once, without retries.\n *\n * @example\n * const collector = createCollector({ sink: httpSink(\"/api/learning-events\") });\n * collector.start({ trajectoryId: crypto.randomUUID() });\n */\nexport function createCollector(options: CollectorOptions) {\n const { sink, routes, beforeSend, enrich } = options;\n const capture = {\n clicks: true,\n navigation: true,\n network: true,\n inputs: true,\n ...options.capture,\n };\n let session: Session | null = null;\n let seq = 0;\n\n const getRoute = () => toRoute(location.pathname, routes);\n\n const flush = (sendOptions?: SinkSendOptions) => {\n const current = session;\n if (current === null) return;\n if (current.queue.length === 0 && current.dropped === 0) return;\n const events = current.queue.splice(0);\n const dropped = current.dropped;\n current.dropped = 0;\n const batch: LearningBatch = {\n trajectoryId: current.trajectoryId,\n learningContainerIds: current.learningContainerIds,\n events,\n dropped,\n };\n const onFailure = () => {\n // No retry: report the loss in the next batch instead.\n if (session === current) current.dropped += events.length + dropped;\n };\n try {\n Promise.resolve(sink(batch, sendOptions)).catch(onFailure);\n } catch {\n onFailure();\n }\n };\n\n const record = (name: string, value: Record<string, unknown>) => {\n const current = session;\n if (current === null) return;\n try {\n seq += 1;\n const event: LearningEvent = {\n type: \"CUSTOM\",\n name,\n value: { ...value, seq },\n timestamp: Date.now(),\n };\n const kept = beforeSend === undefined ? event : beforeSend(event);\n if (kept === null) return;\n current.queue.push(kept);\n if (current.queue.length >= FLUSH_SIZE) flush();\n } catch {\n // Capture must never break the host app.\n }\n };\n\n const start = ({ trajectoryId, learningContainerIds }: StartOptions) => {\n if (session !== null) {\n if (session.trajectoryId !== trajectoryId) {\n console.warn(\n `[@copilotkit/learning] Trajectory \"${session.trajectoryId}\" is active. Stop it before you start \"${trajectoryId}\".`,\n );\n }\n return;\n }\n if (\n typeof window === \"undefined\" ||\n typeof window.addEventListener !== \"function\"\n )\n return;\n const current: Session = {\n trajectoryId,\n learningContainerIds,\n queue: [],\n dropped: 0,\n uninstalls: [],\n timer: undefined,\n };\n session = current;\n const recordCurrent: typeof record = (name, value) => {\n if (session === current) record(name, value);\n };\n // One per session: fields seen and values typed are forgotten on stop().\n const redact = createRedactor();\n current.uninstalls.push(redact.watch());\n const ignoreUrls = [\n ...(options.ignoreUrls ?? []),\n ...(sink.url === undefined ? [] : [sink.url]),\n ];\n if (capture.clicks)\n current.uninstalls.push(\n installClickCapture({ emit: recordCurrent, getRoute, enrich, redact }),\n );\n if (capture.navigation)\n current.uninstalls.push(\n installNavigationCapture({ emit: recordCurrent, routes, redact }),\n );\n if (capture.network)\n current.uninstalls.push(\n installNetworkCapture({\n emit: recordCurrent,\n routes,\n ignoreUrls,\n redact,\n }),\n );\n if (capture.inputs)\n current.uninstalls.push(\n installInputCapture({ emit: recordCurrent, redact }),\n );\n const onPageHide = () => flush({ beacon: true });\n window.addEventListener(\"pagehide\", onPageHide);\n current.uninstalls.push(() =>\n window.removeEventListener(\"pagehide\", onPageHide),\n );\n current.timer = setInterval(() => flush(), FLUSH_INTERVAL_MS);\n record(\"page\", {\n route: getRoute(),\n url: redact.url(location.href),\n title: document.title,\n referrer: redact.url(document.referrer),\n });\n };\n\n const stop = () => {\n const current = session;\n if (current === null) return;\n clearInterval(current.timer);\n for (const uninstall of current.uninstalls) uninstall();\n flush();\n session = null;\n };\n\n const emit = (name: string, value: Record<string, unknown>) => {\n if (BUILT_IN.has(name)) {\n console.warn(\n `[@copilotkit/learning] \"${name}\" is a built-in event name. Use your own name, such as \"deal.approved\".`,\n );\n return;\n }\n record(name, value);\n };\n\n const ɵemit = (name: BuiltInEventName, value: Record<string, unknown>) =>\n record(name, value);\n\n const collector: Collector = {\n start,\n stop,\n emit,\n ɵemit,\n get trajectoryId() {\n return session?.trajectoryId ?? null;\n },\n };\n return collector;\n}\n","import type { LearningBatch, LearningSink, SinkSendOptions } from \"./types\";\n\n/**\n * A sink that POSTs each batch as JSON to `url`.\n *\n * Uses `fetch` with `keepalive`. When the page is going away it uses\n * `navigator.sendBeacon`, which cannot send custom headers. There is no retry:\n * a failed send rejects, and the collector counts those events as dropped.\n *\n * @example createCollector({ sink: httpSink(\"/api/learning-events\") })\n */\nexport function httpSink(\n url: string,\n init: { headers?: Record<string, string> } = {},\n) {\n const send = async (batch: LearningBatch, options: SinkSendOptions = {}) => {\n const body = JSON.stringify(batch);\n const canBeacon =\n options.beacon === true && typeof navigator.sendBeacon === \"function\";\n if (canBeacon) {\n const queued = navigator.sendBeacon(\n url,\n new Blob([body], { type: \"application/json\" }),\n );\n if (!queued) throw new Error(\"sendBeacon refused the batch\");\n return;\n }\n const response = await fetch(url, {\n method: \"POST\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", ...init.headers },\n body,\n });\n if (!response.ok) throw new Error(`Sink responded with ${response.status}`);\n };\n const sink: LearningSink = Object.assign(send, { url });\n return sink;\n}\n","import { installClickCapture } from \"./clicks\";\nimport { installNavigationCapture } from \"./navigation\";\nimport { installInputCapture } from \"./inputs\";\nimport { installNetworkCapture } from \"./network\";\nimport { createRedactor } from \"./redact\";\nimport { toRoute } from \"./routes\";\nimport { BUILT_IN_EVENT_NAMES } from \"./types\";\nimport type {\n JsonValue,\n TrajectoryCaptureOptions,\n TrajectoryCollector,\n TrajectoryEvent,\n} from \"./trajectory-types\";\n\nexport const MAX_TRAJECTORY_EVENT_BYTES = 16 * 1024;\nconst RESERVED_NAMES = new Set<string>(BUILT_IN_EVENT_NAMES);\n\nfunction isJson(\n value: unknown,\n ancestors = new Set<object>(),\n): value is JsonValue {\n if (typeof value === \"string\") return !value.includes(\"\\0\");\n if (value === null || typeof value === \"boolean\") return true;\n if (typeof value === \"number\") return Number.isFinite(value);\n if (typeof value !== \"object\" || ancestors.has(value)) return false;\n if (\n !Array.isArray(value) &&\n Object.getPrototypeOf(value) !== Object.prototype &&\n Object.getPrototypeOf(value) !== null\n )\n return false;\n ancestors.add(value);\n if (\n !Array.isArray(value) &&\n Object.keys(value).some((key) => key.includes(\"\\0\"))\n )\n return false;\n const values = Array.isArray(value) ? value : Object.values(value);\n for (const item of values) {\n if (!isJson(item, ancestors)) return false;\n }\n ancestors.delete(value);\n return true;\n}\n\n/**\n * Captures outside-chat activity as plain AG-UI events, without a send queue.\n * Core starts this collector only after authentication and channel join.\n */\nexport function createTrajectoryCollector(\n options: TrajectoryCaptureOptions & {\n send: (event: TrajectoryEvent) => void;\n },\n): TrajectoryCollector {\n let active = false;\n let uninstalls: (() => void)[] = [];\n\n const report = (code: string, message: string) => {\n try {\n options.onError?.({ code, message });\n } catch {\n // Diagnostic callbacks must not interrupt the host app.\n }\n };\n\n const getRoute = () => toRoute(location.pathname);\n\n const record = (name: string, value: JsonValue) => {\n if (!active) return;\n let payload: TrajectoryEvent;\n try {\n const event: TrajectoryEvent = {\n type: \"CUSTOM\",\n name,\n timestamp: Date.now(),\n value,\n };\n const kept =\n options.beforeSend === undefined ? event : options.beforeSend(event);\n if (kept === null || !active) return;\n if (\n kept.type !== \"CUSTOM\" ||\n typeof kept.name !== \"string\" ||\n !kept.name.trim() ||\n kept.name.length > 200 ||\n kept.name.includes(\"\\0\") ||\n !Number.isFinite(kept.timestamp) ||\n kept.timestamp <= 0 ||\n kept.timestamp >= 253_402_300_800_000 ||\n !isJson(kept.value)\n ) {\n report(\n \"INVALID_EVENT\",\n \"The event must contain a name, timestamp, and JSON value.\",\n );\n return;\n }\n // Keep only the contract envelope and snapshot the value before delivery.\n const serialized = JSON.stringify({\n type: kept.type,\n name: kept.name,\n timestamp: kept.timestamp,\n value: kept.value,\n });\n if (\n new TextEncoder().encode(serialized).byteLength >\n MAX_TRAJECTORY_EVENT_BYTES\n ) {\n report(\"EVENT_TOO_LARGE\", \"The serialized event exceeds 16 KiB.\");\n return;\n }\n payload = JSON.parse(serialized) as TrajectoryEvent;\n } catch {\n report(\"INVALID_EVENT\", \"The event could not be serialized as JSON.\");\n return;\n }\n try {\n options.send(payload);\n } catch {\n report(\n \"PERSISTENCE_UNKNOWN\",\n \"Event delivery failed. The event will not be retried.\",\n );\n }\n };\n\n const capture = (name: string, value: Record<string, unknown>) => {\n // Shared capture modules produce JSON; record validates and snapshots before sending.\n record(name, value as JsonValue);\n };\n\n const stop = () => {\n active = false;\n const installed = uninstalls;\n uninstalls = [];\n for (const uninstall of installed) uninstall();\n };\n\n return {\n start() {\n if (active || typeof window === \"undefined\") return;\n active = true;\n // Native callers can emit developer events without browser observers.\n if (typeof window.addEventListener !== \"function\") return;\n // One per session: fields seen and values typed are forgotten on stop().\n const redact = createRedactor();\n try {\n uninstalls.push(redact.watch());\n if (options.capture?.clicks !== false) {\n uninstalls.push(\n installClickCapture({ emit: capture, getRoute, redact }),\n );\n }\n if (options.capture?.navigation !== false) {\n uninstalls.push(installNavigationCapture({ emit: capture, redact }));\n }\n if (options.capture?.inputs !== false) {\n uninstalls.push(installInputCapture({ emit: capture, redact }));\n }\n if (options.capture?.network !== false) {\n uninstalls.push(\n installNetworkCapture({\n emit: capture,\n ignoreUrls: options.ignoreUrls ?? [],\n redact,\n }),\n );\n }\n record(\"page\", {\n route: getRoute(),\n url: redact.url(location.href),\n title: document.title,\n referrer: redact.url(document.referrer),\n });\n } catch {\n stop();\n report(\"CAPTURE_FAILED\", \"Browser capture could not start.\");\n }\n },\n stop,\n emit(name, value) {\n if (!active) return;\n if (RESERVED_NAMES.has(name)) {\n report(\n \"INVALID_EVENT\",\n \"Use a developer event name, not a reserved capture name.\",\n );\n return;\n }\n record(name, value);\n },\n };\n}\n"],"mappings":";;AACA,MAAa,uBAAuB;CAClC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;;AAKD,MAAa,WAAW;;;;ACXxB,MAAM,aACJ;AAIF,MAAM,kBACJ;AAEF,MAAM,WACJ;AACF,MAAM,wBAAwB;AAE9B,MAAM,QAAQ;AAEd,MAAM,SAAS;AAKf,MAAM,OACJ;AAGF,MAAM,WAAW;;AAEjB,MAAa,aAAa,KAAK;AAC/B,MAAM,oBAAoB;AAE1B,MAAa,mBAAmB,QAAgB;CAC9C,MAAM,QAAQ,IAAI,IACf,QAAQ,oBAAoB,GAAG,QAC9B,OAAO,aAAa,SAAS,KAAK,GAAG,CAAC,CACvC,CACA,QAAQ,uBAAuB,MAAM,CACrC,aAAa,CACb,MAAM,YAAY,CAClB,OAAO,QAAQ,CACf,KAAK,IAAI,CAAC;AACb,QACE,CAAC,SAAS,KAAK,MAAM,KACpB,WAAW,KAAK,MAAM,QAAQ,MAAM,GAAG,CAAC,IAAI,gBAAgB,KAAK,MAAM;;AAI5E,MAAM,UAAU,SAAiB;AAC/B,KAAI;AACF,SAAO,KAAK,MAAM,IAAI,KAAK,GAAG;SACxB;AACN,SAAO;;;;AAKX,MAAM,cAAc,MAAc,UAAkB;CAClD,IAAI,QAAQ;AACZ,MAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,KAAK;EACxC,MAAM,OAAO,KAAK;AAClB,MAAI,SAAS,MAAK;AAChB,UAAO,YAAY;AACnB,UAAO,KAAK,KAAK;AACjB,OAAI,OAAO,YAAY;aACd,SAAS,OAAO,SAAS,KAAK;AACvC;AACA;aACS,SAAS,OAAO,SAAS,KAClC;OAAI,EAAE,QAAQ,EAAG,QAAO;SACnB;AACL,OAAI,CAAC,UAAU,SAAS,OAAO,KAAK,KAAK,KAAK,EAAG,QAAO;AACxD;;AAEF,MAAI,CAAC,MAAO,QAAO,IAAI;;AAEzB,QAAO,KAAK;;;;;;;AAQd,MAAM,cAAc,MAAc,QAAQ,MAAc;CACtD,MAAM,QAAQ,IAAI,OAAO,MAAM;CAC/B,IAAI,UAAU;CACd,MAAM,SAAS,SAAiB;EAC9B,MAAM,UAAU,KAAK,QAAQ,UAAU,KAAK;AAC5C,SAAO,QAAQ,QACb,OACC,MAAM,KAAa,WAAmB,OAAe,WAAmB;AACvE,OAAI,CAAC,gBAAgB,IAAI,CAAE,QAAO;AAElC,OAAI,SAAS,KAAK,WAAW,QAAQ,QAAQ;AAC3C,cAAU;AACV,QAAI,CAAC,MAAO,QAAO;;AAErB,UAAO,MAAM,YAAY;IAE5B;;CAEH,IAAI,MAAM;CACV,IAAI,OAAO;CACX,IAAI;AACJ,QAAQ,QAAQ,MAAM,KAAK,KAAK,EAAG;EACjC,MAAM,CAAC,KAAK,MAAM,QAAQ,SAAS;AACnC,YAAU;AACV,SAAO,MAAM,KAAK,MAAM,MAAM,MAAM,MAAM,CAAC;EAC3C,IAAI,MAAM,MAAM;EAChB,IAAI,cAAc;EAClB,MAAM,QAAQ,KAAM,SAAS,KAAK,GAAG,OAAO,KAAM,GAAG;AACrD,MAAI,OACF;OAAI,gBAAgB,MAAM,EAAE;IAC1B,MAAM,WAAW,WAAW,MAAM,IAAI;AAEtC,QAAI,CAAC,uBAAuB,KAAK,KAAK,MAAM,KAAK,SAAS,CAAC,EAAE;AAC3D,oBAAe,IAAI,SAAS;AAC5B,WAAM;;;aAGD,QAAS,eAAc,IAAI,SAAS;WACtC,QAAQ,KAAK,QAAQ,KAAK,MAAM,EAAE;GACzC,MAAM,WAAW,WAAW,OAAO,QAAQ,EAAE;AAC7C,OAAI,aAAa,MACf,eAAc,KAAK,UAAU,SAAS,CAAC,MACrC,GACA,SAAS,SAAY,GACtB;;AAEL,SAAO;AACP,SAAO,MAAM,YAAY;;AAE3B,QAAO,MAAM,MAAM,KAAK,MAAM,KAAK,CAAC;;;AAItC,SAAgB,iBAAiB;CAC/B,MAAM,uBAAO,IAAI,SAAkB;CAEnC,MAAM,6BAAa,IAAI,KAAsB;CAC7C,IAAI;CAEJ,MAAM,WAAW,YAA8B;AAC7C,OAAK,MAAM,UAAU,QACnB,KAAI,OAAO,UAAU,aAAa,KAAK,WACrC,MAAK,IAAI,OAAO,OAAkB;;;CAIxC,MAAM,SAAS,SAAiB;AAC9B,OAAK,MAAM,SAAS,CAAC,GAAG,WAAW,QAAQ,CAAC,CAAC,MAC1C,GAAG,MAAM,EAAE,SAAS,EAAE,OACxB,CACC,MAAK,MAAM,QAAQ;GACjB;GACA,KAAK,UAAU,MAAM,CAAC,MAAM,GAAG,GAAG;GAClC,mBAAmB,MAAM;GACzB,IAAI,gBAAgB,EAAE,GAAG,OAAO,CAAC,CAAC,UAAU,CAAC,MAAM,EAAE;GACtD,CACC,QAAO,QAAQ,KAAK,KAAK,GAErB,KAAK,QAAQ,OAAO,WAAW,KAAK,UAAU,IAAI,EAAE,SAAS,GAC7D,KAAK,MAAM,KAAK,CAAC,KAAK,SAAS;AACvC,SAAO;;;CAIT,MAAM,cAAc,YAAqB;AACvC,MAAI,SAAU,SAAQ,SAAS,aAAa,CAAC;AAC7C,MACE,EAAE,mBAAmB,qBACrB,EACE,QAAQ,SAAS,cACjB,KAAK,IAAI,QAAQ,IACjB,gBAAgB,QAAQ,KAAK,IAC7B,sBAAsB,KAAK,QAAQ,aAAa,eAAe,IAAI,GAAG,EAGxE,QAAO;AACT,OAAK,IAAI,QAAQ;AACjB,MAAI,QAAQ,MAAM,UAAU,GAAG;AAC7B,cAAW,OAAO,QAAQ;AAC1B,cAAW,IAAI,SAAS,QAAQ,MAAM;AACtC,OAAI,WAAW,OAAO,kBACpB,YAAW,OAAO,WAAW,MAAM,CAAC,MAAM,CAAC,MAAO;;AAEtD,SAAO;;AAGT,QAAO;EACL;EAKA,QAAQ;AAGN,OAAI;AACF,SAAK,MAAM,SAAS,SAAS,iBAAiB,uBAAuB,CACnE,MAAK,IAAI,MAAM;WACX;GAGR,IAAI;AACJ,OAAI;AACF,cAAU,IAAI,iBAAiB,QAAQ;AACvC,YAAQ,QAAQ,UAAU;KACxB,SAAS;KACT,iBAAiB,CAAC,OAAO;KACzB,mBAAmB;KACpB,CAAC;AACF,eAAW;WACL;AACN,cAAU;;GAEZ,MAAM,YAAY,UAAiB;AACjC,QAAI;KACF,MAAM,CAAC,UAAU,MAAM,cAAc;AACrC,SAAI,kBAAkB,QAAS,YAAW,OAAO;YAC3C;;AAIV,QAAK,MAAM,QAAQ,CAAC,SAAS,SAAS,CACpC,QAAO,iBAAiB,MAAM,UAAU,KAAK;AAC/C,gBAAa;AACX,aAAS,YAAY;AACrB,QAAI,YAAY,UAAa,aAAa,QAAS,YAAW;AAC9D,SAAK,MAAM,QAAQ,CAAC,SAAS,SAAS,CACpC,QAAO,oBAAoB,MAAM,UAAU,KAAK;;;EAGtD,QAAQ,KAAa,UACnB,gBAAgB,IAAI,GAAG,WAAW,MAAM,WAAW,MAAM,CAAC;EAE5D,KAAK,MAAc;AACjB,OAAI;AACF,WAAO,MAAM,WAAW,KAAK,MAAM,GAAG,WAAW,CAAC,CAAC;WAC7C;AACN;;;EAIJ,IAAI,OAAe;AACjB,OAAI;AACF,WAAO,MAAM,MAAM,QAAQ,WAAW,SAAS,WAAW,KAAK,CAAC,CAAC;WAC3D;AACN,WAAO;;;EAGZ;;;;;ACxPH,MAAM,cACJ;AACF,MAAM,uBAAuB;AAG7B,MAAM,kBAAkB;;AAGxB,SAAS,oBAAoB,IAAa,UAAkB;CAC1D,IAAI,UAA0B;AAC9B,QAAO,YAAY,MAAM;EACvB,MAAM,QAAQ,QAAQ,QAAQ,SAAS;AACvC,MAAI,UAAU,KAAM,QAAO;EAC3B,MAAM,OAAO,QAAQ,aAAa;AAClC,YAAU,gBAAgB,aAAa,KAAK,OAAO;;AAErD,QAAO;;;;;;AAkBT,SAAgB,eAAe,IAAa,QAAkB;AAC5D,KAAI,oBAAoB,IAAI,2BAA2B,KAAK,KAAM,QAAO;CACzE,MAAM,UAAU,GAAG,QAAQ,YAAY,IAAI;CAE3C,MAAM,WAAW,OAAO,WAAW,QAAQ;AAsB3C,QArB6B;EAC3B;EACA,QAAQ;GACN,KAAK,QAAQ;GACb,MAAM,QAAQ,aAAa,OAAO;GAClC,QAAQ,QAAQ,aAAa,yBAAyB;GACtD,OAAO,QAAQ,eAAe,IAAI,MAAM,GAAG,gBAAgB;GAC3D,YAAY,OAAO,YACjB,MAAM,KAAK,QAAQ,aAAa,EAAE,MAAM,YAAY,CAClD,MAGC,YAAY,SAAS,WACtB,gBAAgB,KAAK,QAAQ,UAAU,GAAG,CAAC,GACvC,WACA,OAAO,IAAI,MAAM,CACtB,CAAC,CACH;GACD,GAAG,iBAAiB,SAAS,OAAO;GACrC;EACF;;;AAKH,SAAgB,iBAAiB,SAAkB,QAAkB;AACnE,KAAI,mBAAmB,iBACrB,QAAO;EACL,OAAO,OAAO,WAAW,QAAQ,GAAG,WAAW,QAAQ;EACvD,GAAI,CAAC,YAAY,QAAQ,CAAC,SAAS,QAAQ,KAAK,GAC5C,EAAE,SAAS,QAAQ,SAAS,GAC5B,EAAE;EACN,GAAI,QAAQ,SAAS,SACjB,EACE,OAAO,MAAM,KAAK,QAAQ,SAAS,EAAE,GAAG,UAAU;GAChD,MAAM,KAAK;GACX,MAAM,KAAK;GACX,MAAM,KAAK;GACX,cAAc,KAAK;GACpB,EAAE,EACJ,GACD,EAAE;EACP;AAEH,KAAI,mBAAmB,kBACrB,QAAO;EACL,OAAO,QAAQ;EACf,gBAAgB,MAAM,KACpB,QAAQ,kBACP,WAAW,OAAO,MACpB;EACF;AAEH,KAAI,mBAAmB,oBAAqB,QAAO,EAAE,OAAO,QAAQ,OAAO;AAC3E,KAAI,QAAQ,QAAQ,qDAAmD,CACrE,QAAO,EAAE,OAAO,QAAQ,eAAe,IAAI;AAE7C,QAAO,EAAE;;AAGX,SAAS,aAAa,OAAc;CAClC,MAAM,CAAC,SAAS,MAAM,cAAc;AACpC,QAAO,iBAAiB,UAAU,QAAQ;;AAG5C,SAAS,iBAAiB,IAAa;AACrC,QAAO,GAAG,cAAc,UAAU,GAAG,cAAc,UAAU,CAAC,GAAG;;AAGnE,SAAS,OAAO,QAAuB,OAAmB;AACxD,QACE,KAAK,IAAI,OAAO,IAAI,MAAM,QAAQ,IAAI,wBACtC,KAAK,IAAI,OAAO,IAAI,MAAM,QAAQ,IAAI;;;;;;;AAS1C,SAAgB,oBAAoB,QAOjC;CACD,MAAM,EACJ,MACA,UACA,QACA,aAAa,UAAU,MAAM,WAC7B,SAAS,gBAAgB,KACvB;CACJ,IAAI,SAA+B;CAEnC,MAAM,iBAAiB,UAAwB;AAC7C,MAAI;AACF,OAAI,MAAM,cAAc,MAAO;GAC/B,MAAM,KAAK,aAAa,MAAM;AAC9B,YAAS;IACP,WAAW,OAAO,OAAO,OAAO,eAAe,IAAI,OAAO;IAC1D,GAAG,MAAM;IACT,GAAG,MAAM;IACV;UACK;AACN,YAAS;;;CAIb,MAAM,WAAW,UAAsB;EACrC,MAAM,gBAAgB;AACtB,WAAS;AACT,MAAI;AACF,OAAI,CAAC,UAAU,MAAM,CAAE;GACvB,MAAM,KAAK,aAAa,MAAM;AAC9B,OAAI,OAAO,KAAM;GAKjB,MAAM,YAHJ,kBAAkB,QAClB,iBAAiB,GAAG,IACpB,OAAO,eAAe,MAAM,GAE1B,cAAc,YACd,eAAe,IAAI,OAAO;AAC9B,OAAI,cAAc,KAAM;GACxB,MAAM,QAAQ,MAAM,WAAW,IAAI,aAAa;AAChD,QAAK,SAAS;IACZ,QAAQ;KAAE,GAAG,UAAU;KAAQ;KAAO;IACtC,OAAO,UAAU;IACjB,KAAK,OAAO,IAAI,SAAS,KAAK;IAC9B,GAAG,SAAS,UAAU,QAAQ;IAC/B,CAAC;UACI;;AAKV,QAAO,iBAAiB,eAAe,eAAe;EACpD,SAAS;EACT,SAAS;EACV,CAAC;AACF,QAAO,iBAAiB,SAAS,SAAS;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AAC3E,cAAa;AACX,SAAO,oBAAoB,eAAe,eAAe,EAAE,SAAS,MAAM,CAAC;AAC3E,SAAO,oBAAoB,SAAS,SAAS,EAAE,SAAS,MAAM,CAAC;;;;;;;ACxLnE,SAAgB,oBAAoB,QAIjC;CACD,MAAM,EACJ,MACA,aAAa,UAAiB,MAAM,WACpC,SAAS,gBAAgB,KACvB;CACJ,MAAM,WAAW,UAAiB;AAChC,MAAI;AACF,OAAI,CAAC,UAAU,MAAM,CAAE;GACvB,MAAM,CAAC,WAAW,MAAM,cAAc;AACtC,OAAI,EAAE,mBAAmB,SAAU;GACnC,MAAM,YAAY,eAAe,SAAS,OAAO;AACjD,OAAI,cAAc,KAAM;GACxB,MAAM,QAAQ,iBAAiB,SAAS,OAAO;AAC/C,OAAI,EAAE,WAAW,OAAQ;AACzB,QAAK,SAAS;IACZ,WAAW,MAAM;IACjB,QAAQ;KAAE,GAAG,UAAU;KAAQ,GAAG;KAAO;IACzC,KAAK,OAAO,IAAI,SAAS,KAAK;IAC9B,OAAO,SAAS;IACjB,CAAC;UACI;;AAIV,QAAO,iBAAiB,SAAS,SAAS;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AAC3E,QAAO,iBAAiB,UAAU,SAAS;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AAC5E,cAAa;AACX,SAAO,oBAAoB,SAAS,SAAS,EAAE,SAAS,MAAM,CAAC;AAC/D,SAAO,oBAAoB,UAAU,SAAS,EAAE,SAAS,MAAM,CAAC;;;;;;ACpBpE,SAAS,mBAAmB,OAA2C;AACrE,QACE,OAAO,UAAU,YACjB,UAAU,QACV,sBAAsB,SACtB,yBAAyB;;AAI7B,SAAS,gBAAgB;CACvB,MAAM,aAAsB,QAAQ,IAAI,QAAQ,aAAa;AAC7D,QAAO,mBAAmB,WAAW,GAAG,aAAa;;AAGvD,SAAS,iBAAiB,OAAgC;AACxD,SAAQ,OAAR;EACE,KAAK;EACL,KAAK;EACL,KAAK,SACH,QAAO;EACT,QACE,QAAO;;;;;;;;;AAUb,SAAgB,yBAAyB,QAItC;CACD,MAAM,EAAE,MAAM,SAAS,gBAAgB,KAAK;CAC5C,IAAI,UAAU,SAAS;CAEvB,MAAM,SAAS,mBAAmC;AAChD,MAAI;GACF,MAAM,OAAO,SAAS;AACtB,OAAI,SAAS,QAAS;AACtB,QAAK,cAAc;IACjB,MAAM,OAAO,IAAI,QAAQ;IACzB,IAAI,OAAO,IAAI,KAAK;IACpB;IACD,CAAC;AACF,aAAU;UACJ;;CAKV,MAAM,aAAa,eAAe;AAClC,KAAI,eAAe,MAAM;EACvB,MAAM,iBAAiB,UACrB,MAAM,iBAAiB,QAAQ,IAAI,OAAO,iBAAiB,CAAC,CAAC;AAC/D,aAAW,iBAAiB,sBAAsB,cAAc;AAChE,eACE,WAAW,oBAAoB,sBAAsB,cAAc;;AAGvE,QAAO,uBAAuB,MAAM;;AAGtC,SAAS,uBACP,OACA;CACA,IAAI,SAAS;CACb,MAAM,WAA2B,EAAE;CAEnC,MAAM,SAAS,QAAiB,WAA0B;EACxD,MAAM,WAAW,OAAO;EACxB,MAAM,UAAkC,SAAyB,GAAG,MAAM;GACxE,MAAM,SAAS,SAAS,MAAM,MAAM,KAAK;AACzC,OAAI,OAAQ,OAAM,WAAW,cAAc,SAAS,UAAU;AAC9D,UAAO;;AAET,SAAO,UAAU;AACjB,WAAS,WAAW;AAElB,OAAI,OAAO,YAAY,QAAS,QAAO,UAAU;IACjD;;AAIJ,MAAK,MAAM,UADsB,CAAC,aAAa,eAAe,EAChC;AAC5B,QAAM,QAAQ,WAAW,OAAO;AAChC,MAAI,OAAO,UAAU,eAAe,KAAK,SAAS,OAAO,CACvD,OAAM,SAAS,OAAO;;CAG1B,MAAM,mBAAmB,MAAM,WAAW;AAC1C,QAAO,iBAAiB,YAAY,WAAW;AAC/C,QAAO,iBAAiB,cAAc,WAAW;AAEjD,cAAa;AACX,WAAS;AACT,SAAO,oBAAoB,YAAY,WAAW;AAClD,SAAO,oBAAoB,cAAc,WAAW;AACpD,OAAK,MAAM,WAAW,SAAU,UAAS;;;;;;ACtG7C,MAAM,aAAa;AACnB,MAAM,kBAAkB;AACxB,MAAM,UAAU,IAAI,aAAa;AACjC,MAAM,yBAAuC;CAC3C,QAAQ;CACR,QAAQ;CACT;;AAID,SAAS,SACP,OACA,QACA,aACA,QACA,QACc;CACd,IAAI;AACJ,KAAI,CAAC,SAAS,YAAY,CACxB,KAAI;AAGF,SAAO,IAAI,YAAY,SAAS,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC,OAAO,OAAO,EACrE,QAAQ,WAAW,YACpB,CAAC;SACI;AAGV,KAAI,SAAS,QAAW;AACtB,SAAO,OAAO,KAAK;AACnB,MAAI,SAAS,OAAW,QAAO,iBAAiB;AAEhD,MAAI,KAAK,SAAS,KAAK,EAAE;AACvB,WAAQ,QAAQ,OAAO,KAAK;AAC5B,UAAO;;;CAGX,MAAM,SAAS,SAAS;AAExB,UAAS,KAAK,OAAO,aAAa,GAAG,MAAM,SAAS,GAAG,WAAW,CAAC,CAAC;CACpE,MAAM,SAAuB;EAC3B,QAAQ,WAAW,cAAc,MAAM,WAAW,IAAI,UAAU;EAChE;EACA,UAAU,SAAS,WAAW;EAC9B,GAAI,SAAS,EAAE,QAAQ,GAAG,EAAE;EAC7B;AACD,KAAI,QAAQ,OAAO,KAAK,UAAU,OAAO,CAAC,CAAC,UAAU,WACnD,QAAO;AACT,QAAO,SAAS,WAAW,aAAa,cAAc;AACtD,QAAO,YAAY;CAEnB,IAAI,MAAM;CACV,IAAI,OAAO,KAAK;AAChB,QAAO,MAAM,MAAM;EACjB,MAAM,SAAS,KAAK,MAAM,MAAM,QAAQ,EAAE;AAC1C,SAAO,OAAO,KAAK,MAAM,GAAG,OAAO;AACnC,MAAI,QAAQ,OAAO,KAAK,UAAU,OAAO,CAAC,CAAC,UAAU,WACnD,OAAM;MACH,QAAO,SAAS;;AAEvB,KAAI,OACF,QAAO,MAAM;UACN,MAAM,KAAK,kBAAkB,KAAK,KAAK,MAAM,GAAI,CAAE;AAC5D,QAAO,OAAO,KAAK,MAAM,GAAG,IAAI;AAChC,QAAO;;AAGT,SAAS,aACP,OACA,QACA,SAAiC,YACjC,QACc;AACd,QAAO,SACL,QAAQ,OAAO,MAAM,MAAM,GAAG,WAAW,CAAC,EAC1C,MAAM,SAAS,cAAc,WAAW,aAAa,cAAc,QACnE,cACA,QACA,OACD;;AAGH,SAAS,SAAS,aAAqB;AACrC,QACE,gBAAgB,MAChB,CAAC,oFAAoF,KACnF,YACD;;;AAKL,SAAgB,kBAAkB,QAAkB;CAClD,MAAM,gCAAgB,IAAI,KAAiB;CAC3C,IAAI,SAAS;CACb,MAAM,QACJ,QACA,cAAc,OACY;AAC1B,MAAI,CAAC,OAAQ,QAAO,QAAQ,QAAQ,EAAE,QAAQ,SAAS,CAAC;AACxD,MAAI,CAAC,OACH,QAAO,QAAQ,QAAQ;GACrB,QAAQ;GACR,QAAQ;GACT,CAAC;EACJ,IAAI;AACJ,MAAI;AACF,YAAS,OAAO,WAAW;UACrB;AACN,UAAO,QAAQ,QAAQ;IAAE,QAAQ;IAAe,QAAQ;IAAe,CAAC;;AAE1E,SAAO,IAAI,SAAS,YAAY;GAC9B,MAAM,QAAQ,IAAI,WAAW,WAAW;GACxC,IAAI,SAAS;GACb,IAAI,WAAW;GACf,MAAM,UAAU,QAAgC,WAAoB;AAClE,QAAI,SAAU;AACd,eAAW;AACX,iBAAa,MAAM;AACnB,kBAAc,OAAO,OAAO;AAC5B,YACE,SACE,MAAM,SAAS,GAAG,OAAO,EACzB,QACA,aACA,OAAO,MACP,OACD,CACF;AAED,IAAK,OAAO,QAAQ,CAAC,YAAY,GAAG;;GAEtC,MAAM,eAAe,OAAO,eAAe,kBAAkB;GAC7D,MAAM,QAAQ,iBACN,OAAO,WAAW,oBAAoB,EAC5C,gBACD;AACD,iBAAc,IAAI,OAAO;AACzB,IAAM,YAAY;AAChB,QAAI;AACF,cAAS;MACP,MAAM,QAAQ,MAAM,OAAO,MAAM;AACjC,UAAI,SAAU;AACd,UAAI,MAAM,MAAM;AACd,cAAO,WAAW;AAClB;;MAEF,MAAM,OAAO,MAAM,MAAM,SAAS,GAAG,aAAa,OAAO;AACzD,YAAM,IAAI,MAAM,OAAO;AACvB,gBAAU,KAAK;AACf,UAAI,WAAW,YAAY;AACzB,cAAO,YAAY;AACnB;;;YAGE;AACN,YAAO,eAAe,mBAAmB;cACjC;AACR,SAAI;AACF,aAAO,aAAa;aACd;;OAIR;IACJ;;CAEJ,MAAM,QACJ,OACA,cAAc,OACY;AAC1B,MAAI;AACF,OAAI,SAAS,KAAM,QAAO,QAAQ,QAAQ,EAAE,QAAQ,SAAS,CAAC;AAC9D,OAAI,iBAAiB,gBAAiB,SAAQ,MAAM,UAAU;AAC9D,OAAI,OAAO,UAAU,SACnB,QAAO,QAAQ,QAAQ,aAAa,OAAO,OAAO,KAAK,CAAC;AAC1D,OACE,OAAO,mBAAmB,eAC1B,iBAAiB,eAGjB,QAAO,QAAQ,QAAQ;IACrB,QAAQ;IACR,QAAQ;IACT,CAAC;AAEJ,OAAI,iBAAiB,UAAU;IAC7B,MAAM,OAAO,IAAI,UAAU;AAC3B,UAAM,SAAS,OAAO,QACpB,KAAK,OACH,KACA,OAAO,UAAU,WAAW,OAAO,MAAM,KAAK,MAAM,GAAG,MACxD,CACF;AACD,YAAQ;;GAEV,MAAM,WAAW,IAAI,SAAS,MAAM;AACpC,UAAO,KACL,SAAS,MACT,eAAe,SAAS,QAAQ,IAAI,eAAe,IAAI,GACxD;UACK;AACN,UAAO,QAAQ,QAAQ;IACrB,QAAQ;IACR,QAAQ;IACT,CAAC;;;AAGN,QAAO;EACL;EACA;EACA,OAAO,OAAe,QAAiC,WACrD,aAAa,OAAO,OAAO,MAAM,QAAQ,OAAO;EAClD,OAAO;AACL,YAAS;AACT,QAAK,MAAM,UAAU,cAAe,SAAQ;;EAE/C;;;;;ACzNH,MAAM,kBAAkB,QAAQ,QAAsB,EAAE,QAAQ,SAAS,CAAC;AAC1E,MAAM,mBAAmB,WACvB,QAAQ,QAAsB;CAAE,QAAQ;CAAe;CAAQ,CAAC;AAClE,MAAM,cAAc,QAAgB,IAAI,IAAI,KAAK,SAAS,KAAK,CAAC;AAKhE,MAAM,qBAAqB,IAAI,IAAI;CACjC;CACA;CACA;CACD,CAAC;AACF,MAAM,eAAe,MAAc,UACjC,mBAAmB,IAAI,KAAK,aAAa,CAAC,IAAI,gBAAgB,KAAK,GAC/D,WACA;AACN,MAAM,gBAAgB,YACpB,OAAO,YACL,MAAM,KAAK,QAAQ,SAAS,GAAG,CAAC,MAAM,WAAW,CAC/C,MACA,YAAY,MAAM,MAAM,CACzB,CAAC,CACH;AAGH,SAAS,aAAa,OAAgB;AACpC,QACE,OAAO,UAAU,YACjB,UAAU,QACV,UAAU,SACV,MAAM,SAAS;;;AAKnB,SAAgB,sBAAsB,QAKnC;CACD,IAAI,SAAS;CACb,MAAM,EAAE,SAAS,gBAAgB,KAAK;CACtC,MAAM,SAAS,kBAAkB,OAAO;CACxC,MAAM,aAAa,OAAO,WAAW,KAAK,YACxC,OAAO,YAAY,WACf,WAAW,QAAQ,GACnB,IAAI,OAAO,QAAQ,QAAQ,QAAQ,MAAM,CAC9C;CACD,MAAM,aAAa,QACjB,WAAW,MAAM,YAAY;AAC3B,MAAI,OAAO,YAAY,SAAU,QAAO,IAAI,WAAW,QAAQ;AAC/D,UAAQ,YAAY;AACpB,SAAO,QAAQ,KAAK,IAAI;GACxB;CAEJ,MAAM,UACJ,WACA,SACA,aACA,WACA,QACA,SACA,aACG;EACH,MAAM,aAAa,KAAK,MAAM,YAAY,KAAK,GAAG,UAAU;EAC5D,MAAM,cAAc,KAAK,KAAK;AAC9B,EAAK,QAAQ,IAAI,CACf,aACA,UAAU,QAAQ,gBAAgB,cAAc,CACjD,CAAC,CACC,MAAM,CAAC,MAAM,cAAc;AAC1B,OAAI,CAAC,OAAQ;GACb,MAAM,OAAO,OAAO,IAAI,QAAQ,IAAI;GACpC,MAAM,MAAM,IAAI,IAAI,KAAK;AACzB,UAAO,KAAK,WAAW;IACrB;IACA,QAAQ,QAAQ;IAChB,KAAK;IACL,QAAQ,IAAI;IACZ,OAAO,IAAI;IACX;IACA;IACA;IACA;IACA,SAAS;KAAE,SAAS,QAAQ;KAAS,MAAM;KAAM;IACjD,UAAU;KACR,GAAI,UAAU,MAAM,EAAE,KAAK,OAAO,IAAI,SAAS,IAAI,EAAE,GAAG,EAAE;KAC1D,SAAS,UAAU,WAAW,EAAE;KAChC,MAAM;KACP;IACF,CAAC;IACF,CACD,YAAY,GAEX;;CAGN,MAAM,gBAAgB,WAAW;CACjC,MAAM,gBAA8B,OAAO,SAAS;AAClD,MAAI,CAAC,OAAQ,QAAO,cAAc,KAAK,YAAY,OAAO,KAAK;EAC/D,IAAI;EACJ,IAAI;AACJ,MAAI;GACF,MAAM,SACJ,OAAO,YAAY,eAAe,iBAAiB,UAC/C,QACA;AACN,aAAU;IACR,KAAK,WAAW,SAAS,OAAO,MAAM,OAAO,MAAM,CAAC;IACpD,SAAS,MAAM,UAAU,QAAQ,UAAU,OAAO,aAAa;IAC/D,SAAS,aAAa,IAAI,QAAQ,MAAM,WAAW,QAAQ,QAAQ,CAAC;IACrE;AACD,aAAU,UAAU,QAAQ,IAAI;UAC1B;AACN,UAAO,cAAc,KAAK,YAAY,OAAO,KAAK;;AAEpD,MAAI,QAAS,QAAO,cAAc,KAAK,YAAY,OAAO,KAAK;EAC/D,MAAM,YAAY,YAAY,KAAK;EACnC,IAAI;AACJ,MAAI;AACF,iBACE,MAAM,QAAQ,OACV,OAAO,KAAK,KAAK,MAAM,QAAQ,QAAQ,gBAAgB,GACvD,OAAO,YAAY,eAAe,iBAAiB,UACjD,OAAO,KAAK,MAAM,OAAO,CAAC,MAAM,QAAQ,QAAQ,gBAAgB,GAChE,WAAW;UACb;AACN,iBAAc,gBAAgB,uBAAuB;;EAEvD,MAAM,UAAU,UACd,OACE,SACA,SACA,aACA,WACA,MACA,aAAa,MAAM,GAAG,YAAY,QACnC;EACH,IAAI;AACJ,MAAI;AACF,aAAU,cAAc,KAAK,YAAY,OAAO,KAAK;WAC9C,OAAO;AACd,UAAO,MAAM;AACb,SAAM;;AAER,EAAK,QACF,MAAM,aAAa;AAClB,OAAI,CAAC,OAAQ;GACb,IAAI;AACJ,OAAI;AACF,mBACE,SAAS,SAAS,YAAY,SAAS,SAAS,mBAC5C,gBAAgB,kBAAkB,GAClC,OAAO,KACL,SAAS,OAAO,CAAC,MACjB,SAAS,QAAQ,IAAI,eAAe,IAAI,GACzC;WACD;AACN,mBAAe,gBAAgB,wBAAwB;;AAEzD,UACE,SACA,SACA,aACA,WACA,SAAS,QACT,MACA;IACE,KAAK,SAAS;IACd,SAAS,aAAa,SAAS,QAAQ;IACvC,MAAM;IACP,CACF;KACA,OAAO,CACT,YAAY,GAAG;AAElB,SAAO;;AAET,KAAI,OAAO,kBAAkB,WAAY,YAAW,QAAQ;CAC5D,MAAM,eAAe,SAAS,WAAW,QAAQ,OAAO;AACxD,cAAa;AACX,WAAS;AACT,SAAO,MAAM;AACb,MAAI,WAAW,UAAU,aAAc,YAAW,QAAQ;AAC1D,gBAAc;;;AAclB,SAAS,SACP,WACA,QACA,QACA;AACA,KAAI,OAAO,mBAAmB,WAAY,cAAa;CACvD,MAAM,QAAQ,eAAe;CAC7B,MAAM,eAAe,MAAM;CAC3B,MAAM,eAAe,MAAM;CAC3B,MAAM,oBAAoB,MAAM;CAChC,MAAM,2BAAW,IAAI,SAAuC;CAC5D,MAAM,4BAAY,IAAI,KAAiB;CAGvC,MAAM,2BAAW,IAAI,SAAqC;CAC1D,IAAI,SAAS;CACb,MAAM,cAAc,SAElB,QACA,KACA,GAAG,MACH;AACA,WAAS,IAAI,KAAK,IAAI;EACtB,MAAM,SAAS,QAAQ,MAAM,cAAc,MAAM;GAAC;GAAQ;GAAK,GAAG;GAAK,CAAC;AACxE,MAAI;AACF,YAAS,IAAI,MAAM;IACjB,KAAK,WAAW,OAAO,IAAI,CAAC;IAC5B,QAAQ,OAAO,aAAa;IAC5B,SAAS,EAAE;IACZ,CAAC;UACI;AACN,YAAS,OAAO,KAAK;;AAEvB,SAAO;;CAET,MAAM,mBAAmB,SAEvB,MACA,OACA;EACA,MAAM,SAAS,kBAAkB,KAAK,MAAM,MAAM,MAAM;EACxD,MAAM,UAAU,SAAS,IAAI,KAAK;AAClC,MAAI,UAAU,QACZ,KAAI;GACF,MAAM,UAAU,IAAI,QAAQ,QAAQ,QAAQ;AAC5C,WAAQ,OAAO,MAAM,MAAM;AAC3B,WAAQ,UAAU,aAAa,QAAQ;UACjC;AAIV,SAAO;;CAET,MAAM,cAAc,SAElB,MACA;EACA,MAAM,UAAU,SAAS,IAAI,KAAK;AAClC,MAAI,CAAC,UAAU,CAAC,WAAW,UAAU,QAAQ,IAAI,CAC/C,QAAO,aAAa,KAAK,MAAM,KAAK;EACtC,MAAM,YAAY,YAAY,KAAK;EACnC,IAAI;AACJ,MAAI;AACF,iBACE,OAAO,aAAa,eAAe,gBAAgB,WAC/C,QAAQ,QACN,OAAO,KAAK,IAAI,eAAe,CAAC,kBAAkB,KAAK,CAAC,CACzD,GACD,OAAO,KACL,MACA,QAAQ,QAAQ,gBACjB;UACD;AACN,iBAAc,gBAAgB,2BAA2B;;EAE3D,IAAI,UAAU;EACd,MAAM,gBAAgB;AACpB,aAAU;;EAEZ,MAAM,gBAAgB;AACpB,QAAK,oBAAoB,SAAS,QAAQ;AAC1C,QAAK,oBAAoB,WAAW,MAAM;AAC1C,aAAU,OAAO,QAAQ;AACzB,OAAI,SAAS,IAAI,KAAK,KAAK,QAAS,UAAS,OAAO,KAAK;;EAE3D,MAAM,cAAc;AAClB,YAAS;AACT,OAAI,CAAC,OAAQ;GACb,MAAM,UAAU,UAAU,YAAY,KAAK,WAAW,IAAI,UAAU;GACpE,MAAM,UAAkC,EAAE;AAC1C,OAAI;AACF,SAAK,MAAM,QAAQ,KAAK,uBAAuB,CAAC,MAAM,QAAQ,EAAE;KAC9D,MAAM,QAAQ,KAAK,QAAQ,IAAI;AAC/B,SAAI,QAAQ,GAAG;MACb,MAAM,OAAO,KAAK,MAAM,GAAG,MAAM,CAAC,aAAa;AAC/C,cAAQ,QAAQ,YAAY,MAAM,KAAK,MAAM,QAAQ,EAAE,CAAC,MAAM,CAAC;;;WAG7D;GAGR,IAAI;AACJ,OAAI;AACF,QAAI,KAAK,iBAAiB,MAAM,KAAK,iBAAiB,OACpD,gBAAe,QAAQ,QACrB,OAAO,KACL,KAAK,cACL,YAAY,OAAO,aAAa,eAChC,YAAY,OAAO,SAAY,WAAW,UAC3C,CACF;aACQ,KAAK,iBAAiB,OAC/B,gBAAe,QAAQ,QACrB,OAAO,KAAK,KAAK,UAAU,KAAK,SAAS,CAAC,CAC3C;aACM,KAAK,iBAAiB,WAC7B,gBAAe,KAAK,cAChB,QAAQ,QACN,OAAO,KACL,IAAI,eAAe,CAAC,kBAAkB,KAAK,YAAY,CACxD,CACF,GACD,gBAAgB,iBAAiB;QAErC,gBAAe,OAAO,KACpB,KAAK,UACL,QAAQ,mBAAmB,2BAC5B;WACG;AACN,mBAAe,gBAAgB,4BAA4B;;AAE7D,UACE,OACA,SACA,aACA,WACA,YAAY,OAAO,KAAK,SAAS,MACjC,SACA;IACE,KAAK,KAAK;IACV;IACA,MAAM;IACP,CACF;;AAEH,OAAK,iBAAiB,SAAS,QAAQ;AACvC,OAAK,iBAAiB,WAAW,MAAM;AACvC,YAAU,IAAI,QAAQ;AACtB,WAAS,IAAI,MAAM,QAAQ;AAC3B,MAAI;AACF,UAAO,aAAa,KAAK,MAAM,KAAK;WAC7B,OAAO;AACd,YAAS;AACT,UACE,OACA,SACA,aACA,WACA,MACA,aAAa,MAAM,GAAG,YAAY,QACnC;AACD,SAAM;;;AAGV,OAAM,OAAO;AACb,OAAM,OAAO;AACb,OAAM,mBAAmB;AACzB,cAAa;AACX,WAAS;AACT,OAAK,MAAM,WAAW,UAAW,UAAS;AAC1C,MAAI,MAAM,SAAS,YAAa,OAAM,OAAO;AAC7C,MAAI,MAAM,SAAS,YAAa,OAAM,OAAO;AAC7C,MAAI,MAAM,qBAAqB,iBAC7B,OAAM,mBAAmB;;;;;;;ACtY/B,SAAgB,QAAQ,UAAkB,aAAuB,EAAE,EAAE;AACnE,QAAO;;;AAIT,SAAgB,iBACd,KACA,aAAuB,EAAE,EACzB,OAAe,WAAW,UAAU,QAAQ,qBAC5C;CACA,MAAM,SAAS,IAAI,IAAI,KAAK,KAAK;AACjC,QAAO;EAAE,KAAK,OAAO;EAAM,QAAQ,OAAO;EAAQ,OAAO,OAAO;EAAU;;;;;ACM5E,MAAM,aAAa;AACnB,MAAM,oBAAoB;AAC1B,MAAM,WAAW,IAAI,IAAY,qBAAqB;;;;;;;;;;AAoBtD,SAAgB,gBAAgB,SAA2B;CACzD,MAAM,EAAE,MAAM,QAAQ,YAAY,WAAW;CAC7C,MAAM,UAAU;EACd,QAAQ;EACR,YAAY;EACZ,SAAS;EACT,QAAQ;EACR,GAAG,QAAQ;EACZ;CACD,IAAI,UAA0B;CAC9B,IAAI,MAAM;CAEV,MAAM,iBAAiB,QAAQ,SAAS,UAAU,OAAO;CAEzD,MAAM,SAAS,gBAAkC;EAC/C,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,MAAI,QAAQ,MAAM,WAAW,KAAK,QAAQ,YAAY,EAAG;EACzD,MAAM,SAAS,QAAQ,MAAM,OAAO,EAAE;EACtC,MAAM,UAAU,QAAQ;AACxB,UAAQ,UAAU;EAClB,MAAM,QAAuB;GAC3B,cAAc,QAAQ;GACtB,sBAAsB,QAAQ;GAC9B;GACA;GACD;EACD,MAAM,kBAAkB;AAEtB,OAAI,YAAY,QAAS,SAAQ,WAAW,OAAO,SAAS;;AAE9D,MAAI;AACF,WAAQ,QAAQ,KAAK,OAAO,YAAY,CAAC,CAAC,MAAM,UAAU;UACpD;AACN,cAAW;;;CAIf,MAAM,UAAU,MAAc,UAAmC;EAC/D,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,MAAI;AACF,UAAO;GACP,MAAM,QAAuB;IAC3B,MAAM;IACN;IACA,OAAO;KAAE,GAAG;KAAO;KAAK;IACxB,WAAW,KAAK,KAAK;IACtB;GACD,MAAM,OAAO,eAAe,SAAY,QAAQ,WAAW,MAAM;AACjE,OAAI,SAAS,KAAM;AACnB,WAAQ,MAAM,KAAK,KAAK;AACxB,OAAI,QAAQ,MAAM,UAAU,WAAY,QAAO;UACzC;;CAKV,MAAM,SAAS,EAAE,cAAc,2BAAyC;AACtE,MAAI,YAAY,MAAM;AACpB,OAAI,QAAQ,iBAAiB,aAC3B,SAAQ,KACN,sCAAsC,QAAQ,aAAa,yCAAyC,aAAa,IAClH;AAEH;;AAEF,MACE,OAAO,WAAW,eAClB,OAAO,OAAO,qBAAqB,WAEnC;EACF,MAAM,UAAmB;GACvB;GACA;GACA,OAAO,EAAE;GACT,SAAS;GACT,YAAY,EAAE;GACd,OAAO;GACR;AACD,YAAU;EACV,MAAM,iBAAgC,MAAM,UAAU;AACpD,OAAI,YAAY,QAAS,QAAO,MAAM,MAAM;;EAG9C,MAAM,SAAS,gBAAgB;AAC/B,UAAQ,WAAW,KAAK,OAAO,OAAO,CAAC;EACvC,MAAM,aAAa,CACjB,GAAI,QAAQ,cAAc,EAAE,EAC5B,GAAI,KAAK,QAAQ,SAAY,EAAE,GAAG,CAAC,KAAK,IAAI,CAC7C;AACD,MAAI,QAAQ,OACV,SAAQ,WAAW,KACjB,oBAAoB;GAAE,MAAM;GAAe;GAAU;GAAQ;GAAQ,CAAC,CACvE;AACH,MAAI,QAAQ,WACV,SAAQ,WAAW,KACjB,yBAAyB;GAAE,MAAM;GAAe;GAAQ;GAAQ,CAAC,CAClE;AACH,MAAI,QAAQ,QACV,SAAQ,WAAW,KACjB,sBAAsB;GACpB,MAAM;GACN;GACA;GACA;GACD,CAAC,CACH;AACH,MAAI,QAAQ,OACV,SAAQ,WAAW,KACjB,oBAAoB;GAAE,MAAM;GAAe;GAAQ,CAAC,CACrD;EACH,MAAM,mBAAmB,MAAM,EAAE,QAAQ,MAAM,CAAC;AAChD,SAAO,iBAAiB,YAAY,WAAW;AAC/C,UAAQ,WAAW,WACjB,OAAO,oBAAoB,YAAY,WAAW,CACnD;AACD,UAAQ,QAAQ,kBAAkB,OAAO,EAAE,kBAAkB;AAC7D,SAAO,QAAQ;GACb,OAAO,UAAU;GACjB,KAAK,OAAO,IAAI,SAAS,KAAK;GAC9B,OAAO,SAAS;GAChB,UAAU,OAAO,IAAI,SAAS,SAAS;GACxC,CAAC;;CAGJ,MAAM,aAAa;EACjB,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,gBAAc,QAAQ,MAAM;AAC5B,OAAK,MAAM,aAAa,QAAQ,WAAY,YAAW;AACvD,SAAO;AACP,YAAU;;CAGZ,MAAM,QAAQ,MAAc,UAAmC;AAC7D,MAAI,SAAS,IAAI,KAAK,EAAE;AACtB,WAAQ,KACN,2BAA2B,KAAK,yEACjC;AACD;;AAEF,SAAO,MAAM,MAAM;;CAGrB,MAAM,SAAS,MAAwB,UACrC,OAAO,MAAM,MAAM;AAWrB,QAT6B;EAC3B;EACA;EACA;EACA;EACA,IAAI,eAAe;AACjB,UAAO,SAAS,gBAAgB;;EAEnC;;;;;;;;;;;;;;ACzLH,SAAgB,SACd,KACA,OAA6C,EAAE,EAC/C;CACA,MAAM,OAAO,OAAO,OAAsB,UAA2B,EAAE,KAAK;EAC1E,MAAM,OAAO,KAAK,UAAU,MAAM;AAGlC,MADE,QAAQ,WAAW,QAAQ,OAAO,UAAU,eAAe,YAC9C;AAKb,OAAI,CAJW,UAAU,WACvB,KACA,IAAI,KAAK,CAAC,KAAK,EAAE,EAAE,MAAM,oBAAoB,CAAC,CAC/C,CACY,OAAM,IAAI,MAAM,+BAA+B;AAC5D;;EAEF,MAAM,WAAW,MAAM,MAAM,KAAK;GAChC,QAAQ;GACR,WAAW;GACX,SAAS;IAAE,gBAAgB;IAAoB,GAAG,KAAK;IAAS;GAChE;GACD,CAAC;AACF,MAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,uBAAuB,SAAS,SAAS;;AAG7E,QAD2B,OAAO,OAAO,MAAM,EAAE,KAAK,CAAC;;;;;ACrBzD,MAAa,6BAA6B,KAAK;AAC/C,MAAM,iBAAiB,IAAI,IAAY,qBAAqB;AAE5D,SAAS,OACP,OACA,4BAAY,IAAI,KAAa,EACT;AACpB,KAAI,OAAO,UAAU,SAAU,QAAO,CAAC,MAAM,SAAS,KAAK;AAC3D,KAAI,UAAU,QAAQ,OAAO,UAAU,UAAW,QAAO;AACzD,KAAI,OAAO,UAAU,SAAU,QAAO,OAAO,SAAS,MAAM;AAC5D,KAAI,OAAO,UAAU,YAAY,UAAU,IAAI,MAAM,CAAE,QAAO;AAC9D,KACE,CAAC,MAAM,QAAQ,MAAM,IACrB,OAAO,eAAe,MAAM,KAAK,OAAO,aACxC,OAAO,eAAe,MAAM,KAAK,KAEjC,QAAO;AACT,WAAU,IAAI,MAAM;AACpB,KACE,CAAC,MAAM,QAAQ,MAAM,IACrB,OAAO,KAAK,MAAM,CAAC,MAAM,QAAQ,IAAI,SAAS,KAAK,CAAC,CAEpD,QAAO;CACT,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,QAAQ,OAAO,OAAO,MAAM;AAClE,MAAK,MAAM,QAAQ,OACjB,KAAI,CAAC,OAAO,MAAM,UAAU,CAAE,QAAO;AAEvC,WAAU,OAAO,MAAM;AACvB,QAAO;;;;;;AAOT,SAAgB,0BACd,SAGqB;CACrB,IAAI,SAAS;CACb,IAAI,aAA6B,EAAE;CAEnC,MAAM,UAAU,MAAc,YAAoB;AAChD,MAAI;AACF,WAAQ,UAAU;IAAE;IAAM;IAAS,CAAC;UAC9B;;CAKV,MAAM,iBAAiB,QAAQ,SAAS,SAAS;CAEjD,MAAM,UAAU,MAAc,UAAqB;AACjD,MAAI,CAAC,OAAQ;EACb,IAAI;AACJ,MAAI;GACF,MAAM,QAAyB;IAC7B,MAAM;IACN;IACA,WAAW,KAAK,KAAK;IACrB;IACD;GACD,MAAM,OACJ,QAAQ,eAAe,SAAY,QAAQ,QAAQ,WAAW,MAAM;AACtE,OAAI,SAAS,QAAQ,CAAC,OAAQ;AAC9B,OACE,KAAK,SAAS,YACd,OAAO,KAAK,SAAS,YACrB,CAAC,KAAK,KAAK,MAAM,IACjB,KAAK,KAAK,SAAS,OACnB,KAAK,KAAK,SAAS,KAAK,IACxB,CAAC,OAAO,SAAS,KAAK,UAAU,IAChC,KAAK,aAAa,KAClB,KAAK,aAAa,kBAClB,CAAC,OAAO,KAAK,MAAM,EACnB;AACA,WACE,iBACA,4DACD;AACD;;GAGF,MAAM,aAAa,KAAK,UAAU;IAChC,MAAM,KAAK;IACX,MAAM,KAAK;IACX,WAAW,KAAK;IAChB,OAAO,KAAK;IACb,CAAC;AACF,OACE,IAAI,aAAa,CAAC,OAAO,WAAW,CAAC,aACrC,4BACA;AACA,WAAO,mBAAmB,uCAAuC;AACjE;;AAEF,aAAU,KAAK,MAAM,WAAW;UAC1B;AACN,UAAO,iBAAiB,6CAA6C;AACrE;;AAEF,MAAI;AACF,WAAQ,KAAK,QAAQ;UACf;AACN,UACE,uBACA,wDACD;;;CAIL,MAAM,WAAW,MAAc,UAAmC;AAEhE,SAAO,MAAM,MAAmB;;CAGlC,MAAM,aAAa;AACjB,WAAS;EACT,MAAM,YAAY;AAClB,eAAa,EAAE;AACf,OAAK,MAAM,aAAa,UAAW,YAAW;;AAGhD,QAAO;EACL,QAAQ;AACN,OAAI,UAAU,OAAO,WAAW,YAAa;AAC7C,YAAS;AAET,OAAI,OAAO,OAAO,qBAAqB,WAAY;GAEnD,MAAM,SAAS,gBAAgB;AAC/B,OAAI;AACF,eAAW,KAAK,OAAO,OAAO,CAAC;AAC/B,QAAI,QAAQ,SAAS,WAAW,MAC9B,YAAW,KACT,oBAAoB;KAAE,MAAM;KAAS;KAAU;KAAQ,CAAC,CACzD;AAEH,QAAI,QAAQ,SAAS,eAAe,MAClC,YAAW,KAAK,yBAAyB;KAAE,MAAM;KAAS;KAAQ,CAAC,CAAC;AAEtE,QAAI,QAAQ,SAAS,WAAW,MAC9B,YAAW,KAAK,oBAAoB;KAAE,MAAM;KAAS;KAAQ,CAAC,CAAC;AAEjE,QAAI,QAAQ,SAAS,YAAY,MAC/B,YAAW,KACT,sBAAsB;KACpB,MAAM;KACN,YAAY,QAAQ,cAAc,EAAE;KACpC;KACD,CAAC,CACH;AAEH,WAAO,QAAQ;KACb,OAAO,UAAU;KACjB,KAAK,OAAO,IAAI,SAAS,KAAK;KAC9B,OAAO,SAAS;KAChB,UAAU,OAAO,IAAI,SAAS,SAAS;KACxC,CAAC;WACI;AACN,UAAM;AACN,WAAO,kBAAkB,mCAAmC;;;EAGhE;EACA,KAAK,MAAM,OAAO;AAChB,OAAI,CAAC,OAAQ;AACb,OAAI,eAAe,IAAI,KAAK,EAAE;AAC5B,WACE,iBACA,2DACD;AACD;;AAEF,UAAO,MAAM,MAAM;;EAEtB"} +\ No newline at end of file ++{"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/redact.ts","../src/clicks.ts","../src/inputs.ts","../src/navigation.ts","../src/network-body.ts","../src/network.ts","../src/routes.ts","../src/collector.ts","../src/http-sink.ts","../src/trajectory-collector.ts"],"sourcesContent":["/** Event names the collector and CopilotKit Core emit. `emit()` rejects them. */\nexport const BUILT_IN_EVENT_NAMES = [\n \"page\",\n \"navigation\",\n \"click\",\n \"input\",\n \"network\",\n \"thread.linked\",\n \"agent.run\",\n \"agent.message\",\n \"tool.call\",\n] as const;\n\nexport type BuiltInEventName = (typeof BUILT_IN_EVENT_NAMES)[number];\n\n/** Replaces values that never leave the browser, even with full capture: passwords and credentials. */\nexport const REDACTED = \"[redacted]\";\n\n/** One captured interaction, shaped as an AG-UI `CUSTOM` event. */\nexport interface LearningEvent<V = Record<string, unknown>> {\n type: \"CUSTOM\";\n name: string;\n value: V;\n timestamp: number;\n}\n\n/** What a sink receives. The Trajectory scope sits on the batch, not on each event. */\nexport interface LearningBatch {\n trajectoryId: string;\n learningContainerIds?: string[];\n events: LearningEvent[];\n /** Events lost since the previous batch (queue overflow or a failed send). */\n dropped: number;\n}\n\nexport interface SinkSendOptions {\n /** True when the page is going away; use a transport that survives unload. */\n beacon?: boolean;\n}\n\n/** Receives batches. `url`, when set, is never captured as a network event. */\nexport type LearningSink = ((\n batch: LearningBatch,\n options?: SinkSendOptions,\n) => void | Promise<void>) & { readonly url?: string };\n\nexport interface ClickTarget {\n tag: string;\n role: string | null;\n action: string | null;\n text: string;\n /**\n * Short, cleaned name of the clicked control (click events only). Comes from\n * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title`, or a\n * control's own text. Omitted when no safe name exists.\n */\n label?: string;\n attributes: Record<string, string>;\n value?: string;\n checked?: boolean;\n selectedValues?: string[];\n files?: { name: string; type: string; size: number; lastModified: number }[];\n input: \"pointer\" | \"keyboard\";\n}\n\n/** Adds fields to a click event, from the element the user clicked. */\nexport type EnrichFn = (target: Element) => Record<string, unknown> | undefined;\n\nexport type Emit = (name: string, value: Record<string, unknown>) => void;\n\nexport interface CaptureOptions {\n clicks?: boolean;\n navigation?: boolean;\n network?: boolean;\n inputs?: boolean;\n}\n\nexport interface CollectorOptions {\n sink: LearningSink;\n /** @deprecated Capture retains raw URLs; route templates no longer transform events. */\n routes?: string[];\n /** URLs (prefix match on the absolute URL) or patterns that network capture skips. */\n ignoreUrls?: (string | RegExp)[];\n /** Turns capture modules on or off. All are on by default. */\n capture?: CaptureOptions;\n /** Runs last, in the browser. Return the event to send, or `null` to drop it. */\n beforeSend?: (event: LearningEvent) => LearningEvent | null;\n enrich?: EnrichFn;\n}\n\nexport interface StartOptions {\n trajectoryId: string;\n learningContainerIds?: string[];\n}\n\nexport interface Collector {\n start(options: StartOptions): void;\n stop(): void;\n /** Developer events. Built-in names warn and are ignored. */\n emit(name: string, value: Record<string, unknown>): void;\n /** @internal For @copilotkit/core only: emits built-in names. */\n ɵemit(name: BuiltInEventName, value: Record<string, unknown>): void;\n readonly trajectoryId: string | null;\n}\n","import { REDACTED } from \"./types\";\n\n// Passwords and unambiguous credentials never leave the browser, even with full\n// capture. Keys are split into lowercase words (`accessToken`, `access_token`,\n// `Access-Token` all become \"access token\"). These match anywhere in the joined words:\nconst CREDENTIAL =\n /password|passwd|passphrase|passcode|credential|api(key|token)|privatekey|subscriptionkey|(access|refresh|auth|session)token|clientsecret/;\n// These match only as whole words (`secret` but not `secretary`, `id token` but\n// not `invalid token`), as the last word (`Set-Cookie` but not `cookieConsent`),\n// or as the entire key.\nconst CREDENTIAL_WORD =\n / (secret|id ?token)s? | (authorization|cookies?|jwt|bearer ?token|[cx]srf ?token) $|^ (pwd|pass|token|otp) $/;\n// Metadata about a credential is not the credential: `passwordExpiresAt`, `credentialId`.\nconst METADATA =\n / (ids?|at|in|expires?|expiry|length|count|enabled|required|policy|hint|type) $/;\nconst PASSWORD_AUTOCOMPLETE = /(current|new)-password|one-time-code/;\n// A JSON string, possibly cut off, with the `:` that makes it a key.\nconst TOKEN = /\"((?:\\\\[\\s\\S]|[^\"\\\\])*)(\"?)(\\s*:\\s*)?/g;\n// A lone trailing backslash belongs to a cut-off string.\nconst STRING = /\"(?:\\\\[\\s\\S]?|[^\"\\\\])*\"?/y;\n// `key=value` and `key: value` outside JSON strings: forms, query strings,\n// GraphQL, YAML. A `:` needs a space or end after it, so `host:port` is no pair.\n// The key length bound keeps scanning long unbroken text linear.\n// An auth scheme (`Bearer x`) belongs to the value.\nconst PAIR =\n /([\\w$.%+[\\]-]{1,64})(\\s*(?:=|:(?=\\s|$))\\s*)((?:(?:basic|bearer|digest|token) +)?[^\\s&,;)}\\]]*)/gi;\n// `scheme://user:pass@` anywhere in text; up to the last `@` before the host.\n// The scheme length bound keeps scanning long words linear.\nconst USERINFO = /([a-z][\\w+.-]{0,31}:\\/\\/)[^\\s/?#\"]*@/gi;\n/** Text beyond this is neither redacted nor kept; snapshots keep 4 KiB of the result. */\nexport const TEXT_LIMIT = 16 * 1024;\nconst REMEMBERED_VALUES = 20;\n\nexport const isCredentialKey = (key: string) => {\n const words = ` ${key\n .replace(/%([\\da-f]{2})/gi, (_, hex: string) =>\n String.fromCharCode(parseInt(hex, 16)),\n )\n .replace(/([a-z\\d])(?=[A-Z])/g, \"$1 \")\n .toLowerCase()\n .split(/[^a-z\\d]+/)\n .filter(Boolean)\n .join(\" \")} `;\n return (\n !METADATA.test(words) &&\n (CREDENTIAL.test(words.replace(/ /g, \"\")) || CREDENTIAL_WORD.test(words))\n );\n};\n\nconst decode = (body: string) => {\n try {\n return JSON.parse(`\"${body}\"`) as string;\n } catch {\n return body;\n }\n};\n\n/** The end of the JSON value at `start`: a string, a balanced object or array, or a bare literal. */\nconst endOfValue = (text: string, start: number) => {\n let depth = 0;\n for (let i = start; i < text.length; i++) {\n const char = text[i]!;\n if (char === '\"') {\n STRING.lastIndex = i;\n STRING.test(text);\n i = STRING.lastIndex - 1;\n } else if (char === \"{\" || char === \"[\") {\n depth++;\n continue;\n } else if (char === \"}\" || char === \"]\") {\n if (--depth < 0) return i;\n } else {\n if (!depth && (char === \",\" || /\\s/.test(char))) return i;\n continue;\n }\n if (!depth) return i + 1;\n }\n return text.length;\n};\n\n/**\n * Redacts credential values in JSON (valid or cut off) and in key/value text,\n * and recurses into string values that hold JSON, forms, or URLs. Unchanged\n * parts keep their exact original text.\n */\nconst redactText = (text: string, depth = 0): string => {\n const token = new RegExp(TOKEN);\n let pending = false;\n const pairs = (part: string) => {\n const segment = part.replace(USERINFO, \"$1\");\n return segment.replace(\n PAIR,\n (pair, key: string, separator: string, value: string, offset: number) => {\n if (!isCredentialKey(key)) return pair;\n // The value may continue in a following string: `password:\"x\"`.\n if (offset + pair.length === segment.length) {\n pending = true;\n if (!value) return pair;\n }\n return key + separator + REDACTED;\n },\n );\n };\n let out = \"\";\n let last = 0;\n let match: RegExpExecArray | null;\n while ((match = token.exec(text))) {\n const [raw, body, closed, colon] = match;\n pending = false;\n out += pairs(text.slice(last, match.index));\n let end = token.lastIndex;\n let replacement = raw;\n const value = body!.includes(\"\\\\\") ? decode(body!) : body!;\n if (colon) {\n if (isCredentialKey(value)) {\n const valueEnd = endOfValue(text, end);\n // Booleans and null say whether a credential exists, not what it is.\n if (!/^(true|false|null|)$/.test(text.slice(end, valueEnd))) {\n replacement += `\"${REDACTED}\"`;\n end = valueEnd;\n }\n }\n } else if (pending) replacement = `\"${REDACTED}\"`;\n else if (depth < 4 && /[=:\"]/.test(value)) {\n const redacted = redactText(value, depth + 1);\n if (redacted !== value)\n replacement = JSON.stringify(redacted).slice(\n 0,\n closed ? undefined : -1,\n );\n }\n out += replacement;\n last = token.lastIndex = end;\n }\n return out + pairs(text.slice(last));\n};\n\n/** Per-session redaction state: password-like fields and the values typed into them. */\nexport function createRedactor() {\n const seen = new WeakSet<Element>();\n // Latest value per field, so each keystroke replaces the previous prefix.\n const remembered = new Map<Element, string>();\n let observer: MutationObserver | undefined;\n\n const toggled = (records: MutationRecord[]) => {\n for (const record of records)\n if (record.oldValue?.toLowerCase() === \"password\")\n seen.add(record.target as Element);\n };\n\n /**\n * Replaces remembered values, raw and in their JSON and URL encodings, plus\n * `normalize(value)` when given and at least 4 characters long.\n */\n const scrub = (text: string, normalize?: (value: string) => string) => {\n for (const value of [...remembered.values()].sort(\n (a, b) => b.length - a.length,\n ))\n for (const form of [\n value,\n JSON.stringify(value).slice(1, -1),\n encodeURIComponent(value),\n new URLSearchParams({ v: value }).toString().slice(2),\n ...[normalize?.(value) ?? \"\"].filter((normal) => normal.length >= 4),\n ])\n text = /^\\d+$/.test(form)\n ? // One-time codes must not match inside longer numbers.\n text.replace(RegExp(`(?<!\\\\d)${form}(?!\\\\d)`, \"g\"), REDACTED)\n : text.split(form).join(REDACTED);\n return text;\n };\n\n /** True for current, former (show-password toggles), and autocomplete password fields. */\n const isPassword = (element: Element) => {\n if (observer) toggled(observer.takeRecords());\n if (\n !(element instanceof HTMLInputElement) ||\n !(\n element.type === \"password\" ||\n seen.has(element) ||\n isCredentialKey(element.name) ||\n PASSWORD_AUTOCOMPLETE.test(element.getAttribute(\"autocomplete\") ?? \"\")\n )\n )\n return false;\n seen.add(element);\n if (element.value.length >= 4) {\n remembered.delete(element);\n remembered.set(element, element.value);\n if (remembered.size > REMEMBERED_VALUES)\n remembered.delete(remembered.keys().next().value!);\n }\n return true;\n };\n\n return {\n isPassword,\n scrub,\n /**\n * Tracks password fields for the session: existing ones, `type` toggles away\n * from \"password\", and typed values even when input capture is off.\n */\n watch() {\n // Each part is optional: a partial DOM (tests, embedded webviews, hardened\n // pages) must not stop capture from starting or break the host app.\n try {\n for (const input of document.querySelectorAll(\"input[type=password]\"))\n seen.add(input);\n } catch {\n // Fields are still caught on first sight by isPassword.\n }\n let current: MutationObserver | undefined;\n try {\n current = new MutationObserver(toggled);\n current.observe(document, {\n subtree: true,\n attributeFilter: [\"type\"],\n attributeOldValue: true,\n });\n observer = current;\n } catch {\n current = undefined;\n }\n const remember = (event: Event) => {\n try {\n const [target] = event.composedPath();\n if (target instanceof Element) isPassword(target);\n } catch {\n // Capture must never break an edit in the host app.\n }\n };\n for (const type of [\"input\", \"change\"])\n window.addEventListener(type, remember, true);\n return () => {\n current?.disconnect();\n if (current !== undefined && observer === current) observer = undefined;\n for (const type of [\"input\", \"change\"])\n window.removeEventListener(type, remember, true);\n };\n },\n field: (key: string, value: string) =>\n isCredentialKey(key) ? REDACTED : scrub(redactText(value)),\n /** Returns the redacted body text, or undefined if redaction failed. */\n body(text: string) {\n try {\n return scrub(redactText(text.slice(0, TEXT_LIMIT)));\n } catch {\n return undefined;\n }\n },\n /** Drops userinfo and redacts credential values; also applied to element attributes. */\n url(value: string) {\n try {\n return scrub(value.replace(/[^#]+/g, (part) => redactText(part)));\n } catch {\n return REDACTED;\n }\n },\n };\n}\n\nexport type Redactor = ReturnType<typeof createRedactor>;\n","import { createRedactor, isCredentialKey } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { ClickTarget, Emit, EnrichFn } from \"./types\";\n\nconst INTERACTIVE =\n 'button, a[href], input, select, textarea, label, summary, [role], [data-copilotkit-action], [contenteditable]:not([contenteditable=\"false\"])';\nconst RECOVERY_DISTANCE_PX = 5;\n// `[role]` also matches containers such as role=\"main\"; cap their text so one\n// click cannot exceed the 16 KiB event limit and be dropped.\nconst MAX_TEXT_LENGTH = 1000;\n\n/** `closest()` that continues through shadow roots to their hosts. */\nfunction closestAcrossShadow(el: Element, selector: string) {\n let current: Element | null = el;\n while (current !== null) {\n const match = current.closest(selector);\n if (match !== null) return match;\n const root = current.getRootNode();\n current = root instanceof ShadowRoot ? root.host : null;\n }\n return null;\n}\n\ninterface Described {\n element: Element;\n target: Omit<ClickTarget, \"input\">;\n}\n\ninterface PointerOrigin {\n described: Described | null;\n x: number;\n y: number;\n}\n\n/**\n * Finds the nearest interactive element and retains its text, attributes, and value.\n * The explicit `data-copilotkit-ignore` opt-out still applies.\n */\nexport function describeTarget(el: Element, redact: Redactor) {\n if (closestAcrossShadow(el, \"[data-copilotkit-ignore]\") !== null) return null;\n const element = el.closest(INTERACTIVE) ?? el;\n // React and server markup can mirror the password into the value attribute.\n const password = redact.isPassword(element);\n const described: Described = {\n element,\n target: {\n tag: element.localName,\n role: element.getAttribute(\"role\"),\n action: element.getAttribute(\"data-copilotkit-action\"),\n text: (element.textContent ?? \"\").slice(0, MAX_TEXT_LENGTH),\n attributes: Object.fromEntries(\n Array.from(element.attributes, ({ name, value }) => [\n name,\n // Credential-named attributes (`data-api-key`) are redacted whole;\n // URL-valued ones (`href`, `action`, …) can carry credentials too.\n (password && name === \"value\") ||\n isCredentialKey(name.replace(/^data-/, \"\"))\n ? REDACTED\n : redact.url(value),\n ]),\n ),\n ...readControlValue(element, redact),\n },\n };\n return described;\n}\n\nconst MAX_LABEL_LENGTH = 80;\n// The text walk stops past these bounds. For a control's own text, that means no name.\nconst MAX_WALK_NODES = 1000;\nconst MAX_WALK_CHARS = 4 * MAX_LABEL_LENGTH;\n// Controls whose name comes from their content. An explicit role overrides the\n// tag, so `<li role=\"option\">` qualifies and `<a href role=\"row\">` does not.\nconst CONTROL_ROLE =\n /^(button|link|tab|menuitem(checkbox|radio)?|option|checkbox|switch|radio|treeitem)$/;\nconst CONTENTEDITABLE = '[contenteditable]:not([contenteditable=\"false\"])';\nconst TEXT_ENTRY = `${CONTENTEDITABLE}, [role~=textbox], [role~=searchbox], [role~=combobox]`;\nconst EDITABLE = `input, textarea, select, ${TEXT_ENTRY}`;\n// Card-like structure: a control containing it gets no name from its text,\n// except for one lone paragraph, or headings inside `summary`.\nconst HEADING = \"h1, h2, h3, h4, h5, h6, [role~=heading]\";\nconst STRUCTURE = `${HEADING}, p, ul, ol, li, dl, table, article, section, blockquote`;\nconst SKIPPED = 'script, style, template, [hidden], [aria-hidden=\"true\"]';\n// Inline elements that do not separate words.\nconst PHRASING =\n /^(b|i|u|em|strong|mark|small|sub|sup|abbr|code|kbd|s|q|cite|dfn|var|time|data|bdi|bdo|wbr|span)$/;\nconst CAPTION_INPUT = /^(submit|button|reset|image)$/;\n// Input types that do not hold typed text.\nconst NON_TEXT_INPUT =\n /^(submit|button|reset|image|checkbox|radio|range|color|file|hidden)$/;\n// Includes bidi controls, zero-width characters, soft hyphens, word joiners,\n// variation selectors, and tag characters.\nconst IGNORABLE = /\\p{Default_Ignorable_Code_Point}/gu;\n// Quantifier bounds and run-start anchors keep each pattern linear. No\n// lookbehind: a regex literal with one fails to parse in older Safari.\n// A URL ends before trailing sentence punctuation.\nconst URL_LIKE =\n /(?:\\b[a-z][\\w+.-]{0,31}:\\/\\/|\\bwww\\.)[^\\s<>\"']*[^\\s<>\"'.,;:!?)]/gi;\nconst EMAIL =\n /[\\p{L}\\p{N}_.%+-]{1,64}@[\\p{L}\\p{N}-]{1,63}(?:\\.[\\p{L}\\p{N}-]{1,63})+/gu;\n// JWTs, and three dotted segments that are each long or contain a digit, but\n// not dotted names such as `settings.notifications.preferences`.\nconst JWT =\n /(^|[^\\w-])(?:eyJ[\\w-]*\\.[\\w-]*\\.[\\w-]*|[\\w-]{16,}\\.[\\w-]{16,}\\.[\\w-]{16,}|(?=[\\w-]*\\d)[\\w-]{8,}\\.(?=[\\w-]*\\d)[\\w-]{8,}\\.(?=[\\w-]*\\d)[\\w-]{8,})/g;\nconst TOKEN_RUN = /[\\w+/=-]{24,}/g;\n\nconst roleOf = (element: Element) =>\n (element.getAttribute(\"role\") ?? \"\").trim().split(/\\s+/)[0]!.toLowerCase();\n\nconst idRefs = (element: Element, name: string) =>\n (element.getAttribute(name) ?? \"\").split(/\\s+/).filter(Boolean);\n\nconst rootOf = (element: Element) => {\n const root = element.getRootNode();\n return root instanceof Document || root instanceof ShadowRoot ? root : null;\n};\n\n/** Buttons, links with `href`, `summary`, and elements with a control role. */\nconst isControl = (element: Element) => {\n const role = roleOf(element);\n return role\n ? CONTROL_ROLE.test(role)\n : element.matches(\"button, a[href], summary\");\n};\n\n/** Text-like inputs, text areas, selects, and anything inside editable content or a text-entry role. */\nconst holdsValue = (element: Element) =>\n element instanceof HTMLInputElement\n ? !NON_TEXT_INPUT.test(element.type)\n : element.matches(\"textarea, select\") ||\n element.closest(TEXT_ENTRY) !== null;\n\n/**\n * The name an image contributes: `alt` of an `img`, or `aria-label` (else a\n * plain `<title>`) of an `svg` or `role=\"img\"` element. `undefined` when the\n * element is not an image.\n */\nfunction imageName(element: Element) {\n if (element.localName === \"img\") return element.getAttribute(\"alt\");\n if (element.localName !== \"svg\" && roleOf(element) !== \"img\")\n return undefined;\n const title = Array.from(element.children).find(\n (child) => child.localName === \"title\" && child.childElementCount === 0,\n );\n return element.getAttribute(\"aria-label\") ?? title?.textContent ?? null;\n}\n\n/**\n * Rendered text. Inline formatting joins words; other elements separate them.\n * Skips hidden subtrees, uses image names, and does\n * not walk into images. Yields nothing for editable content or editable\n * descendants. In `strict` mode (a control's own text) it also yields nothing\n * past the walk bounds, for card-like structure, and for text that looks like\n * separate fields: two `div`s that directly hold text, or three sibling\n * elements that each carry text.\n */\nfunction safeText(root: Element, strict: boolean) {\n if (holdsValue(root)) return \"\";\n let text = \"\";\n let nodes = 0;\n let paragraphs = 0;\n // The first div that holds text; a second one means separate fields.\n let div: Element | undefined;\n const bearers = new Map<Node, Set<Node>>();\n const add = (part: string) => (text = (text + part).replace(/\\s+/g, \" \"));\n // Marks `start` and its ancestors below root as carrying text. False once\n // three siblings do.\n const bear = (start: Node) => {\n if (!strict) return true;\n for (\n let child = start, parent = start.parentNode;\n child !== root && parent !== null;\n child = parent, parent = parent.parentNode\n ) {\n const siblings = bearers.get(parent) ?? new Set<Node>();\n bearers.set(parent, siblings);\n if (siblings.has(child)) break;\n if (siblings.add(child).size > 2) return false;\n }\n return true;\n };\n let node: Node | null = root.firstChild;\n while (node !== null) {\n if (++nodes > MAX_WALK_NODES || text.length > MAX_WALK_CHARS) {\n if (strict) return \"\";\n break;\n }\n let descend = false;\n if (node instanceof Text) {\n const parent = node.parentNode as Element;\n // The block that holds this text, through inline elements.\n let holder = parent;\n while (holder !== root && PHRASING.test(holder.localName))\n holder = holder.parentElement!;\n if (\n /\\S/.test(node.data) &&\n (!bear(parent) ||\n (strict && holder.localName === \"div\" && (div ??= holder) !== holder))\n )\n return \"\";\n add(node.data);\n } else if (node instanceof Element) {\n if (node.matches(EDITABLE)) return \"\";\n if (\n strict &&\n node.matches(STRUCTURE) &&\n !(root.localName === \"summary\" && node.matches(HEADING)) &&\n (node.localName !== \"p\" || ++paragraphs > 1)\n )\n return \"\";\n const name = node.matches(SKIPPED) ? null : imageName(node);\n if (name) {\n if (!bear(node)) return \"\";\n add(` ${name} `);\n } else if (name === undefined) {\n if (!PHRASING.test(node.localName)) add(\" \");\n descend = true;\n }\n }\n if (descend && node.firstChild !== null) node = node.firstChild;\n else {\n while (node !== root && node.nextSibling === null) {\n node = node.parentNode!;\n if (!PHRASING.test((node as Element).localName)) add(\" \");\n }\n node = node === root ? null : node.nextSibling;\n }\n }\n return text;\n}\n\n/** An option or tree item of an autocomplete or search popup, whose text usually repeats what the user typed. */\nconst isSuggestion = (element: Element) =>\n (element.localName === \"option\" ||\n /^(option|treeitem)$/.test(roleOf(element))) &&\n inSuggestionList(element);\n\n/** Whether `element` is, or is inside, a combobox, a datalist, or the popup a combobox or autocomplete field controls or owns. */\nfunction inSuggestionList(element: Element) {\n if (element.closest(\"[role~=combobox], datalist\") !== null) return true;\n const root = rootOf(element);\n return (\n root !== null &&\n Array.from(\n root.querySelectorAll(\"[role~=combobox], [aria-autocomplete]\"),\n ).some((owner) =>\n [...idRefs(owner, \"aria-controls\"), ...idRefs(owner, \"aria-owns\")].some(\n (id) => root.getElementById(id)?.contains(element),\n ),\n )\n );\n}\n\nfunction labelledBy(element: Element) {\n const root = rootOf(element);\n return root === null\n ? null\n : idRefs(element, \"aria-labelledby\")\n .map((id) => {\n const referenced = root.getElementById(id);\n return referenced === null || inSuggestionList(referenced)\n ? \"\"\n : safeText(referenced, false);\n })\n .join(\" \");\n}\n\n/** A control's own text, unless it is longer than a label: then it is content, not a name. */\nconst normalize = (text: string) =>\n text.replace(IGNORABLE, \"\").replace(/\\s+/g, \" \").trim();\n\nfunction contentText(element: Element) {\n const text = safeText(element, true);\n return Array.from(normalize(text)).length > MAX_LABEL_LENGTH ? \"\" : text;\n}\n\n/** Removes invisible characters, credentials, emails, URLs, and tokens, then caps the length. */\nfunction cleanLabel(raw: string, redact: Redactor) {\n const chars = Array.from(\n // Scrub again after collapsing, also for remembered values that differ\n // in whitespace or invisible characters.\n redact\n .scrub(\n normalize(redact.body(raw.replace(IGNORABLE, \"\")) ?? \"\"),\n normalize,\n )\n .replace(URL_LIKE, \"[url]\")\n .replace(EMAIL, \"[email]\")\n .replace(JWT, `$1${REDACTED}`)\n .replace(TOKEN_RUN, (run) => (/\\d/.test(run) ? REDACTED : run)),\n );\n return chars.length > MAX_LABEL_LENGTH\n ? `${chars\n .slice(0, MAX_LABEL_LENGTH - 1)\n .join(\"\")\n .trimEnd()}…`\n : chars.join(\"\");\n}\n\nfunction firstLabel(sources: (() => string | null)[], redact: Redactor) {\n for (const source of sources) {\n const label = cleanLabel(source() ?? \"\", redact);\n if (label) return label;\n }\n return undefined;\n}\n\n/**\n * A short name for the clicked control, from the first source that yields one:\n * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title` (controls\n * only), then the text of a control named by its content. Containers, cards,\n * and editable content never contribute their text. An autocomplete suggestion,\n * or anything inside one, gets no label from any source; `data-copilotkit-action`\n * names it instead. An icon without its own name takes the name of the control\n * around it.\n */\nfunction clickLabel(element: Element, redact: Redactor): string | undefined {\n const option = element.closest(\"option, [role~=option], [role~=treeitem]\");\n if (option !== null && isSuggestion(option)) return undefined;\n // aria-labelledby and aria-label apply to any other target.\n const named = [\n () => labelledBy(element),\n () => element.getAttribute(\"aria-label\"),\n ];\n if (\n /^(img|svg)$/.test(element.localName) ||\n /^(img|presentation|none)$/.test(roleOf(element))\n ) {\n const own = firstLabel(\n [...named, () => imageName(element) ?? null],\n redact,\n );\n if (own !== undefined) return own;\n // parentElement is null at a shadow root, so the search stays inside it.\n let control = element.parentElement;\n while (control !== null && !isControl(control))\n control = control.parentElement;\n return control === null ? undefined : clickLabel(control, redact);\n }\n const control = isControl(element) && !holdsValue(element);\n const captionInput =\n element instanceof HTMLInputElement && CAPTION_INPUT.test(element.type);\n return firstLabel(\n [\n ...named,\n // The caption of a button-like input, or the `alt` of an image input.\n () =>\n captionInput\n ? element.type === \"image\"\n ? element.getAttribute(\"alt\")\n : element.value\n : null,\n () => (control || captionInput ? element.getAttribute(\"title\") : null),\n () => (control ? contentText(element) : null),\n ],\n redact,\n );\n}\n\n/** Snapshot live control properties; HTML attributes do not reflect edited values. */\nexport function readControlValue(element: Element, redact: Redactor) {\n if (element instanceof HTMLInputElement) {\n return {\n value: redact.isPassword(element) ? REDACTED : element.value,\n ...([\"checkbox\", \"radio\"].includes(element.type)\n ? { checked: element.checked }\n : {}),\n ...(element.type === \"file\"\n ? {\n files: Array.from(element.files ?? [], (file) => ({\n name: file.name,\n type: file.type,\n size: file.size,\n lastModified: file.lastModified,\n })),\n }\n : {}),\n };\n }\n if (element instanceof HTMLSelectElement) {\n return {\n value: element.value,\n selectedValues: Array.from(\n element.selectedOptions,\n (option) => option.value,\n ),\n };\n }\n if (element instanceof HTMLTextAreaElement) return { value: element.value };\n if (element.matches(CONTENTEDITABLE)) {\n return { value: element.textContent ?? \"\" };\n }\n return {};\n}\n\nfunction eventElement(event: Event) {\n const [first] = event.composedPath();\n return first instanceof Element ? first : null;\n}\n\nfunction isRootOrDetached(el: Element) {\n return el.localName === \"body\" || el.localName === \"html\" || !el.isConnected;\n}\n\nfunction isNear(origin: PointerOrigin, event: MouseEvent) {\n return (\n Math.abs(origin.x - event.clientX) <= RECOVERY_DISTANCE_PX &&\n Math.abs(origin.y - event.clientY) <= RECOVERY_DISTANCE_PX\n );\n}\n\n/**\n * Listens for clicks in the capture phase on `window`, so handlers that stop\n * propagation do not hide them. The target is described at `pointerdown`,\n * because a re-render before `click` can move the click to `<body>`.\n */\nexport function installClickCapture(params: {\n emit: Emit;\n getRoute: () => string;\n enrich?: EnrichFn;\n // ponytail: test seam only. jsdom cannot create trusted events; the collector never passes this.\n isTrusted?: (event: Event) => boolean;\n redact?: Redactor;\n}) {\n const {\n emit,\n getRoute,\n enrich,\n isTrusted = (event) => event.isTrusted,\n redact = createRedactor(),\n } = params;\n let origin: PointerOrigin | null = null;\n\n // Input events share describeTarget; only clicks carry a label.\n const describeClick = (el: Element) => {\n const described = describeTarget(el, redact);\n if (described === null) return null;\n try {\n const label = clickLabel(described.element, redact);\n if (label !== undefined) described.target.label = label;\n } catch {\n // A failed label must not drop the click.\n }\n return described;\n };\n\n const onPointerDown = (event: PointerEvent) => {\n try {\n if (event.isPrimary === false) return;\n const el = eventElement(event);\n origin = {\n described: el === null ? null : describeClick(el),\n x: event.clientX,\n y: event.clientY,\n };\n } catch {\n origin = null;\n }\n };\n\n const onClick = (event: MouseEvent) => {\n const pointerOrigin = origin;\n origin = null;\n try {\n if (!isTrusted(event)) return;\n const el = eventElement(event);\n if (el === null) return;\n const canRecover =\n pointerOrigin !== null &&\n isRootOrDetached(el) &&\n isNear(pointerOrigin, event);\n const described = canRecover\n ? pointerOrigin.described\n : describeClick(el);\n if (described === null) return;\n const input = event.detail === 0 ? \"keyboard\" : \"pointer\";\n emit(\"click\", {\n target: { ...described.target, input },\n route: getRoute(),\n url: redact.url(location.href),\n ...enrich?.(described.element),\n });\n } catch {\n // Capture must never break the host app's click.\n }\n };\n\n window.addEventListener(\"pointerdown\", onPointerDown, {\n capture: true,\n passive: true,\n });\n window.addEventListener(\"click\", onClick, { capture: true, passive: true });\n return () => {\n window.removeEventListener(\"pointerdown\", onPointerDown, { capture: true });\n window.removeEventListener(\"click\", onClick, { capture: true });\n };\n}\n","import { describeTarget, readControlValue } from \"./clicks\";\nimport { createRedactor } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { ClickTarget, Emit } from \"./types\";\n\nconst TEXT_DEBOUNCE_MS = 300;\nconst TEXT_INPUT_TYPES = new Set([\n \"text\",\n \"search\",\n \"tel\",\n \"url\",\n \"email\",\n \"password\",\n \"number\",\n]);\ntype ControlValue = ReturnType<typeof readControlValue>;\ninterface Edit {\n state: ControlValue;\n payload: {\n eventType: string;\n target: Omit<ClickTarget, \"input\">;\n url: string;\n route: string;\n };\n timer?: ReturnType<typeof setTimeout>;\n}\n\n/** Records settled user edits without interfering with the host app's controls. */\nexport function installInputCapture(params: {\n emit: Emit;\n isTrusted?: (event: Event) => boolean;\n redact?: Redactor;\n}) {\n const {\n emit,\n isTrusted = (event: Event) => event.isTrusted,\n redact = createRedactor(),\n } = params;\n let active = true;\n const pending = new Map<Element, Edit>();\n // A native change can echo a settled input on blur. Consume that one echo;\n // every fresh trusted input is a new edit, even if its redacted value matches.\n const changeEcho = new WeakMap<Element, string>();\n const composing = new WeakMap<Element, boolean>();\n\n const cancel = (element: Element) => {\n clearTimeout(pending.get(element)?.timer);\n pending.delete(element);\n };\n const deliver = (element: Element, edit: Edit) => {\n try {\n // Recheck privacy, but retain the observed value and route even if a host\n // handler reset or removed the control before this trailing delivery.\n if (!active || describeTarget(element, redact) === null) return;\n // A host can reset the live value before making this field sensitive.\n // Drop the old snapshot: mirrored text/attributes may contain its value.\n if (redact.isPassword(element) && edit.state.value !== REDACTED) return;\n if (edit.payload.eventType === \"input\") {\n changeEcho.set(element, JSON.stringify(edit.state));\n }\n edit.payload.url = redact.url(edit.payload.url);\n edit.payload.target.text = redact.url(edit.payload.target.text);\n edit.payload.target.attributes = Object.fromEntries(\n Object.entries(edit.payload.target.attributes).map(([key, value]) => [\n key,\n redact.url(value),\n ]),\n );\n emit(\"input\", edit.payload);\n } catch {\n // Capture must never break an edit or a later action in the host app.\n }\n };\n const flush = () => {\n // Clear before callbacks: an emit can synchronously stop or restart capture.\n const edits = [...pending];\n for (const [element] of edits) cancel(element);\n for (const [element, edit] of edits) deliver(element, edit);\n };\n const observe = (element: Element, eventType: string, settle: boolean) => {\n if (pending.size > 0 && !pending.has(element)) flush();\n if (!active) return;\n const described = describeTarget(element, redact);\n if (described === null) {\n cancel(element);\n return;\n }\n const state = readControlValue(element, redact);\n if (!(\"value\" in state)) return;\n const text =\n element instanceof HTMLTextAreaElement ||\n (element instanceof HTMLInputElement &&\n TEXT_INPUT_TYPES.has(element.type)) ||\n element.matches('[contenteditable]:not([contenteditable=\"false\"])');\n if (!text) flush();\n const signature = JSON.stringify(state);\n const echo =\n eventType === \"change\" &&\n (signature === JSON.stringify(pending.get(element)?.state) ||\n signature === changeEcho.get(element));\n changeEcho.delete(element);\n if (echo) {\n flush();\n changeEcho.delete(element);\n return;\n }\n cancel(element);\n const edit: Edit = {\n state,\n payload: {\n eventType,\n target: { ...described.target, ...state },\n url: redact.url(location.href),\n route: location.pathname,\n },\n };\n if (!text) {\n deliver(element, edit);\n return;\n }\n if (settle) {\n edit.timer = setTimeout(() => {\n cancel(element);\n deliver(element, edit);\n }, TEXT_DEBOUNCE_MS);\n }\n pending.set(element, edit);\n };\n const onEdit = (event: Event) => {\n try {\n if (!active || !isTrusted(event)) return;\n const [element] = event.composedPath();\n if (!(element instanceof Element)) return;\n if (event.type === \"compositionstart\") {\n composing.set(element, true);\n clearTimeout(pending.get(element)?.timer);\n return;\n }\n if (event.type === \"compositionend\") composing.set(element, false);\n // Keep a draft during composition for explicit action/stop flushes. An\n // end event takes precedence over a late input's stale isComposing flag.\n const inComposition =\n composing.get(element) === true ||\n (composing.get(element) !== false &&\n \"isComposing\" in event &&\n event.isComposing === true);\n observe(\n element,\n event.type === \"compositionend\" ? \"input\" : event.type,\n !inComposition,\n );\n if (event.type === \"change\") flush();\n } catch {\n // Capture must never break an edit in the host app.\n }\n };\n const onAction = (event: Event) => {\n if (event.type !== \"pagehide\" && !isTrusted(event)) return;\n if (event.type === \"keydown\") {\n const [element] = event.composedPath();\n // Window capture runs before no-form send handlers can reset the control.\n // IME completion can precede its final keydown, which still reports 229.\n if (\n !(event instanceof KeyboardEvent) ||\n event.key !== \"Enter\" ||\n event.isComposing ||\n event.keyCode === 229 ||\n (element instanceof Element && composing.get(element) === true)\n )\n return;\n }\n flush();\n };\n const editTypes = [\"input\", \"change\", \"compositionstart\", \"compositionend\"];\n const actionTypes = [\"click\", \"submit\", \"focusout\", \"pagehide\", \"keydown\"];\n for (const type of editTypes)\n window.addEventListener(type, onEdit, { capture: true, passive: true });\n for (const type of actionTypes)\n window.addEventListener(type, onAction, { capture: true, passive: true });\n const uninstall = () => {\n active = false;\n for (const element of pending.keys()) cancel(element);\n for (const type of editTypes)\n window.removeEventListener(type, onEdit, true);\n for (const type of actionTypes)\n window.removeEventListener(type, onAction, true);\n };\n return Object.assign(uninstall, { flush });\n}\n","import { createRedactor } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport type { Emit } from \"./types\";\n\ntype NavigationType = \"push\" | \"replace\" | \"traverse\" | \"reload\";\ntype HistoryMethod = \"pushState\" | \"replaceState\";\n\n/** The part of the Navigation API this module uses (not in TypeScript's DOM lib yet). */\ninterface NavigationTarget {\n addEventListener(\n type: \"currententrychange\",\n listener: (event: Event) => void,\n ): void;\n removeEventListener(\n type: \"currententrychange\",\n listener: (event: Event) => void,\n ): void;\n}\n\nfunction isNavigationTarget(value: unknown): value is NavigationTarget {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"addEventListener\" in value &&\n \"removeEventListener\" in value\n );\n}\n\nfunction getNavigation() {\n const navigation: unknown = Reflect.get(window, \"navigation\");\n return isNavigationTarget(navigation) ? navigation : null;\n}\n\nfunction toNavigationType(value: unknown): NavigationType {\n switch (value) {\n case \"replace\":\n case \"traverse\":\n case \"reload\":\n return value;\n default:\n return \"push\";\n }\n}\n\n/**\n * Emits `navigation` when the full URL changes in a single-page app.\n * Uses the Navigation API when it exists. Otherwise patches `pushState` and\n * `replaceState` on the prototype and on the instance, because some routers\n * (Next.js) wrap the instance method.\n */\nexport function installNavigationCapture(params: {\n emit: Emit;\n routes?: string[];\n redact?: Redactor;\n}) {\n const { emit, redact = createRedactor() } = params;\n let current = location.href;\n\n const check = (navigationType: NavigationType) => {\n try {\n const next = location.href;\n if (next === current) return;\n emit(\"navigation\", {\n from: redact.url(current),\n to: redact.url(next),\n navigationType,\n });\n current = next;\n } catch {\n // Capture must never break navigation.\n }\n };\n\n const navigation = getNavigation();\n if (navigation !== null) {\n const onEntryChange = (event: Event) =>\n check(toNavigationType(Reflect.get(event, \"navigationType\")));\n navigation.addEventListener(\"currententrychange\", onEntryChange);\n return () =>\n navigation.removeEventListener(\"currententrychange\", onEntryChange);\n }\n\n return installHistoryFallback(check);\n}\n\nfunction installHistoryFallback(\n check: (navigationType: NavigationType) => void,\n) {\n let active = true;\n const restores: (() => void)[] = [];\n\n const patch = (target: History, method: HistoryMethod) => {\n const original = target[method];\n const wrapper: History[HistoryMethod] = function (this: History, ...args) {\n const result = original.apply(this, args);\n if (active) check(method === \"pushState\" ? \"push\" : \"replace\");\n return result;\n };\n target[method] = wrapper;\n restores.push(() => {\n // Restore only if no one wrapped it after us; otherwise our wrapper stays as a pass-through.\n if (target[method] === wrapper) target[method] = original;\n });\n };\n\n const methods: HistoryMethod[] = [\"pushState\", \"replaceState\"];\n for (const method of methods) {\n patch(History.prototype, method);\n if (Object.prototype.hasOwnProperty.call(history, method))\n patch(history, method);\n }\n\n const onPopState = () => check(\"traverse\");\n window.addEventListener(\"popstate\", onPopState);\n window.addEventListener(\"hashchange\", onPopState);\n\n return () => {\n active = false;\n window.removeEventListener(\"popstate\", onPopState);\n window.removeEventListener(\"hashchange\", onPopState);\n for (const restore of restores) restore();\n };\n}\n","import { TEXT_LIMIT } from \"./redact\";\nimport type { Redactor } from \"./redact\";\n\n/** A bounded observation of a body; never the stream returned to the app. */\nexport interface BodySnapshot {\n status:\n | \"complete\"\n | \"empty\"\n | \"truncated\"\n | \"unavailable\"\n | \"interrupted\"\n | \"timeout\";\n text?: string;\n encoding?: \"utf-8\" | \"base64\";\n truncated?: true;\n reason?: string;\n}\n\nconst BODY_BYTES = 4096;\nconst READ_TIMEOUT_MS = 1000;\nconst encoder = new TextEncoder();\nconst redactionFailed = (): BodySnapshot => ({\n status: \"unavailable\",\n reason: \"redaction-failed\",\n});\ntype RedactText = (text: string) => string | undefined;\n\n/** Redacts up to TEXT_LIMIT bytes of text first, then cuts the result to BODY_BYTES. */\nfunction snapshot(\n bytes: Uint8Array,\n status: BodySnapshot[\"status\"],\n contentType: string,\n redact: RedactText,\n reason?: string,\n): BodySnapshot {\n let text: string | undefined;\n if (!isBinary(contentType))\n try {\n // Declared text decodes lossily so it is always redacted; untyped bytes\n // that are not UTF-8 are treated as binary.\n text = new TextDecoder(\"utf-8\", { fatal: !contentType }).decode(bytes, {\n stream: status !== \"complete\",\n });\n } catch {\n /* Not UTF-8: keep the bytes as base64. */\n }\n if (text !== undefined) {\n text = redact(text);\n if (text === undefined) return redactionFailed();\n // PostgreSQL jsonb rejects NUL in strings; base64 retains the redacted bytes.\n if (text.includes(\"\\0\")) {\n bytes = encoder.encode(text);\n text = undefined;\n }\n }\n const binary = text === undefined;\n // Binary bodies are not inspected; only their first bytes are kept.\n text ??= btoa(String.fromCharCode(...bytes.subarray(0, BODY_BYTES)));\n const result: BodySnapshot = {\n status: status === \"complete\" && bytes.length === 0 ? \"empty\" : status,\n text,\n encoding: binary ? \"base64\" : \"utf-8\",\n ...(reason ? { reason } : {}),\n };\n if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)\n return result;\n result.status = status === \"complete\" ? \"truncated\" : status;\n result.truncated = true;\n // Bound the serialized field too: quotes/control characters expand in JSON.\n let low = 0;\n let high = text.length;\n while (low < high) {\n const middle = Math.ceil((low + high) / 2);\n result.text = text.slice(0, middle);\n if (encoder.encode(JSON.stringify(result)).length <= BODY_BYTES)\n low = middle;\n else high = middle - 1;\n }\n if (binary)\n low -= low % 4; // Keep complete base64 groups.\n else if (low > 0 && /[\\uD800-\\uDBFF]/.test(text[low - 1]!)) low--;\n result.text = text.slice(0, low);\n return result;\n}\n\nfunction snapshotText(\n value: string,\n redact: RedactText,\n status: BodySnapshot[\"status\"] = \"complete\",\n reason?: string,\n): BodySnapshot {\n return snapshot(\n encoder.encode(value.slice(0, TEXT_LIMIT)),\n value.length > TEXT_LIMIT && status === \"complete\" ? \"truncated\" : status,\n \"text/plain\",\n redact,\n reason,\n );\n}\n\nfunction isBinary(contentType: string) {\n return (\n contentType !== \"\" &&\n !/(^text\\/|json|xml|javascript|graphql|x-www-form-urlencoded|multipart\\/form-data)/i.test(\n contentType,\n )\n );\n}\n\n/** Cancel only cloned/synthetic bodies. Cancelling a tee may wait for the app's branch. */\nexport function createBodyCapture(redact: Redactor) {\n const cancellations = new Set<() => void>();\n let active = true;\n const read = (\n stream: ReadableStream<Uint8Array> | null,\n contentType = \"\",\n ): Promise<BodySnapshot> => {\n if (!stream) return Promise.resolve({ status: \"empty\" });\n if (!active)\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"capture-stopped\",\n });\n let reader: ReadableStreamDefaultReader<Uint8Array>;\n try {\n reader = stream.getReader();\n } catch {\n return Promise.resolve({ status: \"unavailable\", reason: \"body-locked\" });\n }\n return new Promise((resolve) => {\n const bytes = new Uint8Array(TEXT_LIMIT);\n let length = 0;\n let finished = false;\n const finish = (status: BodySnapshot[\"status\"], reason?: string) => {\n if (finished) return;\n finished = true;\n clearTimeout(timer);\n cancellations.delete(cancel);\n resolve(\n snapshot(\n bytes.subarray(0, length),\n status,\n contentType,\n redact.body,\n reason,\n ),\n );\n // Do not await cancellation: a cloned fetch stream can share a live SSE source.\n void reader.cancel().catch(() => {});\n };\n const cancel = () => finish(\"interrupted\", \"capture-stopped\");\n const timer = setTimeout(\n () => finish(\"timeout\", \"body-read-timeout\"),\n READ_TIMEOUT_MS,\n );\n cancellations.add(cancel);\n void (async () => {\n try {\n for (;;) {\n const chunk = await reader.read();\n if (finished) break;\n if (chunk.done) {\n finish(\"complete\");\n break;\n }\n const kept = chunk.value.subarray(0, TEXT_LIMIT - length);\n bytes.set(kept, length);\n length += kept.length;\n if (length === TEXT_LIMIT) {\n finish(\"truncated\");\n break;\n }\n }\n } catch {\n finish(\"interrupted\", \"body-read-failed\");\n } finally {\n try {\n reader.releaseLock();\n } catch {\n /* A cancelled read may still be settling. */\n }\n }\n })();\n });\n };\n const body = (\n value: BodyInit | null | undefined,\n contentType = \"\",\n ): Promise<BodySnapshot> => {\n try {\n if (value == null) return Promise.resolve({ status: \"empty\" });\n if (value instanceof URLSearchParams) value = value.toString();\n if (typeof value === \"string\")\n return Promise.resolve(snapshotText(value, redact.body));\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n // A caller-owned init.body cannot be teed without locking/replacing it.\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"caller-owned-stream\",\n });\n }\n if (value instanceof FormData) {\n const form = new FormData();\n value.forEach((field, key) =>\n form.append(\n key,\n typeof field === \"string\" ? redact.field(key, field) : field,\n ),\n );\n value = form;\n }\n const response = new Response(value);\n return read(\n response.body,\n contentType || response.headers.get(\"content-type\") || \"\",\n );\n } catch {\n return Promise.resolve({\n status: \"unavailable\",\n reason: \"body-not-readable\",\n });\n }\n };\n return {\n read,\n body,\n text: (value: string, status?: BodySnapshot[\"status\"], reason?: string) =>\n snapshotText(value, redact.body, status, reason),\n stop() {\n active = false;\n for (const cancel of cancellations) cancel();\n },\n };\n}\n","import { createBodyCapture } from \"./network-body\";\nimport type { BodySnapshot } from \"./network-body\";\nimport { createRedactor, isCredentialKey } from \"./redact\";\nimport type { Redactor } from \"./redact\";\nimport { REDACTED } from \"./types\";\nimport type { Emit } from \"./types\";\n\ntype Outcome = \"ok\" | \"error\" | \"aborted\";\ninterface RequestParts {\n url: string;\n method: string;\n headers: Record<string, string>;\n}\ninterface ResponseParts {\n url?: string;\n headers: Record<string, string>;\n body: Promise<BodySnapshot>;\n}\nconst emptyBody = () => Promise.resolve<BodySnapshot>({ status: \"empty\" });\nconst unavailableBody = (reason: string) =>\n Promise.resolve<BodySnapshot>({ status: \"unavailable\", reason });\nconst toAbsolute = (url: string) => new URL(url, location.href).href;\n// Credentials carry no product signal and must never leave the browser, even\n// with full capture. The header name stays, so the event still shows it was sent.\n// Names the credential-key matcher misses; it covers `authorization`, `cookie`,\n// `x-api-key`, `x-csrf-token`, and similar.\nconst CREDENTIAL_HEADERS = new Set([\n \"x-amz-security-token\",\n \"private-token\",\n \"x-token\",\n]);\nconst headerValue = (name: string, value: string) =>\n CREDENTIAL_HEADERS.has(name.toLowerCase()) || isCredentialKey(name)\n ? REDACTED\n : value;\nconst headerValues = (headers: Headers) =>\n Object.fromEntries(\n Array.from(headers.entries(), ([name, value]) => [\n name,\n headerValue(name, value),\n ]),\n );\n\n// Checks the name, not instanceof: errors can come from another realm.\nfunction isAbortError(error: unknown) {\n return (\n typeof error === \"object\" &&\n error !== null &&\n \"name\" in error &&\n error.name === \"AbortError\"\n );\n}\n\n/** Records browser-visible request/response data without consuming the app's bodies. */\nexport function installNetworkCapture(params: {\n emit: Emit;\n routes?: string[];\n ignoreUrls: (string | RegExp)[];\n redact?: Redactor;\n}) {\n let active = true;\n const { redact = createRedactor() } = params;\n const bodies = createBodyCapture(redact);\n const ignoreUrls = params.ignoreUrls.map((pattern) =>\n typeof pattern === \"string\"\n ? toAbsolute(pattern)\n : new RegExp(pattern.source, pattern.flags),\n );\n const isIgnored = (url: string) =>\n ignoreUrls.some((pattern) => {\n if (typeof pattern === \"string\") return url.startsWith(pattern);\n pattern.lastIndex = 0;\n return pattern.test(url);\n });\n\n const record: RecordFn = (\n transport,\n request,\n requestBody,\n startedAt,\n status,\n outcome,\n response,\n ) => {\n const durationMs = Math.round(performance.now() - startedAt);\n const completedAt = Date.now();\n void Promise.all([\n requestBody,\n response?.body ?? unavailableBody(\"no-response\"),\n ])\n .then(([sent, received]) => {\n if (!active) return;\n const href = redact.url(request.url);\n const url = new URL(href);\n params.emit(\"network\", {\n transport,\n method: request.method,\n url: href,\n origin: url.origin,\n route: url.pathname,\n status,\n durationMs,\n completedAt,\n outcome,\n request: { headers: request.headers, body: sent },\n response: {\n ...(response?.url ? { url: redact.url(response.url) } : {}),\n headers: response?.headers ?? {},\n body: received,\n },\n });\n })\n .catch(() => {\n /* Capture must never affect the host request. */\n });\n };\n\n const originalFetch = globalThis.fetch;\n const wrappedFetch: typeof fetch = (input, init) => {\n if (!active) return originalFetch.call(globalThis, input, init);\n let request: RequestParts;\n let ignored: boolean;\n try {\n const source =\n typeof Request !== \"undefined\" && input instanceof Request\n ? input\n : undefined;\n request = {\n url: toAbsolute(source ? source.url : String(input)),\n method: (init?.method ?? source?.method ?? \"GET\").toUpperCase(),\n headers: headerValues(new Headers(init?.headers ?? source?.headers)),\n };\n ignored = isIgnored(request.url);\n } catch {\n return originalFetch.call(globalThis, input, init);\n }\n if (ignored) return originalFetch.call(globalThis, input, init);\n const startedAt = performance.now();\n let requestBody: Promise<BodySnapshot>;\n try {\n requestBody =\n init?.body != null\n ? bodies.body(init.body, request.headers[\"content-type\"])\n : typeof Request !== \"undefined\" && input instanceof Request\n ? bodies.read(input.clone().body, request.headers[\"content-type\"])\n : emptyBody();\n } catch {\n requestBody = unavailableBody(\"request-clone-failed\");\n }\n const failed = (error: unknown) =>\n record(\n \"fetch\",\n request,\n requestBody,\n startedAt,\n null,\n isAbortError(error) ? \"aborted\" : \"error\",\n );\n let promise: Promise<Response>;\n try {\n promise = originalFetch.call(globalThis, input, init);\n } catch (error) {\n failed(error);\n throw error;\n }\n void promise\n .then((response) => {\n if (!active) return;\n let responseBody: Promise<BodySnapshot>;\n try {\n responseBody =\n response.type === \"opaque\" || response.type === \"opaqueredirect\"\n ? unavailableBody(\"opaque-response\")\n : bodies.read(\n response.clone().body,\n response.headers.get(\"content-type\") ?? \"\",\n );\n } catch {\n responseBody = unavailableBody(\"response-clone-failed\");\n }\n record(\n \"fetch\",\n request,\n requestBody,\n startedAt,\n response.status,\n \"ok\",\n {\n url: response.url,\n headers: headerValues(response.headers),\n body: responseBody,\n },\n );\n }, failed)\n .catch(() => {});\n // Return the identical native Promise, Response, and rejection to the caller.\n return promise;\n };\n if (typeof originalFetch === \"function\") globalThis.fetch = wrappedFetch;\n const uninstallXhr = patchXhr(isIgnored, record, bodies);\n return () => {\n active = false;\n bodies.stop();\n if (globalThis.fetch === wrappedFetch) globalThis.fetch = originalFetch;\n uninstallXhr();\n };\n}\n\ntype RecordFn = (\n transport: \"fetch\" | \"xhr\",\n request: RequestParts,\n requestBody: Promise<BodySnapshot>,\n startedAt: number,\n status: number | null,\n outcome: Outcome,\n response?: ResponseParts,\n) => void;\n\nfunction patchXhr(\n isIgnored: (url: string) => boolean,\n record: RecordFn,\n bodies: ReturnType<typeof createBodyCapture>,\n) {\n if (typeof XMLHttpRequest !== \"function\") return () => {};\n const proto = XMLHttpRequest.prototype;\n const originalOpen = proto.open;\n const originalSend = proto.send;\n const originalSetHeader = proto.setRequestHeader;\n const requests = new WeakMap<XMLHttpRequest, RequestParts>();\n const listeners = new Set<() => void>();\n // open() on an in-flight XHR ends it without `loadend`; its listeners must not\n // report the next request with the old request's details.\n const inFlight = new WeakMap<XMLHttpRequest, () => void>();\n let active = true;\n const wrappedOpen = function (\n this: XMLHttpRequest,\n method: string,\n url: string | URL,\n ...rest: unknown[]\n ) {\n inFlight.get(this)?.();\n const result = Reflect.apply(originalOpen, this, [method, url, ...rest]);\n try {\n requests.set(this, {\n url: toAbsolute(String(url)),\n method: method.toUpperCase(),\n headers: {},\n });\n } catch {\n requests.delete(this);\n }\n return result;\n };\n const wrappedSetHeader = function (\n this: XMLHttpRequest,\n name: string,\n value: string,\n ) {\n const result = originalSetHeader.call(this, name, value);\n const request = requests.get(this);\n if (active && request) {\n try {\n const headers = new Headers(request.headers);\n headers.append(name, value);\n request.headers = headerValues(headers);\n } catch {\n /* Preserve successful native calls even if observation fails. */\n }\n }\n return result;\n };\n const wrappedSend = function (\n this: XMLHttpRequest,\n body?: Document | XMLHttpRequestBodyInit | null,\n ) {\n const request = requests.get(this);\n if (!active || !request || isIgnored(request.url))\n return originalSend.call(this, body);\n const startedAt = performance.now();\n let requestBody: Promise<BodySnapshot>;\n try {\n requestBody =\n typeof Document !== \"undefined\" && body instanceof Document\n ? Promise.resolve(\n bodies.text(new XMLSerializer().serializeToString(body)),\n )\n : bodies.body(\n body as BodyInit | null | undefined,\n request.headers[\"content-type\"],\n );\n } catch {\n requestBody = unavailableBody(\"request-body-unavailable\");\n }\n let aborted = false;\n const onAbort = () => {\n aborted = true;\n };\n const cleanup = () => {\n this.removeEventListener(\"abort\", onAbort);\n this.removeEventListener(\"loadend\", onEnd);\n listeners.delete(cleanup);\n if (inFlight.get(this) === cleanup) inFlight.delete(this);\n };\n const onEnd = () => {\n cleanup();\n if (!active) return;\n const outcome = aborted ? \"aborted\" : this.status === 0 ? \"error\" : \"ok\";\n const headers: Record<string, string> = {};\n try {\n for (const line of this.getAllResponseHeaders().split(/\\r?\\n/)) {\n const split = line.indexOf(\":\");\n if (split > 0) {\n const name = line.slice(0, split).toLowerCase();\n headers[name] = headerValue(name, line.slice(split + 1).trim());\n }\n }\n } catch {\n /* Cross-origin or failed requests can hide response headers. */\n }\n let responseBody: Promise<BodySnapshot>;\n try {\n if (this.responseType === \"\" || this.responseType === \"text\") {\n responseBody = Promise.resolve(\n bodies.text(\n this.responseText,\n outcome === \"ok\" ? \"complete\" : \"interrupted\",\n outcome === \"ok\" ? undefined : `request-${outcome}`,\n ),\n );\n } else if (this.responseType === \"json\")\n responseBody = Promise.resolve(\n bodies.text(JSON.stringify(this.response)),\n );\n else if (this.responseType === \"document\")\n responseBody = this.responseXML\n ? Promise.resolve(\n bodies.text(\n new XMLSerializer().serializeToString(this.responseXML),\n ),\n )\n : unavailableBody(\"empty-document\");\n else\n responseBody = bodies.body(\n this.response as BodyInit,\n headers[\"content-type\"] ?? \"application/octet-stream\",\n );\n } catch {\n responseBody = unavailableBody(\"response-body-unavailable\");\n }\n record(\n \"xhr\",\n request,\n requestBody,\n startedAt,\n outcome === \"ok\" ? this.status : null,\n outcome,\n {\n url: this.responseURL,\n headers,\n body: responseBody,\n },\n );\n };\n this.addEventListener(\"abort\", onAbort);\n this.addEventListener(\"loadend\", onEnd);\n listeners.add(cleanup);\n inFlight.set(this, cleanup);\n try {\n return originalSend.call(this, body);\n } catch (error) {\n cleanup();\n record(\n \"xhr\",\n request,\n requestBody,\n startedAt,\n null,\n isAbortError(error) ? \"aborted\" : \"error\",\n );\n throw error;\n }\n };\n proto.open = wrappedOpen;\n proto.send = wrappedSend;\n proto.setRequestHeader = wrappedSetHeader;\n return () => {\n active = false;\n for (const cleanup of listeners) cleanup();\n if (proto.open === wrappedOpen) proto.open = originalOpen;\n if (proto.send === wrappedSend) proto.send = originalSend;\n if (proto.setRequestHeader === wrappedSetHeader)\n proto.setRequestHeader = originalSetHeader;\n };\n}\n","/** Returns the original pathname. Capture no longer masks or categorizes routes. */\nexport function toRoute(pathname: string, _templates: string[] = []) {\n return pathname;\n}\n\n/** Resolves a URL without removing path segments, query parameters, or fragments. */\nexport function toOriginAndRoute(\n url: string,\n _templates: string[] = [],\n base: string = globalThis.location?.href ?? \"http://localhost/\",\n) {\n const parsed = new URL(url, base);\n return { url: parsed.href, origin: parsed.origin, route: parsed.pathname };\n}\n","import { installClickCapture } from \"./clicks\";\nimport { installInputCapture } from \"./inputs\";\nimport { installNavigationCapture } from \"./navigation\";\nimport { installNetworkCapture } from \"./network\";\nimport { createRedactor } from \"./redact\";\nimport { toRoute } from \"./routes\";\nimport { BUILT_IN_EVENT_NAMES } from \"./types\";\nimport type {\n BuiltInEventName,\n Collector,\n CollectorOptions,\n LearningBatch,\n LearningEvent,\n SinkSendOptions,\n StartOptions,\n} from \"./types\";\n\n// ponytail: flushing at 50 also bounds the queue at 50; add a separate cap only if sends become async-buffered.\nconst FLUSH_SIZE = 50;\nconst FLUSH_INTERVAL_MS = 2000;\nconst BUILT_IN = new Set<string>(BUILT_IN_EVENT_NAMES);\n\ninterface Session {\n trajectoryId: string;\n learningContainerIds: string[] | undefined;\n queue: LearningEvent[];\n dropped: number;\n uninstalls: (() => void)[];\n timer: ReturnType<typeof setInterval> | undefined;\n flushInputs?: () => void;\n}\n\n/**\n * Creates a collector. Nothing is installed until `start()`: no listeners,\n * no patches, no timers, no buffer. `stop()` removes all of them and sends\n * queued events once, without retries.\n *\n * @example\n * const collector = createCollector({ sink: httpSink(\"/api/learning-events\") });\n * collector.start({ trajectoryId: crypto.randomUUID() });\n */\nexport function createCollector(options: CollectorOptions) {\n const { sink, routes, beforeSend, enrich } = options;\n const capture = {\n clicks: true,\n navigation: true,\n network: true,\n inputs: true,\n ...options.capture,\n };\n let session: Session | null = null;\n let seq = 0;\n\n const getRoute = () => toRoute(location.pathname, routes);\n\n const flush = (sendOptions?: SinkSendOptions) => {\n const current = session;\n if (current === null) return;\n if (current.queue.length === 0 && current.dropped === 0) return;\n const events = current.queue.splice(0);\n const dropped = current.dropped;\n current.dropped = 0;\n const batch: LearningBatch = {\n trajectoryId: current.trajectoryId,\n learningContainerIds: current.learningContainerIds,\n events,\n dropped,\n };\n const onFailure = () => {\n // No retry: report the loss in the next batch instead.\n if (session === current) current.dropped += events.length + dropped;\n };\n try {\n Promise.resolve(sink(batch, sendOptions)).catch(onFailure);\n } catch {\n onFailure();\n }\n };\n\n const record = (name: string, value: Record<string, unknown>) => {\n const current = session;\n if (current === null) return;\n if (name !== \"input\" && name !== \"network\") current.flushInputs?.();\n if (session !== current) return;\n try {\n seq += 1;\n const event: LearningEvent = {\n type: \"CUSTOM\",\n name,\n value: { ...value, seq },\n timestamp: Date.now(),\n };\n const kept = beforeSend === undefined ? event : beforeSend(event);\n if (kept === null || session !== current) return;\n current.queue.push(kept);\n if (current.queue.length >= FLUSH_SIZE) flush();\n } catch {\n // Capture must never break the host app.\n }\n };\n\n const start = ({ trajectoryId, learningContainerIds }: StartOptions) => {\n if (session !== null) {\n if (session.trajectoryId !== trajectoryId) {\n console.warn(\n `[@copilotkit/learning] Trajectory \"${session.trajectoryId}\" is active. Stop it before you start \"${trajectoryId}\".`,\n );\n }\n return;\n }\n if (\n typeof window === \"undefined\" ||\n typeof window.addEventListener !== \"function\"\n )\n return;\n const current: Session = {\n trajectoryId,\n learningContainerIds,\n queue: [],\n dropped: 0,\n uninstalls: [],\n timer: undefined,\n };\n session = current;\n const recordCurrent: typeof record = (name, value) => {\n if (session === current) record(name, value);\n };\n // One per session: fields seen and values typed are forgotten on stop().\n const redact = createRedactor();\n current.uninstalls.push(redact.watch());\n const ignoreUrls = [\n ...(options.ignoreUrls ?? []),\n ...(sink.url === undefined ? [] : [sink.url]),\n ];\n if (capture.clicks)\n current.uninstalls.push(\n installClickCapture({ emit: recordCurrent, getRoute, enrich, redact }),\n );\n if (capture.navigation)\n current.uninstalls.push(\n installNavigationCapture({ emit: recordCurrent, routes, redact }),\n );\n if (capture.network)\n current.uninstalls.push(\n installNetworkCapture({\n emit: recordCurrent,\n routes,\n ignoreUrls,\n redact,\n }),\n );\n if (capture.inputs) {\n const inputs = installInputCapture({ emit: recordCurrent, redact });\n current.flushInputs = inputs.flush;\n current.uninstalls.push(inputs);\n }\n const onPageHide = () => flush({ beacon: true });\n window.addEventListener(\"pagehide\", onPageHide);\n current.uninstalls.push(() =>\n window.removeEventListener(\"pagehide\", onPageHide),\n );\n current.timer = setInterval(() => flush(), FLUSH_INTERVAL_MS);\n record(\"page\", {\n route: getRoute(),\n url: redact.url(location.href),\n title: document.title,\n referrer: redact.url(document.referrer),\n });\n };\n\n const stop = () => {\n const current = session;\n if (current === null) return;\n current.flushInputs?.();\n if (session !== current) return;\n clearInterval(current.timer);\n for (const uninstall of current.uninstalls) uninstall();\n flush();\n session = null;\n };\n\n const emit = (name: string, value: Record<string, unknown>) => {\n if (BUILT_IN.has(name)) {\n console.warn(\n `[@copilotkit/learning] \"${name}\" is a built-in event name. Use your own name, such as \"deal.approved\".`,\n );\n return;\n }\n record(name, value);\n };\n\n const ɵemit = (name: BuiltInEventName, value: Record<string, unknown>) =>\n record(name, value);\n\n const collector: Collector = {\n start,\n stop,\n emit,\n ɵemit,\n get trajectoryId() {\n return session?.trajectoryId ?? null;\n },\n };\n return collector;\n}\n","import type { LearningBatch, LearningSink, SinkSendOptions } from \"./types\";\n\n/**\n * A sink that POSTs each batch as JSON to `url`.\n *\n * Uses `fetch` with `keepalive`. When the page is going away it uses\n * `navigator.sendBeacon`, which cannot send custom headers. There is no retry:\n * a failed send rejects, and the collector counts those events as dropped.\n *\n * @example createCollector({ sink: httpSink(\"/api/learning-events\") })\n */\nexport function httpSink(\n url: string,\n init: { headers?: Record<string, string> } = {},\n) {\n const send = async (batch: LearningBatch, options: SinkSendOptions = {}) => {\n const body = JSON.stringify(batch);\n const canBeacon =\n options.beacon === true && typeof navigator.sendBeacon === \"function\";\n if (canBeacon) {\n const queued = navigator.sendBeacon(\n url,\n new Blob([body], { type: \"application/json\" }),\n );\n if (!queued) throw new Error(\"sendBeacon refused the batch\");\n return;\n }\n const response = await fetch(url, {\n method: \"POST\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", ...init.headers },\n body,\n });\n if (!response.ok) throw new Error(`Sink responded with ${response.status}`);\n };\n const sink: LearningSink = Object.assign(send, { url });\n return sink;\n}\n","import { installClickCapture } from \"./clicks\";\nimport { installNavigationCapture } from \"./navigation\";\nimport { installInputCapture } from \"./inputs\";\nimport { installNetworkCapture } from \"./network\";\nimport { createRedactor } from \"./redact\";\nimport { toRoute } from \"./routes\";\nimport { BUILT_IN_EVENT_NAMES } from \"./types\";\nimport type {\n JsonValue,\n TrajectoryCaptureOptions,\n TrajectoryCollector,\n TrajectoryEvent,\n} from \"./trajectory-types\";\n\nexport const MAX_TRAJECTORY_EVENT_BYTES = 16 * 1024;\nconst RESERVED_NAMES = new Set<string>(BUILT_IN_EVENT_NAMES);\n\nfunction isJson(\n value: unknown,\n ancestors = new Set<object>(),\n): value is JsonValue {\n if (typeof value === \"string\") return !value.includes(\"\\0\");\n if (value === null || typeof value === \"boolean\") return true;\n if (typeof value === \"number\") return Number.isFinite(value);\n if (typeof value !== \"object\" || ancestors.has(value)) return false;\n if (\n !Array.isArray(value) &&\n Object.getPrototypeOf(value) !== Object.prototype &&\n Object.getPrototypeOf(value) !== null\n )\n return false;\n ancestors.add(value);\n if (\n !Array.isArray(value) &&\n Object.keys(value).some((key) => key.includes(\"\\0\"))\n )\n return false;\n const values = Array.isArray(value) ? value : Object.values(value);\n for (const item of values) {\n if (!isJson(item, ancestors)) return false;\n }\n ancestors.delete(value);\n return true;\n}\n\n/**\n * Captures outside-chat activity as plain AG-UI events, without a send queue.\n * Core starts this collector only after authentication and channel join.\n */\nexport function createTrajectoryCollector(\n options: TrajectoryCaptureOptions & {\n send: (event: TrajectoryEvent) => void;\n },\n): TrajectoryCollector {\n let active = false;\n let generation = 0;\n let uninstalls: (() => void)[] = [];\n let flushInputs: (() => void) | undefined;\n\n const report = (code: string, message: string) => {\n try {\n options.onError?.({ code, message });\n } catch {\n // Diagnostic callbacks must not interrupt the host app.\n }\n };\n\n const getRoute = () => toRoute(location.pathname);\n\n const record = (name: string, value: JsonValue) => {\n if (!active) return;\n const current = generation;\n if (name !== \"input\" && name !== \"network\") flushInputs?.();\n if (!active || generation !== current) return;\n let payload: TrajectoryEvent;\n try {\n const event: TrajectoryEvent = {\n type: \"CUSTOM\",\n name,\n timestamp: Date.now(),\n value,\n };\n const kept =\n options.beforeSend === undefined ? event : options.beforeSend(event);\n if (kept === null || !active || generation !== current) return;\n if (\n kept.type !== \"CUSTOM\" ||\n typeof kept.name !== \"string\" ||\n !kept.name.trim() ||\n kept.name.length > 200 ||\n kept.name.includes(\"\\0\") ||\n !Number.isFinite(kept.timestamp) ||\n kept.timestamp <= 0 ||\n kept.timestamp >= 253_402_300_800_000 ||\n !isJson(kept.value)\n ) {\n report(\n \"INVALID_EVENT\",\n \"The event must contain a name, timestamp, and JSON value.\",\n );\n return;\n }\n // Keep only the contract envelope and snapshot the value before delivery.\n const serialized = JSON.stringify({\n type: kept.type,\n name: kept.name,\n timestamp: kept.timestamp,\n value: kept.value,\n });\n if (\n new TextEncoder().encode(serialized).byteLength >\n MAX_TRAJECTORY_EVENT_BYTES\n ) {\n report(\"EVENT_TOO_LARGE\", \"The serialized event exceeds 16 KiB.\");\n return;\n }\n payload = JSON.parse(serialized) as TrajectoryEvent;\n } catch {\n report(\"INVALID_EVENT\", \"The event could not be serialized as JSON.\");\n return;\n }\n try {\n options.send(payload);\n } catch {\n report(\n \"PERSISTENCE_UNKNOWN\",\n \"Event delivery failed. The event will not be retried.\",\n );\n }\n };\n\n const capture = (name: string, value: Record<string, unknown>) => {\n // Shared capture modules produce JSON; record validates and snapshots before sending.\n record(name, value as JsonValue);\n };\n\n const stop = () => {\n const current = generation;\n const flush = flushInputs;\n flushInputs = undefined;\n flush?.();\n if (generation !== current) return;\n generation++;\n active = false;\n const installed = uninstalls;\n uninstalls = [];\n for (const uninstall of installed) uninstall();\n };\n\n return {\n start() {\n if (active || typeof window === \"undefined\") return;\n active = true;\n generation++;\n // Native callers can emit developer events without browser observers.\n if (typeof window.addEventListener !== \"function\") return;\n // One per session: fields seen and values typed are forgotten on stop().\n const redact = createRedactor();\n try {\n uninstalls.push(redact.watch());\n if (options.capture?.clicks !== false) {\n uninstalls.push(\n installClickCapture({ emit: capture, getRoute, redact }),\n );\n }\n if (options.capture?.navigation !== false) {\n uninstalls.push(installNavigationCapture({ emit: capture, redact }));\n }\n if (options.capture?.inputs !== false) {\n const inputs = installInputCapture({ emit: capture, redact });\n flushInputs = inputs.flush;\n uninstalls.push(inputs);\n }\n if (options.capture?.network !== false) {\n uninstalls.push(\n installNetworkCapture({\n emit: capture,\n ignoreUrls: options.ignoreUrls ?? [],\n redact,\n }),\n );\n }\n record(\"page\", {\n route: getRoute(),\n url: redact.url(location.href),\n title: document.title,\n referrer: redact.url(document.referrer),\n });\n } catch {\n stop();\n report(\"CAPTURE_FAILED\", \"Browser capture could not start.\");\n }\n },\n stop,\n emit(name, value) {\n if (!active) return;\n if (RESERVED_NAMES.has(name)) {\n report(\n \"INVALID_EVENT\",\n \"Use a developer event name, not a reserved capture name.\",\n );\n return;\n }\n record(name, value);\n },\n };\n}\n"],"mappings":";;AACA,MAAa,uBAAuB;CAClC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;;AAKD,MAAa,WAAW;;;;ACXxB,MAAM,aACJ;AAIF,MAAM,kBACJ;AAEF,MAAM,WACJ;AACF,MAAM,wBAAwB;AAE9B,MAAM,QAAQ;AAEd,MAAM,SAAS;AAKf,MAAM,OACJ;AAGF,MAAM,WAAW;;AAEjB,MAAa,aAAa,KAAK;AAC/B,MAAM,oBAAoB;AAE1B,MAAa,mBAAmB,QAAgB;CAC9C,MAAM,QAAQ,IAAI,IACf,QAAQ,oBAAoB,GAAG,QAC9B,OAAO,aAAa,SAAS,KAAK,GAAG,CAAC,CACvC,CACA,QAAQ,uBAAuB,MAAM,CACrC,aAAa,CACb,MAAM,YAAY,CAClB,OAAO,QAAQ,CACf,KAAK,IAAI,CAAC;AACb,QACE,CAAC,SAAS,KAAK,MAAM,KACpB,WAAW,KAAK,MAAM,QAAQ,MAAM,GAAG,CAAC,IAAI,gBAAgB,KAAK,MAAM;;AAI5E,MAAM,UAAU,SAAiB;AAC/B,KAAI;AACF,SAAO,KAAK,MAAM,IAAI,KAAK,GAAG;SACxB;AACN,SAAO;;;;AAKX,MAAM,cAAc,MAAc,UAAkB;CAClD,IAAI,QAAQ;AACZ,MAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,KAAK;EACxC,MAAM,OAAO,KAAK;AAClB,MAAI,SAAS,MAAK;AAChB,UAAO,YAAY;AACnB,UAAO,KAAK,KAAK;AACjB,OAAI,OAAO,YAAY;aACd,SAAS,OAAO,SAAS,KAAK;AACvC;AACA;aACS,SAAS,OAAO,SAAS,KAClC;OAAI,EAAE,QAAQ,EAAG,QAAO;SACnB;AACL,OAAI,CAAC,UAAU,SAAS,OAAO,KAAK,KAAK,KAAK,EAAG,QAAO;AACxD;;AAEF,MAAI,CAAC,MAAO,QAAO,IAAI;;AAEzB,QAAO,KAAK;;;;;;;AAQd,MAAM,cAAc,MAAc,QAAQ,MAAc;CACtD,MAAM,QAAQ,IAAI,OAAO,MAAM;CAC/B,IAAI,UAAU;CACd,MAAM,SAAS,SAAiB;EAC9B,MAAM,UAAU,KAAK,QAAQ,UAAU,KAAK;AAC5C,SAAO,QAAQ,QACb,OACC,MAAM,KAAa,WAAmB,OAAe,WAAmB;AACvE,OAAI,CAAC,gBAAgB,IAAI,CAAE,QAAO;AAElC,OAAI,SAAS,KAAK,WAAW,QAAQ,QAAQ;AAC3C,cAAU;AACV,QAAI,CAAC,MAAO,QAAO;;AAErB,UAAO,MAAM,YAAY;IAE5B;;CAEH,IAAI,MAAM;CACV,IAAI,OAAO;CACX,IAAI;AACJ,QAAQ,QAAQ,MAAM,KAAK,KAAK,EAAG;EACjC,MAAM,CAAC,KAAK,MAAM,QAAQ,SAAS;AACnC,YAAU;AACV,SAAO,MAAM,KAAK,MAAM,MAAM,MAAM,MAAM,CAAC;EAC3C,IAAI,MAAM,MAAM;EAChB,IAAI,cAAc;EAClB,MAAM,QAAQ,KAAM,SAAS,KAAK,GAAG,OAAO,KAAM,GAAG;AACrD,MAAI,OACF;OAAI,gBAAgB,MAAM,EAAE;IAC1B,MAAM,WAAW,WAAW,MAAM,IAAI;AAEtC,QAAI,CAAC,uBAAuB,KAAK,KAAK,MAAM,KAAK,SAAS,CAAC,EAAE;AAC3D,oBAAe,IAAI,SAAS;AAC5B,WAAM;;;aAGD,QAAS,eAAc,IAAI,SAAS;WACtC,QAAQ,KAAK,QAAQ,KAAK,MAAM,EAAE;GACzC,MAAM,WAAW,WAAW,OAAO,QAAQ,EAAE;AAC7C,OAAI,aAAa,MACf,eAAc,KAAK,UAAU,SAAS,CAAC,MACrC,GACA,SAAS,SAAY,GACtB;;AAEL,SAAO;AACP,SAAO,MAAM,YAAY;;AAE3B,QAAO,MAAM,MAAM,KAAK,MAAM,KAAK,CAAC;;;AAItC,SAAgB,iBAAiB;CAC/B,MAAM,uBAAO,IAAI,SAAkB;CAEnC,MAAM,6BAAa,IAAI,KAAsB;CAC7C,IAAI;CAEJ,MAAM,WAAW,YAA8B;AAC7C,OAAK,MAAM,UAAU,QACnB,KAAI,OAAO,UAAU,aAAa,KAAK,WACrC,MAAK,IAAI,OAAO,OAAkB;;;;;;CAOxC,MAAM,SAAS,MAAc,cAA0C;AACrE,OAAK,MAAM,SAAS,CAAC,GAAG,WAAW,QAAQ,CAAC,CAAC,MAC1C,GAAG,MAAM,EAAE,SAAS,EAAE,OACxB,CACC,MAAK,MAAM,QAAQ;GACjB;GACA,KAAK,UAAU,MAAM,CAAC,MAAM,GAAG,GAAG;GAClC,mBAAmB,MAAM;GACzB,IAAI,gBAAgB,EAAE,GAAG,OAAO,CAAC,CAAC,UAAU,CAAC,MAAM,EAAE;GACrD,GAAG,CAAC,YAAY,MAAM,IAAI,GAAG,CAAC,QAAQ,WAAW,OAAO,UAAU,EAAE;GACrE,CACC,QAAO,QAAQ,KAAK,KAAK,GAErB,KAAK,QAAQ,OAAO,WAAW,KAAK,UAAU,IAAI,EAAE,SAAS,GAC7D,KAAK,MAAM,KAAK,CAAC,KAAK,SAAS;AACvC,SAAO;;;CAIT,MAAM,cAAc,YAAqB;AACvC,MAAI,SAAU,SAAQ,SAAS,aAAa,CAAC;AAC7C,MACE,EAAE,mBAAmB,qBACrB,EACE,QAAQ,SAAS,cACjB,KAAK,IAAI,QAAQ,IACjB,gBAAgB,QAAQ,KAAK,IAC7B,sBAAsB,KAAK,QAAQ,aAAa,eAAe,IAAI,GAAG,EAGxE,QAAO;AACT,OAAK,IAAI,QAAQ;AACjB,MAAI,QAAQ,MAAM,UAAU,GAAG;AAC7B,cAAW,OAAO,QAAQ;AAC1B,cAAW,IAAI,SAAS,QAAQ,MAAM;AACtC,OAAI,WAAW,OAAO,kBACpB,YAAW,OAAO,WAAW,MAAM,CAAC,MAAM,CAAC,MAAO;;AAEtD,SAAO;;AAGT,QAAO;EACL;EACA;EAKA,QAAQ;AAGN,OAAI;AACF,SAAK,MAAM,SAAS,SAAS,iBAAiB,uBAAuB,CACnE,MAAK,IAAI,MAAM;WACX;GAGR,IAAI;AACJ,OAAI;AACF,cAAU,IAAI,iBAAiB,QAAQ;AACvC,YAAQ,QAAQ,UAAU;KACxB,SAAS;KACT,iBAAiB,CAAC,OAAO;KACzB,mBAAmB;KACpB,CAAC;AACF,eAAW;WACL;AACN,cAAU;;GAEZ,MAAM,YAAY,UAAiB;AACjC,QAAI;KACF,MAAM,CAAC,UAAU,MAAM,cAAc;AACrC,SAAI,kBAAkB,QAAS,YAAW,OAAO;YAC3C;;AAIV,QAAK,MAAM,QAAQ,CAAC,SAAS,SAAS,CACpC,QAAO,iBAAiB,MAAM,UAAU,KAAK;AAC/C,gBAAa;AACX,aAAS,YAAY;AACrB,QAAI,YAAY,UAAa,aAAa,QAAS,YAAW;AAC9D,SAAK,MAAM,QAAQ,CAAC,SAAS,SAAS,CACpC,QAAO,oBAAoB,MAAM,UAAU,KAAK;;;EAGtD,QAAQ,KAAa,UACnB,gBAAgB,IAAI,GAAG,WAAW,MAAM,WAAW,MAAM,CAAC;EAE5D,KAAK,MAAc;AACjB,OAAI;AACF,WAAO,MAAM,WAAW,KAAK,MAAM,GAAG,WAAW,CAAC,CAAC;WAC7C;AACN;;;EAIJ,IAAI,OAAe;AACjB,OAAI;AACF,WAAO,MAAM,MAAM,QAAQ,WAAW,SAAS,WAAW,KAAK,CAAC,CAAC;WAC3D;AACN,WAAO;;;EAGZ;;;;;AC7PH,MAAM,cACJ;AACF,MAAM,uBAAuB;AAG7B,MAAM,kBAAkB;;AAGxB,SAAS,oBAAoB,IAAa,UAAkB;CAC1D,IAAI,UAA0B;AAC9B,QAAO,YAAY,MAAM;EACvB,MAAM,QAAQ,QAAQ,QAAQ,SAAS;AACvC,MAAI,UAAU,KAAM,QAAO;EAC3B,MAAM,OAAO,QAAQ,aAAa;AAClC,YAAU,gBAAgB,aAAa,KAAK,OAAO;;AAErD,QAAO;;;;;;AAkBT,SAAgB,eAAe,IAAa,QAAkB;AAC5D,KAAI,oBAAoB,IAAI,2BAA2B,KAAK,KAAM,QAAO;CACzE,MAAM,UAAU,GAAG,QAAQ,YAAY,IAAI;CAE3C,MAAM,WAAW,OAAO,WAAW,QAAQ;AAsB3C,QArB6B;EAC3B;EACA,QAAQ;GACN,KAAK,QAAQ;GACb,MAAM,QAAQ,aAAa,OAAO;GAClC,QAAQ,QAAQ,aAAa,yBAAyB;GACtD,OAAO,QAAQ,eAAe,IAAI,MAAM,GAAG,gBAAgB;GAC3D,YAAY,OAAO,YACjB,MAAM,KAAK,QAAQ,aAAa,EAAE,MAAM,YAAY,CAClD,MAGC,YAAY,SAAS,WACtB,gBAAgB,KAAK,QAAQ,UAAU,GAAG,CAAC,GACvC,WACA,OAAO,IAAI,MAAM,CACtB,CAAC,CACH;GACD,GAAG,iBAAiB,SAAS,OAAO;GACrC;EACF;;AAIH,MAAM,mBAAmB;AAEzB,MAAM,iBAAiB;AACvB,MAAM,iBAAiB,IAAI;AAG3B,MAAM,eACJ;AACF,MAAM,kBAAkB;AACxB,MAAM,aAAa,GAAG,gBAAgB;AACtC,MAAM,WAAW,4BAA4B;AAG7C,MAAM,UAAU;AAChB,MAAM,YAAY,GAAG,QAAQ;AAC7B,MAAM,UAAU;AAEhB,MAAM,WACJ;AACF,MAAM,gBAAgB;AAEtB,MAAM,iBACJ;AAGF,MAAM,YAAY;AAIlB,MAAM,WACJ;AACF,MAAM,QACJ;AAGF,MAAM,MACJ;AACF,MAAM,YAAY;AAElB,MAAM,UAAU,aACb,QAAQ,aAAa,OAAO,IAAI,IAAI,MAAM,CAAC,MAAM,MAAM,CAAC,GAAI,aAAa;AAE5E,MAAM,UAAU,SAAkB,UAC/B,QAAQ,aAAa,KAAK,IAAI,IAAI,MAAM,MAAM,CAAC,OAAO,QAAQ;AAEjE,MAAM,UAAU,YAAqB;CACnC,MAAM,OAAO,QAAQ,aAAa;AAClC,QAAO,gBAAgB,YAAY,gBAAgB,aAAa,OAAO;;;AAIzE,MAAM,aAAa,YAAqB;CACtC,MAAM,OAAO,OAAO,QAAQ;AAC5B,QAAO,OACH,aAAa,KAAK,KAAK,GACvB,QAAQ,QAAQ,2BAA2B;;;AAIjD,MAAM,cAAc,YAClB,mBAAmB,mBACf,CAAC,eAAe,KAAK,QAAQ,KAAK,GAClC,QAAQ,QAAQ,mBAAmB,IACnC,QAAQ,QAAQ,WAAW,KAAK;;;;;;AAOtC,SAAS,UAAU,SAAkB;AACnC,KAAI,QAAQ,cAAc,MAAO,QAAO,QAAQ,aAAa,MAAM;AACnE,KAAI,QAAQ,cAAc,SAAS,OAAO,QAAQ,KAAK,MACrD,QAAO;CACT,MAAM,QAAQ,MAAM,KAAK,QAAQ,SAAS,CAAC,MACxC,UAAU,MAAM,cAAc,WAAW,MAAM,sBAAsB,EACvE;AACD,QAAO,QAAQ,aAAa,aAAa,IAAI,OAAO,eAAe;;;;;;;;;;;AAYrE,SAAS,SAAS,MAAe,QAAiB;AAChD,KAAI,WAAW,KAAK,CAAE,QAAO;CAC7B,IAAI,OAAO;CACX,IAAI,QAAQ;CACZ,IAAI,aAAa;CAEjB,IAAI;CACJ,MAAM,0BAAU,IAAI,KAAsB;CAC1C,MAAM,OAAO,SAAkB,QAAQ,OAAO,MAAM,QAAQ,QAAQ,IAAI;CAGxE,MAAM,QAAQ,UAAgB;AAC5B,MAAI,CAAC,OAAQ,QAAO;AACpB,OACE,IAAI,QAAQ,OAAO,SAAS,MAAM,YAClC,UAAU,QAAQ,WAAW,MAC7B,QAAQ,QAAQ,SAAS,OAAO,YAChC;GACA,MAAM,WAAW,QAAQ,IAAI,OAAO,oBAAI,IAAI,KAAW;AACvD,WAAQ,IAAI,QAAQ,SAAS;AAC7B,OAAI,SAAS,IAAI,MAAM,CAAE;AACzB,OAAI,SAAS,IAAI,MAAM,CAAC,OAAO,EAAG,QAAO;;AAE3C,SAAO;;CAET,IAAI,OAAoB,KAAK;AAC7B,QAAO,SAAS,MAAM;AACpB,MAAI,EAAE,QAAQ,kBAAkB,KAAK,SAAS,gBAAgB;AAC5D,OAAI,OAAQ,QAAO;AACnB;;EAEF,IAAI,UAAU;AACd,MAAI,gBAAgB,MAAM;GACxB,MAAM,SAAS,KAAK;GAEpB,IAAI,SAAS;AACb,UAAO,WAAW,QAAQ,SAAS,KAAK,OAAO,UAAU,CACvD,UAAS,OAAO;AAClB,OACE,KAAK,KAAK,KAAK,KAAK,KACnB,CAAC,KAAK,OAAO,IACX,UAAU,OAAO,cAAc,UAAU,QAAQ,YAAY,QAEhE,QAAO;AACT,OAAI,KAAK,KAAK;aACL,gBAAgB,SAAS;AAClC,OAAI,KAAK,QAAQ,SAAS,CAAE,QAAO;AACnC,OACE,UACA,KAAK,QAAQ,UAAU,IACvB,EAAE,KAAK,cAAc,aAAa,KAAK,QAAQ,QAAQ,MACtD,KAAK,cAAc,OAAO,EAAE,aAAa,GAE1C,QAAO;GACT,MAAM,OAAO,KAAK,QAAQ,QAAQ,GAAG,OAAO,UAAU,KAAK;AAC3D,OAAI,MAAM;AACR,QAAI,CAAC,KAAK,KAAK,CAAE,QAAO;AACxB,QAAI,IAAI,KAAK,GAAG;cACP,SAAS,QAAW;AAC7B,QAAI,CAAC,SAAS,KAAK,KAAK,UAAU,CAAE,KAAI,IAAI;AAC5C,cAAU;;;AAGd,MAAI,WAAW,KAAK,eAAe,KAAM,QAAO,KAAK;OAChD;AACH,UAAO,SAAS,QAAQ,KAAK,gBAAgB,MAAM;AACjD,WAAO,KAAK;AACZ,QAAI,CAAC,SAAS,KAAM,KAAiB,UAAU,CAAE,KAAI,IAAI;;AAE3D,UAAO,SAAS,OAAO,OAAO,KAAK;;;AAGvC,QAAO;;;AAIT,MAAM,gBAAgB,aACnB,QAAQ,cAAc,YACrB,sBAAsB,KAAK,OAAO,QAAQ,CAAC,KAC7C,iBAAiB,QAAQ;;AAG3B,SAAS,iBAAiB,SAAkB;AAC1C,KAAI,QAAQ,QAAQ,6BAA6B,KAAK,KAAM,QAAO;CACnE,MAAM,OAAO,OAAO,QAAQ;AAC5B,QACE,SAAS,QACT,MAAM,KACJ,KAAK,iBAAiB,wCAAwC,CAC/D,CAAC,MAAM,UACN,CAAC,GAAG,OAAO,OAAO,gBAAgB,EAAE,GAAG,OAAO,OAAO,YAAY,CAAC,CAAC,MAChE,OAAO,KAAK,eAAe,GAAG,EAAE,SAAS,QAAQ,CACnD,CACF;;AAIL,SAAS,WAAW,SAAkB;CACpC,MAAM,OAAO,OAAO,QAAQ;AAC5B,QAAO,SAAS,OACZ,OACA,OAAO,SAAS,kBAAkB,CAC/B,KAAK,OAAO;EACX,MAAM,aAAa,KAAK,eAAe,GAAG;AAC1C,SAAO,eAAe,QAAQ,iBAAiB,WAAW,GACtD,KACA,SAAS,YAAY,MAAM;GAC/B,CACD,KAAK,IAAI;;;AAIlB,MAAM,aAAa,SACjB,KAAK,QAAQ,WAAW,GAAG,CAAC,QAAQ,QAAQ,IAAI,CAAC,MAAM;AAEzD,SAAS,YAAY,SAAkB;CACrC,MAAM,OAAO,SAAS,SAAS,KAAK;AACpC,QAAO,MAAM,KAAK,UAAU,KAAK,CAAC,CAAC,SAAS,mBAAmB,KAAK;;;AAItE,SAAS,WAAW,KAAa,QAAkB;CACjD,MAAM,QAAQ,MAAM,KAGlB,OACG,MACC,UAAU,OAAO,KAAK,IAAI,QAAQ,WAAW,GAAG,CAAC,IAAI,GAAG,EACxD,UACD,CACA,QAAQ,UAAU,QAAQ,CAC1B,QAAQ,OAAO,UAAU,CACzB,QAAQ,KAAK,KAAK,WAAW,CAC7B,QAAQ,YAAY,QAAS,KAAK,KAAK,IAAI,GAAG,WAAW,IAAK,CAClE;AACD,QAAO,MAAM,SAAS,mBAClB,GAAG,MACA,MAAM,GAAG,mBAAmB,EAAE,CAC9B,KAAK,GAAG,CACR,SAAS,CAAC,KACb,MAAM,KAAK,GAAG;;AAGpB,SAAS,WAAW,SAAkC,QAAkB;AACtE,MAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,QAAQ,WAAW,QAAQ,IAAI,IAAI,OAAO;AAChD,MAAI,MAAO,QAAO;;;;;;;;;;;;AActB,SAAS,WAAW,SAAkB,QAAsC;CAC1E,MAAM,SAAS,QAAQ,QAAQ,2CAA2C;AAC1E,KAAI,WAAW,QAAQ,aAAa,OAAO,CAAE,QAAO;CAEpD,MAAM,QAAQ,OACN,WAAW,QAAQ,QACnB,QAAQ,aAAa,aAAa,CACzC;AACD,KACE,cAAc,KAAK,QAAQ,UAAU,IACrC,4BAA4B,KAAK,OAAO,QAAQ,CAAC,EACjD;EACA,MAAM,MAAM,WACV,CAAC,GAAG,aAAa,UAAU,QAAQ,IAAI,KAAK,EAC5C,OACD;AACD,MAAI,QAAQ,OAAW,QAAO;EAE9B,IAAI,UAAU,QAAQ;AACtB,SAAO,YAAY,QAAQ,CAAC,UAAU,QAAQ,CAC5C,WAAU,QAAQ;AACpB,SAAO,YAAY,OAAO,SAAY,WAAW,SAAS,OAAO;;CAEnE,MAAM,UAAU,UAAU,QAAQ,IAAI,CAAC,WAAW,QAAQ;CAC1D,MAAM,eACJ,mBAAmB,oBAAoB,cAAc,KAAK,QAAQ,KAAK;AACzE,QAAO,WACL;EACE,GAAG;QAGD,eACI,QAAQ,SAAS,UACf,QAAQ,aAAa,MAAM,GAC3B,QAAQ,QACV;QACC,WAAW,eAAe,QAAQ,aAAa,QAAQ,GAAG;QAC1D,UAAU,YAAY,QAAQ,GAAG;EACzC,EACD,OACD;;;AAIH,SAAgB,iBAAiB,SAAkB,QAAkB;AACnE,KAAI,mBAAmB,iBACrB,QAAO;EACL,OAAO,OAAO,WAAW,QAAQ,GAAG,WAAW,QAAQ;EACvD,GAAI,CAAC,YAAY,QAAQ,CAAC,SAAS,QAAQ,KAAK,GAC5C,EAAE,SAAS,QAAQ,SAAS,GAC5B,EAAE;EACN,GAAI,QAAQ,SAAS,SACjB,EACE,OAAO,MAAM,KAAK,QAAQ,SAAS,EAAE,GAAG,UAAU;GAChD,MAAM,KAAK;GACX,MAAM,KAAK;GACX,MAAM,KAAK;GACX,cAAc,KAAK;GACpB,EAAE,EACJ,GACD,EAAE;EACP;AAEH,KAAI,mBAAmB,kBACrB,QAAO;EACL,OAAO,QAAQ;EACf,gBAAgB,MAAM,KACpB,QAAQ,kBACP,WAAW,OAAO,MACpB;EACF;AAEH,KAAI,mBAAmB,oBAAqB,QAAO,EAAE,OAAO,QAAQ,OAAO;AAC3E,KAAI,QAAQ,QAAQ,gBAAgB,CAClC,QAAO,EAAE,OAAO,QAAQ,eAAe,IAAI;AAE7C,QAAO,EAAE;;AAGX,SAAS,aAAa,OAAc;CAClC,MAAM,CAAC,SAAS,MAAM,cAAc;AACpC,QAAO,iBAAiB,UAAU,QAAQ;;AAG5C,SAAS,iBAAiB,IAAa;AACrC,QAAO,GAAG,cAAc,UAAU,GAAG,cAAc,UAAU,CAAC,GAAG;;AAGnE,SAAS,OAAO,QAAuB,OAAmB;AACxD,QACE,KAAK,IAAI,OAAO,IAAI,MAAM,QAAQ,IAAI,wBACtC,KAAK,IAAI,OAAO,IAAI,MAAM,QAAQ,IAAI;;;;;;;AAS1C,SAAgB,oBAAoB,QAOjC;CACD,MAAM,EACJ,MACA,UACA,QACA,aAAa,UAAU,MAAM,WAC7B,SAAS,gBAAgB,KACvB;CACJ,IAAI,SAA+B;CAGnC,MAAM,iBAAiB,OAAgB;EACrC,MAAM,YAAY,eAAe,IAAI,OAAO;AAC5C,MAAI,cAAc,KAAM,QAAO;AAC/B,MAAI;GACF,MAAM,QAAQ,WAAW,UAAU,SAAS,OAAO;AACnD,OAAI,UAAU,OAAW,WAAU,OAAO,QAAQ;UAC5C;AAGR,SAAO;;CAGT,MAAM,iBAAiB,UAAwB;AAC7C,MAAI;AACF,OAAI,MAAM,cAAc,MAAO;GAC/B,MAAM,KAAK,aAAa,MAAM;AAC9B,YAAS;IACP,WAAW,OAAO,OAAO,OAAO,cAAc,GAAG;IACjD,GAAG,MAAM;IACT,GAAG,MAAM;IACV;UACK;AACN,YAAS;;;CAIb,MAAM,WAAW,UAAsB;EACrC,MAAM,gBAAgB;AACtB,WAAS;AACT,MAAI;AACF,OAAI,CAAC,UAAU,MAAM,CAAE;GACvB,MAAM,KAAK,aAAa,MAAM;AAC9B,OAAI,OAAO,KAAM;GAKjB,MAAM,YAHJ,kBAAkB,QAClB,iBAAiB,GAAG,IACpB,OAAO,eAAe,MAAM,GAE1B,cAAc,YACd,cAAc,GAAG;AACrB,OAAI,cAAc,KAAM;GACxB,MAAM,QAAQ,MAAM,WAAW,IAAI,aAAa;AAChD,QAAK,SAAS;IACZ,QAAQ;KAAE,GAAG,UAAU;KAAQ;KAAO;IACtC,OAAO,UAAU;IACjB,KAAK,OAAO,IAAI,SAAS,KAAK;IAC9B,GAAG,SAAS,UAAU,QAAQ;IAC/B,CAAC;UACI;;AAKV,QAAO,iBAAiB,eAAe,eAAe;EACpD,SAAS;EACT,SAAS;EACV,CAAC;AACF,QAAO,iBAAiB,SAAS,SAAS;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AAC3E,cAAa;AACX,SAAO,oBAAoB,eAAe,eAAe,EAAE,SAAS,MAAM,CAAC;AAC3E,SAAO,oBAAoB,SAAS,SAAS,EAAE,SAAS,MAAM,CAAC;;;;;;ACzenE,MAAM,mBAAmB;AACzB,MAAM,mBAAmB,IAAI,IAAI;CAC/B;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;;AAcF,SAAgB,oBAAoB,QAIjC;CACD,MAAM,EACJ,MACA,aAAa,UAAiB,MAAM,WACpC,SAAS,gBAAgB,KACvB;CACJ,IAAI,SAAS;CACb,MAAM,0BAAU,IAAI,KAAoB;CAGxC,MAAM,6BAAa,IAAI,SAA0B;CACjD,MAAM,4BAAY,IAAI,SAA2B;CAEjD,MAAM,UAAU,YAAqB;AACnC,eAAa,QAAQ,IAAI,QAAQ,EAAE,MAAM;AACzC,UAAQ,OAAO,QAAQ;;CAEzB,MAAM,WAAW,SAAkB,SAAe;AAChD,MAAI;AAGF,OAAI,CAAC,UAAU,eAAe,SAAS,OAAO,KAAK,KAAM;AAGzD,OAAI,OAAO,WAAW,QAAQ,IAAI,KAAK,MAAM,UAAU,SAAU;AACjE,OAAI,KAAK,QAAQ,cAAc,QAC7B,YAAW,IAAI,SAAS,KAAK,UAAU,KAAK,MAAM,CAAC;AAErD,QAAK,QAAQ,MAAM,OAAO,IAAI,KAAK,QAAQ,IAAI;AAC/C,QAAK,QAAQ,OAAO,OAAO,OAAO,IAAI,KAAK,QAAQ,OAAO,KAAK;AAC/D,QAAK,QAAQ,OAAO,aAAa,OAAO,YACtC,OAAO,QAAQ,KAAK,QAAQ,OAAO,WAAW,CAAC,KAAK,CAAC,KAAK,WAAW,CACnE,KACA,OAAO,IAAI,MAAM,CAClB,CAAC,CACH;AACD,QAAK,SAAS,KAAK,QAAQ;UACrB;;CAIV,MAAM,cAAc;EAElB,MAAM,QAAQ,CAAC,GAAG,QAAQ;AAC1B,OAAK,MAAM,CAAC,YAAY,MAAO,QAAO,QAAQ;AAC9C,OAAK,MAAM,CAAC,SAAS,SAAS,MAAO,SAAQ,SAAS,KAAK;;CAE7D,MAAM,WAAW,SAAkB,WAAmB,WAAoB;AACxE,MAAI,QAAQ,OAAO,KAAK,CAAC,QAAQ,IAAI,QAAQ,CAAE,QAAO;AACtD,MAAI,CAAC,OAAQ;EACb,MAAM,YAAY,eAAe,SAAS,OAAO;AACjD,MAAI,cAAc,MAAM;AACtB,UAAO,QAAQ;AACf;;EAEF,MAAM,QAAQ,iBAAiB,SAAS,OAAO;AAC/C,MAAI,EAAE,WAAW,OAAQ;EACzB,MAAM,OACJ,mBAAmB,uBAClB,mBAAmB,oBAClB,iBAAiB,IAAI,QAAQ,KAAK,IACpC,QAAQ,QAAQ,qDAAmD;AACrE,MAAI,CAAC,KAAM,QAAO;EAClB,MAAM,YAAY,KAAK,UAAU,MAAM;EACvC,MAAM,OACJ,cAAc,aACb,cAAc,KAAK,UAAU,QAAQ,IAAI,QAAQ,EAAE,MAAM,IACxD,cAAc,WAAW,IAAI,QAAQ;AACzC,aAAW,OAAO,QAAQ;AAC1B,MAAI,MAAM;AACR,UAAO;AACP,cAAW,OAAO,QAAQ;AAC1B;;AAEF,SAAO,QAAQ;EACf,MAAM,OAAa;GACjB;GACA,SAAS;IACP;IACA,QAAQ;KAAE,GAAG,UAAU;KAAQ,GAAG;KAAO;IACzC,KAAK,OAAO,IAAI,SAAS,KAAK;IAC9B,OAAO,SAAS;IACjB;GACF;AACD,MAAI,CAAC,MAAM;AACT,WAAQ,SAAS,KAAK;AACtB;;AAEF,MAAI,OACF,MAAK,QAAQ,iBAAiB;AAC5B,UAAO,QAAQ;AACf,WAAQ,SAAS,KAAK;KACrB,iBAAiB;AAEtB,UAAQ,IAAI,SAAS,KAAK;;CAE5B,MAAM,UAAU,UAAiB;AAC/B,MAAI;AACF,OAAI,CAAC,UAAU,CAAC,UAAU,MAAM,CAAE;GAClC,MAAM,CAAC,WAAW,MAAM,cAAc;AACtC,OAAI,EAAE,mBAAmB,SAAU;AACnC,OAAI,MAAM,SAAS,oBAAoB;AACrC,cAAU,IAAI,SAAS,KAAK;AAC5B,iBAAa,QAAQ,IAAI,QAAQ,EAAE,MAAM;AACzC;;AAEF,OAAI,MAAM,SAAS,iBAAkB,WAAU,IAAI,SAAS,MAAM;GAGlE,MAAM,gBACJ,UAAU,IAAI,QAAQ,KAAK,QAC1B,UAAU,IAAI,QAAQ,KAAK,SAC1B,iBAAiB,SACjB,MAAM,gBAAgB;AAC1B,WACE,SACA,MAAM,SAAS,mBAAmB,UAAU,MAAM,MAClD,CAAC,cACF;AACD,OAAI,MAAM,SAAS,SAAU,QAAO;UAC9B;;CAIV,MAAM,YAAY,UAAiB;AACjC,MAAI,MAAM,SAAS,cAAc,CAAC,UAAU,MAAM,CAAE;AACpD,MAAI,MAAM,SAAS,WAAW;GAC5B,MAAM,CAAC,WAAW,MAAM,cAAc;AAGtC,OACE,EAAE,iBAAiB,kBACnB,MAAM,QAAQ,WACd,MAAM,eACN,MAAM,YAAY,OACjB,mBAAmB,WAAW,UAAU,IAAI,QAAQ,KAAK,KAE1D;;AAEJ,SAAO;;CAET,MAAM,YAAY;EAAC;EAAS;EAAU;EAAoB;EAAiB;CAC3E,MAAM,cAAc;EAAC;EAAS;EAAU;EAAY;EAAY;EAAU;AAC1E,MAAK,MAAM,QAAQ,UACjB,QAAO,iBAAiB,MAAM,QAAQ;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;AACzE,MAAK,MAAM,QAAQ,YACjB,QAAO,iBAAiB,MAAM,UAAU;EAAE,SAAS;EAAM,SAAS;EAAM,CAAC;CAC3E,MAAM,kBAAkB;AACtB,WAAS;AACT,OAAK,MAAM,WAAW,QAAQ,MAAM,CAAE,QAAO,QAAQ;AACrD,OAAK,MAAM,QAAQ,UACjB,QAAO,oBAAoB,MAAM,QAAQ,KAAK;AAChD,OAAK,MAAM,QAAQ,YACjB,QAAO,oBAAoB,MAAM,UAAU,KAAK;;AAEpD,QAAO,OAAO,OAAO,WAAW,EAAE,OAAO,CAAC;;;;;ACzK5C,SAAS,mBAAmB,OAA2C;AACrE,QACE,OAAO,UAAU,YACjB,UAAU,QACV,sBAAsB,SACtB,yBAAyB;;AAI7B,SAAS,gBAAgB;CACvB,MAAM,aAAsB,QAAQ,IAAI,QAAQ,aAAa;AAC7D,QAAO,mBAAmB,WAAW,GAAG,aAAa;;AAGvD,SAAS,iBAAiB,OAAgC;AACxD,SAAQ,OAAR;EACE,KAAK;EACL,KAAK;EACL,KAAK,SACH,QAAO;EACT,QACE,QAAO;;;;;;;;;AAUb,SAAgB,yBAAyB,QAItC;CACD,MAAM,EAAE,MAAM,SAAS,gBAAgB,KAAK;CAC5C,IAAI,UAAU,SAAS;CAEvB,MAAM,SAAS,mBAAmC;AAChD,MAAI;GACF,MAAM,OAAO,SAAS;AACtB,OAAI,SAAS,QAAS;AACtB,QAAK,cAAc;IACjB,MAAM,OAAO,IAAI,QAAQ;IACzB,IAAI,OAAO,IAAI,KAAK;IACpB;IACD,CAAC;AACF,aAAU;UACJ;;CAKV,MAAM,aAAa,eAAe;AAClC,KAAI,eAAe,MAAM;EACvB,MAAM,iBAAiB,UACrB,MAAM,iBAAiB,QAAQ,IAAI,OAAO,iBAAiB,CAAC,CAAC;AAC/D,aAAW,iBAAiB,sBAAsB,cAAc;AAChE,eACE,WAAW,oBAAoB,sBAAsB,cAAc;;AAGvE,QAAO,uBAAuB,MAAM;;AAGtC,SAAS,uBACP,OACA;CACA,IAAI,SAAS;CACb,MAAM,WAA2B,EAAE;CAEnC,MAAM,SAAS,QAAiB,WAA0B;EACxD,MAAM,WAAW,OAAO;EACxB,MAAM,UAAkC,SAAyB,GAAG,MAAM;GACxE,MAAM,SAAS,SAAS,MAAM,MAAM,KAAK;AACzC,OAAI,OAAQ,OAAM,WAAW,cAAc,SAAS,UAAU;AAC9D,UAAO;;AAET,SAAO,UAAU;AACjB,WAAS,WAAW;AAElB,OAAI,OAAO,YAAY,QAAS,QAAO,UAAU;IACjD;;AAIJ,MAAK,MAAM,UADsB,CAAC,aAAa,eAAe,EAChC;AAC5B,QAAM,QAAQ,WAAW,OAAO;AAChC,MAAI,OAAO,UAAU,eAAe,KAAK,SAAS,OAAO,CACvD,OAAM,SAAS,OAAO;;CAG1B,MAAM,mBAAmB,MAAM,WAAW;AAC1C,QAAO,iBAAiB,YAAY,WAAW;AAC/C,QAAO,iBAAiB,cAAc,WAAW;AAEjD,cAAa;AACX,WAAS;AACT,SAAO,oBAAoB,YAAY,WAAW;AAClD,SAAO,oBAAoB,cAAc,WAAW;AACpD,OAAK,MAAM,WAAW,SAAU,UAAS;;;;;;ACtG7C,MAAM,aAAa;AACnB,MAAM,kBAAkB;AACxB,MAAM,UAAU,IAAI,aAAa;AACjC,MAAM,yBAAuC;CAC3C,QAAQ;CACR,QAAQ;CACT;;AAID,SAAS,SACP,OACA,QACA,aACA,QACA,QACc;CACd,IAAI;AACJ,KAAI,CAAC,SAAS,YAAY,CACxB,KAAI;AAGF,SAAO,IAAI,YAAY,SAAS,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC,OAAO,OAAO,EACrE,QAAQ,WAAW,YACpB,CAAC;SACI;AAGV,KAAI,SAAS,QAAW;AACtB,SAAO,OAAO,KAAK;AACnB,MAAI,SAAS,OAAW,QAAO,iBAAiB;AAEhD,MAAI,KAAK,SAAS,KAAK,EAAE;AACvB,WAAQ,QAAQ,OAAO,KAAK;AAC5B,UAAO;;;CAGX,MAAM,SAAS,SAAS;AAExB,UAAS,KAAK,OAAO,aAAa,GAAG,MAAM,SAAS,GAAG,WAAW,CAAC,CAAC;CACpE,MAAM,SAAuB;EAC3B,QAAQ,WAAW,cAAc,MAAM,WAAW,IAAI,UAAU;EAChE;EACA,UAAU,SAAS,WAAW;EAC9B,GAAI,SAAS,EAAE,QAAQ,GAAG,EAAE;EAC7B;AACD,KAAI,QAAQ,OAAO,KAAK,UAAU,OAAO,CAAC,CAAC,UAAU,WACnD,QAAO;AACT,QAAO,SAAS,WAAW,aAAa,cAAc;AACtD,QAAO,YAAY;CAEnB,IAAI,MAAM;CACV,IAAI,OAAO,KAAK;AAChB,QAAO,MAAM,MAAM;EACjB,MAAM,SAAS,KAAK,MAAM,MAAM,QAAQ,EAAE;AAC1C,SAAO,OAAO,KAAK,MAAM,GAAG,OAAO;AACnC,MAAI,QAAQ,OAAO,KAAK,UAAU,OAAO,CAAC,CAAC,UAAU,WACnD,OAAM;MACH,QAAO,SAAS;;AAEvB,KAAI,OACF,QAAO,MAAM;UACN,MAAM,KAAK,kBAAkB,KAAK,KAAK,MAAM,GAAI,CAAE;AAC5D,QAAO,OAAO,KAAK,MAAM,GAAG,IAAI;AAChC,QAAO;;AAGT,SAAS,aACP,OACA,QACA,SAAiC,YACjC,QACc;AACd,QAAO,SACL,QAAQ,OAAO,MAAM,MAAM,GAAG,WAAW,CAAC,EAC1C,MAAM,SAAS,cAAc,WAAW,aAAa,cAAc,QACnE,cACA,QACA,OACD;;AAGH,SAAS,SAAS,aAAqB;AACrC,QACE,gBAAgB,MAChB,CAAC,oFAAoF,KACnF,YACD;;;AAKL,SAAgB,kBAAkB,QAAkB;CAClD,MAAM,gCAAgB,IAAI,KAAiB;CAC3C,IAAI,SAAS;CACb,MAAM,QACJ,QACA,cAAc,OACY;AAC1B,MAAI,CAAC,OAAQ,QAAO,QAAQ,QAAQ,EAAE,QAAQ,SAAS,CAAC;AACxD,MAAI,CAAC,OACH,QAAO,QAAQ,QAAQ;GACrB,QAAQ;GACR,QAAQ;GACT,CAAC;EACJ,IAAI;AACJ,MAAI;AACF,YAAS,OAAO,WAAW;UACrB;AACN,UAAO,QAAQ,QAAQ;IAAE,QAAQ;IAAe,QAAQ;IAAe,CAAC;;AAE1E,SAAO,IAAI,SAAS,YAAY;GAC9B,MAAM,QAAQ,IAAI,WAAW,WAAW;GACxC,IAAI,SAAS;GACb,IAAI,WAAW;GACf,MAAM,UAAU,QAAgC,WAAoB;AAClE,QAAI,SAAU;AACd,eAAW;AACX,iBAAa,MAAM;AACnB,kBAAc,OAAO,OAAO;AAC5B,YACE,SACE,MAAM,SAAS,GAAG,OAAO,EACzB,QACA,aACA,OAAO,MACP,OACD,CACF;AAED,IAAK,OAAO,QAAQ,CAAC,YAAY,GAAG;;GAEtC,MAAM,eAAe,OAAO,eAAe,kBAAkB;GAC7D,MAAM,QAAQ,iBACN,OAAO,WAAW,oBAAoB,EAC5C,gBACD;AACD,iBAAc,IAAI,OAAO;AACzB,IAAM,YAAY;AAChB,QAAI;AACF,cAAS;MACP,MAAM,QAAQ,MAAM,OAAO,MAAM;AACjC,UAAI,SAAU;AACd,UAAI,MAAM,MAAM;AACd,cAAO,WAAW;AAClB;;MAEF,MAAM,OAAO,MAAM,MAAM,SAAS,GAAG,aAAa,OAAO;AACzD,YAAM,IAAI,MAAM,OAAO;AACvB,gBAAU,KAAK;AACf,UAAI,WAAW,YAAY;AACzB,cAAO,YAAY;AACnB;;;YAGE;AACN,YAAO,eAAe,mBAAmB;cACjC;AACR,SAAI;AACF,aAAO,aAAa;aACd;;OAIR;IACJ;;CAEJ,MAAM,QACJ,OACA,cAAc,OACY;AAC1B,MAAI;AACF,OAAI,SAAS,KAAM,QAAO,QAAQ,QAAQ,EAAE,QAAQ,SAAS,CAAC;AAC9D,OAAI,iBAAiB,gBAAiB,SAAQ,MAAM,UAAU;AAC9D,OAAI,OAAO,UAAU,SACnB,QAAO,QAAQ,QAAQ,aAAa,OAAO,OAAO,KAAK,CAAC;AAC1D,OACE,OAAO,mBAAmB,eAC1B,iBAAiB,eAGjB,QAAO,QAAQ,QAAQ;IACrB,QAAQ;IACR,QAAQ;IACT,CAAC;AAEJ,OAAI,iBAAiB,UAAU;IAC7B,MAAM,OAAO,IAAI,UAAU;AAC3B,UAAM,SAAS,OAAO,QACpB,KAAK,OACH,KACA,OAAO,UAAU,WAAW,OAAO,MAAM,KAAK,MAAM,GAAG,MACxD,CACF;AACD,YAAQ;;GAEV,MAAM,WAAW,IAAI,SAAS,MAAM;AACpC,UAAO,KACL,SAAS,MACT,eAAe,SAAS,QAAQ,IAAI,eAAe,IAAI,GACxD;UACK;AACN,UAAO,QAAQ,QAAQ;IACrB,QAAQ;IACR,QAAQ;IACT,CAAC;;;AAGN,QAAO;EACL;EACA;EACA,OAAO,OAAe,QAAiC,WACrD,aAAa,OAAO,OAAO,MAAM,QAAQ,OAAO;EAClD,OAAO;AACL,YAAS;AACT,QAAK,MAAM,UAAU,cAAe,SAAQ;;EAE/C;;;;;ACzNH,MAAM,kBAAkB,QAAQ,QAAsB,EAAE,QAAQ,SAAS,CAAC;AAC1E,MAAM,mBAAmB,WACvB,QAAQ,QAAsB;CAAE,QAAQ;CAAe;CAAQ,CAAC;AAClE,MAAM,cAAc,QAAgB,IAAI,IAAI,KAAK,SAAS,KAAK,CAAC;AAKhE,MAAM,qBAAqB,IAAI,IAAI;CACjC;CACA;CACA;CACD,CAAC;AACF,MAAM,eAAe,MAAc,UACjC,mBAAmB,IAAI,KAAK,aAAa,CAAC,IAAI,gBAAgB,KAAK,GAC/D,WACA;AACN,MAAM,gBAAgB,YACpB,OAAO,YACL,MAAM,KAAK,QAAQ,SAAS,GAAG,CAAC,MAAM,WAAW,CAC/C,MACA,YAAY,MAAM,MAAM,CACzB,CAAC,CACH;AAGH,SAAS,aAAa,OAAgB;AACpC,QACE,OAAO,UAAU,YACjB,UAAU,QACV,UAAU,SACV,MAAM,SAAS;;;AAKnB,SAAgB,sBAAsB,QAKnC;CACD,IAAI,SAAS;CACb,MAAM,EAAE,SAAS,gBAAgB,KAAK;CACtC,MAAM,SAAS,kBAAkB,OAAO;CACxC,MAAM,aAAa,OAAO,WAAW,KAAK,YACxC,OAAO,YAAY,WACf,WAAW,QAAQ,GACnB,IAAI,OAAO,QAAQ,QAAQ,QAAQ,MAAM,CAC9C;CACD,MAAM,aAAa,QACjB,WAAW,MAAM,YAAY;AAC3B,MAAI,OAAO,YAAY,SAAU,QAAO,IAAI,WAAW,QAAQ;AAC/D,UAAQ,YAAY;AACpB,SAAO,QAAQ,KAAK,IAAI;GACxB;CAEJ,MAAM,UACJ,WACA,SACA,aACA,WACA,QACA,SACA,aACG;EACH,MAAM,aAAa,KAAK,MAAM,YAAY,KAAK,GAAG,UAAU;EAC5D,MAAM,cAAc,KAAK,KAAK;AAC9B,EAAK,QAAQ,IAAI,CACf,aACA,UAAU,QAAQ,gBAAgB,cAAc,CACjD,CAAC,CACC,MAAM,CAAC,MAAM,cAAc;AAC1B,OAAI,CAAC,OAAQ;GACb,MAAM,OAAO,OAAO,IAAI,QAAQ,IAAI;GACpC,MAAM,MAAM,IAAI,IAAI,KAAK;AACzB,UAAO,KAAK,WAAW;IACrB;IACA,QAAQ,QAAQ;IAChB,KAAK;IACL,QAAQ,IAAI;IACZ,OAAO,IAAI;IACX;IACA;IACA;IACA;IACA,SAAS;KAAE,SAAS,QAAQ;KAAS,MAAM;KAAM;IACjD,UAAU;KACR,GAAI,UAAU,MAAM,EAAE,KAAK,OAAO,IAAI,SAAS,IAAI,EAAE,GAAG,EAAE;KAC1D,SAAS,UAAU,WAAW,EAAE;KAChC,MAAM;KACP;IACF,CAAC;IACF,CACD,YAAY,GAEX;;CAGN,MAAM,gBAAgB,WAAW;CACjC,MAAM,gBAA8B,OAAO,SAAS;AAClD,MAAI,CAAC,OAAQ,QAAO,cAAc,KAAK,YAAY,OAAO,KAAK;EAC/D,IAAI;EACJ,IAAI;AACJ,MAAI;GACF,MAAM,SACJ,OAAO,YAAY,eAAe,iBAAiB,UAC/C,QACA;AACN,aAAU;IACR,KAAK,WAAW,SAAS,OAAO,MAAM,OAAO,MAAM,CAAC;IACpD,SAAS,MAAM,UAAU,QAAQ,UAAU,OAAO,aAAa;IAC/D,SAAS,aAAa,IAAI,QAAQ,MAAM,WAAW,QAAQ,QAAQ,CAAC;IACrE;AACD,aAAU,UAAU,QAAQ,IAAI;UAC1B;AACN,UAAO,cAAc,KAAK,YAAY,OAAO,KAAK;;AAEpD,MAAI,QAAS,QAAO,cAAc,KAAK,YAAY,OAAO,KAAK;EAC/D,MAAM,YAAY,YAAY,KAAK;EACnC,IAAI;AACJ,MAAI;AACF,iBACE,MAAM,QAAQ,OACV,OAAO,KAAK,KAAK,MAAM,QAAQ,QAAQ,gBAAgB,GACvD,OAAO,YAAY,eAAe,iBAAiB,UACjD,OAAO,KAAK,MAAM,OAAO,CAAC,MAAM,QAAQ,QAAQ,gBAAgB,GAChE,WAAW;UACb;AACN,iBAAc,gBAAgB,uBAAuB;;EAEvD,MAAM,UAAU,UACd,OACE,SACA,SACA,aACA,WACA,MACA,aAAa,MAAM,GAAG,YAAY,QACnC;EACH,IAAI;AACJ,MAAI;AACF,aAAU,cAAc,KAAK,YAAY,OAAO,KAAK;WAC9C,OAAO;AACd,UAAO,MAAM;AACb,SAAM;;AAER,EAAK,QACF,MAAM,aAAa;AAClB,OAAI,CAAC,OAAQ;GACb,IAAI;AACJ,OAAI;AACF,mBACE,SAAS,SAAS,YAAY,SAAS,SAAS,mBAC5C,gBAAgB,kBAAkB,GAClC,OAAO,KACL,SAAS,OAAO,CAAC,MACjB,SAAS,QAAQ,IAAI,eAAe,IAAI,GACzC;WACD;AACN,mBAAe,gBAAgB,wBAAwB;;AAEzD,UACE,SACA,SACA,aACA,WACA,SAAS,QACT,MACA;IACE,KAAK,SAAS;IACd,SAAS,aAAa,SAAS,QAAQ;IACvC,MAAM;IACP,CACF;KACA,OAAO,CACT,YAAY,GAAG;AAElB,SAAO;;AAET,KAAI,OAAO,kBAAkB,WAAY,YAAW,QAAQ;CAC5D,MAAM,eAAe,SAAS,WAAW,QAAQ,OAAO;AACxD,cAAa;AACX,WAAS;AACT,SAAO,MAAM;AACb,MAAI,WAAW,UAAU,aAAc,YAAW,QAAQ;AAC1D,gBAAc;;;AAclB,SAAS,SACP,WACA,QACA,QACA;AACA,KAAI,OAAO,mBAAmB,WAAY,cAAa;CACvD,MAAM,QAAQ,eAAe;CAC7B,MAAM,eAAe,MAAM;CAC3B,MAAM,eAAe,MAAM;CAC3B,MAAM,oBAAoB,MAAM;CAChC,MAAM,2BAAW,IAAI,SAAuC;CAC5D,MAAM,4BAAY,IAAI,KAAiB;CAGvC,MAAM,2BAAW,IAAI,SAAqC;CAC1D,IAAI,SAAS;CACb,MAAM,cAAc,SAElB,QACA,KACA,GAAG,MACH;AACA,WAAS,IAAI,KAAK,IAAI;EACtB,MAAM,SAAS,QAAQ,MAAM,cAAc,MAAM;GAAC;GAAQ;GAAK,GAAG;GAAK,CAAC;AACxE,MAAI;AACF,YAAS,IAAI,MAAM;IACjB,KAAK,WAAW,OAAO,IAAI,CAAC;IAC5B,QAAQ,OAAO,aAAa;IAC5B,SAAS,EAAE;IACZ,CAAC;UACI;AACN,YAAS,OAAO,KAAK;;AAEvB,SAAO;;CAET,MAAM,mBAAmB,SAEvB,MACA,OACA;EACA,MAAM,SAAS,kBAAkB,KAAK,MAAM,MAAM,MAAM;EACxD,MAAM,UAAU,SAAS,IAAI,KAAK;AAClC,MAAI,UAAU,QACZ,KAAI;GACF,MAAM,UAAU,IAAI,QAAQ,QAAQ,QAAQ;AAC5C,WAAQ,OAAO,MAAM,MAAM;AAC3B,WAAQ,UAAU,aAAa,QAAQ;UACjC;AAIV,SAAO;;CAET,MAAM,cAAc,SAElB,MACA;EACA,MAAM,UAAU,SAAS,IAAI,KAAK;AAClC,MAAI,CAAC,UAAU,CAAC,WAAW,UAAU,QAAQ,IAAI,CAC/C,QAAO,aAAa,KAAK,MAAM,KAAK;EACtC,MAAM,YAAY,YAAY,KAAK;EACnC,IAAI;AACJ,MAAI;AACF,iBACE,OAAO,aAAa,eAAe,gBAAgB,WAC/C,QAAQ,QACN,OAAO,KAAK,IAAI,eAAe,CAAC,kBAAkB,KAAK,CAAC,CACzD,GACD,OAAO,KACL,MACA,QAAQ,QAAQ,gBACjB;UACD;AACN,iBAAc,gBAAgB,2BAA2B;;EAE3D,IAAI,UAAU;EACd,MAAM,gBAAgB;AACpB,aAAU;;EAEZ,MAAM,gBAAgB;AACpB,QAAK,oBAAoB,SAAS,QAAQ;AAC1C,QAAK,oBAAoB,WAAW,MAAM;AAC1C,aAAU,OAAO,QAAQ;AACzB,OAAI,SAAS,IAAI,KAAK,KAAK,QAAS,UAAS,OAAO,KAAK;;EAE3D,MAAM,cAAc;AAClB,YAAS;AACT,OAAI,CAAC,OAAQ;GACb,MAAM,UAAU,UAAU,YAAY,KAAK,WAAW,IAAI,UAAU;GACpE,MAAM,UAAkC,EAAE;AAC1C,OAAI;AACF,SAAK,MAAM,QAAQ,KAAK,uBAAuB,CAAC,MAAM,QAAQ,EAAE;KAC9D,MAAM,QAAQ,KAAK,QAAQ,IAAI;AAC/B,SAAI,QAAQ,GAAG;MACb,MAAM,OAAO,KAAK,MAAM,GAAG,MAAM,CAAC,aAAa;AAC/C,cAAQ,QAAQ,YAAY,MAAM,KAAK,MAAM,QAAQ,EAAE,CAAC,MAAM,CAAC;;;WAG7D;GAGR,IAAI;AACJ,OAAI;AACF,QAAI,KAAK,iBAAiB,MAAM,KAAK,iBAAiB,OACpD,gBAAe,QAAQ,QACrB,OAAO,KACL,KAAK,cACL,YAAY,OAAO,aAAa,eAChC,YAAY,OAAO,SAAY,WAAW,UAC3C,CACF;aACQ,KAAK,iBAAiB,OAC/B,gBAAe,QAAQ,QACrB,OAAO,KAAK,KAAK,UAAU,KAAK,SAAS,CAAC,CAC3C;aACM,KAAK,iBAAiB,WAC7B,gBAAe,KAAK,cAChB,QAAQ,QACN,OAAO,KACL,IAAI,eAAe,CAAC,kBAAkB,KAAK,YAAY,CACxD,CACF,GACD,gBAAgB,iBAAiB;QAErC,gBAAe,OAAO,KACpB,KAAK,UACL,QAAQ,mBAAmB,2BAC5B;WACG;AACN,mBAAe,gBAAgB,4BAA4B;;AAE7D,UACE,OACA,SACA,aACA,WACA,YAAY,OAAO,KAAK,SAAS,MACjC,SACA;IACE,KAAK,KAAK;IACV;IACA,MAAM;IACP,CACF;;AAEH,OAAK,iBAAiB,SAAS,QAAQ;AACvC,OAAK,iBAAiB,WAAW,MAAM;AACvC,YAAU,IAAI,QAAQ;AACtB,WAAS,IAAI,MAAM,QAAQ;AAC3B,MAAI;AACF,UAAO,aAAa,KAAK,MAAM,KAAK;WAC7B,OAAO;AACd,YAAS;AACT,UACE,OACA,SACA,aACA,WACA,MACA,aAAa,MAAM,GAAG,YAAY,QACnC;AACD,SAAM;;;AAGV,OAAM,OAAO;AACb,OAAM,OAAO;AACb,OAAM,mBAAmB;AACzB,cAAa;AACX,WAAS;AACT,OAAK,MAAM,WAAW,UAAW,UAAS;AAC1C,MAAI,MAAM,SAAS,YAAa,OAAM,OAAO;AAC7C,MAAI,MAAM,SAAS,YAAa,OAAM,OAAO;AAC7C,MAAI,MAAM,qBAAqB,iBAC7B,OAAM,mBAAmB;;;;;;;ACtY/B,SAAgB,QAAQ,UAAkB,aAAuB,EAAE,EAAE;AACnE,QAAO;;;AAIT,SAAgB,iBACd,KACA,aAAuB,EAAE,EACzB,OAAe,WAAW,UAAU,QAAQ,qBAC5C;CACA,MAAM,SAAS,IAAI,IAAI,KAAK,KAAK;AACjC,QAAO;EAAE,KAAK,OAAO;EAAM,QAAQ,OAAO;EAAQ,OAAO,OAAO;EAAU;;;;;ACM5E,MAAM,aAAa;AACnB,MAAM,oBAAoB;AAC1B,MAAM,WAAW,IAAI,IAAY,qBAAqB;;;;;;;;;;AAqBtD,SAAgB,gBAAgB,SAA2B;CACzD,MAAM,EAAE,MAAM,QAAQ,YAAY,WAAW;CAC7C,MAAM,UAAU;EACd,QAAQ;EACR,YAAY;EACZ,SAAS;EACT,QAAQ;EACR,GAAG,QAAQ;EACZ;CACD,IAAI,UAA0B;CAC9B,IAAI,MAAM;CAEV,MAAM,iBAAiB,QAAQ,SAAS,UAAU,OAAO;CAEzD,MAAM,SAAS,gBAAkC;EAC/C,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,MAAI,QAAQ,MAAM,WAAW,KAAK,QAAQ,YAAY,EAAG;EACzD,MAAM,SAAS,QAAQ,MAAM,OAAO,EAAE;EACtC,MAAM,UAAU,QAAQ;AACxB,UAAQ,UAAU;EAClB,MAAM,QAAuB;GAC3B,cAAc,QAAQ;GACtB,sBAAsB,QAAQ;GAC9B;GACA;GACD;EACD,MAAM,kBAAkB;AAEtB,OAAI,YAAY,QAAS,SAAQ,WAAW,OAAO,SAAS;;AAE9D,MAAI;AACF,WAAQ,QAAQ,KAAK,OAAO,YAAY,CAAC,CAAC,MAAM,UAAU;UACpD;AACN,cAAW;;;CAIf,MAAM,UAAU,MAAc,UAAmC;EAC/D,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,MAAI,SAAS,WAAW,SAAS,UAAW,SAAQ,eAAe;AACnE,MAAI,YAAY,QAAS;AACzB,MAAI;AACF,UAAO;GACP,MAAM,QAAuB;IAC3B,MAAM;IACN;IACA,OAAO;KAAE,GAAG;KAAO;KAAK;IACxB,WAAW,KAAK,KAAK;IACtB;GACD,MAAM,OAAO,eAAe,SAAY,QAAQ,WAAW,MAAM;AACjE,OAAI,SAAS,QAAQ,YAAY,QAAS;AAC1C,WAAQ,MAAM,KAAK,KAAK;AACxB,OAAI,QAAQ,MAAM,UAAU,WAAY,QAAO;UACzC;;CAKV,MAAM,SAAS,EAAE,cAAc,2BAAyC;AACtE,MAAI,YAAY,MAAM;AACpB,OAAI,QAAQ,iBAAiB,aAC3B,SAAQ,KACN,sCAAsC,QAAQ,aAAa,yCAAyC,aAAa,IAClH;AAEH;;AAEF,MACE,OAAO,WAAW,eAClB,OAAO,OAAO,qBAAqB,WAEnC;EACF,MAAM,UAAmB;GACvB;GACA;GACA,OAAO,EAAE;GACT,SAAS;GACT,YAAY,EAAE;GACd,OAAO;GACR;AACD,YAAU;EACV,MAAM,iBAAgC,MAAM,UAAU;AACpD,OAAI,YAAY,QAAS,QAAO,MAAM,MAAM;;EAG9C,MAAM,SAAS,gBAAgB;AAC/B,UAAQ,WAAW,KAAK,OAAO,OAAO,CAAC;EACvC,MAAM,aAAa,CACjB,GAAI,QAAQ,cAAc,EAAE,EAC5B,GAAI,KAAK,QAAQ,SAAY,EAAE,GAAG,CAAC,KAAK,IAAI,CAC7C;AACD,MAAI,QAAQ,OACV,SAAQ,WAAW,KACjB,oBAAoB;GAAE,MAAM;GAAe;GAAU;GAAQ;GAAQ,CAAC,CACvE;AACH,MAAI,QAAQ,WACV,SAAQ,WAAW,KACjB,yBAAyB;GAAE,MAAM;GAAe;GAAQ;GAAQ,CAAC,CAClE;AACH,MAAI,QAAQ,QACV,SAAQ,WAAW,KACjB,sBAAsB;GACpB,MAAM;GACN;GACA;GACA;GACD,CAAC,CACH;AACH,MAAI,QAAQ,QAAQ;GAClB,MAAM,SAAS,oBAAoB;IAAE,MAAM;IAAe;IAAQ,CAAC;AACnE,WAAQ,cAAc,OAAO;AAC7B,WAAQ,WAAW,KAAK,OAAO;;EAEjC,MAAM,mBAAmB,MAAM,EAAE,QAAQ,MAAM,CAAC;AAChD,SAAO,iBAAiB,YAAY,WAAW;AAC/C,UAAQ,WAAW,WACjB,OAAO,oBAAoB,YAAY,WAAW,CACnD;AACD,UAAQ,QAAQ,kBAAkB,OAAO,EAAE,kBAAkB;AAC7D,SAAO,QAAQ;GACb,OAAO,UAAU;GACjB,KAAK,OAAO,IAAI,SAAS,KAAK;GAC9B,OAAO,SAAS;GAChB,UAAU,OAAO,IAAI,SAAS,SAAS;GACxC,CAAC;;CAGJ,MAAM,aAAa;EACjB,MAAM,UAAU;AAChB,MAAI,YAAY,KAAM;AACtB,UAAQ,eAAe;AACvB,MAAI,YAAY,QAAS;AACzB,gBAAc,QAAQ,MAAM;AAC5B,OAAK,MAAM,aAAa,QAAQ,WAAY,YAAW;AACvD,SAAO;AACP,YAAU;;CAGZ,MAAM,QAAQ,MAAc,UAAmC;AAC7D,MAAI,SAAS,IAAI,KAAK,EAAE;AACtB,WAAQ,KACN,2BAA2B,KAAK,yEACjC;AACD;;AAEF,SAAO,MAAM,MAAM;;CAGrB,MAAM,SAAS,MAAwB,UACrC,OAAO,MAAM,MAAM;AAWrB,QAT6B;EAC3B;EACA;EACA;EACA;EACA,IAAI,eAAe;AACjB,UAAO,SAAS,gBAAgB;;EAEnC;;;;;;;;;;;;;;AC/LH,SAAgB,SACd,KACA,OAA6C,EAAE,EAC/C;CACA,MAAM,OAAO,OAAO,OAAsB,UAA2B,EAAE,KAAK;EAC1E,MAAM,OAAO,KAAK,UAAU,MAAM;AAGlC,MADE,QAAQ,WAAW,QAAQ,OAAO,UAAU,eAAe,YAC9C;AAKb,OAAI,CAJW,UAAU,WACvB,KACA,IAAI,KAAK,CAAC,KAAK,EAAE,EAAE,MAAM,oBAAoB,CAAC,CAC/C,CACY,OAAM,IAAI,MAAM,+BAA+B;AAC5D;;EAEF,MAAM,WAAW,MAAM,MAAM,KAAK;GAChC,QAAQ;GACR,WAAW;GACX,SAAS;IAAE,gBAAgB;IAAoB,GAAG,KAAK;IAAS;GAChE;GACD,CAAC;AACF,MAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,uBAAuB,SAAS,SAAS;;AAG7E,QAD2B,OAAO,OAAO,MAAM,EAAE,KAAK,CAAC;;;;;ACrBzD,MAAa,6BAA6B,KAAK;AAC/C,MAAM,iBAAiB,IAAI,IAAY,qBAAqB;AAE5D,SAAS,OACP,OACA,4BAAY,IAAI,KAAa,EACT;AACpB,KAAI,OAAO,UAAU,SAAU,QAAO,CAAC,MAAM,SAAS,KAAK;AAC3D,KAAI,UAAU,QAAQ,OAAO,UAAU,UAAW,QAAO;AACzD,KAAI,OAAO,UAAU,SAAU,QAAO,OAAO,SAAS,MAAM;AAC5D,KAAI,OAAO,UAAU,YAAY,UAAU,IAAI,MAAM,CAAE,QAAO;AAC9D,KACE,CAAC,MAAM,QAAQ,MAAM,IACrB,OAAO,eAAe,MAAM,KAAK,OAAO,aACxC,OAAO,eAAe,MAAM,KAAK,KAEjC,QAAO;AACT,WAAU,IAAI,MAAM;AACpB,KACE,CAAC,MAAM,QAAQ,MAAM,IACrB,OAAO,KAAK,MAAM,CAAC,MAAM,QAAQ,IAAI,SAAS,KAAK,CAAC,CAEpD,QAAO;CACT,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,QAAQ,OAAO,OAAO,MAAM;AAClE,MAAK,MAAM,QAAQ,OACjB,KAAI,CAAC,OAAO,MAAM,UAAU,CAAE,QAAO;AAEvC,WAAU,OAAO,MAAM;AACvB,QAAO;;;;;;AAOT,SAAgB,0BACd,SAGqB;CACrB,IAAI,SAAS;CACb,IAAI,aAAa;CACjB,IAAI,aAA6B,EAAE;CACnC,IAAI;CAEJ,MAAM,UAAU,MAAc,YAAoB;AAChD,MAAI;AACF,WAAQ,UAAU;IAAE;IAAM;IAAS,CAAC;UAC9B;;CAKV,MAAM,iBAAiB,QAAQ,SAAS,SAAS;CAEjD,MAAM,UAAU,MAAc,UAAqB;AACjD,MAAI,CAAC,OAAQ;EACb,MAAM,UAAU;AAChB,MAAI,SAAS,WAAW,SAAS,UAAW,gBAAe;AAC3D,MAAI,CAAC,UAAU,eAAe,QAAS;EACvC,IAAI;AACJ,MAAI;GACF,MAAM,QAAyB;IAC7B,MAAM;IACN;IACA,WAAW,KAAK,KAAK;IACrB;IACD;GACD,MAAM,OACJ,QAAQ,eAAe,SAAY,QAAQ,QAAQ,WAAW,MAAM;AACtE,OAAI,SAAS,QAAQ,CAAC,UAAU,eAAe,QAAS;AACxD,OACE,KAAK,SAAS,YACd,OAAO,KAAK,SAAS,YACrB,CAAC,KAAK,KAAK,MAAM,IACjB,KAAK,KAAK,SAAS,OACnB,KAAK,KAAK,SAAS,KAAK,IACxB,CAAC,OAAO,SAAS,KAAK,UAAU,IAChC,KAAK,aAAa,KAClB,KAAK,aAAa,kBAClB,CAAC,OAAO,KAAK,MAAM,EACnB;AACA,WACE,iBACA,4DACD;AACD;;GAGF,MAAM,aAAa,KAAK,UAAU;IAChC,MAAM,KAAK;IACX,MAAM,KAAK;IACX,WAAW,KAAK;IAChB,OAAO,KAAK;IACb,CAAC;AACF,OACE,IAAI,aAAa,CAAC,OAAO,WAAW,CAAC,aACrC,4BACA;AACA,WAAO,mBAAmB,uCAAuC;AACjE;;AAEF,aAAU,KAAK,MAAM,WAAW;UAC1B;AACN,UAAO,iBAAiB,6CAA6C;AACrE;;AAEF,MAAI;AACF,WAAQ,KAAK,QAAQ;UACf;AACN,UACE,uBACA,wDACD;;;CAIL,MAAM,WAAW,MAAc,UAAmC;AAEhE,SAAO,MAAM,MAAmB;;CAGlC,MAAM,aAAa;EACjB,MAAM,UAAU;EAChB,MAAM,QAAQ;AACd,gBAAc;AACd,WAAS;AACT,MAAI,eAAe,QAAS;AAC5B;AACA,WAAS;EACT,MAAM,YAAY;AAClB,eAAa,EAAE;AACf,OAAK,MAAM,aAAa,UAAW,YAAW;;AAGhD,QAAO;EACL,QAAQ;AACN,OAAI,UAAU,OAAO,WAAW,YAAa;AAC7C,YAAS;AACT;AAEA,OAAI,OAAO,OAAO,qBAAqB,WAAY;GAEnD,MAAM,SAAS,gBAAgB;AAC/B,OAAI;AACF,eAAW,KAAK,OAAO,OAAO,CAAC;AAC/B,QAAI,QAAQ,SAAS,WAAW,MAC9B,YAAW,KACT,oBAAoB;KAAE,MAAM;KAAS;KAAU;KAAQ,CAAC,CACzD;AAEH,QAAI,QAAQ,SAAS,eAAe,MAClC,YAAW,KAAK,yBAAyB;KAAE,MAAM;KAAS;KAAQ,CAAC,CAAC;AAEtE,QAAI,QAAQ,SAAS,WAAW,OAAO;KACrC,MAAM,SAAS,oBAAoB;MAAE,MAAM;MAAS;MAAQ,CAAC;AAC7D,mBAAc,OAAO;AACrB,gBAAW,KAAK,OAAO;;AAEzB,QAAI,QAAQ,SAAS,YAAY,MAC/B,YAAW,KACT,sBAAsB;KACpB,MAAM;KACN,YAAY,QAAQ,cAAc,EAAE;KACpC;KACD,CAAC,CACH;AAEH,WAAO,QAAQ;KACb,OAAO,UAAU;KACjB,KAAK,OAAO,IAAI,SAAS,KAAK;KAC9B,OAAO,SAAS;KAChB,UAAU,OAAO,IAAI,SAAS,SAAS;KACxC,CAAC;WACI;AACN,UAAM;AACN,WAAO,kBAAkB,mCAAmC;;;EAGhE;EACA,KAAK,MAAM,OAAO;AAChB,OAAI,CAAC,OAAQ;AACb,OAAI,eAAe,IAAI,KAAK,EAAE;AAC5B,WACE,iBACA,2DACD;AACD;;AAEF,UAAO,MAAM,MAAM;;EAEtB"} +\ No newline at end of file +diff --git a/src/clicks.ts b/src/clicks.ts +index b1e2992e3493df22f8eff7d32afa98438481d3ef..e0918a51ce53727cb1e127fea72f06585b3f59fb 100644 +--- a/src/clicks.ts ++++ b/src/clicks.ts +@@ -66,6 +66,298 @@ export function describeTarget(el: Element, redact: Redactor) { + return described; + } + ++const MAX_LABEL_LENGTH = 80; ++// The text walk stops past these bounds. For a control's own text, that means no name. ++const MAX_WALK_NODES = 1000; ++const MAX_WALK_CHARS = 4 * MAX_LABEL_LENGTH; ++// Controls whose name comes from their content. An explicit role overrides the ++// tag, so `<li role="option">` qualifies and `<a href role="row">` does not. ++const CONTROL_ROLE = ++ /^(button|link|tab|menuitem(checkbox|radio)?|option|checkbox|switch|radio|treeitem)$/; ++const CONTENTEDITABLE = '[contenteditable]:not([contenteditable="false"])'; ++const TEXT_ENTRY = `${CONTENTEDITABLE}, [role~=textbox], [role~=searchbox], [role~=combobox]`; ++const EDITABLE = `input, textarea, select, ${TEXT_ENTRY}`; ++// Card-like structure: a control containing it gets no name from its text, ++// except for one lone paragraph, or headings inside `summary`. ++const HEADING = "h1, h2, h3, h4, h5, h6, [role~=heading]"; ++const STRUCTURE = `${HEADING}, p, ul, ol, li, dl, table, article, section, blockquote`; ++const SKIPPED = 'script, style, template, [hidden], [aria-hidden="true"]'; ++// Inline elements that do not separate words. ++const PHRASING = ++ /^(b|i|u|em|strong|mark|small|sub|sup|abbr|code|kbd|s|q|cite|dfn|var|time|data|bdi|bdo|wbr|span)$/; ++const CAPTION_INPUT = /^(submit|button|reset|image)$/; ++// Input types that do not hold typed text. ++const NON_TEXT_INPUT = ++ /^(submit|button|reset|image|checkbox|radio|range|color|file|hidden)$/; ++// Includes bidi controls, zero-width characters, soft hyphens, word joiners, ++// variation selectors, and tag characters. ++const IGNORABLE = /\p{Default_Ignorable_Code_Point}/gu; ++// Quantifier bounds and run-start anchors keep each pattern linear. No ++// lookbehind: a regex literal with one fails to parse in older Safari. ++// A URL ends before trailing sentence punctuation. ++const URL_LIKE = ++ /(?:\b[a-z][\w+.-]{0,31}:\/\/|\bwww\.)[^\s<>"']*[^\s<>"'.,;:!?)]/gi; ++const EMAIL = ++ /[\p{L}\p{N}_.%+-]{1,64}@[\p{L}\p{N}-]{1,63}(?:\.[\p{L}\p{N}-]{1,63})+/gu; ++// JWTs, and three dotted segments that are each long or contain a digit, but ++// not dotted names such as `settings.notifications.preferences`. ++const JWT = ++ /(^|[^\w-])(?:eyJ[\w-]*\.[\w-]*\.[\w-]*|[\w-]{16,}\.[\w-]{16,}\.[\w-]{16,}|(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,}\.(?=[\w-]*\d)[\w-]{8,})/g; ++const TOKEN_RUN = /[\w+/=-]{24,}/g; ++ ++const roleOf = (element: Element) => ++ (element.getAttribute("role") ?? "").trim().split(/\s+/)[0]!.toLowerCase(); ++ ++const idRefs = (element: Element, name: string) => ++ (element.getAttribute(name) ?? "").split(/\s+/).filter(Boolean); ++ ++const rootOf = (element: Element) => { ++ const root = element.getRootNode(); ++ return root instanceof Document || root instanceof ShadowRoot ? root : null; ++}; ++ ++/** Buttons, links with `href`, `summary`, and elements with a control role. */ ++const isControl = (element: Element) => { ++ const role = roleOf(element); ++ return role ++ ? CONTROL_ROLE.test(role) ++ : element.matches("button, a[href], summary"); ++}; ++ ++/** Text-like inputs, text areas, selects, and anything inside editable content or a text-entry role. */ ++const holdsValue = (element: Element) => ++ element instanceof HTMLInputElement ++ ? !NON_TEXT_INPUT.test(element.type) ++ : element.matches("textarea, select") || ++ element.closest(TEXT_ENTRY) !== null; ++ ++/** ++ * The name an image contributes: `alt` of an `img`, or `aria-label` (else a ++ * plain `<title>`) of an `svg` or `role="img"` element. `undefined` when the ++ * element is not an image. ++ */ ++function imageName(element: Element) { ++ if (element.localName === "img") return element.getAttribute("alt"); ++ if (element.localName !== "svg" && roleOf(element) !== "img") ++ return undefined; ++ const title = Array.from(element.children).find( ++ (child) => child.localName === "title" && child.childElementCount === 0, ++ ); ++ return element.getAttribute("aria-label") ?? title?.textContent ?? null; ++} ++ ++/** ++ * Rendered text. Inline formatting joins words; other elements separate them. ++ * Skips hidden subtrees, uses image names, and does ++ * not walk into images. Yields nothing for editable content or editable ++ * descendants. In `strict` mode (a control's own text) it also yields nothing ++ * past the walk bounds, for card-like structure, and for text that looks like ++ * separate fields: two `div`s that directly hold text, or three sibling ++ * elements that each carry text. ++ */ ++function safeText(root: Element, strict: boolean) { ++ if (holdsValue(root)) return ""; ++ let text = ""; ++ let nodes = 0; ++ let paragraphs = 0; ++ // The first div that holds text; a second one means separate fields. ++ let div: Element | undefined; ++ const bearers = new Map<Node, Set<Node>>(); ++ const add = (part: string) => (text = (text + part).replace(/\s+/g, " ")); ++ // Marks `start` and its ancestors below root as carrying text. False once ++ // three siblings do. ++ const bear = (start: Node) => { ++ if (!strict) return true; ++ for ( ++ let child = start, parent = start.parentNode; ++ child !== root && parent !== null; ++ child = parent, parent = parent.parentNode ++ ) { ++ const siblings = bearers.get(parent) ?? new Set<Node>(); ++ bearers.set(parent, siblings); ++ if (siblings.has(child)) break; ++ if (siblings.add(child).size > 2) return false; ++ } ++ return true; ++ }; ++ let node: Node | null = root.firstChild; ++ while (node !== null) { ++ if (++nodes > MAX_WALK_NODES || text.length > MAX_WALK_CHARS) { ++ if (strict) return ""; ++ break; ++ } ++ let descend = false; ++ if (node instanceof Text) { ++ const parent = node.parentNode as Element; ++ // The block that holds this text, through inline elements. ++ let holder = parent; ++ while (holder !== root && PHRASING.test(holder.localName)) ++ holder = holder.parentElement!; ++ if ( ++ /\S/.test(node.data) && ++ (!bear(parent) || ++ (strict && holder.localName === "div" && (div ??= holder) !== holder)) ++ ) ++ return ""; ++ add(node.data); ++ } else if (node instanceof Element) { ++ if (node.matches(EDITABLE)) return ""; ++ if ( ++ strict && ++ node.matches(STRUCTURE) && ++ !(root.localName === "summary" && node.matches(HEADING)) && ++ (node.localName !== "p" || ++paragraphs > 1) ++ ) ++ return ""; ++ const name = node.matches(SKIPPED) ? null : imageName(node); ++ if (name) { ++ if (!bear(node)) return ""; ++ add(` ${name} `); ++ } else if (name === undefined) { ++ if (!PHRASING.test(node.localName)) add(" "); ++ descend = true; ++ } ++ } ++ if (descend && node.firstChild !== null) node = node.firstChild; ++ else { ++ while (node !== root && node.nextSibling === null) { ++ node = node.parentNode!; ++ if (!PHRASING.test((node as Element).localName)) add(" "); ++ } ++ node = node === root ? null : node.nextSibling; ++ } ++ } ++ return text; ++} ++ ++/** An option or tree item of an autocomplete or search popup, whose text usually repeats what the user typed. */ ++const isSuggestion = (element: Element) => ++ (element.localName === "option" || ++ /^(option|treeitem)$/.test(roleOf(element))) && ++ inSuggestionList(element); ++ ++/** Whether `element` is, or is inside, a combobox, a datalist, or the popup a combobox or autocomplete field controls or owns. */ ++function inSuggestionList(element: Element) { ++ if (element.closest("[role~=combobox], datalist") !== null) return true; ++ const root = rootOf(element); ++ return ( ++ root !== null && ++ Array.from( ++ root.querySelectorAll("[role~=combobox], [aria-autocomplete]"), ++ ).some((owner) => ++ [...idRefs(owner, "aria-controls"), ...idRefs(owner, "aria-owns")].some( ++ (id) => root.getElementById(id)?.contains(element), ++ ), ++ ) ++ ); ++} ++ ++function labelledBy(element: Element) { ++ const root = rootOf(element); ++ return root === null ++ ? null ++ : idRefs(element, "aria-labelledby") ++ .map((id) => { ++ const referenced = root.getElementById(id); ++ return referenced === null || inSuggestionList(referenced) ++ ? "" ++ : safeText(referenced, false); ++ }) ++ .join(" "); ++} ++ ++/** A control's own text, unless it is longer than a label: then it is content, not a name. */ ++const normalize = (text: string) => ++ text.replace(IGNORABLE, "").replace(/\s+/g, " ").trim(); ++ ++function contentText(element: Element) { ++ const text = safeText(element, true); ++ return Array.from(normalize(text)).length > MAX_LABEL_LENGTH ? "" : text; ++} ++ ++/** Removes invisible characters, credentials, emails, URLs, and tokens, then caps the length. */ ++function cleanLabel(raw: string, redact: Redactor) { ++ const chars = Array.from( ++ // Scrub again after collapsing, also for remembered values that differ ++ // in whitespace or invisible characters. ++ redact ++ .scrub( ++ normalize(redact.body(raw.replace(IGNORABLE, "")) ?? ""), ++ normalize, ++ ) ++ .replace(URL_LIKE, "[url]") ++ .replace(EMAIL, "[email]") ++ .replace(JWT, `$1${REDACTED}`) ++ .replace(TOKEN_RUN, (run) => (/\d/.test(run) ? REDACTED : run)), ++ ); ++ return chars.length > MAX_LABEL_LENGTH ++ ? `${chars ++ .slice(0, MAX_LABEL_LENGTH - 1) ++ .join("") ++ .trimEnd()}…` ++ : chars.join(""); ++} ++ ++function firstLabel(sources: (() => string | null)[], redact: Redactor) { ++ for (const source of sources) { ++ const label = cleanLabel(source() ?? "", redact); ++ if (label) return label; ++ } ++ return undefined; ++} ++ ++/** ++ * A short name for the clicked control, from the first source that yields one: ++ * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title` (controls ++ * only), then the text of a control named by its content. Containers, cards, ++ * and editable content never contribute their text. An autocomplete suggestion, ++ * or anything inside one, gets no label from any source; `data-copilotkit-action` ++ * names it instead. An icon without its own name takes the name of the control ++ * around it. ++ */ ++function clickLabel(element: Element, redact: Redactor): string | undefined { ++ const option = element.closest("option, [role~=option], [role~=treeitem]"); ++ if (option !== null && isSuggestion(option)) return undefined; ++ // aria-labelledby and aria-label apply to any other target. ++ const named = [ ++ () => labelledBy(element), ++ () => element.getAttribute("aria-label"), ++ ]; ++ if ( ++ /^(img|svg)$/.test(element.localName) || ++ /^(img|presentation|none)$/.test(roleOf(element)) ++ ) { ++ const own = firstLabel( ++ [...named, () => imageName(element) ?? null], ++ redact, ++ ); ++ if (own !== undefined) return own; ++ // parentElement is null at a shadow root, so the search stays inside it. ++ let control = element.parentElement; ++ while (control !== null && !isControl(control)) ++ control = control.parentElement; ++ return control === null ? undefined : clickLabel(control, redact); ++ } ++ const control = isControl(element) && !holdsValue(element); ++ const captionInput = ++ element instanceof HTMLInputElement && CAPTION_INPUT.test(element.type); ++ return firstLabel( ++ [ ++ ...named, ++ // The caption of a button-like input, or the `alt` of an image input. ++ () => ++ captionInput ++ ? element.type === "image" ++ ? element.getAttribute("alt") ++ : element.value ++ : null, ++ () => (control || captionInput ? element.getAttribute("title") : null), ++ () => (control ? contentText(element) : null), ++ ], ++ redact, ++ ); ++} ++ + /** Snapshot live control properties; HTML attributes do not reflect edited values. */ + export function readControlValue(element: Element, redact: Redactor) { + if (element instanceof HTMLInputElement) { +@@ -96,7 +388,7 @@ export function readControlValue(element: Element, redact: Redactor) { + }; + } + if (element instanceof HTMLTextAreaElement) return { value: element.value }; +- if (element.matches('[contenteditable]:not([contenteditable="false"])')) { ++ if (element.matches(CONTENTEDITABLE)) { + return { value: element.textContent ?? "" }; + } + return {}; +@@ -140,12 +432,25 @@ export function installClickCapture(params: { + } = params; + let origin: PointerOrigin | null = null; + ++ // Input events share describeTarget; only clicks carry a label. ++ const describeClick = (el: Element) => { ++ const described = describeTarget(el, redact); ++ if (described === null) return null; ++ try { ++ const label = clickLabel(described.element, redact); ++ if (label !== undefined) described.target.label = label; ++ } catch { ++ // A failed label must not drop the click. ++ } ++ return described; ++ }; ++ + const onPointerDown = (event: PointerEvent) => { + try { + if (event.isPrimary === false) return; + const el = eventElement(event); + origin = { +- described: el === null ? null : describeTarget(el, redact), ++ described: el === null ? null : describeClick(el), + x: event.clientX, + y: event.clientY, + }; +@@ -167,7 +472,7 @@ export function installClickCapture(params: { + isNear(pointerOrigin, event); + const described = canRecover + ? pointerOrigin.described +- : describeTarget(el, redact); ++ : describeClick(el); + if (described === null) return; + const input = event.detail === 0 ? "keyboard" : "pointer"; + emit("click", { +diff --git a/src/collector.ts b/src/collector.ts +index 107c55dbdd8e32efae5ca4578d179efaf5c2a850..cfd22a29d24b05ff54fd11faeb916c8c6ea81696 100644 +--- a/src/collector.ts ++++ b/src/collector.ts +@@ -27,6 +27,7 @@ interface Session { + dropped: number; + uninstalls: (() => void)[]; + timer: ReturnType<typeof setInterval> | undefined; ++ flushInputs?: () => void; + } + + /** +@@ -79,6 +80,8 @@ export function createCollector(options: CollectorOptions) { + const record = (name: string, value: Record<string, unknown>) => { + const current = session; + if (current === null) return; ++ if (name !== "input" && name !== "network") current.flushInputs?.(); ++ if (session !== current) return; + try { + seq += 1; + const event: LearningEvent = { +@@ -88,7 +91,7 @@ export function createCollector(options: CollectorOptions) { + timestamp: Date.now(), + }; + const kept = beforeSend === undefined ? event : beforeSend(event); +- if (kept === null) return; ++ if (kept === null || session !== current) return; + current.queue.push(kept); + if (current.queue.length >= FLUSH_SIZE) flush(); + } catch { +@@ -146,10 +149,11 @@ export function createCollector(options: CollectorOptions) { + redact, + }), + ); +- if (capture.inputs) +- current.uninstalls.push( +- installInputCapture({ emit: recordCurrent, redact }), +- ); ++ if (capture.inputs) { ++ const inputs = installInputCapture({ emit: recordCurrent, redact }); ++ current.flushInputs = inputs.flush; ++ current.uninstalls.push(inputs); ++ } + const onPageHide = () => flush({ beacon: true }); + window.addEventListener("pagehide", onPageHide); + current.uninstalls.push(() => +@@ -167,6 +171,8 @@ export function createCollector(options: CollectorOptions) { + const stop = () => { + const current = session; + if (current === null) return; ++ current.flushInputs?.(); ++ if (session !== current) return; + clearInterval(current.timer); + for (const uninstall of current.uninstalls) uninstall(); + flush(); +diff --git a/src/inputs.ts b/src/inputs.ts +index 1b9ec893b40ffe3bf76f1fb0ddc65a819e62963e..0229091e96ac929320cd2f34ef37d6918cfad60b 100644 +--- a/src/inputs.ts ++++ b/src/inputs.ts +@@ -1,9 +1,32 @@ + import { describeTarget, readControlValue } from "./clicks"; + import { createRedactor } from "./redact"; + import type { Redactor } from "./redact"; +-import type { Emit } from "./types"; ++import { REDACTED } from "./types"; ++import type { ClickTarget, Emit } from "./types"; + +-/** Records user edits without changing the control or interfering with its listeners. */ ++const TEXT_DEBOUNCE_MS = 300; ++const TEXT_INPUT_TYPES = new Set([ ++ "text", ++ "search", ++ "tel", ++ "url", ++ "email", ++ "password", ++ "number", ++]); ++type ControlValue = ReturnType<typeof readControlValue>; ++interface Edit { ++ state: ControlValue; ++ payload: { ++ eventType: string; ++ target: Omit<ClickTarget, "input">; ++ url: string; ++ route: string; ++ }; ++ timer?: ReturnType<typeof setTimeout>; ++} ++ ++/** Records settled user edits without interfering with the host app's controls. */ + export function installInputCapture(params: { + emit: Emit; + isTrusted?: (event: Event) => boolean; +@@ -14,29 +37,154 @@ export function installInputCapture(params: { + isTrusted = (event: Event) => event.isTrusted, + redact = createRedactor(), + } = params; +- const onInput = (event: Event) => { ++ let active = true; ++ const pending = new Map<Element, Edit>(); ++ // A native change can echo a settled input on blur. Consume that one echo; ++ // every fresh trusted input is a new edit, even if its redacted value matches. ++ const changeEcho = new WeakMap<Element, string>(); ++ const composing = new WeakMap<Element, boolean>(); ++ ++ const cancel = (element: Element) => { ++ clearTimeout(pending.get(element)?.timer); ++ pending.delete(element); ++ }; ++ const deliver = (element: Element, edit: Edit) => { + try { +- if (!isTrusted(event)) return; +- const [element] = event.composedPath(); +- if (!(element instanceof Element)) return; +- const described = describeTarget(element, redact); +- if (described === null) return; +- const value = readControlValue(element, redact); +- if (!("value" in value)) return; +- emit("input", { +- eventType: event.type, +- target: { ...described.target, ...value }, ++ // Recheck privacy, but retain the observed value and route even if a host ++ // handler reset or removed the control before this trailing delivery. ++ if (!active || describeTarget(element, redact) === null) return; ++ // A host can reset the live value before making this field sensitive. ++ // Drop the old snapshot: mirrored text/attributes may contain its value. ++ if (redact.isPassword(element) && edit.state.value !== REDACTED) return; ++ if (edit.payload.eventType === "input") { ++ changeEcho.set(element, JSON.stringify(edit.state)); ++ } ++ edit.payload.url = redact.url(edit.payload.url); ++ edit.payload.target.text = redact.url(edit.payload.target.text); ++ edit.payload.target.attributes = Object.fromEntries( ++ Object.entries(edit.payload.target.attributes).map(([key, value]) => [ ++ key, ++ redact.url(value), ++ ]), ++ ); ++ emit("input", edit.payload); ++ } catch { ++ // Capture must never break an edit or a later action in the host app. ++ } ++ }; ++ const flush = () => { ++ // Clear before callbacks: an emit can synchronously stop or restart capture. ++ const edits = [...pending]; ++ for (const [element] of edits) cancel(element); ++ for (const [element, edit] of edits) deliver(element, edit); ++ }; ++ const observe = (element: Element, eventType: string, settle: boolean) => { ++ if (pending.size > 0 && !pending.has(element)) flush(); ++ if (!active) return; ++ const described = describeTarget(element, redact); ++ if (described === null) { ++ cancel(element); ++ return; ++ } ++ const state = readControlValue(element, redact); ++ if (!("value" in state)) return; ++ const text = ++ element instanceof HTMLTextAreaElement || ++ (element instanceof HTMLInputElement && ++ TEXT_INPUT_TYPES.has(element.type)) || ++ element.matches('[contenteditable]:not([contenteditable="false"])'); ++ if (!text) flush(); ++ const signature = JSON.stringify(state); ++ const echo = ++ eventType === "change" && ++ (signature === JSON.stringify(pending.get(element)?.state) || ++ signature === changeEcho.get(element)); ++ changeEcho.delete(element); ++ if (echo) { ++ flush(); ++ changeEcho.delete(element); ++ return; ++ } ++ cancel(element); ++ const edit: Edit = { ++ state, ++ payload: { ++ eventType, ++ target: { ...described.target, ...state }, + url: redact.url(location.href), + route: location.pathname, +- }); ++ }, ++ }; ++ if (!text) { ++ deliver(element, edit); ++ return; ++ } ++ if (settle) { ++ edit.timer = setTimeout(() => { ++ cancel(element); ++ deliver(element, edit); ++ }, TEXT_DEBOUNCE_MS); ++ } ++ pending.set(element, edit); ++ }; ++ const onEdit = (event: Event) => { ++ try { ++ if (!active || !isTrusted(event)) return; ++ const [element] = event.composedPath(); ++ if (!(element instanceof Element)) return; ++ if (event.type === "compositionstart") { ++ composing.set(element, true); ++ clearTimeout(pending.get(element)?.timer); ++ return; ++ } ++ if (event.type === "compositionend") composing.set(element, false); ++ // Keep a draft during composition for explicit action/stop flushes. An ++ // end event takes precedence over a late input's stale isComposing flag. ++ const inComposition = ++ composing.get(element) === true || ++ (composing.get(element) !== false && ++ "isComposing" in event && ++ event.isComposing === true); ++ observe( ++ element, ++ event.type === "compositionend" ? "input" : event.type, ++ !inComposition, ++ ); ++ if (event.type === "change") flush(); + } catch { + // Capture must never break an edit in the host app. + } + }; +- window.addEventListener("input", onInput, { capture: true, passive: true }); +- window.addEventListener("change", onInput, { capture: true, passive: true }); +- return () => { +- window.removeEventListener("input", onInput, { capture: true }); +- window.removeEventListener("change", onInput, { capture: true }); ++ const onAction = (event: Event) => { ++ if (event.type !== "pagehide" && !isTrusted(event)) return; ++ if (event.type === "keydown") { ++ const [element] = event.composedPath(); ++ // Window capture runs before no-form send handlers can reset the control. ++ // IME completion can precede its final keydown, which still reports 229. ++ if ( ++ !(event instanceof KeyboardEvent) || ++ event.key !== "Enter" || ++ event.isComposing || ++ event.keyCode === 229 || ++ (element instanceof Element && composing.get(element) === true) ++ ) ++ return; ++ } ++ flush(); ++ }; ++ const editTypes = ["input", "change", "compositionstart", "compositionend"]; ++ const actionTypes = ["click", "submit", "focusout", "pagehide", "keydown"]; ++ for (const type of editTypes) ++ window.addEventListener(type, onEdit, { capture: true, passive: true }); ++ for (const type of actionTypes) ++ window.addEventListener(type, onAction, { capture: true, passive: true }); ++ const uninstall = () => { ++ active = false; ++ for (const element of pending.keys()) cancel(element); ++ for (const type of editTypes) ++ window.removeEventListener(type, onEdit, true); ++ for (const type of actionTypes) ++ window.removeEventListener(type, onAction, true); + }; ++ return Object.assign(uninstall, { flush }); + } +diff --git a/src/redact.ts b/src/redact.ts +index df240f6a1dc909bda4fcbdc38123dac8bdb9c076..e96d95e98d499ea98b779fb77af0b68e8476744e 100644 +--- a/src/redact.ts ++++ b/src/redact.ts +@@ -148,8 +148,11 @@ export function createRedactor() { + seen.add(record.target as Element); + }; + +- /** Replaces remembered values, raw and in their JSON and URL encodings. */ +- const scrub = (text: string) => { ++ /** ++ * Replaces remembered values, raw and in their JSON and URL encodings, plus ++ * `normalize(value)` when given and at least 4 characters long. ++ */ ++ const scrub = (text: string, normalize?: (value: string) => string) => { + for (const value of [...remembered.values()].sort( + (a, b) => b.length - a.length, + )) +@@ -158,6 +161,7 @@ export function createRedactor() { + JSON.stringify(value).slice(1, -1), + encodeURIComponent(value), + new URLSearchParams({ v: value }).toString().slice(2), ++ ...[normalize?.(value) ?? ""].filter((normal) => normal.length >= 4), + ]) + text = /^\d+$/.test(form) + ? // One-time codes must not match inside longer numbers. +@@ -191,6 +195,7 @@ export function createRedactor() { + + return { + isPassword, ++ scrub, + /** + * Tracks password fields for the session: existing ones, `type` toggles away + * from "password", and typed values even when input capture is off. +diff --git a/src/trajectory-collector.ts b/src/trajectory-collector.ts +index 877c444366bae42b57a0bafde21d6a4b04f5c26c..d9062f6c14257652f327c6c8d10ea9e149374d3d 100644 +--- a/src/trajectory-collector.ts ++++ b/src/trajectory-collector.ts +@@ -53,7 +53,9 @@ export function createTrajectoryCollector( + }, + ): TrajectoryCollector { + let active = false; ++ let generation = 0; + let uninstalls: (() => void)[] = []; ++ let flushInputs: (() => void) | undefined; + + const report = (code: string, message: string) => { + try { +@@ -67,6 +69,9 @@ export function createTrajectoryCollector( + + const record = (name: string, value: JsonValue) => { + if (!active) return; ++ const current = generation; ++ if (name !== "input" && name !== "network") flushInputs?.(); ++ if (!active || generation !== current) return; + let payload: TrajectoryEvent; + try { + const event: TrajectoryEvent = { +@@ -77,7 +82,7 @@ export function createTrajectoryCollector( + }; + const kept = + options.beforeSend === undefined ? event : options.beforeSend(event); +- if (kept === null || !active) return; ++ if (kept === null || !active || generation !== current) return; + if ( + kept.type !== "CUSTOM" || + typeof kept.name !== "string" || +@@ -130,6 +135,12 @@ export function createTrajectoryCollector( + }; + + const stop = () => { ++ const current = generation; ++ const flush = flushInputs; ++ flushInputs = undefined; ++ flush?.(); ++ if (generation !== current) return; ++ generation++; + active = false; + const installed = uninstalls; + uninstalls = []; +@@ -140,6 +151,7 @@ export function createTrajectoryCollector( + start() { + if (active || typeof window === "undefined") return; + active = true; ++ generation++; + // Native callers can emit developer events without browser observers. + if (typeof window.addEventListener !== "function") return; + // One per session: fields seen and values typed are forgotten on stop(). +@@ -155,7 +167,9 @@ export function createTrajectoryCollector( + uninstalls.push(installNavigationCapture({ emit: capture, redact })); + } + if (options.capture?.inputs !== false) { +- uninstalls.push(installInputCapture({ emit: capture, redact })); ++ const inputs = installInputCapture({ emit: capture, redact }); ++ flushInputs = inputs.flush; ++ uninstalls.push(inputs); + } + if (options.capture?.network !== false) { + uninstalls.push( +diff --git a/src/types.ts b/src/types.ts +index 8a59a2e9105c2656e204351bb343ec58ade245fb..6d07aa5c6cae3fcfdad0bc712ba3f89cfb3303a5 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -49,6 +49,12 @@ export interface ClickTarget { + role: string | null; + action: string | null; + text: string; ++ /** ++ * Short, cleaned name of the clicked control (click events only). Comes from ++ * `aria-labelledby`, `aria-label`, an input caption or `alt`, `title`, or a ++ * control's own text. Omitted when no safe name exists. ++ */ ++ label?: string; + attributes: Record<string, string>; + value?: string; + checked?: boolean; diff --git a/server/Dockerfile b/server/Dockerfile index 0dda2d096..8a1b6b941 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -12,6 +12,8 @@ WORKDIR /app # Manifests before sources, so a source change does not reinstall the world. COPY package.json bun.lock ./ +# The dependency patches package.json names in patchedDependencies: the install stops without them. +COPY patches patches # The shared tsconfig, which every package extends. Vite reads it while transforming and fails # without it, in a stack trace that names esbuild rather than a missing file. COPY tsconfig.base.json tsconfig.base.json @@ -21,6 +23,7 @@ COPY server/package.json server/package.json COPY worker/package.json worker/package.json COPY shared/package.json shared/package.json COPY toolkit/package.json toolkit/package.json +COPY extension/package.json extension/package.json RUN bun install --frozen-lockfile @@ -53,6 +56,8 @@ COPY --from=deps /app/package.json package.json COPY --from=deps /app/bun.lock bun.lock COPY --from=deps /app/server/node_modules server/node_modules COPY --from=deps /app/toolkit/node_modules toolkit/node_modules +# shared files import zod at runtime, and the isolated install resolves it from shared/node_modules +COPY --from=deps /app/shared/node_modules shared/node_modules COPY server server COPY shared shared diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index 93687ee38..12a00a4d4 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ "./src/db/schema/team-bots.ts", "./src/db/schema/shared-use.ts", "./src/db/schema/identity.ts", + "./src/db/schema/recorder.ts", ], out: "./drizzle", dbCredentials: { diff --git a/server/drizzle/0057_recorder_extension.sql b/server/drizzle/0057_recorder_extension.sql new file mode 100644 index 000000000..d4bf68cdd --- /dev/null +++ b/server/drizzle/0057_recorder_extension.sql @@ -0,0 +1,62 @@ +CREATE TABLE "learning_open_skills" ( + "user_id" text NOT NULL, + "skill_name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "learning_open_skills_user_id_skill_name_pk" PRIMARY KEY("user_id","skill_name") +); +--> statement-breakpoint +CREATE TABLE "learning_person_spaces" ( + "user_id" text PRIMARY KEY NOT NULL, + "container_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "learning_person_spaces_container_id_unique" UNIQUE("container_id") +); +--> statement-breakpoint +CREATE TABLE "learning_skill_sharing" ( + "user_id" text PRIMARY KEY NOT NULL, + "mode" text DEFAULT 'closed' NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "learning_skill_sharing_mode_check" CHECK ("learning_skill_sharing"."mode" IN ('closed', 'open', 'choose')) +); +--> statement-breakpoint +CREATE TABLE "recorder_connect_codes" ( + "code_hash" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "via" text NOT NULL, + "organization_session" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "recorder_connections" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "token_hash" text NOT NULL, + "label" text NOT NULL, + "via" text NOT NULL, + "extension_id" text NOT NULL, + "install_id" text NOT NULL, + "organization_session" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + CONSTRAINT "recorder_connections_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE "recorder_trajectory_bindings" ( + "trajectory_id" uuid PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "learning_open_skills" ADD CONSTRAINT "learning_open_skills_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "learning_person_spaces" ADD CONSTRAINT "learning_person_spaces_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "learning_skill_sharing" ADD CONSTRAINT "learning_skill_sharing_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recorder_connect_codes" ADD CONSTRAINT "recorder_connect_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recorder_connections" ADD CONSTRAINT "recorder_connections_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recorder_trajectory_bindings" ADD CONSTRAINT "recorder_trajectory_bindings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "learning_skill_sharing_open_idx" ON "learning_skill_sharing" USING btree ("user_id") WHERE "learning_skill_sharing"."mode" <> 'closed';--> statement-breakpoint +CREATE INDEX "recorder_connect_codes_user_idx" ON "recorder_connect_codes" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "recorder_connections_user_idx" ON "recorder_connections" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "recorder_connections_live_install_idx" ON "recorder_connections" USING btree ("user_id","install_id") WHERE "recorder_connections"."revoked_at" IS NULL;--> statement-breakpoint +CREATE INDEX "recorder_trajectory_bindings_user_idx" ON "recorder_trajectory_bindings" USING btree ("user_id"); \ No newline at end of file diff --git a/server/drizzle/meta/0057_snapshot.json b/server/drizzle/meta/0057_snapshot.json new file mode 100644 index 000000000..097a23ade --- /dev/null +++ b/server/drizzle/meta/0057_snapshot.json @@ -0,0 +1,10328 @@ +{ + "id": "bb895955-21ba-467f-87b0-898afef9a3e4", + "prevId": "1e95338a-aa54-4146-9f37-32111c369e75", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_group": { + "name": "upload_group", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "attachments_channel_idx": { + "name": "attachments_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_upload_group_idx": { + "name": "attachments_upload_group_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upload_group", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_staged_idx": { + "name": "attachments_staged_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"attachments\".\"attached_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_channel_id_channels_id_fk": { + "name": "attachments_channel_id_channels_id_fk", + "tableFrom": "attachments", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attachments_uploaded_by_users_id_fk": { + "name": "attachments_uploaded_by_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_initiator_time_idx": { + "name": "audit_events_initiator_time_idx", + "columns": [ + { + "expression": "initiator_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_at": { + "name": "summary_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_source_id": { + "name": "last_message_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channels_awaiting_summary_idx": { + "name": "channels_awaiting_summary_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_instructions": { + "name": "user_instructions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_instructions_user_id_users_id_fk": { + "name": "user_instructions_user_id_users_id_fk", + "tableFrom": "user_instructions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_signed_in_at": { + "name": "last_signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'schedule'" + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_sweeps": { + "name": "routine_sweeps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "swept_at": { + "name": "swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brokered_connections": { + "name": "brokered_connections", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holder": { + "name": "holder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_user_id": { + "name": "vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brokered_connections_person_idx": { + "name": "brokered_connections_person_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'person'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_deployment_idx": { + "name": "brokered_connections_deployment_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brokered_connections\".\"holder\" = 'deployment'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brokered_connections_user_idx": { + "name": "brokered_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brokered_connections_holder_check": { + "name": "brokered_connections_holder_check", + "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)" + }, + "brokered_connections_provider_check": { + "name": "brokered_connections_provider_check", + "value": "\"brokered_connections\".\"provider\" IN ('composio')" + } + }, + "isRLSEnabled": false + }, + "public.composio_connections": { + "name": "composio_connections", + "schema": "", + "columns": { + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "probe_action": { + "name": "probe_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "composio_connections_user_idx": { + "name": "composio_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "composio_connections_toolkit_user_id_pk": { + "name": "composio_connections_toolkit_user_id_pk", + "columns": [ + "toolkit", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "auth_scheme": { + "name": "auth_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_mode": { + "name": "account_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_vendor_user_id": { + "name": "shared_vendor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_account_mode_check": { + "name": "mcp_servers_account_mode_check", + "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destructive": { + "name": "destructive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_sessions": { + "name": "voice_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_message_id": { + "name": "anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_status": { + "name": "summary_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'failed'" + } + }, + "indexes": { + "voice_sessions_channel_started_idx": { + "name": "voice_sessions_channel_started_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voice_sessions_channel_id_channels_id_fk": { + "name": "voice_sessions_channel_id_channels_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_sessions_user_id_users_id_fk": { + "name": "voice_sessions_user_id_users_id_fk", + "tableFrom": "voice_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voice_sessions_duration_check": { + "name": "voice_sessions_duration_check", + "value": "\"voice_sessions\".\"duration_seconds\" >= 0" + }, + "voice_sessions_summary_check": { + "name": "voice_sessions_summary_check", + "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.learning_open_skills": { + "name": "learning_open_skills", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "skill_name": { + "name": "skill_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "learning_open_skills_user_id_users_id_fk": { + "name": "learning_open_skills_user_id_users_id_fk", + "tableFrom": "learning_open_skills", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "learning_open_skills_user_id_skill_name_pk": { + "name": "learning_open_skills_user_id_skill_name_pk", + "columns": [ + "user_id", + "skill_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_person_spaces": { + "name": "learning_person_spaces", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "learning_person_spaces_user_id_users_id_fk": { + "name": "learning_person_spaces_user_id_users_id_fk", + "tableFrom": "learning_person_spaces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "learning_person_spaces_container_id_unique": { + "name": "learning_person_spaces_container_id_unique", + "nullsNotDistinct": false, + "columns": [ + "container_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_settings": { + "name": "learning_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_skill_sharing": { + "name": "learning_skill_sharing", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'closed'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "learning_skill_sharing_open_idx": { + "name": "learning_skill_sharing_open_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"learning_skill_sharing\".\"mode\" <> 'closed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "learning_skill_sharing_user_id_users_id_fk": { + "name": "learning_skill_sharing_user_id_users_id_fk", + "tableFrom": "learning_skill_sharing", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "learning_skill_sharing_mode_check": { + "name": "learning_skill_sharing_mode_check", + "value": "\"learning_skill_sharing\".\"mode\" IN ('closed', 'open', 'choose')" + } + }, + "isRLSEnabled": false + }, + "public.learning_thread_bindings": { + "name": "learning_thread_bindings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "learning_thread_bindings_user_id_thread_id_pk": { + "name": "learning_thread_bindings_user_id_thread_id_pk", + "columns": [ + "user_id", + "thread_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibilities": { + "name": "responsibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success_criteria": { + "name": "success_criteria", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "progress": { + "name": "progress", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptions": { + "name": "subscriptions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibilities_owner_status_idx": { + "name": "responsibilities_owner_status_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibilities_owner_user_id_users_id_fk": { + "name": "responsibilities_owner_user_id_users_id_fk", + "tableFrom": "responsibilities", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibilities_agent_id_agents_id_fk": { + "name": "responsibilities_agent_id_agents_id_fk", + "tableFrom": "responsibilities", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_events": { + "name": "responsibility_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_events_source_id_idx": { + "name": "responsibility_events_source_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_events_owner_user_id_users_id_fk": { + "name": "responsibility_events_owner_user_id_users_id_fk", + "tableFrom": "responsibility_events", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_runs": { + "name": "responsibility_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "responsibility_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "waiting": { + "name": "waiting", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_runs_event_idx": { + "name": "responsibility_runs_event_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_runs_status_idx": { + "name": "responsibility_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_runs_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_runs_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_runs_event_id_responsibility_events_id_fk": { + "name": "responsibility_runs_event_id_responsibility_events_id_fk", + "tableFrom": "responsibility_runs", + "tableTo": "responsibility_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_source_bindings": { + "name": "responsibility_source_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_source_bindings_owner_idx": { + "name": "responsibility_source_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_source_bindings_owner_user_id_users_id_fk": { + "name": "responsibility_source_bindings_owner_user_id_users_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_source_bindings_credential_id_credentials_id_fk": { + "name": "responsibility_source_bindings_credential_id_credentials_id_fk", + "tableFrom": "responsibility_source_bindings", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.responsibility_triggers": { + "name": "responsibility_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "responsibility_id": { + "name": "responsibility_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "responsibility_triggers_owner_idx": { + "name": "responsibility_triggers_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_responsibility_idx": { + "name": "responsibility_triggers_responsibility_idx", + "columns": [ + { + "expression": "responsibility_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "responsibility_triggers_kind_idx": { + "name": "responsibility_triggers_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "responsibility_triggers_owner_user_id_users_id_fk": { + "name": "responsibility_triggers_owner_user_id_users_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_responsibility_id_responsibilities_id_fk": { + "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "responsibilities", + "columnsFrom": [ + "responsibility_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "responsibility_triggers_credential_id_credentials_id_fk": { + "name": "responsibility_triggers_credential_id_credentials_id_fk", + "tableFrom": "responsibility_triggers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_preferences": { + "name": "approval_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_review": { + "name": "auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "host_commands": { + "name": "host_commands", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ask'" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_preferences_owner_user_id_users_id_fk": { + "name": "approval_preferences_owner_user_id_users_id_fk", + "tableFrom": "approval_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_preferences_host_commands_check": { + "name": "approval_preferences_host_commands_check", + "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.approval_requests": { + "name": "approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_digest": { + "name": "action_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_requests_action_idx": { + "name": "approval_requests_action_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_requests_inbox_idx": { + "name": "approval_requests_inbox_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_requests_owner_user_id_users_id_fk": { + "name": "approval_requests_owner_user_id_users_id_fk", + "tableFrom": "approval_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_status_check": { + "name": "approval_status_check", + "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')" + }, + "approval_decision_check": { + "name": "approval_decision_check", + "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')" + } + }, + "isRLSEnabled": false + }, + "public.approval_rules": { + "name": "approval_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_rules_owner_idx": { + "name": "approval_rules_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_rules_owner_user_id_users_id_fk": { + "name": "approval_rules_owner_user_id_users_id_fk", + "tableFrom": "approval_rules", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_rules_behaviour_check": { + "name": "approval_rules_behaviour_check", + "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_rules": { + "name": "approval_team_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "behaviour": { + "name": "behaviour", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_rules_created_by_users_id_fk": { + "name": "approval_team_rules_created_by_users_id_fk", + "tableFrom": "approval_team_rules", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_rules_behaviour_check": { + "name": "approval_team_rules_behaviour_check", + "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')" + } + }, + "isRLSEnabled": false + }, + "public.approval_team_settings": { + "name": "approval_team_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enforce_auto_review": { + "name": "enforce_auto_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_rules_enabled": { + "name": "custom_rules_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "host_commands_cap": { + "name": "host_commands_cap", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'allow'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_team_settings_updated_by_users_id_fk": { + "name": "approval_team_settings_updated_by_users_id_fk", + "tableFrom": "approval_team_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "approval_team_settings_host_commands_cap_check": { + "name": "approval_team_settings_host_commands_cap_check", + "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')" + } + }, + "isRLSEnabled": false + }, + "public.memory_sources": { + "name": "memory_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_ref": { + "name": "tool_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_sources_owner_idx": { + "name": "memory_sources_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_sources_due_idx": { + "name": "memory_sources_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_sources_owner_user_id_users_id_fk": { + "name": "memory_sources_owner_user_id_users_id_fk", + "tableFrom": "memory_sources", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_sources_agent_id_agents_id_fk": { + "name": "memory_sources_agent_id_agents_id_fk", + "tableFrom": "memory_sources", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_memories": { + "name": "personal_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_digest": { + "name": "import_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unreviewed'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "formed_by": { + "name": "formed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'person'" + }, + "formed_by_agent_id": { + "name": "formed_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "intelligence_memory_id": { + "name": "intelligence_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "intelligence_digest": { + "name": "intelligence_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "personal_memories_owner_idx": { + "name": "personal_memories_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_intelligence_idx": { + "name": "personal_memories_intelligence_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "intelligence_memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_idx": { + "name": "personal_memories_formed_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formed_by_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_import_idx": { + "name": "personal_memories_import_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "personal_memories_formed_digest_idx": { + "name": "personal_memories_formed_digest_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "import_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"personal_memories\".\"formed_by\" = 'bot'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_memories_owner_user_id_users_id_fk": { + "name": "personal_memories_owner_user_id_users_id_fk", + "tableFrom": "personal_memories", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_source_id_memory_sources_id_fk": { + "name": "personal_memories_source_id_memory_sources_id_fk", + "tableFrom": "personal_memories", + "tableTo": "memory_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "personal_memories_formed_by_agent_id_agents_id_fk": { + "name": "personal_memories_formed_by_agent_id_agents_id_fk", + "tableFrom": "personal_memories", + "tableTo": "agents", + "columnsFrom": [ + "formed_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.default_opt_outs": { + "name": "default_opt_outs", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "default_opt_outs_owner_user_id_users_id_fk": { + "name": "default_opt_outs_owner_user_id_users_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "default_opt_outs_agent_id_agents_id_fk": { + "name": "default_opt_outs_agent_id_agents_id_fk", + "tableFrom": "default_opt_outs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": { + "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk", + "columns": [ + "owner_user_id", + "agent_id", + "feature", + "scope" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "default_opt_outs_feature_check": { + "name": "default_opt_outs_feature_check", + "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_settings": { + "name": "proactive_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "focus": { + "name": "focus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "interval_minutes": { + "name": "interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_settings_owner_agent_idx": { + "name": "proactive_settings_owner_agent_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_settings_due_idx": { + "name": "proactive_settings_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_settings_owner_user_id_users_id_fk": { + "name": "proactive_settings_owner_user_id_users_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_settings_agent_id_agents_id_fk": { + "name": "proactive_settings_agent_id_agents_id_fk", + "tableFrom": "proactive_settings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_settings_interval_check": { + "name": "proactive_settings_interval_check", + "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440" + }, + "proactive_settings_status_check": { + "name": "proactive_settings_status_check", + "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.proactive_suggestions": { + "name": "proactive_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setting_id": { + "name": "setting_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_app": { + "name": "source_app", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proactive_suggestions_owner_idx": { + "name": "proactive_suggestions_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "proactive_suggestions_run_idx": { + "name": "proactive_suggestions_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proactive_suggestions_owner_user_id_users_id_fk": { + "name": "proactive_suggestions_owner_user_id_users_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_agent_id_agents_id_fk": { + "name": "proactive_suggestions_agent_id_agents_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proactive_suggestions_setting_id_proactive_settings_id_fk": { + "name": "proactive_suggestions_setting_id_proactive_settings_id_fk", + "tableFrom": "proactive_suggestions", + "tableTo": "proactive_settings", + "columnsFrom": [ + "setting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "proactive_suggestions_status_check": { + "name": "proactive_suggestions_status_check", + "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')" + } + }, + "isRLSEnabled": false + }, + "public.demonstrations": { + "name": "demonstrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'recording'" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "draft": { + "name": "draft", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_slug": { + "name": "skill_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "demonstrations_owner_idx": { + "name": "demonstrations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demonstrations_active_bot_idx": { + "name": "demonstrations_active_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"demonstrations\".\"status\" = 'recording'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demonstrations_owner_user_id_users_id_fk": { + "name": "demonstrations_owner_user_id_users_id_fk", + "tableFrom": "demonstrations", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "demonstrations_bot_id_agents_id_fk": { + "name": "demonstrations_bot_id_agents_id_fk", + "tableFrom": "demonstrations", + "tableTo": "agents", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_bindings": { + "name": "delivery_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mention_id": { + "name": "mention_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_binding_identity_idx": { + "name": "delivery_binding_identity_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_bindings_owner_idx": { + "name": "delivery_bindings_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_bindings_owner_user_id_users_id_fk": { + "name": "delivery_bindings_owner_user_id_users_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_channel_id_channels_id_fk": { + "name": "delivery_bindings_channel_id_channels_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_bindings_agent_id_agents_id_fk": { + "name": "delivery_bindings_agent_id_agents_id_fk", + "tableFrom": "delivery_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_challenges": { + "name": "delivery_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "delivery_challenges_owner_user_id_users_id_fk": { + "name": "delivery_challenges_owner_user_id_users_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_channel_id_channels_id_fk": { + "name": "delivery_challenges_channel_id_channels_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_challenges_agent_id_agents_id_fk": { + "name": "delivery_challenges_agent_id_agents_id_fk", + "tableFrom": "delivery_challenges", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_inbox": { + "name": "delivery_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_inbox_dedupe_idx": { + "name": "delivery_inbox_dedupe_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_inbox_owner_idx": { + "name": "delivery_inbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_inbox_owner_user_id_users_id_fk": { + "name": "delivery_inbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_channel_id_channels_id_fk": { + "name": "delivery_inbox_channel_id_channels_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_inbox_agent_id_agents_id_fk": { + "name": "delivery_inbox_agent_id_agents_id_fk", + "tableFrom": "delivery_inbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delivery_outbox": { + "name": "delivery_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "delivery_outbox_owner_idx": { + "name": "delivery_outbox_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "delivery_outbox_provider_idx": { + "name": "delivery_outbox_provider_idx", + "columns": [ + { + "expression": "transport", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "delivery_outbox_owner_user_id_users_id_fk": { + "name": "delivery_outbox_owner_user_id_users_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_channel_id_channels_id_fk": { + "name": "delivery_outbox_channel_id_channels_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "delivery_outbox_agent_id_agents_id_fk": { + "name": "delivery_outbox_agent_id_agents_id_fk", + "tableFrom": "delivery_outbox", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "delivery_outbox_dedupe_key_unique": { + "name": "delivery_outbox_dedupe_key_unique", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_devices": { + "name": "push_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_devices_owner_idx": { + "name": "push_devices_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_devices_owner_user_id_users_id_fk": { + "name": "push_devices_owner_user_id_users_id_fk", + "tableFrom": "push_devices", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_devices_token_unique": { + "name": "push_devices_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_bot_threads": { + "name": "group_bot_threads", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_bot_threads_thread_idx": { + "name": "group_bot_threads_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_bot_threads_owner_user_id_users_id_fk": { + "name": "group_bot_threads_owner_user_id_users_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_bot_threads_channel_id_channels_id_fk": { + "name": "group_bot_threads_channel_id_channels_id_fk", + "tableFrom": "group_bot_threads", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "group_bot_threads_owner_user_id_channel_id_agent_id_pk": { + "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk", + "columns": [ + "owner_user_id", + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_messages": { + "name": "group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "group_messages_channel_idx": { + "name": "group_messages_channel_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "group_messages_channel_id_channels_id_fk": { + "name": "group_messages_channel_id_channels_id_fk", + "tableFrom": "group_messages", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_messages_owner_user_id_users_id_fk": { + "name": "group_messages_owner_user_id_users_id_fk", + "tableFrom": "group_messages", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "group_messages_status_check": { + "name": "group_messages_status_check", + "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.action_records": { + "name": "action_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "action_records_created_idx": { + "name": "action_records_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "action_records_bot_idx": { + "name": "action_records_bot_idx", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "action_records_surface_check": { + "name": "action_records_surface_check", + "value": "\"action_records\".\"surface\" IN ('cloud', 'local')" + } + }, + "isRLSEnabled": false + }, + "public.capability_settings": { + "name": "capability_settings", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "capability_settings_scope_kind_scope_id_capability_pk": { + "name": "capability_settings_scope_kind_scope_id_capability_pk", + "columns": [ + "scope_kind", + "scope_id", + "capability" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "capability_settings_scope_kind_check": { + "name": "capability_settings_scope_kind_check", + "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.enterprise_settings": { + "name": "enterprise_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_usage": { + "name": "model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_usage_created_idx": { + "name": "model_usage_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_usage_model_idx": { + "name": "model_usage_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_usage_source_check": { + "name": "model_usage_source_check", + "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.network_policies": { + "name": "network_policies", + "schema": "", + "columns": { + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"entries\":[]}'::jsonb" + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "network_policies_scope_kind_scope_id_pk": { + "name": "network_policies_scope_kind_scope_id_pk", + "columns": [ + "scope_kind", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "network_policies_scope_kind_check": { + "name": "network_policies_scope_kind_check", + "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')" + }, + "network_policies_mode_check": { + "name": "network_policies_mode_check", + "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')" + } + }, + "isRLSEnabled": false + }, + "public.scim_connection_bindings": { + "name": "scim_connection_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_bindings_connection_idx": { + "name": "scim_connection_bindings_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_bindings_connection_key_unique": { + "name": "scim_connection_bindings_connection_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_members": { + "name": "scim_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_members_connection_idx": { + "name": "scim_group_members_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_group_idx": { + "name": "scim_group_members_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_members_scim_user_idx": { + "name": "scim_group_members_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_members_group_id_scim_groups_id_fk": { + "name": "scim_group_members_group_id_scim_groups_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_members_scim_user_id_scim_users_id_fk": { + "name": "scim_group_members_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_group_members", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_members_membership_key_unique": { + "name": "scim_group_members_membership_key_unique", + "nullsNotDistinct": false, + "columns": [ + "membership_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_groups": { + "name": "scim_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_groups_connection_idx": { + "name": "scim_groups_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_groups_domain_idx": { + "name": "scim_groups_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_groups_display_name_key_unique": { + "name": "scim_groups_display_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "display_name_key" + ] + }, + "scim_groups_external_id_key_unique": { + "name": "scim_groups_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_groups_order_key_unique": { + "name": "scim_groups_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstones": { + "name": "scim_identity_tombstones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstones_connection_idx": { + "name": "scim_identity_tombstones_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_domain_idx": { + "name": "scim_identity_tombstones_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstones_user_idx": { + "name": "scim_identity_tombstones_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstones_user_id_users_id_fk": { + "name": "scim_identity_tombstones_user_id_users_id_fk", + "tableFrom": "scim_identity_tombstones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstones_external_id_key_unique": { + "name": "scim_identity_tombstones_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grants": { + "name": "scim_projection_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grants_connection_idx": { + "name": "scim_projection_grants_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_domain_idx": { + "name": "scim_projection_grants_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_scim_user_idx": { + "name": "scim_projection_grants_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grants_user_idx": { + "name": "scim_projection_grants_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grants_scim_user_id_scim_users_id_fk": { + "name": "scim_projection_grants_scim_user_id_scim_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "scim_users", + "columnsFrom": [ + "scim_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grants_user_id_users_id_fk": { + "name": "scim_projection_grants_user_id_users_id_fk", + "tableFrom": "scim_projection_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grants_grant_key_unique": { + "name": "scim_projection_grants_grant_key_unique", + "nullsNotDistinct": false, + "columns": [ + "grant_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subjects": { + "name": "scim_subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subjects_profile_source_idx": { + "name": "scim_subjects_profile_source_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subjects_user_id_users_id_fk": { + "name": "scim_subjects_user_id_users_id_fk", + "tableFrom": "scim_subjects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subjects_user_id_unique": { + "name": "scim_subjects_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_users": { + "name": "scim_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_users_connection_idx": { + "name": "scim_users_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_domain_idx": { + "name": "scim_users_domain_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_users_user_idx": { + "name": "scim_users_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_users_user_id_users_id_fk": { + "name": "scim_users_user_id_users_id_fk", + "tableFrom": "scim_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_users_connection_user_key_unique": { + "name": "scim_users_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": [ + "connection_user_key" + ] + }, + "scim_users_user_name_key_unique": { + "name": "scim_users_user_name_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_name_key" + ] + }, + "scim_users_external_id_key_unique": { + "name": "scim_users_external_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "external_id_key" + ] + }, + "scim_users_order_key_unique": { + "name": "scim_users_order_key_unique", + "nullsNotDistinct": false, + "columns": [ + "order_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_lifecycle": { + "name": "bot_lifecycle", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notify": { + "name": "notify", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_lifecycle_owner_user_id_users_id_fk": { + "name": "bot_lifecycle_owner_user_id_users_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_agent_id_agents_id_fk": { + "name": "bot_lifecycle_agent_id_agents_id_fk", + "tableFrom": "bot_lifecycle", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bot_lifecycle_owner_user_id_agent_id_pk": { + "name": "bot_lifecycle_owner_user_id_agent_id_pk", + "columns": [ + "owner_user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.update_routing_preferences": { + "name": "update_routing_preferences", + "schema": "", + "columns": { + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "update_routing_preferences_owner_user_id_users_id_fk": { + "name": "update_routing_preferences_owner_user_id_users_id_fk", + "tableFrom": "update_routing_preferences", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "update_routing_preferences_owner_user_id_kind_pk": { + "name": "update_routing_preferences_owner_user_id_kind_pk", + "columns": [ + "owner_user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_logins": { + "name": "saved_logins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "saved_logins_owner_origin_username_idx": { + "name": "saved_logins_owner_origin_username_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "saved_logins_owner_idx": { + "name": "saved_logins_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_logins_owner_user_id_users_id_fk": { + "name": "saved_logins_owner_user_id_users_id_fk", + "tableFrom": "saved_logins", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sign_in_requests": { + "name": "sign_in_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "control_request_id": { + "name": "control_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continuation": { + "name": "continuation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filling_until": { + "name": "filling_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sign_in_requests_owner_idx": { + "name": "sign_in_requests_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sign_in_requests_owner_user_id_users_id_fk": { + "name": "sign_in_requests_owner_user_id_users_id_fk", + "tableFrom": "sign_in_requests", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sign_in_requests_status_check": { + "name": "sign_in_requests_status_check", + "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')" + }, + "sign_in_requests_method_check": { + "name": "sign_in_requests_method_check", + "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_assignments": { + "name": "team_bot_assignments", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_assignments_agent_id_agents_id_fk": { + "name": "team_bot_assignments_agent_id_agents_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_assignments_assigned_by_users_id_fk": { + "name": "team_bot_assignments_assigned_by_users_id_fk", + "tableFrom": "team_bot_assignments", + "tableTo": "users", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_assignments_agent_id_group_name_pk": { + "name": "team_bot_assignments_agent_id_group_name_pk", + "columns": [ + "agent_id", + "group_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_audience": { + "name": "team_bot_audience", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_audience_agent_id_agents_id_fk": { + "name": "team_bot_audience_agent_id_agents_id_fk", + "tableFrom": "team_bot_audience", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_audience_agent_id_kind_value_pk": { + "name": "team_bot_audience_agent_id_kind_value_pk", + "columns": [ + "agent_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_audience_kind_check": { + "name": "team_bot_audience_kind_check", + "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_consent_defaults": { + "name": "team_bot_consent_defaults", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consent_defaults_user_id_users_id_fk": { + "name": "team_bot_consent_defaults_user_id_users_id_fk", + "tableFrom": "team_bot_consent_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consent_defaults_user_id_server_id_pk": { + "name": "team_bot_consent_defaults_user_id_server_id_pk", + "columns": [ + "user_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_bot_consents": { + "name": "team_bot_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_consents_user_id_users_id_fk": { + "name": "team_bot_consents_user_id_users_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_consents_agent_id_agents_id_fk": { + "name": "team_bot_consents_agent_id_agents_id_fk", + "tableFrom": "team_bot_consents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_bot_consents_user_id_agent_id_server_id_pk": { + "name": "team_bot_consents_user_id_agent_id_server_id_pk", + "columns": [ + "user_id", + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_consents_decision_check": { + "name": "team_bot_consents_decision_check", + "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')" + } + }, + "isRLSEnabled": false + }, + "public.team_bot_publications": { + "name": "team_bot_publications", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_bot_publications_agent_id_agents_id_fk": { + "name": "team_bot_publications_agent_id_agents_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_bot_publications_published_by_users_id_fk": { + "name": "team_bot_publications_published_by_users_id_fk", + "tableFrom": "team_bot_publications", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "team_bot_publications_audience_check": { + "name": "team_bot_publications_audience_check", + "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approval_members": { + "name": "shared_use_approval_members", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approval_members_approval_fk": { + "name": "shared_use_approval_members_approval_fk", + "tableFrom": "shared_use_approval_members", + "tableTo": "shared_use_approvals", + "columnsFrom": [ + "agent_id", + "server_id" + ], + "columnsTo": [ + "agent_id", + "server_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approval_members_agent_id_server_id_kind_value_pk": { + "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk", + "columns": [ + "agent_id", + "server_id", + "kind", + "value" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approval_members_kind_check": { + "name": "shared_use_approval_members_kind_check", + "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_approvals": { + "name": "shared_use_approvals", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outside_input": { + "name": "outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shared_use_approvals_agent_id_agents_id_fk": { + "name": "shared_use_approvals_agent_id_agents_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_approvals_server_id_mcp_servers_id_fk": { + "name": "shared_use_approvals_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_approvals", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_use_approvals_agent_id_server_id_pk": { + "name": "shared_use_approvals_agent_id_server_id_pk", + "columns": [ + "agent_id", + "server_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_approvals_audience_check": { + "name": "shared_use_approvals_audience_check", + "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')" + } + }, + "isRLSEnabled": false + }, + "public.shared_use_requests": { + "name": "shared_use_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_audience": { + "name": "proposed_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_outside_input": { + "name": "proposed_outside_input", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "proposed_members": { + "name": "proposed_members", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shared_use_requests_pending_idx": { + "name": "shared_use_requests_pending_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shared_use_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_use_requests_status_idx": { + "name": "shared_use_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_use_requests_agent_id_agents_id_fk": { + "name": "shared_use_requests_agent_id_agents_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_use_requests_server_id_mcp_servers_id_fk": { + "name": "shared_use_requests_server_id_mcp_servers_id_fk", + "tableFrom": "shared_use_requests", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_use_requests_audience_check": { + "name": "shared_use_requests_audience_check", + "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')" + }, + "shared_use_requests_reason_check": { + "name": "shared_use_requests_reason_check", + "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')" + }, + "shared_use_requests_status_check": { + "name": "shared_use_requests_status_check", + "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')" + } + }, + "isRLSEnabled": false + }, + "public.identity_link_challenges": { + "name": "identity_link_challenges", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "identity_link_challenges_expiry_idx": { + "name": "identity_link_challenges_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_link_challenges_user_provider_idx": { + "name": "identity_link_challenges_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_links": { + "name": "identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "realm": { + "name": "realm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identity_links_identity_idx": { + "name": "identity_links_identity_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_user_realm_idx": { + "name": "identity_links_user_realm_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "realm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "identity_links_credential_idx": { + "name": "identity_links_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"identity_links\".\"credential_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "identity_links_credential_id_credentials_id_fk": { + "name": "identity_links_credential_id_credentials_id_fk", + "tableFrom": "identity_links", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "identity_links_verified_by_check": { + "name": "identity_links_verified_by_check", + "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')" + }, + "identity_links_status_check": { + "name": "identity_links_status_check", + "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')" + } + }, + "isRLSEnabled": false + }, + "public.recorder_connect_codes": { + "name": "recorder_connect_codes", + "schema": "", + "columns": { + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "via": { + "name": "via", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_session": { + "name": "organization_session", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "recorder_connect_codes_user_idx": { + "name": "recorder_connect_codes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_connect_codes_user_id_users_id_fk": { + "name": "recorder_connect_codes_user_id_users_id_fk", + "tableFrom": "recorder_connect_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recorder_connections": { + "name": "recorder_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "via": { + "name": "via", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "install_id": { + "name": "install_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_session": { + "name": "organization_session", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "recorder_connections_user_idx": { + "name": "recorder_connections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recorder_connections_live_install_idx": { + "name": "recorder_connections_live_install_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "install_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"recorder_connections\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_connections_user_id_users_id_fk": { + "name": "recorder_connections_user_id_users_id_fk", + "tableFrom": "recorder_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recorder_connections_token_hash_unique": { + "name": "recorder_connections_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recorder_trajectory_bindings": { + "name": "recorder_trajectory_bindings", + "schema": "", + "columns": { + "trajectory_id": { + "name": "trajectory_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recorder_trajectory_bindings_user_idx": { + "name": "recorder_trajectory_bindings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recorder_trajectory_bindings_user_id_users_id_fk": { + "name": "recorder_trajectory_bindings_user_id_users_id_fk", + "tableFrom": "recorder_trajectory_bindings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui", + "remote_mastra" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped", + "waiting" + ] + }, + "public.responsibility_run_status": { + "name": "responsibility_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting", + "succeeded", + "failed", + "skipped" + ] + }, + "public.responsibility_status": { + "name": "responsibility_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 70e4dfa60..16c393c77 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -400,6 +400,13 @@ "when": 1791866936937, "tag": "0056_sign_in_code_step", "breakpoints": true + }, + { + "idx": 57, + "version": "7", + "when": 1792361511278, + "tag": "0057_recorder_extension", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/package.json b/server/package.json index df7114e93..f18f11081 100644 --- a/server/package.json +++ b/server/package.json @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@ag-ui/client": "1.0.1", - "@ag-ui/core": "1.0.1", + "@ag-ui/client": "1.0.2", + "@ag-ui/core": "1.0.2", "@ag-ui/mastra": "1.1.6", "@better-auth/drizzle-adapter": "^1.7.1", "@better-auth/electron": "1.7.1", @@ -21,7 +21,7 @@ "@better-auth/scim": "1.7.1", "@better-auth/sso": "^1.7.1", "@composio/core": "^0.18.1", - "@copilotkit/runtime": "1.77.0", + "@copilotkit/runtime": "1.79.1", "@mastra/client-js": "^1.43.0", "@mastra/core": "^1.64.0", "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/server/src/agents/agent-resolver.ts b/server/src/agents/agent-resolver.ts index 1314d4444..531398f74 100644 --- a/server/src/agents/agent-resolver.ts +++ b/server/src/agents/agent-resolver.ts @@ -92,8 +92,17 @@ export function createActorAgentResolver( */ onlyAgentId?: string, context?: AgentResolutionContext, - ) => - resolveRuntimeAgents( + ) => { + /* + * Learned Skills for this person's run: the Bot's own, plus the person's own Learning space and every Skill another + * person opened (learning/runtime.ts). Bound here, where the person is known, so every path that builds a Bot for + * them (a turn, a hop, a routine, a remote Bot) asks for the same set. + */ + const acquire = deps.acquireLearnedSkills; + const acquireForActor: AcquireLearnedSkills | undefined = acquire + ? (agentId, signal) => acquire(agentId, signal, actor.id) + : undefined; + return resolveRuntimeAgents( () => Promise.resolve(registered), deps.model, deps.resolveModelApiKey, @@ -110,9 +119,10 @@ export function createActorAgentResolver( context?.initiator, deps.loadAttachmentForActor?.(actor.id), deps.markAttachmentsSentForActor?.(actor.id), - deps.acquireLearnedSkills, + acquireForActor, deps.loadPersonalMemoryForActor?.(actor.id), ); + }; const resolveAgentsForActor = async (actor: AgentActor) => resolveRegisteredAgents(actor, await deps.loadAgents(actor)); diff --git a/server/src/app.ts b/server/src/app.ts index ea2ac3357..eedda7556 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,8 +1,8 @@ +import { MAX_IMAGE_BYTES } from "@openbot/shared/attachments"; import type { Hono as HonoApp, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import { serveStatic } from "hono/bun"; -import { MAX_IMAGE_BYTES } from "@openbot/shared/attachments"; import { enterpriseControls } from "./admin/controls"; import { createEnterpriseGate } from "./admin/gate"; import { createEnterpriseAdminRoutes } from "./admin/routes"; @@ -25,10 +25,10 @@ import type { ApprovalService } from "./approvals/service"; import { type AuditEventType, type AuditInitiator, - auditEventsToCsv, AuditQueryError, type AuditReader, type AuditStore, + auditEventsToCsv, auditQueryFromUrl, DEPLOYMENT_INITIATOR, readAuditExportRows, @@ -89,6 +89,10 @@ import { createLearningRoutes, type LearningAdminDependencies, } from "./learning/routes"; +import { + createPersonalLearningRoutes, + type PersonalLearningRouteDeps, +} from "./learning/sharing-routes"; import type { MemoryIngestion } from "./memory/ingestion"; import { createMemoryRoutes } from "./memory/routes"; import type { MemoryStore } from "./memory/store"; @@ -115,6 +119,9 @@ import { type ModelProviderProxy, mountProviderOAuthProxy, } from "./provider-oauth"; +import { createRequireRecorder } from "./recorder/guard"; +import { createRecorderRoutes } from "./recorder/routes"; +import type { RecorderExtensionDeps } from "./recorder/wiring"; import type { ResponsibilityBindingStore } from "./responsibilities/bindings"; import type { ResponsibilityEngine } from "./responsibilities/engine"; import { createGithubResponsibilityRoutes } from "./responsibilities/github"; @@ -398,6 +405,10 @@ export function createApp( recorder: DemonstrationRecorder; }; approvals?: ApprovalService; + /** The Recorder extension's routes and guards (recorder/wiring.ts). Absent, nothing about it is mounted. */ + recorder?: RecorderExtensionDeps; + /** Settings, Learning: who else's Bots use a person's own learned Skills. Absent, the screen has nothing to read. */ + personalLearning?: PersonalLearningRouteDeps; /** * Shared apps: who may use an app's one team account, and the switch that makes an app Shared. * @@ -1594,6 +1605,28 @@ export function createApp( createDeliveryRoutes({ ...coworker.delivery.authenticated, requireUser }), ); } + if (coworker?.recorder) { + app.route( + "/api/recorder", + createRecorderRoutes({ + ...coworker.recorder, + requireUser, + requireRecorder: createRequireRecorder( + coworker.recorder.resolve, + coworker.recorder.onRefused, + ), + auditStore, + }), + ); + } + if (coworker?.personalLearning) + app.route( + "/api/settings/learning", + createPersonalLearningRoutes( + { ...coworker.personalLearning, auditStore }, + requireUser, + ), + ); if (coworker?.demonstrations) app.route( "/api/demonstrations", diff --git a/server/src/audit.ts b/server/src/audit.ts index 356e0f0a8..db7a2c60f 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -1,3 +1,4 @@ +import type { AuditInitiator } from "@openbot/toolkit/initiator"; import { and, desc, @@ -13,7 +14,6 @@ import type { PgColumn } from "drizzle-orm/pg-core"; import type { Database } from "./db/client"; import { auditEvents } from "./db/schema"; import { parsePageLimit } from "./paging"; -import type { AuditInitiator } from "@openbot/toolkit/initiator"; const sensitiveKeys = new Set([ "access_token", @@ -553,6 +553,37 @@ export const auditEventTypes = [ "routine.enabled_changed", /* A run refused because its model is not on the team model allowlist. */ "model.refused", + /* + * A Recorder extension's bearer turned away: malformed, unknown or revoked, the person removed, + * their organization sign-in ended, or no role. Target the first 16 hex digits of the token's + * SHA-256, never the token; payload `reason` and `path`. At most one row per digest and reason a + * minute per replica, because a browser retrying a dead token would otherwise flood the trail. + */ + "recorder.bearer_refused", + /* + * A browser connected to OpenBot as the Recorder, by whom and through which extension copy. Never + * the token or the code; the label is the person's own name for the browser. + */ + "recorder.connected", + /* A connection attempt from an origin that is not the Recorder's. Payload: the origin offered. */ + "recorder.connect_refused", + /* A connection ended; payload `reason` is replaced (the same copy connected again), disconnected or revoked. */ + "recorder.revoked", + /* + * The capture pass (the runtime's trajectory/connect) turned a caller away: an id that is not a UUID, no + * Recorder bearer or one that no longer resolves, no role, the organization unable to answer, or a trajectory + * already bound to another person. Target the trajectory id; payload `reason`. At most one row per person, + * trajectory and reason a minute per replica. + */ + "recorder.trajectory_refused", + /* + * A person changed who else's Bots use the Skills learned in their own Learning space (Settings, Learning). Target + * the person; payload `from` and `to`, each `closed`, `open` or `choose`. + */ + "learning.sharing_changed", + /* A person opened one of their own learned Skills to every Bot, or closed it again. Target and payload `skill`. */ + "learning.skill_opened", + "learning.skill_closed", ] as const; export type AuditEventType = (typeof auditEventTypes)[number]; diff --git a/server/src/auth/organization.ts b/server/src/auth/organization.ts index cdae37107..2e723a1c6 100644 --- a/server/src/auth/organization.ts +++ b/server/src/auth/organization.ts @@ -4,10 +4,11 @@ import { } from "../desktop-connection-failure"; import type { AuthenticatedActor, AuthService } from "./guards"; -const COOKIE = "openbot.organization-session"; +export const ORGANIZATION_SESSION_COOKIE = "openbot.organization-session"; +const COOKIE = ORGANIZATION_SESSION_COOKIE; const HANDOFF_PATH = "/api/auth/organization/session"; const COOKIE_AGE = 7 * 24 * 60 * 60; -class AuthorityUnavailable extends Error {} +export class AuthorityUnavailable extends Error {} /** Explicit customer OpenBot authority, never inferred from an Intelligence endpoint. */ export function organizationAuthority(value: string): string { @@ -70,12 +71,23 @@ function sessionCookie(value: unknown): value is string { export function createOrganizationAuth(options: { authorityUrl: string; materializeUser: (user: AuthenticatedActor) => Promise<AuthenticatedActor>; -}): AuthService { +}): AuthService & { + verifySessionQuietly: ( + cookieHeader: string, + ) => Promise<AuthenticatedActor | null>; +} { const authority = organizationAuthority(options.authorityUrl); const tickets = new Map<string, { cookie: string; expires: number }>(); - async function verify(cookie: string): Promise<AuthenticatedActor | null> { - if (!sessionCookie(cookie)) return null; + type Verdict = + | { kind: "actor"; actor: AuthenticatedActor } + | { kind: "refused"; authenticationFailed: boolean } + | { kind: "unexpected"; status: number }; + + /** Asks the organization about a cookie and reports what it said, with no side effects on Desktop's failure state. */ + async function verifyAtAuthority(cookie: string): Promise<Verdict> { + if (!sessionCookie(cookie)) + return { kind: "refused", authenticationFailed: false }; let response: Response; try { response = await fetch(`${authority}/api/me`, { @@ -92,19 +104,27 @@ export function createOrganizationAuth(options: { throw new AuthorityUnavailable( "The organization is temporarily unavailable. Try again.", ); - if (!response.ok) { - if (response.status === 401 || response.status === 403) + if (response.status === 401 || response.status === 403) + return { kind: "refused", authenticationFailed: true }; + if (!response.ok) return { kind: "unexpected", status: response.status }; + const user = actor(await response.json().catch(() => null)); + if (!user) return { kind: "unexpected", status: response.status }; + return { kind: "actor", actor: await options.materializeUser(user) }; + } + + async function verify(cookie: string): Promise<AuthenticatedActor | null> { + const verdict = await verifyAtAuthority(cookie); + if (verdict.kind === "refused") { + if (verdict.authenticationFailed) recordDesktopConnectionFailure({ connection: "organization", code: "organization_authentication_failed", }); return null; } - const user = actor(await response.json().catch(() => null)); - if (!user) return null; - const localUser = await options.materializeUser(user); + if (verdict.kind === "unexpected") return null; clearDesktopConnectionFailure("organization"); - return localUser; + return verdict.actor; } function cookieFrom(headers: Headers): string | null { @@ -146,8 +166,23 @@ export function createOrganizationAuth(options: { return user ? { user } : null; }; + /** Verifies a forwarded cookie header without touching Desktop's failure state; an odd answer is unavailable, never a sign-out. */ + async function verifySessionQuietly( + cookieHeader: string, + ): Promise<AuthenticatedActor | null> { + const cookie = cookieFrom(new Headers({ cookie: cookieHeader })); + if (!cookie) return null; + const verdict = await verifyAtAuthority(cookie); + if (verdict.kind === "actor") return verdict.actor; + if (verdict.kind === "refused") return null; + throw new AuthorityUnavailable( + "The organization gave an answer OpenBot could not read. Try again.", + ); + } + return { api: { getSession }, + verifySessionQuietly, async handler(request) { try { const url = new URL(request.url); diff --git a/server/src/config.ts b/server/src/config.ts index 51e29edc8..2820b0acf 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -170,6 +170,11 @@ export type HandoffCaps = { }; export type DeploymentConfig = { + /** + * Browser extension ids OpenBot trusts as the Recorder besides its own fixed id, for a fork or a + * build signed with another key. From OPENBOT_RECORDER_EXTRA_EXTENSION_IDS, never from NODE_ENV. + */ + recorder: { extraExtensionIds: string[] }; /** Optional environment default. A saved Admin setting takes precedence. */ learning?: LearningTarget; /** @@ -1364,6 +1369,7 @@ export function loadConfig( tenantPackageDirectory: optional(environment, "TENANT_PACKAGE_DIR") ?? "../examples/fintech", runtime: runtimeCapabilities(environment), + recorder: { extraExtensionIds: recorderExtraExtensionIds(environment) }, learning: learningDefault(environment), learningAutoAssign: learningAutoAssignEnabled(environment), ...learningSetupCredential(environment), @@ -1410,6 +1416,20 @@ function learningAutoAssignEnabled(environment: Environment): boolean { return value !== "false" && value !== "0"; } +/** Comma separated, each trimmed, each a Chrome extension id (32 letters a to p). */ +function recorderExtraExtensionIds(environment: Environment): string[] { + const value = optional(environment, "OPENBOT_RECORDER_EXTRA_EXTENSION_IDS"); + if (!value) return []; + const ids = value.split(",").map((id) => id.trim()); + for (const id of ids) { + if (!/^[a-p]{32}$/.test(id)) + throw new Error( + `OPENBOT_RECORDER_EXTRA_EXTENSION_IDS lists "${id}", which is not an extension id. An id is 32 letters from a to p; separate several with commas.`, + ); + } + return ids; +} + /** On unless the variable is "false" or "0", the rule every default-on switch here follows. */ function switchedOn(environment: Environment, name: string): boolean { const value = optional(environment, name); diff --git a/server/src/copilot.ts b/server/src/copilot.ts index 5c5bca798..6a0d82003 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -1,6 +1,10 @@ import type { Message, RunAgentInput } from "@ag-ui/client"; import { type AbstractAgent, HttpAgent } from "@ag-ui/client"; -import type { BuiltInAgentConfiguration } from "@copilotkit/runtime/v2"; +import type { + BuiltInAgentConfiguration, + CopilotRuntimeHooks, + GetTrajectoryLearningContainerIds, +} from "@copilotkit/runtime/v2"; import { type CopilotKitIntelligence, CopilotRuntime, @@ -53,6 +57,7 @@ import { LEARNED_SKILL_TOOL_NAMES, type LearnedSkillInvocation, type LearningRuntime, + type PersonalSkillSources, } from "./learning/runtime"; import type { LearningSettingsStore } from "./learning/settings"; import { @@ -1701,10 +1706,31 @@ export function mountCopilotRuntime( basePath?: string; onRunBusy?: (input: { threadId: string; busy: boolean }) => void; learningSettings?: LearningSettingsStore; + /** + * Each person's own Learning space and everyone's opened Skills, which join the Bot's own in every run + * (learning/runtime.ts). Absent, a run gets only the Bot's own learned Skills. + */ + personalSkills?: PersonalSkillSources; + /** + * The Recorder's capture pass (`trajectory/connect`): the gate that lets only a connected browser through, and + * the selector that assigns the trajectory to its person's own Learning space (`recorder/wiring.ts`). Absent, the + * route is the runtime's own: any person identifyUser resolves gets a pass that joins no Learning space. + * `index.ts` always passes it. + */ + trajectoryCapture?: { + gate: NonNullable<CopilotRuntimeHooks["onBeforeHandler"]>; + selectLearningContainers: GetTrajectoryLearningContainerIds; + }; } = {}, ) { const { intelligence } = config.runtime; - const { basePath = "/api/copilotkit", onRunBusy, learningSettings } = options; + const { + basePath = "/api/copilotkit", + onRunBusy, + learningSettings, + personalSkills, + trajectoryCapture, + } = options; /** * The same Bot a person's run would get, built without a request. @@ -1764,6 +1790,12 @@ export function mountCopilotRuntime( userId: user.id, }); }, + ...(trajectoryCapture + ? { + getTrajectoryLearningContainerIds: + trajectoryCapture.selectLearningContainers, + } + : {}), }), ); @@ -1771,6 +1803,7 @@ export function mountCopilotRuntime( ? createLearningRuntime({ store: learningSettings, client: intelligenceClient, + ...(personalSkills ? { people: personalSkills } : {}), }) : undefined; @@ -1817,7 +1850,13 @@ export function mountCopilotRuntime( }); return { - handler: createCopilotHonoHandler({ runtime, basePath }), + handler: createCopilotHonoHandler({ + runtime, + basePath, + ...(trajectoryCapture + ? { hooks: { onBeforeHandler: trajectoryCapture.gate } } + : {}), + }), learning, /** * How to reach the platform's runner, exactly as the runtime reaches it. diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts index 38ccf182d..94f06ced4 100644 --- a/server/src/db/schema/index.ts +++ b/server/src/db/schema/index.ts @@ -17,6 +17,7 @@ export * from "./memory"; export * from "./passwords"; export * from "./plugins"; export * from "./proactive"; +export * from "./recorder"; export * from "./responsibilities"; export * from "./scim"; export * from "./shared-use"; diff --git a/server/src/db/schema/learning.ts b/server/src/db/schema/learning.ts index 25009d63b..486dbe355 100644 --- a/server/src/db/schema/learning.ts +++ b/server/src/db/schema/learning.ts @@ -1,5 +1,17 @@ -import { pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"; -import type { LearningSettings } from "@openbot/shared/learning"; +import type { + LearningSettings, + LearningSharingMode, +} from "@openbot/shared/learning"; +import { sql } from "drizzle-orm"; +import { + check, + index, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; +import { users } from "./core"; import { jsonb } from "./json"; export const learningSettings = pgTable("learning_settings", { @@ -24,3 +36,64 @@ export const learningThreadBindings = pgTable( }, (table) => [primaryKey({ columns: [table.userId, table.threadId] })], ); + +/** + * A person's own Learning space: the Intelligence Learning container every recording they make joins + * (learning/personal-space.ts). A row means OpenBot saw the container exist, because it created it, Intelligence said + * the id was already taken, or the runtime key read it. The id is derived from the person's, so the row only remembers + * that it exists. It goes with its person; the container stays in Intelligence. + */ +export const learningPersonSpaces = pgTable("learning_person_spaces", { + userId: text("user_id") + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + containerId: text("container_id").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +/** + * Who else's Bots use the Skills learned in a person's own space: `closed` (only their own Bots, and the meaning of no + * row), `open` (every Bot in the deployment) or `choose` (every Bot, for the Skills listed in learning_open_skills). + */ +export const learningSkillSharing = pgTable( + "learning_skill_sharing", + { + userId: text("user_id") + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + mode: text("mode").$type<LearningSharingMode>().notNull().default("closed"), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + check( + "learning_skill_sharing_mode_check", + sql`${table.mode} IN ('closed', 'open', 'choose')`, + ), + // Delivery reads only the people who opened something. + index("learning_skill_sharing_open_idx") + .on(table.userId) + .where(sql`${table.mode} <> 'closed'`), + ], +); + +/** + * The Skills a person in `choose` opened to every Bot, by name. Kept when they switch to another mode, so switching back + * opens the same ones again. + */ +export const learningOpenSkills = pgTable( + "learning_open_skills", + { + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + skillName: text("skill_name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [primaryKey({ columns: [table.userId, table.skillName] })], +); diff --git a/server/src/db/schema/recorder.ts b/server/src/db/schema/recorder.ts new file mode 100644 index 000000000..a82487bc8 --- /dev/null +++ b/server/src/db/schema/recorder.ts @@ -0,0 +1,78 @@ +import { sql } from "drizzle-orm"; +import { + index, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { users } from "./core"; + +export const recorderConnectCodes = pgTable( + "recorder_connect_codes", + { + codeHash: text("code_hash").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + via: text("via").$type<"web" | "desktop">().notNull(), + /** Organization mode only: the minting request's organization session cookie, sealed with KEY_ENCRYPTION_KEY. The + * code is deleted when it is used and once it has expired, and this copy with it. */ + organizationSession: text("organization_session"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (table) => [index("recorder_connect_codes_user_idx").on(table.userId)], +); + +export const recorderConnections = pgTable( + "recorder_connections", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull().unique(), + label: text("label").notNull(), + via: text("via").$type<"web" | "desktop">().notNull(), + extensionId: text("extension_id").notNull(), + /** The connecting copy's install id for this OpenBot. */ + installId: text("install_id").notNull(), + /** Organization mode only, copied from the connect code: re-verified against the authority. Cleared on revoke. */ + organizationSession: text("organization_session"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + }, + (table) => [ + index("recorder_connections_user_idx").on(table.userId, table.createdAt), + // One live connection per person and installed copy; connecting the same copy again revokes the older one. + uniqueIndex("recorder_connections_live_install_idx") + .on(table.userId, table.installId) + .where(sql`${table.revokedAt} IS NULL`), + ], +); + +/** + * Which person a Recorder trajectory belongs to, written by the capture pass's gate and read by its Learning space + * selector, which joins it to that person's own Learning space. Nothing recorded is kept here: the trajectory itself + * lives in Intelligence. A binding goes with its person. + */ +export const recorderTrajectoryBindings = pgTable( + "recorder_trajectory_bindings", + { + trajectoryId: uuid("trajectory_id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [index("recorder_trajectory_bindings_user_idx").on(table.userId)], +); diff --git a/server/src/index.ts b/server/src/index.ts index 1a519b93c..25d10c950 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -74,7 +74,6 @@ import { createRoleRepository } from "./auth/guards"; import { createIdentityProviderStore } from "./auth/identity-provider-store"; import { createOrganizationAuth } from "./auth/organization"; import { organizationUserStore } from "./auth/organization-store"; -import type { OpenBotRole } from "./auth/roles"; import { loadAttachmentForTurn, markAttachmentsSent, @@ -116,8 +115,6 @@ import { import { createSnapshotStore } from "./computer/snapshot-store"; import { loadConfig } from "./config"; import { - type IdentifyActor, - type IdentifyUser, mountCopilotRuntime, normalizeModelBaseUrls, runtimeModelForEnvironment, @@ -134,6 +131,7 @@ import { channelMemberships, channels, intelligenceChannelMappings, + users, } from "./db/schema"; import { createDeliveryRouter, DELIVERY_WORK_KINDS } from "./delivery/router"; import { @@ -159,6 +157,7 @@ import { createIntelligenceClient, observeIntelligenceAuthentication, } from "./intelligence-client"; +import { reportIntelligenceEntitlement } from "./intelligence-entitlement-check"; import { createLearningAutoAssign, probeLearningContainer, @@ -166,19 +165,29 @@ import { } from "./learning/auto-assign"; import { cliLoginPresent, + createLearningContainer, ensureLearningContainer, intelligenceApiOrigin, keyIntelligence, + type LearningContainerCredentials, learningSetupInstructions, } from "./learning/ensure-container"; +import { + createPersonalLearningSpaces, + createPersonSpaceStore, +} from "./learning/personal-space"; import { clearLearningRevisionFallback } from "./learning/runtime"; import { createLearningSettingsStore } from "./learning/settings"; +import { + createLearningSharingStore, + createOpenSkillIndex, +} from "./learning/sharing"; import { createMemoryDefaults } from "./memory/defaults"; import { createMemoryIngestion } from "./memory/ingestion"; import { createIntelligenceMemory } from "./memory/intelligence"; import { createMemoryStore } from "./memory/store"; -import { reportModelAvailability } from "./model-availability-check"; import { memoryTools } from "./memory/tools"; +import { reportModelAvailability } from "./model-availability-check"; import { createSignInNotifier, createSignInResumer } from "./passwords/resume"; import { createSignInService } from "./passwords/service"; import { createPasswordStore } from "./passwords/store"; @@ -214,6 +223,8 @@ import { } from "./proactive/restriction"; import { createProactiveStore } from "./proactive/store"; import { createProviderOAuthProxy } from "./provider-oauth"; +import { createRecorderExtension } from "./recorder/wiring"; +import { createRequestActor } from "./request-actor"; import { createResponsibilityBindingStore } from "./responsibilities/bindings"; import { createEmailTriggerRoutes, @@ -238,7 +249,6 @@ import { createRoutineRunner } from "./routines/runner"; import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; import { createModelCompleter } from "./routing/model"; -import { reportIntelligenceEntitlement } from "./intelligence-entitlement-check"; import { createSelfHostBanner } from "./self-host-banner"; import { createTeamBots } from "./team-bots/team-bots"; import { @@ -253,66 +263,6 @@ import { createVoiceSummarizer } from "./voice/summary"; import { repeatAfterEach } from "./work/loop"; import { createWorkQueue, type WorkOfferedListener } from "./work/queue"; -/** - * Who is asking, for a CopilotKit request. - * - * One resolver, because a run has two questions to answer about the same person: whose threads and - * memory these are, and which coworkers they may run. Answering them from different places is how - * one person ends up running another's private coworker, or reading their thread. - */ -async function resolveRequestActor(request: Request): Promise<{ - id: string; - name: string; - role: OpenBotRole; -}> { - if (config.singleUser) { - return { id: DEV_ACTOR.id, name: DEV_ACTOR.email, role: DEV_ACTOR.role }; - } - const session = await auth?.api.getSession({ headers: request.headers }); - const user = session?.user; - if (!user) { - throw new Error("A CopilotKit run requires a signed-in user."); - } - const roles = user.role - ? [user.role] - : await roleRepository.rolesForUser(user.id); - if (!roles.includes("admin") && !roles.includes("user")) { - throw new Error("A CopilotKit run requires an authorized user."); - } - return { - id: user.id, - name: user.name ?? user.email ?? user.id, - role: roles.includes("admin") ? "admin" : "user", - }; -} - -/** The Intelligence projection of {@link resolveRequestActor}: threads are scoped to this person. */ -const identifyUser: IdentifyUser = async (request) => { - const { id, name } = await resolveRequestActor(request); - return { id, name }; -}; - -/** - * The authorization projection of the same person: agent visibility is decided from this. - * - * An unauthenticated request resolves to a person who owns nothing rather than an error, so the - * runtime can still describe itself, `/info` reports the licence and the public roster, which is - * what a deployment check reads to tell "the licence is invalid" apart from "chat is silently - * broken". It grants nothing: this actor matches no private profile and is not an administrator, - * and a run still fails in `identifyUser`, which has no anonymous case because a thread must belong - * to somebody. - */ -const ANONYMOUS_ACTOR = { id: "", role: "user" } as const; - -const identifyActor: IdentifyActor = async (request) => { - try { - const { id, role } = await resolveRequestActor(request); - return { id, role }; - } catch { - return ANONYMOUS_ACTOR; - } -}; - const config = loadConfig(); // The environment seeds local policy once. The SDK fallback must not reapply an old revision // after an administrator clears a pin to follow the latest published Skills. @@ -346,6 +296,26 @@ const learningApiOrigin = (() => { const learningCanCreate = learningApiOrigin !== undefined && (config.learningSetup !== undefined || cliLoginPresent()); +/** The credential that can create a Learning container, for the `openbot` default and for each person's own space. */ +const learningCredentials = (apiUrl: string): LearningContainerCredentials => ({ + directory: process.cwd(), + apiUrl, + nonInteractive: true, + key: keyIntelligence( + apiUrl, + config.runtime.intelligence.apiKey, + config.runtime.intelligence.gatewayWsUrl, + ), + cliLogin: cliLoginPresent(), + ...(config.learningSetup + ? { + serviceToken: { + token: config.learningSetup.serviceToken, + projectId: config.learningSetup.projectId, + }, + } + : {}), +}); const learningAutoAssign = createLearningAutoAssign({ enabled: config.learningAutoAssign, store: learningSettingsStore, @@ -357,29 +327,43 @@ const learningAutoAssign = createLearningAutoAssign({ ...(learningCanCreate && learningApiOrigin ? { create: () => - ensureLearningContainer({ - directory: process.cwd(), - apiUrl: learningApiOrigin, - nonInteractive: true, - key: keyIntelligence( - learningApiOrigin, - config.runtime.intelligence.apiKey, - config.runtime.intelligence.gatewayWsUrl, - ), - cliLogin: cliLoginPresent(), - ...(config.learningSetup - ? { - serviceToken: { - token: config.learningSetup.serviceToken, - projectId: config.learningSetup.projectId, - }, - } - : {}), - }), + ensureLearningContainer(learningCredentials(learningApiOrigin)), } : {}), audit: createAuditStore(database), }); +/* + * Each person's own Learning space: every recording they make joins it, and every Bot they run uses its Skills. Made + * with the same credential as the default container, on first need: when their browser connects, and again on a + * capture pass if it is still missing. With no such credential a missing space cannot be made, and the Recorder says so. + */ +const personalLearningSpaces = createPersonalLearningSpaces({ + readSettings: () => learningSettingsStore.read(), + snapshots: learningProbeIntelligence, + store: createPersonSpaceStore(database), + ...(learningCanCreate && learningApiOrigin + ? { + create: (container) => + createLearningContainer({ + ...learningCredentials(learningApiOrigin), + container, + }), + } + : {}), + nameFor: async (userId) => + ( + await database + .select({ name: users.name }) + .from(users) + .where(eq(users.id, userId)) + .limit(1) + ).map((row) => row.name?.trim() || null)[0] ?? null, +}); +const learningSharingStore = createLearningSharingStore(database); +/** What everyone opened to every Bot, in memory: runs read this, never each person's space or the table. */ +const openSkillIndex = createOpenSkillIndex({ + read: () => learningSharingStore.openSources(), +}); const learningSettings = withLearningAutoAssign( learningSettingsStore, learningAutoAssign, @@ -485,19 +469,33 @@ const identityProviderStore = createIdentityProviderStore(database); * store that receives those rows has to exist before anything can sign in. */ const signInAuditStore = createAuditStore(database); -const auth: AuthService | undefined = config.organizationAuthUrl +// Its own const so the Recorder can re-check a stored session through `verifySessionQuietly`. +const organizationAuth = config.organizationAuthUrl ? createOrganizationAuth({ authorityUrl: config.organizationAuthUrl, materializeUser: organizationUserStore(database), }) - : config.auth + : undefined; +const auth: AuthService | undefined = + organizationAuth ?? + (config.auth ? createAuth( config, database, (email) => peopleStore.isRevoked(email), signInAuditStore, ) - : undefined; + : undefined); +const { resolveRequestActor, identifyUser, identifyActor } = createRequestActor( + { + singleUser: config.singleUser, + // Answered only on the capture pass. Called per request, after the Recorder's wiring below exists. + resolveRecorder: (request) => recorderExtension.resolve(request), + getSession: async (headers) => + (await auth?.api.getSession({ headers })) ?? null, + rolesForUser: (id) => roleRepository.rolesForUser(id), + }, +); // Each computer is pushed its network policy on the way to the action that woke it, because a // computer refuses every connection until one arrives (see `withPolicyPushOnWake`). const computerProvider = config.computer @@ -1163,13 +1161,22 @@ const personalMemoryStore = createMemoryStore(database, { client: createIntelligenceClient(config.runtime.intelligence), }), }); +const recorderExtension = createRecorderExtension({ + config, + database, + organizationAuth, + roleRepository, + peopleStore, + personalSpaces: personalLearningSpaces, + auditStore: bootAuditStore, +}); const demonstrationStore = createDemonstrationStore(database); const demonstrationRecorder = createDemonstrationRecorder({ store: demonstrationStore, // Teaching a shared deployment Bot is allowed as well as an owned one; the skill is the owner's. - ownsBot: async (owner, bot) => - (await pluginStore.agentOwner(bot)) === owner || - (await agentProfileStore.get(await actorFor(owner), bot)) !== null, + ownsBot: async (ownerUserId, botId) => + (await pluginStore.agentOwner(botId)) === ownerUserId || + (await agentProfileStore.get(await actorFor(ownerUserId), botId)) !== null, ownsSkill: async (owner, slug) => (await pluginStore.skillOwner(slug)) === owner, routines: routineStore, @@ -1639,6 +1646,11 @@ const copilotRuntime = mountCopilotRuntime( void channelStore.signalBusy(input.threadId, input.busy).catch(() => {}); }, learningSettings, + personalSkills: { + containerFor: personalLearningSpaces.containerFor, + openSkills: () => openSkillIndex.sources(), + }, + trajectoryCapture: recorderExtension.trajectoryCapture, }, ); @@ -2424,6 +2436,14 @@ const app = createApp( store: demonstrationStore, recorder: demonstrationRecorder, }, + recorder: recorderExtension, + personalLearning: { + store: learningSharingStore, + skillsOf: (userId) => + copilotRuntime.learning?.personalSkills(userId) ?? + Promise.resolve({ unavailable: "unavailable" as const }), + invalidate: () => openSkillIndex.invalidate(), + }, delivery: { authenticated: { store: deliveryStore, diff --git a/server/src/learning/ensure-container.ts b/server/src/learning/ensure-container.ts index 916389e57..85275b5a3 100644 --- a/server/src/learning/ensure-container.ts +++ b/server/src/learning/ensure-container.ts @@ -19,7 +19,9 @@ * when somebody is there (`scripts/setup-learning.ts`). * * Shared by `scripts/setup-learning.ts` (run by `scripts/start.sh`), the server (at boot and from - * Admin → Automatic Learning), and the Helm chart's setup Job, which runs this file directly. + * Admin → Automatic Learning), and the Helm chart's setup Job, which runs this file directly. The + * server also makes each person's own Learning space with the same credential (`createLearningContainer`, + * called by learning/personal-space.ts). */ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; @@ -35,10 +37,16 @@ import { } from "./auto-assign"; export const MANAGED_API = "https://api.intelligence.copilotkit.ai"; -const CONTAINER_ID = "openbot"; -const CONTAINER_NAME = "OpenBot"; const SERVICE_TOKEN_HEADER = "x-copilotkit-ei-service-token"; +/** A Learning container to create: its id (Intelligence's 1 to 64 lowercase letters, digits and single hyphens) and name. */ +export type LearningContainerSpec = { id: string; name: string }; +/** The project's shared default, which Automatic Learning assigns. */ +export const OPENBOT_CONTAINER: LearningContainerSpec = { + id: "openbot", + name: "OpenBot", +}; + export type CliResult = { exitCode: number; stdout: string }; export type RunCli = (args: string[], directory: string) => Promise<CliResult>; @@ -187,6 +195,7 @@ export function verifiedContainer( result: CliResult, projectId: string, action: string, + containerId: string = OPENBOT_CONTAINER.id, ) { const value = json(result.stdout); if ( @@ -196,11 +205,11 @@ export function verifiedContainer( value.status !== "success" || value.projectId !== projectId || !object(value.container) || - value.container.id !== CONTAINER_ID || + value.container.id !== containerId || String(value.container.projectId) !== projectId ) { throw new Error( - "The openbot container could not be verified in the selected project. Check Learning access in Intelligence, then run this helper again.", + `The ${containerId} container could not be verified in the selected project. Check Learning access in Intelligence, then run this helper again.`, ); } } @@ -231,12 +240,17 @@ export function learningSetupInstructions(apiUrl: string): string { : `Run bun scripts/setup-learning.ts from the OpenBot checkout and sign in to Intelligence in the browser it opens. On a CopilotKit local evaluation install, set INTELLIGENCE_SERVICE_TOKEN and INTELLIGENCE_PROJECT_ID instead and OpenBot creates it by itself. ${create}`; } -/** Create the container with a service token. Intelligence admits one only on local evaluation. */ +/** + * Create the container with a service token. Intelligence admits one only on local evaluation. Idempotent: an id that is + * already taken in the project is `present`, so replicas racing to create the same container all end up with it. + */ async function createWithServiceToken(input: { apiUrl: string; serviceToken: { token: string; projectId: string }; fetch: typeof fetch; + container: LearningContainerSpec; }): Promise<"created" | "present"> { + const { container } = input; const response = await input.fetch( `${input.apiUrl}/api/learning/projects/${encodeURIComponent(input.serviceToken.projectId)}/containers`, { @@ -247,7 +261,7 @@ async function createWithServiceToken(input: { "content-type": "application/json", [SERVICE_TOKEN_HEADER]: input.serviceToken.token, }, - body: JSON.stringify({ id: CONTAINER_ID, name: CONTAINER_NAME }), + body: JSON.stringify({ id: container.id, name: container.name }), signal: AbortSignal.timeout(15_000), }, ); @@ -256,12 +270,12 @@ async function createWithServiceToken(input: { if ( object(body) && object(body.container) && - body.container.id === CONTAINER_ID && + body.container.id === container.id && String(body.container.projectId) === input.serviceToken.projectId ) return "created"; throw new Error( - "Intelligence answered the service token's container request with something other than the openbot container. Nothing was assigned.", + `Intelligence answered the service token's container request with something other than the ${container.id} container. Nothing was assigned.`, ); } if ( @@ -276,7 +290,7 @@ async function createWithServiceToken(input: { "Intelligence refused the service token for Learning. Service tokens create Learning containers only on a CopilotKit local evaluation install; anywhere else, sign in instead (bun scripts/setup-learning.ts).", ); throw new Error( - `Intelligence could not create the openbot Learning container with the service token (HTTP ${response.status}). Nothing was assigned.`, + `Intelligence could not create the ${container.id} Learning container with the service token (HTTP ${response.status}). Nothing was assigned.`, ); } @@ -367,45 +381,51 @@ function keysProject( return matches.length === 1 ? matches[0] : undefined; } +/** Get the container, or create it when the get says it is missing. True when it was already there. */ async function getOrCreate( run: RunCli, directory: string, projectId: string, + container: LearningContainerSpec, ): Promise<boolean> { - const get = await run( - ["learning", "containers", "get", CONTAINER_ID, "--json"], - directory, - ); + const getIt = () => + run(["learning", "containers", "get", container.id, "--json"], directory); + const get = await getIt(); if (containerMissing(json(get.stdout))) { - verifiedContainer( - await run( - [ - "learning", - "containers", - "create", - "--id", - CONTAINER_ID, - "--name", - CONTAINER_NAME, - "--json", - ], - directory, - ), - projectId, - "create", - ); - return false; + try { + verifiedContainer( + await run( + [ + "learning", + "containers", + "create", + "--id", + container.id, + "--name", + container.name, + "--json", + ], + directory, + ), + projectId, + "create", + container.id, + ); + return false; + } catch (error) { + // Another replica may have created it between the get and the create: if it is there now, it is present. + const again = await getIt(); + if (containerMissing(json(again.stdout))) throw error; + verifiedContainer(again, projectId, "get", container.id); + return true; + } } - verifiedContainer(get, projectId, "get"); + verifiedContainer(get, projectId, "get", container.id); return true; } -/** - * Make sure the runtime key's project has `openbot`, creating it with whichever credential is here. - * Never writes `.env` and never replaces the key: the server assigns the container once it exists - * (learning/auto-assign.ts). - */ -export async function ensureLearningContainer(input: { +/** The credentials `createLearningContainer` may use, and where it runs. */ +export type LearningContainerCredentials = { directory: string; apiUrl: string; nonInteractive: boolean; @@ -417,9 +437,125 @@ export async function ensureLearningContainer(input: { setupSelfHosted?: () => Promise<void>; /** Whether to try the CLI when nobody is there. Default true; the server passes `cliLoginPresent()`. */ cliLogin?: boolean; -}): Promise<EnsureContainerResult> { +}; + +/** + * Create `container` with whichever credential is here, without asking the runtime key anything first or after: + * `created`, `present` (the id was already taken in that project, so a replica that lost a race gets it too), or + * `skipped` when nothing here can create it. `ensureLearningContainer` wraps it with the key's checks; a person's own + * Learning space (learning/personal-space.ts) calls it directly. + */ +export async function createLearningContainer( + input: LearningContainerCredentials & { container?: LearningContainerSpec }, +): Promise<"created" | "present" | "skipped"> { const run = input.run ?? runCli; - const found = await input.key.probe(CONTAINER_ID); + const container = input.container ?? OPENBOT_CONTAINER; + // The operator's fallback is to create the shared default by hand; it only applies to that container. + const orCreateIt = + container.id === OPENBOT_CONTAINER.id + ? CREATE_IN_INTELLIGENCE + : `or create a Learning container with ID "${container.id}" in that project in Intelligence.`; + if (input.serviceToken) { + return createWithServiceToken({ + apiUrl: input.apiUrl, + serviceToken: input.serviceToken, + fetch: input.fetch ?? fetch, + container, + }); + } + if (input.apiUrl !== MANAGED_API) { + // Self-hosted creation needs a browser sign-in, which only a person can complete. + if (input.nonInteractive || !input.setupSelfHosted) return "skipped"; + await input.setupSelfHosted(); + return "created"; + } + const selectedPath = resolve(input.directory, ".copilotkit/project.json"); + const text = await readFile(selectedPath, "utf8").catch(() => undefined); + if (text === undefined && input.nonInteractive && input.cliLogin === false) + return "skipped"; + const owner = await input.key.project(); + if (text !== undefined) { + const selected = json(text); + const projectId = selected.projectId; + if (typeof projectId !== "string" || !projectId.trim()) { + throw new Error( + "The selected project is invalid. Run npx --yes copilotkit@latest project select again.", + ); + } + const confirmed = + owner && + typeof selected.projectSlug === "string" && + selected.projectSlug === owner.projectSlug + ? await (async () => { + const listing = listedProjects( + await run(["project", "list", "--json"], input.directory), + ); + return listing + ? keysProject( + owner, + await loginOrganization(run, input.directory), + listing, + projectId, + ) + : undefined; + })() + : undefined; + if (!confirmed) { + throw new Error( + `The CopilotKit CLI's selected project could not be confirmed as the one this runtime key belongs to, so nothing was created. Select the key's project in the CLI, ${orCreateIt}`, + ); + } + return (await getOrCreate(run, input.directory, projectId, container)) + ? "present" + : "created"; + } + // No project selected here: find the key's among the projects the login can see, and act on it + // from a scratch directory so this checkout's selection is never made or changed. + const scratch = await mkdtemp(join(tmpdir(), "openbot-learning-")); + try { + const listing = listedProjects( + await run(["project", "list", "--json"], scratch), + ); + if (!listing) { + if (input.nonInteractive) return "skipped"; + throw new Error( + `This runtime key's Intelligence project has no ${container.id} Learning container, and no CopilotKit CLI login is here to create one with. Sign in with npx --yes copilotkit@latest login and run this helper again, ${orCreateIt}`, + ); + } + const project = owner + ? keysProject(owner, await loginOrganization(run, scratch), listing) + : undefined; + if (!project) { + throw new Error( + `None of the projects this CopilotKit CLI login can see could be confirmed as the one this runtime key belongs to, so nothing was created. Sign in to the key's organization with npx --yes copilotkit@latest login, ${orCreateIt}`, + ); + } + await mkdir(join(scratch, ".copilotkit")); + await writeFile( + join(scratch, ".copilotkit/project.json"), + JSON.stringify({ + projectId: project.id, + projectSlug: project.slug, + clerkOrgId: project.organizationId, + }), + ); + return (await getOrCreate(run, scratch, project.id, container)) + ? "present" + : "created"; + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} + +/** + * Make sure the runtime key's project has `openbot`, creating it with whichever credential is here. + * Never writes `.env` and never replaces the key: the server assigns the container once it exists + * (learning/auto-assign.ts). + */ +export async function ensureLearningContainer( + input: LearningContainerCredentials, +): Promise<EnsureContainerResult> { + const found = await input.key.probe(OPENBOT_CONTAINER.id); if (found === "present") return "present"; if (found === "not_entitled") { throw new Error( @@ -431,101 +567,18 @@ export async function ensureLearningContainer(input: { "Intelligence could not be asked about the openbot Learning container with the configured runtime key. Check INTELLIGENCE_API_URL and INTELLIGENCE_API_KEY, then run this helper again.", ); } - let reused = false; - if (input.serviceToken) { - reused = - (await createWithServiceToken({ - apiUrl: input.apiUrl, - serviceToken: input.serviceToken, - fetch: input.fetch ?? fetch, - })) === "present"; - } else if (input.apiUrl !== MANAGED_API) { - // Self-hosted creation needs a browser sign-in, which only a person can complete. - if (input.nonInteractive || !input.setupSelfHosted) return "skipped"; - await input.setupSelfHosted(); - } else { - const selectedPath = resolve(input.directory, ".copilotkit/project.json"); - const text = await readFile(selectedPath, "utf8").catch(() => undefined); - const owner = await input.key.project(); - if (text !== undefined) { - const selected = json(text); - const projectId = selected.projectId; - if (typeof projectId !== "string" || !projectId.trim()) { - throw new Error( - "The selected project is invalid. Run npx --yes copilotkit@latest project select again.", - ); - } - const confirmed = - owner && - typeof selected.projectSlug === "string" && - selected.projectSlug === owner.projectSlug - ? await (async () => { - const listing = listedProjects( - await run(["project", "list", "--json"], input.directory), - ); - return listing - ? keysProject( - owner, - await loginOrganization(run, input.directory), - listing, - projectId, - ) - : undefined; - })() - : undefined; - if (!confirmed) { - throw new Error( - `The CopilotKit CLI's selected project could not be confirmed as the one this runtime key belongs to, so nothing was created. Select the key's project in the CLI, ${CREATE_IN_INTELLIGENCE}`, - ); - } - reused = await getOrCreate(run, input.directory, projectId); - } else { - if (input.nonInteractive && input.cliLogin === false) return "skipped"; - // No project selected here: find the key's among the projects the login can see, and act on it - // from a scratch directory so this checkout's selection is never made or changed. - const scratch = await mkdtemp(join(tmpdir(), "openbot-learning-")); - try { - const listing = listedProjects( - await run(["project", "list", "--json"], scratch), - ); - if (!listing) { - if (input.nonInteractive) return "skipped"; - throw new Error( - `This runtime key's Intelligence project has no openbot Learning container, and no CopilotKit CLI login is here to create one with. Sign in with npx --yes copilotkit@latest login and run this helper again, ${CREATE_IN_INTELLIGENCE}`, - ); - } - const project = owner - ? keysProject(owner, await loginOrganization(run, scratch), listing) - : undefined; - if (!project) { - throw new Error( - `None of the projects this CopilotKit CLI login can see could be confirmed as the one this runtime key belongs to, so nothing was created. Sign in to the key's organization with npx --yes copilotkit@latest login, ${CREATE_IN_INTELLIGENCE}`, - ); - } - await mkdir(join(scratch, ".copilotkit")); - await writeFile( - join(scratch, ".copilotkit/project.json"), - JSON.stringify({ - projectId: project.id, - projectSlug: project.slug, - clerkOrgId: project.organizationId, - }), - ); - reused = await getOrCreate(run, scratch, project.id); - } finally { - await rm(scratch, { recursive: true, force: true }); - } - } - } + const created = await createLearningContainer(input); + if (created === "skipped") return "skipped"; + const reused = created === "present"; // The check that matters, whatever the project match said: can the key see it now? - if ((await input.key.probe(CONTAINER_ID)) !== "present") { + if ((await input.key.probe(OPENBOT_CONTAINER.id)) !== "present") { throw new Error( reused ? "The openbot container exists in the project that was used, but this runtime key cannot see it, so the key belongs to another project. Nothing was assigned." : "Created an openbot container, but this runtime key still cannot see it, so it is in another project. Nothing was assigned; remove that container in Intelligence if it is not wanted.", ); } - return reused ? "present" : "created"; + return created; } /** diff --git a/server/src/learning/personal-space.ts b/server/src/learning/personal-space.ts new file mode 100644 index 000000000..50a8338cf --- /dev/null +++ b/server/src/learning/personal-space.ts @@ -0,0 +1,286 @@ +import { createHash } from "node:crypto"; +import type { CopilotKitIntelligence } from "@copilotkit/runtime/v2"; +import type { LearningSettings } from "@openbot/shared/learning"; +import type { LearningSpaceState } from "@openbot/shared/recorder-api"; +import { eq } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { learningPersonSpaces } from "../db/schema/learning"; +import type { LearningContainerSpec } from "./ensure-container"; + +/** + * A person's own Learning space: one Intelligence Learning container per OpenBot person, which every recording they make + * joins, whichever Bot they later use, and whose published Skills reach every Bot they run (learning/runtime.ts). + * + * Its id is derived from the person's: `user-` and the first 32 hex digits of the SHA-256 of their OpenBot user id. A + * user id is any text (mixed case, a better-auth id, `dev-local-user`), and Intelligence takes 1 to 64 lowercase letters, + * digits and single hyphens, so the id is hashed rather than copied: the same person always gets the same space, on + * every replica, with nothing to look up, and two people never share one. + */ +export function personalLearningContainerId(userId: string): string { + return `user-${createHash("sha256").update(userId).digest("hex").slice(0, 32)}`; +} + +/** One branch per LearnedSkillsError code (or success): the existence question only. */ +export function classifyLearningSpaceProbe( + outcome: "ok" | { code: string }, +): "assigned" | "missing" | "learning-off" | "unverified" { + if (outcome === "ok") return "assigned"; + switch (outcome.code) { + // The container exists; nothing is published yet, or delivery is paused. + case "REVISION_NOT_FOUND": + case "DELIVERY_DISABLED": + case "REVISION_REVOKED": + return "assigned"; + case "CONTAINER_NOT_FOUND": + return "missing"; + // Intelligence's Learning delivery is unlicensed, refused or off (or does not serve the route): existence cannot + // be checked. + case "ENTITLEMENT_REQUIRED": + case "AUTHORIZATION_FAILED": + case "UNSUPPORTED_SERVER": + return "learning-off"; + default: + return "unverified"; + } +} + +function errorCode(error: unknown): string { + const code = + error !== null && typeof error === "object" && "code" in error + ? (error as { code: unknown }).code + : undefined; + return typeof code === "string" ? code : ""; +} + +/** The rows that remember which people's spaces OpenBot has seen exist. */ +export function createPersonSpaceStore(database: Database) { + return { + async confirmed(userId: string): Promise<boolean> { + const [row] = await database + .select({ userId: learningPersonSpaces.userId }) + .from(learningPersonSpaces) + .where(eq(learningPersonSpaces.userId, userId)) + .limit(1); + return row !== undefined; + }, + /** Idempotent, so replicas confirming the same space at once all succeed. */ + async confirm(userId: string, containerId: string): Promise<void> { + await database + .insert(learningPersonSpaces) + .values({ userId, containerId }) + .onConflictDoNothing(); + }, + async forget(userId: string): Promise<void> { + await database + .delete(learningPersonSpaces) + .where(eq(learningPersonSpaces.userId, userId)); + }, + }; +} +export type PersonSpaceStore = ReturnType<typeof createPersonSpaceStore>; + +export type PersonalLearningSpaces = { + containerFor(userId: string): string; + /** + * Whether this person's recordings land in their space, and its container id when a capture pass should name it. + * Creates the space when Intelligence says it is missing (or cannot say) and a credential here can, waiting for that + * only until `signal` aborts; the creation itself goes on. Never throws. + */ + state(userId: string, signal?: AbortSignal): Promise<LearningSpaceState>; + /** Make sure the person's space exists, in the background. Never throws and never waits. */ + kick(userId: string): void; +}; + +/** A probe answer is kept this long per container, as the Bot spaces' check kept it. */ +const CACHE_MS = 60_000; +/** After a failed creation, the next attempt for that person waits this long, so a refusing Intelligence (or its + * per-caller rate limit) is not asked on every popup and every capture pass. */ +const RETRY_MS = 60_000; + +/** Resolves with the promise's value, or with `undefined` as soon as `signal` aborts. */ +function until<T>(promise: Promise<T>, signal?: AbortSignal) { + if (!signal) return promise; + if (signal.aborted) return Promise.resolve(undefined); + return new Promise<T | undefined>((resolve) => { + const aborted = () => resolve(undefined); + signal.addEventListener("abort", aborted, { once: true }); + promise + .then(resolve, () => resolve(undefined)) + .finally(() => { + signal.removeEventListener("abort", aborted); + }); + }); +} + +/** + * Each person's own Learning space, created on first need. + * + * THE RUNTIME KEY CANNOT CREATE A CONTAINER (learning/ensure-container.ts): only a service token on a local evaluation + * install, or a CopilotKit CLI login against managed Intelligence, can. `create` is whichever of those this deployment + * holds, and absent when it holds neither; then a space that does not exist cannot be made here, and the person is told + * so. A service token creates in one request, well inside the capture pass's deadline; the CLI takes longer than that, + * which is why the space is also made when the browser connects (`kick`), so it is there before the first recording. + * + * Safe on many replicas without a lock: Intelligence's container id is unique per project, so a second creation of the + * same space answers "already exists", which counts as present, and the row that remembers it is a conditional insert. + * Within one replica, concurrent requests for one person share one creation. + */ +export function createPersonalLearningSpaces(deps: { + readSettings: () => Promise<LearningSettings>; + /** The runtime key's client, for the skills read that proves a container exists. */ + snapshots: Pick<CopilotKitIntelligence, "getLearnedSkillsSnapshot">; + store: PersonSpaceStore; + /** Creates a container with a credential that can; absent when this deployment holds none. */ + create?: ( + container: LearningContainerSpec, + ) => Promise<"created" | "present" | "skipped">; + /** The person's display name, for the container's name in Intelligence. */ + nameFor?: (userId: string) => Promise<string | null>; + /** Default 3_000, so a check fits inside the runtime's 5 s selector deadline. */ + probeTimeoutMs?: number; + now?: () => number; + log?: (entry: Record<string, unknown>) => void; +}): PersonalLearningSpaces { + const probeTimeoutMs = deps.probeTimeoutMs ?? 3_000; + const now = deps.now ?? Date.now; + const log = deps.log ?? ((entry) => console.warn(JSON.stringify(entry))); + const probes = new Map< + string, + { expiresAt: number; state: LearningSpaceState["state"] } + >(); + const creating = new Map<string, Promise<boolean>>(); + const retryAt = new Map<string, number>(); + + const probe = async ( + containerId: string, + signal?: AbortSignal, + cached = true, + ): Promise<ReturnType<typeof classifyLearningSpaceProbe>> => { + const kept = probes.get(containerId); + if (cached && kept && kept.expiresAt > now()) { + return kept.state as ReturnType<typeof classifyLearningSpaceProbe>; + } + let state: ReturnType<typeof classifyLearningSpaceProbe>; + try { + await deps.snapshots.getLearnedSkillsSnapshot({ + containerId, + signal: AbortSignal.any([ + signal ?? new AbortController().signal, + AbortSignal.timeout(probeTimeoutMs), + ]), + }); + state = classifyLearningSpaceProbe("ok"); + } catch (error) { + state = classifyLearningSpaceProbe({ code: errorCode(error) }); + } + // An unverified answer is not kept: Intelligence coming back should be seen at once. + if (state !== "unverified") { + probes.set(containerId, { expiresAt: now() + CACHE_MS, state }); + } + return state; + }; + + /** True once the space exists (made here, or already there). One attempt per person at a time on this replica. */ + const create = (userId: string, containerId: string): Promise<boolean> => { + const running = creating.get(userId); + if (running) return running; + const create = deps.create; + if (!create || (retryAt.get(userId) ?? 0) > now()) { + return Promise.resolve(false); + } + const attempt = (async () => { + try { + const name = (await deps.nameFor?.(userId).catch(() => null))?.trim(); + const result = await create({ + id: containerId, + name: `OpenBot: ${name || containerId}`.slice(0, 255), + }); + if (result === "skipped") return false; + // A key that can answer and still cannot see it means the credential made it in another project. + if ((await probe(containerId, undefined, false)) === "missing") { + throw new Error( + "The credential created the space, but this runtime key cannot see it, so it is in another Intelligence project.", + ); + } + await deps.store.confirm(userId, containerId); + probes.set(containerId, { + expiresAt: now() + CACHE_MS, + state: "assigned", + }); + retryAt.delete(userId); + log({ + type: "learning-personal-space", + outcome: result, + containerId, + }); + return true; + } catch (error) { + retryAt.set(userId, now() + RETRY_MS); + log({ + type: "learning-personal-space", + outcome: "create_failed", + containerId, + // A credential's error can name an account; only its own sentence is kept, and briefly. + message: + error instanceof Error + ? error.message.slice(0, 300) + : "The space could not be created.", + }); + return false; + } + })().finally(() => { + creating.delete(userId); + }); + creating.set(userId, attempt); + return attempt; + }; + + const confirmed = (userId: string) => + deps.store.confirmed(userId).catch(() => false); + + const state: PersonalLearningSpaces["state"] = async (userId, signal) => { + let settings: LearningSettings; + try { + settings = await deps.readSettings(); + } catch { + return { state: "unverified", containerId: null }; + } + // Automatic Learning switched off in Admin: no space, and nothing is created. + if (!settings.enabled) return { state: "unassigned", containerId: null }; + const containerId = personalLearningContainerId(userId); + try { + const found = await probe(containerId, signal); + if (found === "assigned") { + void deps.store.confirm(userId, containerId).catch(() => {}); + return { state: "assigned", containerId }; + } + if (found === "missing") { + // Removed in Intelligence, or never made: forget it, and make it again if a credential here can. + await deps.store.forget(userId).catch(() => {}); + const made = await until(create(userId, containerId), signal); + if (made) return { state: "assigned", containerId }; + // Still being made when the caller stopped waiting: not there yet, but not refused either. + return made === undefined && creating.has(userId) + ? { state: "unverified", containerId: null } + : { state: "missing", containerId: null }; + } + // Intelligence could not say. A space seen before, or made now, is named; so is one nobody here can check or make, + // because the runtime joins without it when it turns out to be absent, so naming it never costs a recording. + if (await confirmed(userId)) return { state: found, containerId }; + if (await until(create(userId, containerId), signal)) { + return { state: "assigned", containerId }; + } + return { state: found, containerId }; + } catch { + return { state: "unverified", containerId: null }; + } + }; + + return { + containerFor: personalLearningContainerId, + state, + kick(userId) { + void state(userId).catch(() => {}); + }, + }; +} diff --git a/server/src/learning/runtime.ts b/server/src/learning/runtime.ts index 0013c9c9f..a7e58fc3f 100644 --- a/server/src/learning/runtime.ts +++ b/server/src/learning/runtime.ts @@ -1,18 +1,22 @@ +import { createHash } from "node:crypto"; import type { Tool } from "@ag-ui/client"; import { formatSkillCatalog, loadSkill, readSkillFile, SkillRegistry, + type SnapshotSkill, + type VerifiedSnapshot, } from "@copilotkit/runtime/internal/learned-skills"; import type { CopilotKitIntelligence } from "@copilotkit/runtime/v2"; -import { z } from "zod"; import type { LearningDeliveryStatus, LearningSettings, LearningTarget, } from "@openbot/shared/learning"; +import { z } from "zod"; import type { LearningSettingsStore } from "./settings"; +import type { OpenSkillSource } from "./sharing"; // This published internal export is pinned to runtime 1.73.1. Keep its use here: the SDK owns // archive verification, refresh, revocation and stale-cache policy for every OpenBot framework. @@ -21,9 +25,14 @@ export type LearnedSkillInvocation = { readonly tools: readonly Tool[]; execute(name: string, args: unknown): Promise<string>; }; +/** + * The learned Skills one run of `agentId` may use. With `userId`, the person the run is for: their own Learning space's + * Skills and every Skill another person opened join the Bot's own (`mergeLearnedSkills`). + */ export type AcquireLearnedSkills = ( agentId: string, signal?: AbortSignal, + userId?: string, ) => Promise<LearnedSkillInvocation | undefined>; export const LEARNED_SKILL_TOOL_NAMES = new Set([ @@ -47,6 +56,93 @@ const skillTools: readonly Tool[] = [ }, ]; +/** Whether an administrator excluded this Bot from Automatic Learning (a per-Bot mapping of null). */ +function excludedFromLearning( + settings: LearningSettings, + agentId: string, +): boolean { + return ( + Object.hasOwn(settings.agents, agentId) && settings.agents[agentId] === null + ); +} + +/** Skills another person opened, with the snapshot of their space they came from. */ +export type OpenedSkills = { + source: OpenSkillSource; + snapshot: VerifiedSnapshot; +}; + +/** + * One run's Skills from up to three places, in this order of precedence when two share a name: + * + * 1. The Bot's own (its Learning target, as before). Always kept as they are. + * 2. The run's person's own space. A Skill whose name the Bot already has is left out: the Bot's own wins. + * 3. Skills other people opened (`open`, or picked under `choose`). One whose name the Bot or the person already has is + * left out. When two or more people opened a Skill with the same name, all of them are kept, each named + * `<their space id>/<name>` (the SDK's own multi-container naming), so none is lost and the choice never depends on + * timing; a name only one person opened keeps its plain name. + * + * Nothing merged is the Bot's own snapshot unchanged. + */ +export function mergeLearnedSkills(input: { + bot?: VerifiedSnapshot; + own?: VerifiedSnapshot; + opened?: readonly OpenedSkills[]; +}): VerifiedSnapshot | undefined { + const opened = input.opened ?? []; + if (!input.own && opened.length === 0) return input.bot; + const skills: SnapshotSkill[] = [...(input.bot?.skills ?? [])]; + const taken = new Set(skills.map((skill) => skill.name)); + for (const skill of input.own?.skills ?? []) { + if (taken.has(skill.name)) continue; + skills.push(skill); + taken.add(skill.name); + } + const candidates = opened + .flatMap(({ source, snapshot }) => + snapshot.skills + .filter( + (skill) => source.skills === "all" || source.skills.has(skill.name), + ) + .map((skill) => ({ containerId: source.containerId, skill })), + ) + .filter(({ skill }) => !taken.has(skill.name)) + .sort( + (a, b) => + a.skill.name.localeCompare(b.skill.name) || + a.containerId.localeCompare(b.containerId), + ); + const owners = new Map<string, number>(); + for (const { skill } of candidates) + owners.set(skill.name, (owners.get(skill.name) ?? 0) + 1); + for (const { containerId, skill } of candidates) { + skills.push( + (owners.get(skill.name) ?? 0) > 1 + ? Object.freeze({ ...skill, name: `${containerId}/${skill.name}` }) + : skill, + ); + } + const identity = createHash("sha256") + .update( + JSON.stringify([ + input.bot ? [input.bot.revision, input.bot.etag] : null, + input.own ? [input.own.revision, input.own.etag] : null, + opened.map(({ source, snapshot }) => [ + source.containerId, + snapshot.revision, + snapshot.etag, + source.skills === "all" ? "all" : [...source.skills].sort(), + ]), + ]), + ) + .digest("hex"); + return Object.freeze({ + revision: `merged:${identity}`, + etag: `"${identity}"`, + skills: Object.freeze(skills), + }); +} + export function selectLearningTarget( settings: LearningSettings, agentId: string, @@ -88,12 +184,35 @@ export type LearningThread = { userId: string; }; +/** + * Where a person's own Skills and other people's opened Skills come from. Absent, a run gets only the Bot's own Skills. + */ +export type PersonalSkillSources = { + /** The person's own Learning space (learning/personal-space.ts). */ + containerFor(userId: string): string; + /** Everyone's opened Skills, from the in-memory index (learning/sharing.ts), never read per person per run. */ + openSkills(): Promise<readonly OpenSkillSource[]>; +}; + +/** The longest a run waits for a person's or an opened space before going on without it. Its refresh carries on. */ +const PERSONAL_WAIT_MS = 2_000; +/** How long Settings, Learning waits for the person's own Skills. */ +const PERSONAL_READ_MS = 5_000; +/** A space that refused (missing, revoked, not entitled, unreadable) is not asked again by runs for this long. */ +const PERSONAL_RETRY_MS = 60_000; + export function createLearningRuntime(options: { store: LearningSettingsStore; client: CopilotKitIntelligence; freshnessWindowMs?: number; + people?: PersonalSkillSources; + /** Default 2_000. */ + personalWaitMs?: number; + now?: () => number; }) { - const { store, client } = options; + const { store, client, people } = options; + const personalWaitMs = options.personalWaitMs ?? PERSONAL_WAIT_MS; + const now = options.now ?? Date.now; const registries = new Map<string, SkillRegistry>(); const registryKey = (target: LearningTarget) => JSON.stringify([target.containerId, target.revision]); @@ -114,16 +233,105 @@ export function createLearningRuntime(options: { return registry; }; - const acquire: AcquireLearnedSkills = async (agentId, signal) => { + /** Containers whose last read refused, and until when runs leave them alone. */ + const refusedUntil = new Map<string, number>(); + /** One log line per container per refusal window, so a missing space does not write a line per run. */ + const warned = new Map<string, number>(); + + /** + * A person's or an opened space's snapshot, or undefined. FAILS SOFT: whatever goes wrong (the space is missing, + * Intelligence is down, the read is slow) the run goes on with the Skills it has, because a person's space must never + * break a Bot that works without it. + */ + const softSnapshot = async ( + containerId: string, + signal?: AbortSignal, + ): Promise<VerifiedSnapshot | undefined> => { + if ((refusedUntil.get(containerId) ?? 0) > now()) return undefined; + let timer: ReturnType<typeof setTimeout> | undefined; + try { + return await Promise.race([ + waitForSnapshot(registryFor({ containerId }).acquireSnapshot(), signal), + new Promise<undefined>((resolve) => { + timer = setTimeout(() => resolve(undefined), personalWaitMs); + }), + ]); + } catch (error) { + if (signal?.aborted) return undefined; + refusedUntil.set(containerId, now() + PERSONAL_RETRY_MS); + if ((warned.get(containerId) ?? 0) <= now()) { + warned.set(containerId, now() + PERSONAL_RETRY_MS); + const code = + error !== null && typeof error === "object" && "code" in error + ? String((error as { code: unknown }).code) + : ""; + console.warn( + JSON.stringify({ + type: "learning-personal-skills-unavailable", + containerId, + code, + }), + ); + } + return undefined; + } finally { + clearTimeout(timer); + } + }; + + /** The run's person's own Skills and everyone else's opened ones, each fetched at once and each failing soft. */ + const personalSnapshots = async ( + userId: string, + signal?: AbortSignal, + ): Promise<{ own?: VerifiedSnapshot; opened: OpenedSkills[] }> => { + if (!people) return { opened: [] }; + let sources: readonly OpenSkillSource[] = []; + try { + sources = await people.openSkills(); + } catch { + console.warn(JSON.stringify({ type: "learning-open-skills-unread" })); + } + const others = sources.filter((source) => source.userId !== userId); + const [own, ...opened] = await Promise.all([ + softSnapshot(people.containerFor(userId), signal), + ...others.map((source) => softSnapshot(source.containerId, signal)), + ]); + return { + ...(own ? { own } : {}), + opened: others.flatMap((source, index) => { + const snapshot = opened[index]; + return snapshot ? [{ source, snapshot }] : []; + }), + }; + }; + + const acquire: AcquireLearnedSkills = async (agentId, signal, userId) => { signal?.throwIfAborted(); - const target = selectLearningTarget(await store.read(), agentId); + const settings = await store.read(); signal?.throwIfAborted(); - if (!target) return undefined; - const snapshot = await waitForSnapshot( - registryFor(target).acquireSnapshot(), - signal, - ); + // Learning off, or this Bot excluded by an administrator: no learned Skills of any kind. + if (!settings.enabled || excludedFromLearning(settings, agentId)) { + return undefined; + } + const target = selectLearningTarget(settings, agentId); + const personal = + userId && people + ? personalSnapshots(userId, signal) + : Promise.resolve({ opened: [] as OpenedSkills[] }); + if (!target && !(userId && people)) return undefined; + // The Bot's own read fails the run exactly as it did before; the personal reads never throw. + const [bot, extra] = await Promise.all([ + target + ? waitForSnapshot(registryFor(target).acquireSnapshot(), signal) + : undefined, + personal, + ]); signal?.throwIfAborted(); + const snapshot = mergeLearnedSkills({ bot, ...extra }); + // A Bot with no space of its own and nothing from anyone runs exactly as one with no Learning does. + if (!snapshot || (!target && snapshot.skills.length === 0)) { + return undefined; + } return { catalog: snapshot.skills.length > 0 ? formatSkillCatalog(snapshot) : "", tools: snapshot.skills.length > 0 ? skillTools : [], @@ -142,6 +350,44 @@ export function createLearningRuntime(options: { return { acquire, + /** + * The Skills published in this person's own Learning space, read the way a run reads them (the same registry and + * Intelligence call), for Settings, Learning. `off` when Automatic Learning is off here; `unavailable` when + * Intelligence did not deliver them (its Learning delivery off, the space not made yet, or not reachable). + */ + async personalSkills( + userId: string, + ): Promise< + | { skills: { name: string; description: string }[] } + | { unavailable: "off" | "unavailable" } + > { + if (!people) return { unavailable: "unavailable" }; + if (!(await store.read()).enabled) return { unavailable: "off" }; + try { + const snapshot = await waitForSnapshot( + registryFor({ + containerId: people.containerFor(userId), + }).acquireSnapshot(), + AbortSignal.timeout(PERSONAL_READ_MS), + ); + return { + skills: snapshot.skills.map(({ name, description }) => ({ + name, + description, + })), + }; + } catch (error) { + // The space exists and nothing is published in it yet. + if ( + error !== null && + typeof error === "object" && + "code" in error && + error.code === "REVISION_NOT_FOUND" + ) + return { skills: [] }; + return { unavailable: "unavailable" }; + } + }, /** * Bind once, before thread creation. The SDK calls this selector before its ownership check, * so the verified user scopes the durable decision. Existing Threads are never backfilled. diff --git a/server/src/learning/sharing-routes.ts b/server/src/learning/sharing-routes.ts new file mode 100644 index 000000000..151bdb291 --- /dev/null +++ b/server/src/learning/sharing-routes.ts @@ -0,0 +1,123 @@ +import { + isLearnedSkillName, + isLearningSharingMode, + type PersonalLearningSharing, +} from "@openbot/shared/learning"; +import { Hono, type MiddlewareHandler } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { type AuditStore, recordAuditEvent } from "../audit"; +import type { AppVariables } from "../auth/guards"; +import type { LearningSharingStore } from "./sharing"; + +export type PersonalLearningRouteDeps = { + store: LearningSharingStore; + /** The Skills in this person's own space, read the way a run reads them (learning/runtime.ts `personalSkills`). */ + skillsOf: ( + userId: string, + ) => Promise< + | { skills: { name: string; description: string }[] } + | { unavailable: "off" | "unavailable" } + >; + /** Tells this replica's open-Skill index to read again; other replicas see the change within its TTL. */ + invalidate: () => void; + auditStore?: AuditStore; +}; + +const smallBody = bodyLimit({ + maxSize: 2 * 1024, + onError: (context) => + context.json({ error: "That request is too large." }, 413), +}); + +/** + * Settings, Learning: who else's Bots use the Skills learned from a person's own recordings. Theirs alone, scoped to the + * signed-in person and never to anything in the path, like their standing instructions: there is no route for reading + * or changing somebody else's. + */ +export function createPersonalLearningRoutes( + deps: PersonalLearningRouteDeps, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + routes.use("*", requireUser); + + const audit = async (event: Parameters<typeof recordAuditEvent>[1]) => { + if (!deps.auditStore) return; + await recordAuditEvent(deps.auditStore, event); + }; + + routes.get("/", async (context) => { + const userId = context.var.actor.id; + const [sharing, read] = await Promise.all([ + deps.store.read(userId), + deps.skillsOf(userId), + ]); + const opened = new Set(sharing.openSkills); + const answer: PersonalLearningSharing = + "skills" in read + ? { + mode: sharing.mode, + skills: read.skills.map((skill) => ({ + ...skill, + open: opened.has(skill.name), + })), + } + : { mode: sharing.mode, skills: null, unavailable: read.unavailable }; + return context.json(answer); + }); + + routes.put("/mode", smallBody, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + mode?: unknown; + } | null; + if (!isLearningSharingMode(body?.mode)) { + return context.json( + { error: "Choose closed, open or choose for your Skills." }, + 400, + ); + } + const userId = context.var.actor.id; + const { from, to } = await deps.store.setMode(userId, body.mode); + deps.invalidate(); + if (from !== to) { + await audit({ + eventType: "learning.sharing_changed", + targetType: "learning_sharing", + targetId: userId, + actorUserId: userId, + payload: { from, to }, + }); + } + return context.json({ mode: to }); + }); + + routes.put("/skills/:name", smallBody, async (context) => { + const name = context.req.param("name"); + const body = (await context.req.json().catch(() => null)) as { + open?: unknown; + } | null; + if (!isLearnedSkillName(name) || typeof body?.open !== "boolean") { + return context.json( + { error: "Name a Skill and say whether it is open to everyone." }, + 400, + ); + } + const userId = context.var.actor.id; + const changed = await deps.store.setSkillOpen(userId, name, body.open); + deps.invalidate(); + if (changed) { + await audit({ + eventType: body.open + ? "learning.skill_opened" + : "learning.skill_closed", + targetType: "learned_skill", + targetId: name, + actorUserId: userId, + payload: { skill: name }, + }); + } + return context.json({ name, open: body.open }); + }); + + return routes; +} diff --git a/server/src/learning/sharing.ts b/server/src/learning/sharing.ts new file mode 100644 index 000000000..17ba1b092 --- /dev/null +++ b/server/src/learning/sharing.ts @@ -0,0 +1,192 @@ +import type { LearningSharingMode } from "@openbot/shared/learning"; +import { and, eq, inArray, ne } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { + learningOpenSkills, + learningSkillSharing, +} from "../db/schema/learning"; +import { personalLearningContainerId } from "./personal-space"; + +/** + * One person's Skills that every Bot in the deployment may use: all of them (`open`), or the ones they opened by name + * (`choose`). A person whose choice is `closed`, or `choose` with nothing opened, has no entry. + */ +export type OpenSkillSource = { + userId: string; + containerId: string; + skills: "all" | ReadonlySet<string>; +}; + +/** The sharing choice a person made for the Skills in their own Learning space. */ +export function createLearningSharingStore(database: Database) { + return { + /** Their choice (`closed` when they never made one) and every Skill they opened under `choose`. */ + async read( + userId: string, + ): Promise<{ mode: LearningSharingMode; openSkills: string[] }> { + const [row] = await database + .select({ mode: learningSkillSharing.mode }) + .from(learningSkillSharing) + .where(eq(learningSkillSharing.userId, userId)) + .limit(1); + const opened = await database + .select({ name: learningOpenSkills.skillName }) + .from(learningOpenSkills) + .where(eq(learningOpenSkills.userId, userId)) + .orderBy(learningOpenSkills.skillName); + return { + mode: row?.mode ?? "closed", + openSkills: opened.map((skill) => skill.name), + }; + }, + + /** Saves the choice and says what it was before. Two saves at once are taken in turn, so each `from` is true. */ + async setMode( + userId: string, + mode: LearningSharingMode, + ): Promise<{ from: LearningSharingMode; to: LearningSharingMode }> { + return database.transaction(async (transaction) => { + // Lock the person's row (or, with none yet, take the insert's turn) before reading what it holds. + await transaction + .insert(learningSkillSharing) + .values({ userId }) + .onConflictDoNothing(); + const [row] = await transaction + .select({ mode: learningSkillSharing.mode }) + .from(learningSkillSharing) + .where(eq(learningSkillSharing.userId, userId)) + .for("update"); + await transaction + .update(learningSkillSharing) + .set({ mode, updatedAt: new Date() }) + .where(eq(learningSkillSharing.userId, userId)); + return { from: row?.mode ?? "closed", to: mode }; + }); + }, + + /** Opens or closes one Skill by name. True when that changed anything. */ + async setSkillOpen( + userId: string, + skillName: string, + open: boolean, + ): Promise<boolean> { + if (open) { + const inserted = await database + .insert(learningOpenSkills) + .values({ userId, skillName }) + .onConflictDoNothing() + .returning({ name: learningOpenSkills.skillName }); + return inserted.length > 0; + } + const deleted = await database + .delete(learningOpenSkills) + .where( + and( + eq(learningOpenSkills.userId, userId), + eq(learningOpenSkills.skillName, skillName), + ), + ) + .returning({ name: learningOpenSkills.skillName }); + return deleted.length > 0; + }, + + /** Everyone who opened something, and what. Read by the open-Skill index, never per run. */ + async openSources(): Promise<OpenSkillSource[]> { + const sharing = await database + .select({ + userId: learningSkillSharing.userId, + mode: learningSkillSharing.mode, + }) + .from(learningSkillSharing) + .where(ne(learningSkillSharing.mode, "closed")); + const choosing = sharing + .filter((row) => row.mode === "choose") + .map((row) => row.userId); + const chosen = + choosing.length === 0 + ? [] + : await database + .select({ + userId: learningOpenSkills.userId, + name: learningOpenSkills.skillName, + }) + .from(learningOpenSkills) + .where(inArray(learningOpenSkills.userId, choosing)); + const byPerson = new Map<string, Set<string>>(); + for (const row of chosen) { + let names = byPerson.get(row.userId); + if (!names) { + names = new Set(); + byPerson.set(row.userId, names); + } + names.add(row.name); + } + return sharing.flatMap((row): OpenSkillSource[] => { + const containerId = personalLearningContainerId(row.userId); + if (row.mode === "open") { + return [{ userId: row.userId, containerId, skills: "all" }]; + } + const names = byPerson.get(row.userId); + return names && names.size > 0 + ? [{ userId: row.userId, containerId, skills: names }] + : []; + }); + }, + }; +} +export type LearningSharingStore = ReturnType< + typeof createLearningSharingStore +>; + +/** How long a replica keeps the open-Skill index before reading it again. A change made on this replica is seen at once. */ +const INDEX_TTL_MS = 30_000; + +/** + * What every person has opened, kept in memory so a run never asks the database (let alone each person's space) who + * shares what. Read again after `ttlMs`, and at once after `invalidate()`, which the Settings routes call on every + * change; another replica sees that change within `ttlMs`. A failed read keeps the last index, or none. + */ +export function createOpenSkillIndex(deps: { + read: () => Promise<OpenSkillSource[]>; + ttlMs?: number; + now?: () => number; +}) { + const ttlMs = deps.ttlMs ?? INDEX_TTL_MS; + const now = deps.now ?? Date.now; + let current: { sources: OpenSkillSource[]; readAt: number } | undefined; + let reading: Promise<OpenSkillSource[]> | undefined; + /** Bumped by `invalidate`, so a read that began before a change cannot overwrite what came after. */ + let generation = 0; + + const refresh = () => { + if (reading) return reading; + const started = generation; + reading = deps + .read() + .then((sources) => { + if (started === generation) current = { sources, readAt: now() }; + return sources; + }) + .finally(() => { + reading = undefined; + }); + return reading; + }; + + return { + async sources(): Promise<OpenSkillSource[]> { + if (current && now() - current.readAt < ttlMs) return current.sources; + try { + return await refresh(); + } catch (error) { + if (current) return current.sources; + throw error; + } + }, + invalidate() { + generation++; + current = undefined; + }, + }; +} +export type OpenSkillIndex = ReturnType<typeof createOpenSkillIndex>; diff --git a/server/src/recorder/connections.ts b/server/src/recorder/connections.ts new file mode 100644 index 000000000..827f431a5 --- /dev/null +++ b/server/src/recorder/connections.ts @@ -0,0 +1,205 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + type ConnectCodeResponse, + RECORDER_CODE_PREFIX, + RECORDER_CONNECT_CODE_TTL_MS, + RECORDER_TOKEN_PREFIX, + type RecorderConnection, + type RecorderConnectVia, +} from "@openbot/shared/recorder-api"; +import { and, asc, count, eq, gt, isNull, lte, or } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { + recorderConnectCodes, + recorderConnections, +} from "../db/schema/recorder"; + +/** A live connection's last use is written at most this often. */ +const LAST_USED_INTERVAL_MS = 60_000; +/** Unexpired connect codes one person may hold at once. A person mints one per click, so more than this is a script. */ +const LIVE_CODES_PER_PERSON = 10; + +export type RecorderConnectionStore = ReturnType< + typeof createRecorderConnectionStore +>; + +const digest = (secret: string) => + createHash("sha256").update(secret).digest("hex"); +const secretWithPrefix = (prefix: string) => + `${prefix}${randomBytes(32).toString("base64url")}`; + +type ConnectionRow = typeof recorderConnections.$inferSelect; +function connectionView(row: ConnectionRow): RecorderConnection { + return { + id: row.id, + label: row.label, + via: row.via, + extensionId: row.extensionId, + installId: row.installId, + createdAt: row.createdAt.toISOString(), + lastUsedAt: row.lastUsedAt?.toISOString() ?? null, + }; +} + +export function createRecorderConnectionStore( + database: Database, + options: { now?: () => Date } = {}, +) { + const now = options.now ?? (() => new Date()); + return { + /** Null when this person already holds {@link LIVE_CODES_PER_PERSON} unexpired codes. Every expired code, and the + * sealed organization session it held, is deleted first. */ + async mintCode( + userId: string, + via: RecorderConnectVia, + organizationSession: string | null = null, + ): Promise<ConnectCodeResponse | null> { + const created = now(); + await database + .delete(recorderConnectCodes) + .where(lte(recorderConnectCodes.expiresAt, created)); + const [live] = await database + .select({ codes: count() }) + .from(recorderConnectCodes) + .where(eq(recorderConnectCodes.userId, userId)); + if ((live?.codes ?? 0) >= LIVE_CODES_PER_PERSON) return null; + const code = secretWithPrefix(RECORDER_CODE_PREFIX); + const expiresAt = new Date( + created.getTime() + RECORDER_CONNECT_CODE_TTL_MS, + ); + await database.insert(recorderConnectCodes).values({ + codeHash: digest(code), + userId, + via, + organizationSession, + createdAt: created, + expiresAt, + }); + return { code, expiresAt: expiresAt.toISOString() }; + }, + + /** Consumes the code atomically (it is deleted, with the sealed organization session it held) and, in the same + * transaction, revokes this person's older live connection with the same `installId` (the same copy connecting again, + * Decision 18). Null when the code is unknown, expired or already used. */ + async exchange(input: { + code: string; + label: string; + extensionId: string; + installId: string; + }) { + const at = now(); + return database.transaction(async (transaction) => { + const [consumed] = await transaction + .delete(recorderConnectCodes) + .where( + and( + eq(recorderConnectCodes.codeHash, digest(input.code)), + gt(recorderConnectCodes.expiresAt, at), + ), + ) + .returning(); + if (!consumed) return null; + const [replaced] = await transaction + .update(recorderConnections) + .set({ revokedAt: at, organizationSession: null }) + .where( + and( + eq(recorderConnections.userId, consumed.userId), + eq(recorderConnections.installId, input.installId), + isNull(recorderConnections.revokedAt), + ), + ) + .returning({ id: recorderConnections.id }); + const token = secretWithPrefix(RECORDER_TOKEN_PREFIX); + const [connection] = await transaction + .insert(recorderConnections) + .values({ + id: randomUUID(), + userId: consumed.userId, + tokenHash: digest(token), + label: input.label, + via: consumed.via, + extensionId: input.extensionId, + installId: input.installId, + organizationSession: consumed.organizationSession, + createdAt: at, + }) + .returning(); + if (!connection) throw new Error("The connection was not stored."); + return { + token, + connection: connectionView(connection), + userId: consumed.userId, + replaced: replaced?.id ?? null, + }; + }); + }, + + /** Live connections only, oldest first. */ + async list(userId: string): Promise<RecorderConnection[]> { + const rows = await database + .select() + .from(recorderConnections) + .where( + and( + eq(recorderConnections.userId, userId), + isNull(recorderConnections.revokedAt), + ), + ) + .orderBy(asc(recorderConnections.createdAt)); + return rows.map(connectionView); + }, + + /** A revoked connection keeps no organization session: nothing may ask the organization for it again. */ + async revoke(userId: string, connectionId: string): Promise<boolean> { + const revoked = await database + .update(recorderConnections) + .set({ revokedAt: now(), organizationSession: null }) + .where( + and( + eq(recorderConnections.id, connectionId), + eq(recorderConnections.userId, userId), + isNull(recorderConnections.revokedAt), + ), + ) + .returning({ id: recorderConnections.id }); + return revoked.length > 0; + }, + + /** sha256(token) lookup of a live connection; touches last_used_at at most once a minute. */ + async resolve(token: string) { + const [row] = await database + .select() + .from(recorderConnections) + .where( + and( + eq(recorderConnections.tokenHash, digest(token)), + isNull(recorderConnections.revokedAt), + ), + ) + .limit(1); + if (!row) return null; + const at = now(); + await database + .update(recorderConnections) + .set({ lastUsedAt: at }) + .where( + and( + eq(recorderConnections.id, row.id), + or( + isNull(recorderConnections.lastUsedAt), + lte( + recorderConnections.lastUsedAt, + new Date(at.getTime() - LAST_USED_INTERVAL_MS), + ), + ), + ), + ); + return { + connectionId: row.id, + userId: row.userId, + organizationSession: row.organizationSession, + }; + }, + }; +} diff --git a/server/src/recorder/extension-origins.ts b/server/src/recorder/extension-origins.ts new file mode 100644 index 000000000..2219dc90a --- /dev/null +++ b/server/src/recorder/extension-origins.ts @@ -0,0 +1,9 @@ +import { recorderExtensionIds } from "@openbot/shared/recorder-extension"; +import type { DeploymentConfig } from "../config"; + +/** The Recorder's own id plus the configured extras (a fork, or a build signed with another key). Never decided by NODE_ENV. */ +export function allowedExtensionIds( + config: Pick<DeploymentConfig, "recorder">, +): string[] { + return recorderExtensionIds({ extra: config.recorder.extraExtensionIds }); +} diff --git a/server/src/recorder/guard.ts b/server/src/recorder/guard.ts new file mode 100644 index 000000000..15f70e56f --- /dev/null +++ b/server/src/recorder/guard.ts @@ -0,0 +1,241 @@ +import { createHash } from "node:crypto"; +import { + RECORDER_AUTHORITY_UNAVAILABLE, + RECORDER_SIGNED_OUT, + RECORDER_TOKEN_PREFIX, + TRAJECTORY_GATE_REFUSALS, +} from "@openbot/shared/recorder-api"; +import type { MiddlewareHandler } from "hono"; +import { DEV_ACTOR } from "../auth/dev-actor"; +import { emailDomainAllowed } from "../auth/email-domain"; +import type { AuthenticatedActor, RoleRepository } from "../auth/guards"; +import { + AuthorityUnavailable, + ORGANIZATION_SESSION_COOKIE, +} from "../auth/organization"; +import type { OpenBotRole } from "../auth/roles"; +import type { RecorderConnectionStore } from "./connections"; +import type { RecorderVariables } from "./variables"; + +export type RecorderActor = AuthenticatedActor & { connectionId: string }; +export type RecorderRefusal = + | "malformed" + | "unknown" + | "removed" + | "organization" + | "forbidden"; +/** null = no recorder bearer on the request. `unavailable`: the organization's sign-in server could not answer for now + * (it threw AuthorityUnavailable, or answered something other than a valid actor, a 401 or a 403): never a sign-out, never cached. */ +export type RecorderResolution = + | { actor: RecorderActor } + | { refused: RecorderRefusal; userId?: string } + | { unavailable: true } + | null; +export type RecorderResolver = ( + request: Request, +) => Promise<RecorderResolution>; +export type RecorderMode = + | { kind: "single-user" } + | { + kind: "better-auth"; + roles: RoleRepository; + isRevoked(email: string): Promise<boolean>; + allowedEmailDomains: readonly string[]; + } + | { + kind: "organization"; + /** The wiring's `organizationAuth.verifySessionQuietly`. */ + session(cookieHeader: string): Promise<AuthenticatedActor | null>; + unseal(value: string): Promise<string>; + }; +export type RecorderRefusalSink = (refusal: { + reason: RecorderRefusal; + path: string; + tokenDigest: string | null; + userId?: string; +}) => void; + +export const RECORDER_BEARER = /^Bearer (obr_[A-Za-z0-9_-]{43})$/; + +/** Anything that starts like a recorder token is taken as one, and only as one; anything else is not this guard's. */ +export const RECORDER_BEARER_PREFIX = `Bearer ${RECORDER_TOKEN_PREFIX}`; +/** How long the organization's verdict on one connection is trusted on this replica. */ +const ORGANIZATION_VERDICT_TTL_MS = 60_000; + +type OrganizationVerdict = { role: OpenBotRole } | { refused: "organization" }; +/** The organization's verdicts on this replica, by connection id, oldest first. */ +export type OrganizationVerdictCache = Map< + string, + { at: number; verdict: OrganizationVerdict } +>; + +/** The first 16 hex digits of the offered recorder token's SHA-256, so a refusal can be correlated without keeping the token. */ +function digestOf(header: string | undefined): string | null { + if (!header?.startsWith(RECORDER_BEARER_PREFIX)) return null; + return createHash("sha256") + .update(header.slice("Bearer ".length)) + .digest("hex") + .slice(0, 16); +} + +/** Admin beats user, exactly as `createRequireUser` decides. */ +function strongestRole(roles: OpenBotRole[]): OpenBotRole | undefined { + if (roles.includes("admin")) return "admin"; + if (roles.includes("user")) return "user"; + return undefined; +} + +export function createRecorderResolver(deps: { + connections: Pick<RecorderConnectionStore, "resolve">; + mode: RecorderMode; + user: (userId: string) => Promise<{ + id: string; + email: string; + name: string | null; + image: string | null; + } | null>; + now?: () => number; + /** Test seam: the cache the organization's verdicts are kept in. */ + verdicts?: OrganizationVerdictCache; +}): RecorderResolver { + const now = deps.now ?? Date.now; + const organizationVerdicts: OrganizationVerdictCache = + deps.verdicts ?? new Map(); + + /** Keeps a fresh verdict and drops the expired ones. A key is deleted before it is set again, so insertion order is + * time order and the expired entries are a prefix. */ + function remember(connectionId: string, verdict: OrganizationVerdict) { + const at = now(); + for (const [key, entry] of organizationVerdicts) { + if (at - entry.at < ORGANIZATION_VERDICT_TTL_MS) break; + organizationVerdicts.delete(key); + } + organizationVerdicts.delete(connectionId); + organizationVerdicts.set(connectionId, { at, verdict }); + } + + /** Asks the organization whether the connection's sealed session is still signed in, and as whom. */ + async function askOrganization( + mode: Extract<RecorderMode, { kind: "organization" }>, + connection: { userId: string; organizationSession: string | null }, + ): Promise<OrganizationVerdict> { + if (!connection.organizationSession) return { refused: "organization" }; + const value = await mode.unseal(connection.organizationSession); + const session = await mode.session( + `${ORGANIZATION_SESSION_COOKIE}=${value}`, + ); + // A session the authority now answers for somebody else is not this connection's person signing in. + if (!session || session.id !== connection.userId) { + return { refused: "organization" }; + } + return { role: session.role }; + } + + return async (request) => { + const header = request.headers.get("authorization"); + if (!header?.startsWith(RECORDER_BEARER_PREFIX)) return null; + const token = RECORDER_BEARER.exec(header)?.[1]; + if (!token) return { refused: "malformed" }; + const connection = await deps.connections.resolve(token); + if (!connection) return { refused: "unknown" }; + const { connectionId, userId } = connection; + const user = await deps.user(userId); + if (!user) return { refused: "unknown", userId }; + // The runtime refuses a blank name, so the email stands in for a missing one. + const person = { + id: userId, + email: user.email, + name: user.name?.trim() || user.email, + image: user.image, + }; + + const { mode } = deps; + if (mode.kind === "single-user") { + if (userId !== DEV_ACTOR.id) return { refused: "unknown", userId }; + return { + actor: { ...DEV_ACTOR, name: DEV_ACTOR.email, connectionId }, + }; + } + + if (mode.kind === "better-auth") { + if ( + (await mode.isRevoked(user.email)) || + !emailDomainAllowed(user.email, mode.allowedEmailDomains) + ) { + return { refused: "removed", userId }; + } + const role = strongestRole(await mode.roles.rolesForUser(userId)); + if (!role) return { refused: "forbidden", userId }; + return { actor: { ...person, role, connectionId } }; + } + + let verdict: OrganizationVerdict; + const cached = organizationVerdicts.get(connectionId); + if (cached && now() - cached.at < ORGANIZATION_VERDICT_TTL_MS) { + verdict = cached.verdict; + } else { + try { + verdict = await askOrganization(mode, connection); + } catch (error) { + // Not a sign-out, and not cached: the next request asks again. + if (error instanceof AuthorityUnavailable) return { unavailable: true }; + throw error; + } + remember(connectionId, verdict); + } + if ("refused" in verdict) return { refused: "organization", userId }; + return { actor: { ...person, role: verdict.role, connectionId } }; + }; +} + +export function createRequireRecorder( + resolve: RecorderResolver, + onRefused: RecorderRefusalSink, +): MiddlewareHandler<{ Variables: RecorderVariables }> { + return async (context, next) => { + const resolution = await resolve(context.req.raw); + if (resolution && "actor" in resolution) { + const { connectionId, ...person } = resolution.actor; + context.set("actor", person); + context.set("recorderConnectionId", connectionId); + return next(); + } + if (resolution && "unavailable" in resolution) { + // The organization's sign-in server could not answer: transient, not a sign-out. + return context.json( + { + error: TRAJECTORY_GATE_REFUSALS.authorityUnavailable.message, + code: RECORDER_AUTHORITY_UNAVAILABLE, + }, + 503, + ); + } + // No bearer at all on a recorder-only route is an unknown token. + const reason = resolution?.refused ?? "unknown"; + onRefused({ + reason, + path: context.req.path, + tokenDigest: digestOf(context.req.header("authorization")), + ...(resolution?.userId ? { userId: resolution.userId } : {}), + }); + if (reason === "forbidden") { + return context.json({ error: "Authorization required." }, 403); + } + return context.json( + { + error: + reason === "organization" + ? "Your organization sign-in ended. Connect this browser to OpenBot again." + : "This browser is no longer connected to OpenBot. Connect it again.", + code: RECORDER_SIGNED_OUT, + reason: + reason === "removed" + ? "removed" + : reason === "organization" + ? "organization" + : "revoked", + }, + 401, + ); + }; +} diff --git a/server/src/recorder/refusal-audit.ts b/server/src/recorder/refusal-audit.ts new file mode 100644 index 000000000..74ad6bcb0 --- /dev/null +++ b/server/src/recorder/refusal-audit.ts @@ -0,0 +1,50 @@ +import { + type AuditStore, + DEPLOYMENT_INITIATOR, + recordAuditEvent, +} from "../audit"; +import type { RecorderRefusalSink } from "./guard"; + +/** One row per (token digest, reason) in this window: a browser retrying a dead token must not flood the trail. */ +const WINDOW_MS = 60_000; + +/** Writes recorder.bearer_refused at most once per (token digest prefix, reason) per minute on this replica. */ +export function createRecorderRefusalAudit( + auditStore: AuditStore | undefined, + options: { now?: () => number } = {}, +): RecorderRefusalSink { + if (!auditStore) return () => {}; + const now = options.now ?? Date.now; + // Insertion order is time order (a key is only ever added after its old entry is gone), so the expired keys are a prefix. + const written = new Map<string, number>(); + + return (refusal) => { + const at = now(); + for (const [key, writtenAt] of written) { + if (at - writtenAt < WINDOW_MS) break; + written.delete(key); + } + const key = `${refusal.tokenDigest ?? "none"}:${refusal.reason}`; + if (written.has(key)) return; + written.set(key, at); + + recordAuditEvent(auditStore, { + eventType: "recorder.bearer_refused", + targetType: "recorder_token", + ...(refusal.tokenDigest ? { targetId: refusal.tokenDigest } : {}), + // A refused token whose person is known is that person's; one nobody can be named for is the deployment refusing a caller. + ...(refusal.userId + ? { actorUserId: refusal.userId } + : { initiator: DEPLOYMENT_INITIATOR }), + payload: { reason: refusal.reason, path: refusal.path }, + }).catch((error: unknown) => { + // Never fatal: the refusal is already decided and sent. + console.error( + JSON.stringify({ + type: "recorder-refusal-audit-write-failed", + error: String(error), + }), + ); + }); + }; +} diff --git a/server/src/recorder/routes.ts b/server/src/recorder/routes.ts new file mode 100644 index 000000000..606f5278f --- /dev/null +++ b/server/src/recorder/routes.ts @@ -0,0 +1,263 @@ +import { + connectCodeRequestSchema, + type ExchangeResponse, + exchangeRequestSchema, + type LearningSpaceState, + type RecorderConnectionsResponse, + type RecorderMe, + type RecorderUser, +} from "@openbot/shared/recorder-api"; +import { extensionIdFromOrigin } from "@openbot/shared/recorder-extension"; +import { Hono, type MiddlewareHandler } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { + type AuditStore, + DEPLOYMENT_INITIATOR, + recordAuditEvent, +} from "../audit"; +import type { AppVariables, AuthenticatedActor } from "../auth/guards"; +import type { RecorderConnectionStore } from "./connections"; +import type { RecorderVariables } from "./variables"; + +export type RecorderRouteDeps = { + connections: RecorderConnectionStore; + requireUser: MiddlewareHandler<{ Variables: AppVariables }>; + requireRecorder: MiddlewareHandler<{ Variables: RecorderVariables }>; + allowedExtensionIds: () => string[]; + /** Whether this person's recordings land in their own Learning space (made on first need). Never throws. */ + learningSpace: (actor: AuthenticatedActor) => Promise<LearningSpaceState>; + /** A browser connected as this person: make their Learning space in the background. Never throws or waits. */ + onConnected?: (userId: string) => void; + /** The name falls back to the email, never empty. */ + user: (userId: string) => Promise<RecorderUser | null>; + /** Null outside organization mode. */ + sealOrganizationSession: ( + cookieHeader: string | undefined, + ) => Promise<string | null>; + auditStore?: AuditStore; +}; + +const BODY_LIMIT_BYTES = 2 * 1024; + +/** A cross-site form can only send these three content types, so requiring JSON stops one. */ +const jsonOnly: MiddlewareHandler = async (context, next) => { + if (!context.req.header("content-type")?.startsWith("application/json")) { + return context.json({ error: "Send the request as JSON." }, 415); + } + await next(); +}; + +const smallBody = bodyLimit({ + maxSize: BODY_LIMIT_BYTES, + onError: (context) => + context.json({ error: "That request is too large." }, 413), +}); + +export function createRecorderRoutes(deps: RecorderRouteDeps) { + const routes = new Hono<{ Variables: RecorderVariables }>(); + const audit = (event: Parameters<typeof recordAuditEvent>[1]) => + deps.auditStore + ? recordAuditEvent(deps.auditStore, event).catch((error: unknown) => { + console.error( + JSON.stringify({ + type: "recorder-audit-write-failed", + eventType: event.eventType, + error: String(error), + }), + ); + }) + : Promise.resolve(); + + routes.post( + "/connect-codes", + deps.requireUser, + jsonOnly, + smallBody, + async (context) => { + const body = connectCodeRequestSchema.safeParse( + await context.req.json().catch(() => null), + ); + if (!body.success) { + return context.json( + { error: "Choose how this browser connects." }, + 400, + ); + } + const sealed = await deps.sealOrganizationSession( + context.req.header("cookie"), + ); + const minted = await deps.connections.mintCode( + context.var.actor.id, + body.data.via, + sealed, + ); + if (!minted) { + return context.json( + { + error: + "Too many connection codes were asked for at once. Wait a few minutes and try again.", + }, + 429, + ); + } + return context.json(minted, 201); + }, + ); + + // The code is the credential here, so the only gate is who is asking: Chrome sets Origin and a page cannot forge it. + routes.post("/connections", jsonOnly, smallBody, async (context) => { + const origin = context.req.header("origin"); + const extensionId = extensionIdFromOrigin( + origin, + deps.allowedExtensionIds(), + ); + if (!extensionId) { + await audit({ + eventType: "recorder.connect_refused", + targetType: "recorder_connection", + initiator: DEPLOYMENT_INITIATOR, + payload: { origin: origin ?? null }, + }); + return context.json( + { error: "Only the OpenBot Recorder extension can finish connecting." }, + 403, + ); + } + const body = exchangeRequestSchema.safeParse( + await context.req.json().catch(() => null), + ); + if (!body.success) { + return context.json( + { error: "The connection request was not complete." }, + 400, + ); + } + const made = await deps.connections.exchange({ + code: body.data.code, + label: body.data.label, + installId: body.data.installId, + extensionId, + }); + if (!made) { + return context.json( + { + error: + "This connection code has expired or was already used. Choose Connect this browser in OpenBot again.", + }, + 400, + ); + } + await audit({ + eventType: "recorder.connected", + targetType: "recorder_connection", + targetId: made.connection.id, + actorUserId: made.userId, + payload: { + connectionId: made.connection.id, + via: made.connection.via, + extensionId, + installId: made.connection.installId, + label: made.connection.label, + }, + }); + try { + deps.onConnected?.(made.userId); + } catch (error) { + // Making the person's Learning space is a nicety here: the capture pass makes it too. Never fatal to connecting. + console.error( + JSON.stringify({ + type: "recorder-learning-space-kick-failed", + error: String(error), + }), + ); + } + if (made.replaced) { + await audit({ + eventType: "recorder.revoked", + targetType: "recorder_connection", + targetId: made.replaced, + actorUserId: made.userId, + payload: { connectionId: made.replaced, reason: "replaced" }, + }); + } + const user = await deps.user(made.userId); + if (!user) { + return context.json( + { error: "This person could not be found. Try again." }, + 500, + ); + } + const answer: ExchangeResponse = { + token: made.token, + connection: made.connection, + user, + }; + return context.json(answer, 201); + }); + + routes.get("/connections", deps.requireUser, async (context) => { + const answer: RecorderConnectionsResponse = { + connections: await deps.connections.list(context.var.actor.id), + extensionIds: deps.allowedExtensionIds(), + }; + return context.json(answer); + }); + + // Declared before `:id`, or "current" would be taken for a connection id. + routes.delete( + "/connections/current", + deps.requireRecorder, + async (context) => { + const connectionId = context.var.recorderConnectionId; + if ( + !connectionId || + !(await deps.connections.revoke(context.var.actor.id, connectionId)) + ) { + return context.json({ error: "This browser is not connected." }, 404); + } + await audit({ + eventType: "recorder.revoked", + targetType: "recorder_connection", + targetId: connectionId, + actorUserId: context.var.actor.id, + payload: { connectionId, reason: "disconnected" }, + }); + return context.body(null, 204); + }, + ); + + routes.delete("/connections/:id", deps.requireUser, async (context) => { + const connectionId = context.req.param("id"); + if (!(await deps.connections.revoke(context.var.actor.id, connectionId))) { + return context.json({ error: "That connection was not found." }, 404); + } + await audit({ + eventType: "recorder.revoked", + targetType: "recorder_connection", + targetId: connectionId, + actorUserId: context.var.actor.id, + payload: { connectionId, reason: "revoked" }, + }); + return context.body(null, 204); + }); + + routes.get("/me", deps.requireRecorder, async (context) => { + const actor = context.var.actor; + const user = (await deps.user(actor.id)) ?? { + id: actor.id, + email: actor.email, + name: actor.name?.trim() || actor.email, + }; + const answer: RecorderMe = { + user, + connectionId: context.var.recorderConnectionId ?? "", + }; + return context.json(answer); + }); + + routes.get("/learning", deps.requireRecorder, async (context) => + context.json({ learning: await deps.learningSpace(context.var.actor) }), + ); + + return routes; +} diff --git a/server/src/recorder/trajectory-bindings.ts b/server/src/recorder/trajectory-bindings.ts new file mode 100644 index 000000000..01172234d --- /dev/null +++ b/server/src/recorder/trajectory-bindings.ts @@ -0,0 +1,50 @@ +import { and, eq } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { recorderTrajectoryBindings } from "../db/schema/recorder"; + +/** + * Which person each Recorder trajectory belongs to. The first capture pass for a trajectory binds it to the person whose + * browser asked; a later pass for it must come from the same person. Nothing recorded is kept: the trajectory lives in + * Intelligence, in that person's own Learning space. + */ +export function createTrajectoryBindingStore(database: Database) { + /** Whether this trajectory is bound to this person. */ + const isBoundTo = async ( + trajectoryId: string, + userId: string, + ): Promise<boolean> => { + const [bound] = await database + .select({ userId: recorderTrajectoryBindings.userId }) + .from(recorderTrajectoryBindings) + .where( + and( + eq(recorderTrajectoryBindings.trajectoryId, trajectoryId), + eq(recorderTrajectoryBindings.userId, userId), + ), + ) + .limit(1); + return bound !== undefined; + }; + + return { + /** + * Binds the trajectory to this person, or finds it already bound to them. False when it is bound to anyone else. + * `trajectoryId` must already be a UUID (the column is one). + */ + async bind(input: { + trajectoryId: string; + userId: string; + }): Promise<boolean> { + const [inserted] = await database + .insert(recorderTrajectoryBindings) + .values(input) + .onConflictDoNothing({ + target: recorderTrajectoryBindings.trajectoryId, + }) + .returning({ trajectoryId: recorderTrajectoryBindings.trajectoryId }); + if (inserted) return true; + return isBoundTo(input.trajectoryId, input.userId); + }, + isBoundTo, + }; +} diff --git a/server/src/recorder/trajectory-gate.ts b/server/src/recorder/trajectory-gate.ts new file mode 100644 index 000000000..1cab9af79 --- /dev/null +++ b/server/src/recorder/trajectory-gate.ts @@ -0,0 +1,334 @@ +import type { + CopilotRuntimeHooks, + GetTrajectoryLearningContainerIds, +} from "@copilotkit/runtime/v2"; +import { + joinedLearningContainer, + type LearningSpaceState, + RECORDER_SIGNED_OUT, + TRAJECTORY_GATE_REFUSALS, +} from "@openbot/shared/recorder-api"; +import { + type AuditStore, + DEPLOYMENT_INITIATOR, + recordAuditEvent, +} from "../audit"; +import type { PersonalLearningSpaces } from "../learning/personal-space"; +import type { RecorderRefusal, RecorderResolver } from "./guard"; + +type TrajectoryRefusal = { + reason: + | "invalid-id" + | "signed-out" + | "forbidden" + | "unavailable" + | "bound-elsewhere"; + trajectoryId: string; + userId: string | null; +}; + +/** One row per (person, trajectory, reason) in this window: a browser retrying a refused pass must not flood the trail. */ +const WINDOW_MS = 60_000; +/** The id is the caller's own text until it proves to be a UUID, so only its head is kept. */ +const TRAJECTORY_ID_AUDIT_CHARS = 64; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const SIGNED_OUT_MESSAGES: Record< + Exclude<RecorderRefusal, "forbidden">, + string +> = { + malformed: + "This browser is no longer connected to OpenBot. Connect it again.", + unknown: "This browser is no longer connected to OpenBot. Connect it again.", + removed: "This browser is no longer connected to OpenBot. Connect it again.", + organization: + "Your organization sign-in ended. Connect this browser to OpenBot again.", +}; + +/** The pass's refusal about the trajectory's binding; the code is the one the Recorder already reads. */ +const BINDING_REFUSALS = { + boundElsewhere: { + status: 404, + code: "NOT_FOUND", + message: "This trajectory belongs to another recording.", + }, +} as const; + +/** What the extension is told when its token no longer resolves: the same reasons the recorder guard gives. */ +function signedOutReason(refused: Exclude<RecorderRefusal, "forbidden">) { + if (refused === "removed") return "removed"; + if (refused === "organization") return "organization"; + return "revoked"; +} + +function refuse(status: number, body: Record<string, unknown>): Response { + return Response.json(body, { + status, + headers: { "Cache-Control": "no-store" }, + }); +} + +/** + * The capture pass's gate, on the runtime's own resolved route (onBeforeHandler runs before handleTrajectoryConnect's + * isValidTrajectoryId). Only a connected browser gets a pass: a valid Recorder bearer and a UUID trajectory id. No Bot + * is named: a recording belongs to the person, and goes into their own Learning space. The first pass binds the + * trajectory to that person; a later pass for it from anyone else is not found. + */ +export function createTrajectoryConnectHook(deps: { + resolveRecorder: RecorderResolver; + /** False when the trajectory is already bound to another person. */ + bind(input: { trajectoryId: string; userId: string }): Promise<boolean>; + /** Writes recorder.trajectory_refused, at most once per (person, trajectory, reason) a minute on this replica, and once per reason a minute for callers with no person. */ + audit: (refusal: TrajectoryRefusal) => void; +}): NonNullable<CopilotRuntimeHooks["onBeforeHandler"]> { + return async ({ request, route }) => { + if (route.method !== "trajectory/connect") return; + const { trajectoryId } = route; + const refusal = ( + reason: TrajectoryRefusal["reason"], + userId: string | null, + response: Response, + ): never => { + deps.audit({ reason, trajectoryId, userId }); + throw response; + }; + const known = ( + reason: TrajectoryRefusal["reason"], + userId: string | null, + { status, ...body }: { status: number; code: string; message: string }, + ): never => refusal(reason, userId, refuse(status, body)); + + if (!UUID.test(trajectoryId)) { + known("invalid-id", null, TRAJECTORY_GATE_REFUSALS.invalidId); + } + + const resolution = await deps.resolveRecorder(request); + if (!resolution) { + // No Recorder bearer: a signed-in session alone gets no pass, because only a connected browser records. + return refusal( + "signed-out", + null, + refuse(401, { + code: "IDENTITY_REQUIRED", + message: "Connect this browser to OpenBot to record.", + }), + ); + } + if ("unavailable" in resolution) { + return known( + "unavailable", + null, + TRAJECTORY_GATE_REFUSALS.authorityUnavailable, + ); + } + if ("refused" in resolution) { + const userId = resolution.userId ?? null; + if (resolution.refused === "forbidden") { + return known("forbidden", userId, TRAJECTORY_GATE_REFUSALS.forbidden); + } + return refusal( + "signed-out", + userId, + refuse(401, { + code: RECORDER_SIGNED_OUT, + message: SIGNED_OUT_MESSAGES[resolution.refused], + reason: signedOutReason(resolution.refused), + }), + ); + } + const personId = resolution.actor.id; + + if (!(await deps.bind({ trajectoryId, userId: personId }))) { + known("bound-elsewhere", personId, BINDING_REFUSALS.boundElsewhere); + } + }; +} + +/** Inside the runtime's 5 s selector deadline (`core/learning.ts` `resolveTrajectoryLearningContainerIds`). */ +const SELECTOR_BUDGET_MS = 4_000; +/** The check is told to stop this long before the budget ends, so a probe it cuts short can still name its container. */ +const CHECK_STOP_EARLY_MS = 100; + +/** + * The ids a pass joins. Only a space that does not exist and could not be made, or no space at all, joins none: an + * answer Intelligence could not give (unverified, or Learning delivery off) still names the person's container, so one + * slow probe does not change which space a trajectory joins from one pass to the next. Never `[null]`, which the runtime + * would reject whole and join with no container. + */ +function containerIdsFor(learning: LearningSpaceState): string[] | null { + const containerId = joinedLearningContainer(learning); + return containerId === null ? null : [containerId]; +} + +/** + * Chooses a trajectory's Learning space for the runtime: the own Learning space of the person its first pass bound it + * to, made on the spot when it does not exist yet and a credential here can make it (learning/personal-space.ts). + * Stable for a trajectory, because a person's space id never changes. It answers inside `budgetMs` and as soon as + * `signal` aborts, and never throws, because the runtime joins without any container when the selector throws or misses + * its deadline (`intelligence-platform/client.ts` `ɵconnectTrajectory`). + */ +export function createTrajectoryLearningSelector(deps: { + /** Whether this person's capture pass bound this trajectory. */ + isBoundTo(trajectoryId: string, userId: string): Promise<boolean>; + personalSpace: Pick<PersonalLearningSpaces, "state">; + /** Default 4_000: answer inside the runtime's 5 s deadline. */ + budgetMs?: number; +}): GetTrajectoryLearningContainerIds { + const budgetMs = deps.budgetMs ?? SELECTOR_BUDGET_MS; + const failed = (type: string, trajectoryId: string, error: unknown) => + console.error(JSON.stringify({ type, trajectoryId, error: String(error) })); + + return ({ trajectoryId, user }, signal) => + new Promise<string[] | null>((resolve) => { + /** Aborted shortly before the budget ends or when the runtime gives up, so the probe stops too. */ + const stopCheck = new AbortController(); + let answered = false; + + const answer = (ids: string[] | null) => { + if (answered) return; + answered = true; + clearTimeout(stopTimer); + clearTimeout(budgetTimer); + signal?.removeEventListener("abort", onAbort); + stopCheck.abort(new Error("Learning space selection is over")); + resolve(ids); + }; + + const onAbort = () => answer(null); + const stopTimer = setTimeout( + () => + stopCheck.abort(new Error("Learning space selection is out of time")), + Math.max(0, budgetMs - CHECK_STOP_EARLY_MS), + ); + const budgetTimer = setTimeout(() => { + console.warn( + JSON.stringify({ + type: "recorder-trajectory-learning-cut-short", + trajectoryId, + }), + ); + answer(null); + }, budgetMs); + if (signal?.aborted) answer(null); + else signal?.addEventListener("abort", onAbort, { once: true }); + + void (async () => { + let bound: boolean; + try { + bound = await deps.isBoundTo(trajectoryId, user.id); + } catch (error) { + failed( + "recorder-trajectory-learning-binding-failed", + trajectoryId, + error, + ); + answer(null); + return; + } + if (!bound || answered) { + answer(null); + return; + } + try { + answer( + containerIdsFor( + await deps.personalSpace.state(user.id, stopCheck.signal), + ), + ); + } catch (error) { + failed( + "recorder-trajectory-learning-check-failed", + trajectoryId, + error, + ); + answer(null); + } + })(); + }); +} + +/** + * Defense in depth for identifyUser (`request-actor.ts` answers a Recorder bearer only where this is true): the same + * unanchored shape the runtime's router matches (`core/fetch-router.ts` `matchRoute`): the last three non-empty + * segments are `trajectory/<id>/connect`, and the id decodes to something. + */ +export function isTrajectoryConnectPath( + pathname: string, + basePath?: string, +): boolean { + let remainder = pathname; + if (basePath) { + const base = + basePath.length > 1 && basePath.endsWith("/") + ? basePath.slice(0, -1) + : basePath; + if (base !== "/") { + if (!pathname.startsWith(base)) return false; + const afterBase = pathname.slice(base.length); + if (afterBase.length > 0 && !afterBase.startsWith("/")) return false; + remainder = afterBase || "/"; + } + } + const segments = remainder.split("/").filter(Boolean); + const length = segments.length; + if ( + length < 3 || + segments[length - 3] !== "trajectory" || + segments[length - 1] !== "connect" + ) { + return false; + } + try { + return decodeURIComponent(segments[length - 2] ?? "") !== ""; + } catch { + return false; + } +} + +/** Writes recorder.trajectory_refused at most once per (person, trajectory, reason) a minute on this replica, and once per reason a minute for callers with no person. */ +export function createTrajectoryRefusalAudit( + auditStore: AuditStore | undefined, + options: { now?: () => number } = {}, +): (refusal: TrajectoryRefusal) => void { + if (!auditStore) return () => {}; + const now = options.now ?? Date.now; + // Insertion order is time order (a key is only ever added after its old entry is gone), so the expired keys are a prefix. + const written = new Map<string, number>(); + + return (refusal) => { + const at = now(); + for (const [key, writtenAt] of written) { + if (at - writtenAt < WINDOW_MS) break; + written.delete(key); + } + const trajectoryId = refusal.trajectoryId.slice( + 0, + TRAJECTORY_ID_AUDIT_CHARS, + ); + // A caller nobody is signed in as can invent trajectory ids without limit, so theirs dedupe on the reason alone. + const key = refusal.userId + ? `${refusal.userId}:${trajectoryId}:${refusal.reason}` + : `none:${refusal.reason}`; + if (written.has(key)) return; + written.set(key, at); + + recordAuditEvent(auditStore, { + eventType: "recorder.trajectory_refused", + targetType: "recorder_trajectory", + targetId: trajectoryId, + // A refusal whose person is known is that person's; one nobody can be named for is the deployment refusing a caller. + ...(refusal.userId + ? { actorUserId: refusal.userId } + : { initiator: DEPLOYMENT_INITIATOR }), + payload: { reason: refusal.reason }, + }).catch((error: unknown) => { + // Never fatal: the refusal is already decided and sent. + console.error( + JSON.stringify({ + type: "recorder-trajectory-refusal-audit-write-failed", + error: String(error), + }), + ); + }); + }; +} diff --git a/server/src/recorder/variables.ts b/server/src/recorder/variables.ts new file mode 100644 index 000000000..55cbb7692 --- /dev/null +++ b/server/src/recorder/variables.ts @@ -0,0 +1,5 @@ +import type { AppVariables } from "../auth/guards"; + +export type RecorderVariables = AppVariables & { + recorderConnectionId?: string; +}; diff --git a/server/src/recorder/wiring.ts b/server/src/recorder/wiring.ts new file mode 100644 index 000000000..e0faf547c --- /dev/null +++ b/server/src/recorder/wiring.ts @@ -0,0 +1,158 @@ +import type { + CopilotRuntimeHooks, + GetTrajectoryLearningContainerIds, +} from "@copilotkit/runtime/v2"; +import { eq } from "drizzle-orm"; +import type { AuditStore } from "../audit"; +import type { AuthenticatedActor, RoleRepository } from "../auth/guards"; +import { ORGANIZATION_SESSION_COOKIE } from "../auth/organization"; +import type { DeploymentConfig } from "../config"; +import { decryptSecret, encryptSecret } from "../credentials"; +import type { Database } from "../db/client"; +import { users } from "../db/schema"; +import type { PersonalLearningSpaces } from "../learning/personal-space"; +import type { PeopleStore } from "../people/store"; +import { createRecorderConnectionStore } from "./connections"; +import { allowedExtensionIds } from "./extension-origins"; +import { + createRecorderResolver, + type RecorderMode, + type RecorderRefusalSink, + type RecorderResolver, +} from "./guard"; +import { createRecorderRefusalAudit } from "./refusal-audit"; +import type { RecorderRouteDeps } from "./routes"; +import { createTrajectoryBindingStore } from "./trajectory-bindings"; +import { + createTrajectoryConnectHook, + createTrajectoryLearningSelector, + createTrajectoryRefusalAudit, +} from "./trajectory-gate"; + +/** What createApp's `coworker.recorder` takes. */ +export type RecorderExtensionDeps = Omit< + RecorderRouteDeps, + "requireUser" | "requireRecorder" | "auditStore" +> & { + resolve: RecorderResolver; + onRefused: RecorderRefusalSink; +}; + +/** How long the Recorder's popup waits for the person's Learning space: the capture pass's own budget. */ +const LEARNING_SPACE_WAIT_MS = 4_000; + +/** The value of one cookie in a Cookie header, or undefined. */ +function cookieValue(header: string, name: string): string | undefined { + for (const part of header.split(";")) { + const at = part.indexOf("="); + if (at > 0 && part.slice(0, at).trim() === name) { + return part.slice(at + 1).trim(); + } + } + return undefined; +} + +/** + * Every piece of Recorder wiring `index.ts` would otherwise hold, so tests build exactly what `index.ts` builds + * (`index.ts` cannot be imported by a test: it loads the config and starts the server at top level). + */ +export function createRecorderExtension(deps: { + config: DeploymentConfig; + database: Database; + organizationAuth: + | { + verifySessionQuietly( + cookieHeader: string, + ): Promise<AuthenticatedActor | null>; + } + | undefined; + roleRepository: RoleRepository; + peopleStore: Pick<PeopleStore, "isRevoked">; + /** Each person's own Learning space, which every recording they make joins. */ + personalSpaces: PersonalLearningSpaces; + auditStore: AuditStore; +}): RecorderExtensionDeps & { + /** The capture pass, for `mountCopilotRuntime`: its gate (an onBeforeHandler hook) and its Learning space selector. */ + trajectoryCapture: { + gate: NonNullable<CopilotRuntimeHooks["onBeforeHandler"]>; + selectLearningContainers: GetTrajectoryLearningContainerIds; + }; +} { + const { config, database, organizationAuth } = deps; + const connections = createRecorderConnectionStore(database); + const trajectoryBindings = createTrajectoryBindingStore(database); + const { personalSpaces } = deps; + + const mode: RecorderMode = config.singleUser + ? { kind: "single-user" } + : organizationAuth + ? { + kind: "organization", + session: (cookie) => organizationAuth.verifySessionQuietly(cookie), + unseal: (value) => decryptSecret(config.keyEncryptionKey, value), + } + : { + kind: "better-auth", + roles: deps.roleRepository, + isRevoked: (email) => deps.peopleStore.isRevoked(email), + allowedEmailDomains: config.auth?.allowedEmailDomains ?? [], + }; + + const userRow = async (userId: string) => + ( + await database + .select({ + id: users.id, + email: users.email, + name: users.name, + image: users.image, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1) + )[0] ?? null; + const resolve = createRecorderResolver({ connections, mode, user: userRow }); + + const sealOrganizationSession: RecorderExtensionDeps["sealOrganizationSession"] = + organizationAuth + ? async (cookieHeader) => { + const value = cookieHeader + ? cookieValue(cookieHeader, ORGANIZATION_SESSION_COOKIE) + : undefined; + return value ? encryptSecret(config.keyEncryptionKey, value) : null; + } + : async () => null; + + return { + connections, + allowedExtensionIds: () => allowedExtensionIds(config), + // Bounded like a capture pass, so the popup is not held by a slow Intelligence; a creation it started goes on. + learningSpace: (actor) => + personalSpaces.state( + actor.id, + AbortSignal.timeout(LEARNING_SPACE_WAIT_MS), + ), + // Made when the browser connects, so a space only a slow credential can make is there before the first recording. + onConnected: (userId) => personalSpaces.kick(userId), + user: async (userId) => { + const row = await userRow(userId); + return row + ? { id: row.id, email: row.email, name: row.name?.trim() || row.email } + : null; + }, + sealOrganizationSession, + resolve, + onRefused: createRecorderRefusalAudit(deps.auditStore), + trajectoryCapture: { + gate: createTrajectoryConnectHook({ + resolveRecorder: resolve, + bind: trajectoryBindings.bind, + audit: createTrajectoryRefusalAudit(deps.auditStore), + }), + selectLearningContainers: createTrajectoryLearningSelector({ + isBoundTo: trajectoryBindings.isBoundTo, + personalSpace: personalSpaces, + }), + }, + }; +} diff --git a/server/src/request-actor.ts b/server/src/request-actor.ts new file mode 100644 index 000000000..96567a1f8 --- /dev/null +++ b/server/src/request-actor.ts @@ -0,0 +1,109 @@ +import { DEV_ACTOR } from "./auth/dev-actor"; +import type { OpenBotRole } from "./auth/roles"; +import type { IdentifyActor, IdentifyUser } from "./copilot"; +import { + RECORDER_BEARER_PREFIX, + type RecorderResolver, +} from "./recorder/guard"; +import { isTrajectoryConnectPath } from "./recorder/trajectory-gate"; + +type RequestActor = { id: string; name: string; role: OpenBotRole }; + +type SessionLike = { + user: { + id: string; + email?: string | null; + name?: string | null; + role?: OpenBotRole | null; + }; +}; + +/** + * The authorization projection's answer for an unauthenticated request: a person who owns nothing + * rather than an error, so the runtime can still describe itself, `/info` reports the licence and + * the public roster, which is what a deployment check reads to tell "the licence is invalid" apart + * from "chat is silently broken". It grants nothing: this actor matches no private profile and is + * not an administrator, and a run still fails in `identifyUser`, which has no anonymous case + * because a thread must belong to somebody. + */ +const ANONYMOUS_ACTOR = { id: "", role: "user" } as const; + +/** The name the runtime is given: never blank, because its `resolveUser` refuses a blank name. */ +export function actorName(user: { + id: string; + name?: string | null; + email?: string | null; +}): string { + return user.name?.trim() || user.email || user.id; +} + +/** + * Who is asking, for a CopilotKit request. + * + * One resolver, because a run has two questions to answer about the same person: whose threads and + * memory these are, and which coworkers they may run. Answering them from different places is how + * one person ends up running another's private coworker, or reading their thread. + */ +export function createRequestActor(deps: { + singleUser: boolean; + resolveRecorder?: RecorderResolver; + getSession(headers: Headers): Promise<SessionLike | null | undefined>; + rolesForUser(userId: string): Promise<OpenBotRole[]>; +}): { + resolveRequestActor(request: Request): Promise<RequestActor>; + identifyUser: IdentifyUser; + identifyActor: IdentifyActor; +} { + async function resolveRequestActor(request: Request): Promise<RequestActor> { + // A Recorder token is answered only on the capture pass, in every auth mode: it can never run a Bot. + if ( + request.headers.get("authorization")?.startsWith(RECORDER_BEARER_PREFIX) + ) { + if ( + deps.resolveRecorder && + isTrajectoryConnectPath(new URL(request.url).pathname) + ) { + const resolution = await deps.resolveRecorder(request); + if (resolution && "actor" in resolution) { + const { actor } = resolution; + return { id: actor.id, name: actorName(actor), role: actor.role }; + } + } + throw new Error("A CopilotKit run requires a signed-in user."); + } + if (deps.singleUser) { + return { id: DEV_ACTOR.id, name: DEV_ACTOR.email, role: DEV_ACTOR.role }; + } + const session = await deps.getSession(request.headers); + const user = session?.user; + if (!user) { + throw new Error("A CopilotKit run requires a signed-in user."); + } + const roles = user.role ? [user.role] : await deps.rolesForUser(user.id); + if (!roles.includes("admin") && !roles.includes("user")) { + throw new Error("A CopilotKit run requires an authorized user."); + } + return { + id: user.id, + name: actorName(user), + role: roles.includes("admin") ? "admin" : "user", + }; + } + + /** The Intelligence projection of {@link resolveRequestActor}: threads are scoped to this person. */ + const identifyUser: IdentifyUser = async (request) => { + const { id, name } = await resolveRequestActor(request); + return { id, name }; + }; + + const identifyActor: IdentifyActor = async (request) => { + try { + const { id, role } = await resolveRequestActor(request); + return { id, role }; + } catch { + return ANONYMOUS_ACTOR; + } + }; + + return { resolveRequestActor, identifyUser, identifyActor }; +} diff --git a/server/tests/fixtures/learned-skill-archive.ts b/server/tests/fixtures/learned-skill-archive.ts new file mode 100644 index 000000000..a9969686d --- /dev/null +++ b/server/tests/fixtures/learned-skill-archive.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; +import { crc32 } from "node:zlib"; +import type { LearnedSkillsSnapshotResult } from "@copilotkit/runtime/v2"; + +/** + * A published learned-skills snapshot holding the named Skills, built the way Intelligence builds one: a ZIP with a + * `manifest.json` and a `<name>/SKILL.md` per Skill, the manifest naming each file's size and SHA-256, and the ETag the + * archive's SHA-256. Stored rather than deflated, which the SDK reads the same way. For tests that need Skills with + * chosen names; `learned-skills.ts` holds the fixed pair the older tests use. + */ +export function skillArchive( + revision: string, + skills: readonly { name: string; description?: string; body?: string }[], +): LearnedSkillsSnapshotResult { + const sorted = [...skills].sort((a, b) => + Buffer.compare(Buffer.from(a.name), Buffer.from(b.name)), + ); + const files: { path: string; bytes: Buffer }[] = []; + const manifest = { + schemaVersion: 1, + revision, + skills: sorted.map((skill) => { + const bytes = Buffer.from( + skill.body ?? `# ${skill.name}\n\nGuidance for ${skill.name}.\n`, + ); + files.push({ path: `${skill.name}/SKILL.md`, bytes }); + return { + name: skill.name, + description: skill.description ?? `What ${skill.name} does.`, + files: [ + { + path: "SKILL.md", + size: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + ], + }; + }), + }; + files.unshift({ + path: "manifest.json", + bytes: Buffer.from(JSON.stringify(manifest)), + }); + const bytes = zip(files); + return { + status: "snapshot", + bytes: Uint8Array.from(bytes), + revision, + etag: `"${createHash("sha256").update(bytes).digest("hex")}"`, + contentType: "application/zip", + }; +} + +/** A minimal stored ZIP: one local header per file, then the central directory and its end record. */ +function zip(files: readonly { path: string; bytes: Buffer }[]): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + for (const file of files) { + const name = Buffer.from(file.path); + const crc = crc32(file.bytes); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(0, 8); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(file.bytes.length, 18); + local.writeUInt32LE(file.bytes.length, 22); + local.writeUInt16LE(name.length, 26); + locals.push(local, name, file.bytes); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(0, 10); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(file.bytes.length, 20); + central.writeUInt32LE(file.bytes.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(offset, 42); + centrals.push(central, name); + offset += local.length + name.length + file.bytes.length; + } + const directory = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, directory, end]); +} diff --git a/server/tests/learning-personal-skills.test.ts b/server/tests/learning-personal-skills.test.ts new file mode 100644 index 000000000..ad3536aef --- /dev/null +++ b/server/tests/learning-personal-skills.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, test } from "bun:test"; +import type { VerifiedSnapshot } from "@copilotkit/runtime/internal/learned-skills"; +import { + CopilotKitIntelligence, + LearnedSkillsError, + type LearnedSkillsSnapshotResult, +} from "@copilotkit/runtime/v2"; +import type { LearningSettings } from "@openbot/shared/learning"; +import { personalLearningContainerId } from "../src/learning/personal-space"; +import { + createLearningRuntime, + mergeLearnedSkills, +} from "../src/learning/runtime"; +import type { LearningSettingsStore } from "../src/learning/settings"; +import type { OpenSkillSource } from "../src/learning/sharing"; +import { skillArchive } from "./fixtures/learned-skill-archive"; + +const ADA = "person-ada"; +const GRACE = "person-grace"; +const ALAN = "person-alan"; +const space = personalLearningContainerId; + +/** What each Learning container publishes, or the error its skills read answers with. */ +type Published = Record< + string, + | LearnedSkillsSnapshotResult + | LearnedSkillsError + | (() => Promise<LearnedSkillsSnapshotResult>) +>; + +function runtime( + options: { + settings?: LearningSettings; + published?: Published; + open?: OpenSkillSource[] | (() => Promise<OpenSkillSource[]>); + personalWaitMs?: number; + people?: false; + } = {}, +) { + let clock = 1_000_000; + const settings: LearningSettings = options.settings ?? { + enabled: true, + defaultTarget: { containerId: "openbot" }, + agents: {}, + }; + const store = { + read: async () => settings, + } as unknown as LearningSettingsStore; + const client = new CopilotKitIntelligence({ apiKey: "synthetic-never-sent" }); + const requests: string[] = []; + client.getLearnedSkillsSnapshot = async ({ containerId }) => { + requests.push(containerId); + const answer = options.published?.[containerId]; + if (answer === undefined) + throw new LearnedSkillsError("CONTAINER_NOT_FOUND", false); + if (answer instanceof LearnedSkillsError) throw answer; + return typeof answer === "function" ? answer() : answer; + }; + const open = options.open ?? []; + const learning = createLearningRuntime({ + store, + client, + freshnessWindowMs: 0, + ...(options.people === false + ? {} + : { + people: { + containerFor: space, + openSkills: typeof open === "function" ? open : async () => open, + }, + }), + ...(options.personalWaitMs === undefined + ? {} + : { personalWaitMs: options.personalWaitMs }), + now: () => clock, + }); + return { + learning, + requests, + advance: (ms: number) => { + clock += ms; + }, + }; +} + +/** The Skill names a run's catalog offers. */ +function names(catalog: string | undefined): string[] { + const json = catalog?.match(/\[.*\]/s)?.[0]; + return json + ? (JSON.parse(json) as { name: string }[]).map((skill) => skill.name) + : []; +} + +const botSkills = skillArchive("bot-r1", [ + { name: "bot-only" }, + { name: "refund-order", body: "The Bot's own refund steps." }, +]); +const adaSkills = skillArchive("ada-r1", [ + { name: "ada-only" }, + { name: "refund-order", body: "Ada's refund steps." }, + { name: "shared-name", body: "Ada's shared-name." }, +]); +const graceSkills = skillArchive("grace-r1", [ + { name: "grace-only" }, + { name: "grace-picked" }, + { name: "refund-order", body: "Grace's refund steps." }, + { name: "ada-only", body: "Grace's ada-only." }, + { name: "both-opened", body: "Grace's both-opened." }, +]); +const alanSkills = skillArchive("alan-r1", [ + { name: "both-opened", body: "Alan's both-opened." }, + { name: "alan-only" }, +]); + +describe("a person's run", () => { + test("gets the Bot's own Skills and the person's own, and the Bot's own wins a name clash", async () => { + const { learning } = runtime({ + published: { openbot: botSkills, [space(ADA)]: adaSkills }, + }); + const run = await learning.acquire("bot", undefined, ADA); + expect(names(run?.catalog)).toEqual([ + "ada-only", + "bot-only", + "refund-order", + "shared-name", + ]); + expect( + await run?.execute("copilotkit_load_skill", { + skill_name: "refund-order", + }), + ).toContain("The Bot's own refund steps."); + expect( + await run?.execute("copilotkit_load_skill", { skill_name: "ada-only" }), + ).toContain("Guidance for ada-only."); + }); + + test("of a Bot with no Learning space of its own still gets the person's Skills", async () => { + const { learning } = runtime({ + settings: { enabled: true, defaultTarget: null, agents: {} }, + published: { [space(ADA)]: adaSkills }, + }); + const run = await learning.acquire("bot", undefined, ADA); + expect(names(run?.catalog)).toEqual([ + "ada-only", + "refund-order", + "shared-name", + ]); + expect(run?.tools.map((tool) => tool.name)).toEqual([ + "copilotkit_load_skill", + "copilotkit_read_skill_file", + ]); + }); + + test("with no person named gets the Bot's own Skills only, exactly as before", async () => { + const { learning, requests } = runtime({ + published: { openbot: botSkills, [space(ADA)]: adaSkills }, + }); + const run = await learning.acquire("bot"); + expect(names(run?.catalog)).toEqual(["bot-only", "refund-order"]); + expect(requests).toEqual(["openbot"]); + }); + + test("Learning off, or the Bot excluded by an administrator, gets nothing and asks nothing", async () => { + for (const settings of [ + { enabled: false, defaultTarget: { containerId: "openbot" }, agents: {} }, + { + enabled: true, + defaultTarget: { containerId: "openbot" }, + agents: { bot: null }, + }, + ] satisfies LearningSettings[]) { + const { learning, requests } = runtime({ + settings, + published: { openbot: botSkills, [space(ADA)]: adaSkills }, + }); + expect(await learning.acquire("bot", undefined, ADA)).toBeUndefined(); + expect(requests).toEqual([]); + } + }); + + test("a Bot with no space and a person with no Skills runs exactly as one with no Learning", async () => { + const { learning } = runtime({ + settings: { enabled: true, defaultTarget: null, agents: {} }, + }); + expect(await learning.acquire("bot", undefined, ADA)).toBeUndefined(); + }); +}); + +describe("a person's space failing", () => { + test.each([ + ["missing", new LearnedSkillsError("CONTAINER_NOT_FOUND", false)], + ["unreachable", new LearnedSkillsError("NETWORK_ERROR", true)], + ["refused", new LearnedSkillsError("AUTHORIZATION_FAILED", false)], + ["not served", new LearnedSkillsError("UNSUPPORTED_SERVER", false)], + ])( + "%s never breaks the run: it goes on with the Bot's own Skills", + async (_label, error) => { + const { learning } = runtime({ + published: { openbot: botSkills, [space(ADA)]: error }, + }); + const run = await learning.acquire("bot", undefined, ADA); + expect(names(run?.catalog)).toEqual(["bot-only", "refund-order"]); + }, + ); + + test("a space that never answers is left behind after the wait, and the run goes on", async () => { + const { learning } = runtime({ + published: { + openbot: botSkills, + [space(ADA)]: () => new Promise(() => {}), + }, + personalWaitMs: 30, + }); + const started = performance.now(); + const run = await learning.acquire("bot", undefined, ADA); + expect(performance.now() - started).toBeLessThan(1_000); + expect(names(run?.catalog)).toEqual(["bot-only", "refund-order"]); + }); + + test("a refused space is not asked again by runs for a minute", async () => { + const { learning, requests, advance } = runtime({ + published: { + openbot: botSkills, + [space(ADA)]: new LearnedSkillsError("CONTAINER_NOT_FOUND", false), + }, + }); + await learning.acquire("bot", undefined, ADA); + await learning.acquire("bot", undefined, ADA); + expect(requests.filter((id) => id === space(ADA))).toHaveLength(1); + advance(60_001); + await learning.acquire("bot", undefined, ADA); + expect(requests.filter((id) => id === space(ADA))).toHaveLength(2); + }); + + test("an open-Skill index that cannot be read leaves the person's own Skills in the run", async () => { + const { learning } = runtime({ + published: { openbot: botSkills, [space(ADA)]: adaSkills }, + open: async () => { + throw new Error("database down"); + }, + }); + const run = await learning.acquire("bot", undefined, ADA); + expect(names(run?.catalog)).toContain("ada-only"); + }); + + test("the Bot's own refusal still fails the run, as it always has", async () => { + const { learning } = runtime({ + published: { + openbot: new LearnedSkillsError("DELIVERY_DISABLED", false), + [space(ADA)]: adaSkills, + }, + }); + await expect(learning.acquire("bot", undefined, ADA)).rejects.toThrow(); + }); +}); + +describe("other people's opened Skills", () => { + const published = { + openbot: botSkills, + [space(ADA)]: adaSkills, + [space(GRACE)]: graceSkills, + [space(ALAN)]: alanSkills, + }; + + test("closed: nobody else's Bots see them", async () => { + // A closed person has no entry in the index at all. + const { learning, requests } = runtime({ published, open: [] }); + const run = await learning.acquire("bot", undefined, ADA); + expect(names(run?.catalog)).not.toContain("grace-only"); + expect(requests).not.toContain(space(GRACE)); + }); + + test("open: every Skill joins another person's run, under the clash rule", async () => { + const { learning } = runtime({ + published, + open: [{ userId: GRACE, containerId: space(GRACE), skills: "all" }], + }); + const run = await learning.acquire("bot", undefined, ADA); + expect(names(run?.catalog)).toEqual([ + "ada-only", + "bot-only", + "both-opened", + "grace-only", + "grace-picked", + "refund-order", + "shared-name", + ]); + // The Bot's own beats an opened one; the person's own beats an opened one. + expect( + await run?.execute("copilotkit_load_skill", { + skill_name: "refund-order", + }), + ).toContain("The Bot's own refund steps."); + expect( + await run?.execute("copilotkit_load_skill", { skill_name: "ada-only" }), + ).toContain("Guidance for ada-only."); + }); + + test("choose: only the Skills picked join another person's run", async () => { + const { learning } = runtime({ + published, + open: [ + { + userId: GRACE, + containerId: space(GRACE), + skills: new Set(["grace-picked"]), + }, + ], + }); + const run = await learning.acquire("bot", undefined, ADA); + expect(names(run?.catalog)).toContain("grace-picked"); + expect(names(run?.catalog)).not.toContain("grace-only"); + }); + + test("a person's own run always has all their own Skills, whatever they opened", async () => { + const { learning } = runtime({ + published, + open: [ + { + userId: GRACE, + containerId: space(GRACE), + skills: new Set(["grace-picked"]), + }, + ], + }); + const run = await learning.acquire("bot", undefined, GRACE); + expect(names(run?.catalog)).toEqual( + expect.arrayContaining(["grace-only", "grace-picked", "both-opened"]), + ); + }); + + test("two people opening a Skill with the same name both keep it, each under their space's name", async () => { + const { learning } = runtime({ + published, + open: [ + { userId: GRACE, containerId: space(GRACE), skills: "all" }, + { userId: ALAN, containerId: space(ALAN), skills: "all" }, + ], + }); + const run = await learning.acquire("bot", undefined, ADA); + const offered = names(run?.catalog); + expect(offered).not.toContain("both-opened"); + expect(offered).toEqual( + expect.arrayContaining([ + `${space(GRACE)}/both-opened`, + `${space(ALAN)}/both-opened`, + "alan-only", + "grace-only", + ]), + ); + expect( + await run?.execute("copilotkit_load_skill", { + skill_name: `${space(ALAN)}/both-opened`, + }), + ).toContain("Alan's both-opened."); + expect( + await run?.execute("copilotkit_load_skill", { + skill_name: `${space(GRACE)}/both-opened`, + }), + ).toContain("Grace's both-opened."); + }); + + test("an opened space that fails is left out, and the rest still arrive", async () => { + const { learning } = runtime({ + published: { + ...published, + [space(GRACE)]: new LearnedSkillsError("NETWORK_ERROR", true), + }, + open: [ + { userId: GRACE, containerId: space(GRACE), skills: "all" }, + { userId: ALAN, containerId: space(ALAN), skills: "all" }, + ], + }); + const run = await learning.acquire("bot", undefined, ADA); + expect(names(run?.catalog)).toEqual( + expect.arrayContaining(["alan-only", "both-opened"]), + ); + expect(names(run?.catalog)).not.toContain("grace-only"); + }); +}); + +describe("mergeLearnedSkills", () => { + const snapshot = (revision: string, skillNames: string[]): VerifiedSnapshot => + Object.freeze({ + revision, + etag: `"${revision}"`, + skills: skillNames.map((name) => + Object.freeze({ + name, + description: `${revision}:${name}`, + files: [], + }), + ), + }); + + test("with nothing to add, the Bot's own snapshot comes back unchanged", () => { + const bot = snapshot("bot", ["a"]); + expect(mergeLearnedSkills({ bot })).toBe(bot); + expect(mergeLearnedSkills({ bot, opened: [] })).toBe(bot); + }); + + test("the Bot's own, then the person's own, then opened ones, with clashing opened names qualified", () => { + const merged = mergeLearnedSkills({ + bot: snapshot("bot", ["x", "y"]), + own: snapshot("own", ["y", "z"]), + opened: [ + { + source: { userId: "g", containerId: "user-g", skills: "all" }, + snapshot: snapshot("g", ["x", "z", "w", "v"]), + }, + { + source: { + userId: "h", + containerId: "user-h", + skills: new Set(["w"]), + }, + snapshot: snapshot("h", ["w", "u"]), + }, + ], + }); + expect( + merged?.skills.map((skill) => [skill.name, skill.description]), + ).toEqual([ + ["x", "bot:x"], + ["y", "bot:y"], + ["z", "own:z"], + ["v", "g:v"], + ["user-g/w", "g:w"], + ["user-h/w", "h:w"], + ]); + }); + + test("the merged snapshot's identity changes with any source and stays the same for the same sources", () => { + const input = { + bot: snapshot("bot", ["a"]), + own: snapshot("own", ["b"]), + }; + const first = mergeLearnedSkills(input); + expect(mergeLearnedSkills(input)?.etag).toBe(first?.etag); + expect( + mergeLearnedSkills({ ...input, own: snapshot("own-2", ["b"]) })?.etag, + ).not.toBe(first?.etag); + expect(first?.etag).toMatch(/^"[0-9a-f]{64}"$/); + }); +}); diff --git a/server/tests/learning-personal-space.integration.test.ts b/server/tests/learning-personal-space.integration.test.ts new file mode 100644 index 000000000..0145efe64 --- /dev/null +++ b/server/tests/learning-personal-space.integration.test.ts @@ -0,0 +1,150 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { LearnedSkillsError } from "@copilotkit/runtime/v2"; +import { eq, inArray } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { users } from "../src/db/schema/core"; +import { learningPersonSpaces } from "../src/db/schema/learning"; +import { + createLearningContainer, + type KeyIntelligence, +} from "../src/learning/ensure-container"; +import { + createPersonalLearningSpaces, + createPersonSpaceStore, + personalLearningContainerId, +} from "../src/learning/personal-space"; +import { skillSnapshot } from "./fixtures/learned-skills"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/* + * Several OpenBot replicas making the same person's Learning space at once, against one database and one Intelligence. + * Intelligence is faked as it behaves: a container id is unique per project, a second create answers 409 + * LEARNING_CONTAINER_ALREADY_EXISTS, and the runtime key's skills read says whether a container exists. Each replica + * creates through the real service-token path in learning/ensure-container.ts. + */ + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const person = `personal-space-${randomUUID()}`; +const SPACE = personalLearningContainerId(person); +const API = "https://intelligence.customer.example"; + +const containers = new Map<string, string>(); +const posts: { id: string; name: string }[] = []; +const fetch = Object.assign( + async (url: RequestInfo | URL, init?: RequestInit) => { + expect(String(url)).toBe(`${API}/api/learning/projects/7/containers`); + expect( + ((init?.headers ?? {}) as Record<string, string>)[ + "x-copilotkit-ei-service-token" + ], + ).toBe("synthetic-service-token"); + const body = JSON.parse(String(init?.body)) as { id: string; name: string }; + posts.push(body); + // A real round trip, so the replicas genuinely overlap. + await new Promise((resolve) => setTimeout(resolve, 15)); + if (containers.has(body.id)) { + return Response.json( + { + error: { + code: "LEARNING_CONTAINER_ALREADY_EXISTS", + message: "Taken.", + category: "conflict", + retryable: false, + }, + }, + { status: 409 }, + ); + } + containers.set(body.id, body.name); + return Response.json( + { container: { id: body.id, name: body.name, projectId: 7 } }, + { status: 201 }, + ); + }, + { preconnect: () => {} }, +) as typeof globalThis.fetch; + +const key: KeyIntelligence = { + probe: async (containerId) => + containers.has(containerId) ? "present" : "absent", + project: async () => undefined, +}; + +function replica() { + return createPersonalLearningSpaces({ + readSettings: async () => ({ + enabled: true, + defaultTarget: null, + agents: {}, + }), + snapshots: { + getLearnedSkillsSnapshot: async ({ + containerId, + }: { + containerId: string; + }) => { + if (!containers.has(containerId)) + throw new LearnedSkillsError("CONTAINER_NOT_FOUND", false); + return skillSnapshot(); + }, + } as never, + store: createPersonSpaceStore(database), + create: (container) => + createLearningContainer({ + directory: process.cwd(), + apiUrl: API, + nonInteractive: true, + key, + serviceToken: { token: "synthetic-service-token", projectId: "7" }, + fetch, + container, + }), + nameFor: async () => "Ada Lovelace", + log: () => {}, + }); +} + +beforeAll(async () => { + await database + .insert(users) + .values({ id: person, email: `${person}@example.test` }); +}); + +afterAll(async () => { + await database.delete(users).where(inArray(users.id, [person])); + await database.$client.close(); +}); + +test("a person's space is created once across concurrent calls on several replicas", async () => { + const replicas = [replica(), replica(), replica()]; + const answers = await Promise.all( + replicas.flatMap((spaces) => + Array.from({ length: 5 }, () => spaces.state(person)), + ), + ); + + for (const answer of answers) + expect(answer).toEqual({ state: "assigned", containerId: SPACE }); + // Intelligence holds it once; each replica asked at most once, and a replica that lost the race was told it exists. + expect([...containers.keys()]).toEqual([SPACE]); + expect(containers.get(SPACE)).toBe("OpenBot: Ada Lovelace"); + expect(posts.length).toBeGreaterThanOrEqual(1); + expect(posts.length).toBeLessThanOrEqual(replicas.length); + expect(posts.every((post) => post.id === SPACE)).toBe(true); + expect( + await database + .select({ containerId: learningPersonSpaces.containerId }) + .from(learningPersonSpaces) + .where(eq(learningPersonSpaces.userId, person)), + ).toEqual([{ containerId: SPACE }]); +}); + +test("a replica that starts after the space exists never asks Intelligence to create it", async () => { + const before = posts.length; + expect(await replica().state(person)).toEqual({ + state: "assigned", + containerId: SPACE, + }); + expect(posts.length).toBe(before); +}); diff --git a/server/tests/learning-personal-space.test.ts b/server/tests/learning-personal-space.test.ts new file mode 100644 index 000000000..7f9a28a07 --- /dev/null +++ b/server/tests/learning-personal-space.test.ts @@ -0,0 +1,574 @@ +import { describe, expect, test } from "bun:test"; +import { + type CopilotKitIntelligence, + LearnedSkillsError, +} from "@copilotkit/runtime/v2"; +import { + isLearningContainerId, + type LearningSettings, +} from "@openbot/shared/learning"; +import type { LearningSpaceState } from "@openbot/shared/recorder-api"; +import type { LearningContainerSpec } from "../src/learning/ensure-container"; +import { + classifyLearningSpaceProbe, + createPersonalLearningSpaces, + type PersonalLearningSpaces, + type PersonSpaceStore, + personalLearningContainerId, +} from "../src/learning/personal-space"; +import { createTrajectoryLearningSelector } from "../src/recorder/trajectory-gate"; +import { skillSnapshot } from "./fixtures/learned-skills"; + +type Snapshots = Pick<CopilotKitIntelligence, "getLearnedSkillsSnapshot">; +type Call = { containerId: string; signal?: AbortSignal }; + +const enabled: LearningSettings = { + enabled: true, + defaultTarget: { containerId: "openbot" }, + agents: {}, +}; +const ADA = "person-ada"; +const ADA_SPACE = personalLearningContainerId(ADA); +const wait = (ms: number) => + new Promise<void>((resolve) => setTimeout(resolve, ms)); +const failWith = (code: string) => async () => { + throw new LearnedSkillsError(code as never, false); +}; + +function memoryStore(): PersonSpaceStore & { rows: Map<string, string> } { + const rows = new Map<string, string>(); + return { + rows, + confirmed: async (userId) => rows.has(userId), + confirm: async (userId, containerId) => { + if (!rows.has(userId)) rows.set(userId, containerId); + }, + forget: async (userId) => { + rows.delete(userId); + }, + }; +} + +function harness( + options: { + settings?: LearningSettings | (() => Promise<LearningSettings>); + probe?: (call: Call) => Promise<unknown>; + create?: ( + container: LearningContainerSpec, + ) => Promise<"created" | "present" | "skipped">; + probeTimeoutMs?: number; + } = {}, +) { + const probes: Call[] = []; + const creates: LearningContainerSpec[] = []; + const logs: Record<string, unknown>[] = []; + let clock = 1_000_000; + const store = memoryStore(); + const settings = options.settings ?? enabled; + const spaces = createPersonalLearningSpaces({ + readSettings: + typeof settings === "function" ? settings : async () => settings, + snapshots: { + getLearnedSkillsSnapshot: async (call: Call) => { + probes.push(call); + return options.probe ? options.probe(call) : skillSnapshot(); + }, + } as unknown as Snapshots, + store, + ...(options.create + ? { + create: async (container: LearningContainerSpec) => { + creates.push(container); + return (options.create as NonNullable<typeof options.create>)( + container, + ); + }, + } + : {}), + nameFor: async (userId) => (userId === ADA ? "Ada Lovelace" : null), + probeTimeoutMs: options.probeTimeoutMs, + now: () => clock, + log: (entry) => logs.push(entry), + }); + return { + spaces, + probes, + creates, + logs, + store, + advance: (ms: number) => { + clock += ms; + }, + }; +} + +describe("a person's Learning space id", () => { + test("is a valid Intelligence container id, derived from the person, the same on every call", () => { + for (const userId of [ + "dev-local-user", + "aB3dE9fGhIjKlMnOpQrStUvWxYz01234", + "someone@example.com", + "x".repeat(500), + ]) { + const id = personalLearningContainerId(userId); + expect(isLearningContainerId(id)).toBe(true); + expect(id).toMatch(/^user-[0-9a-f]{32}$/); + expect(personalLearningContainerId(userId)).toBe(id); + } + }); + + test("two people never share one, even when their ids differ only in case", () => { + expect(personalLearningContainerId("AbC")).not.toBe( + personalLearningContainerId("abc"), + ); + }); +}); + +describe("the person's Learning space", () => { + test("Automatic Learning off is unassigned: nothing is asked or created", async () => { + const h = harness({ + settings: { ...enabled, enabled: false }, + create: async () => "created", + }); + expect(await h.spaces.state(ADA)).toEqual({ + state: "unassigned", + containerId: null, + }); + expect(h.probes).toHaveLength(0); + expect(h.creates).toHaveLength(0); + }); + + test("a space Intelligence has is assigned, probed by the person's own id, and remembered", async () => { + const h = harness(); + expect(await h.spaces.state(ADA)).toEqual({ + state: "assigned", + containerId: ADA_SPACE, + }); + expect(h.probes.map((call) => call.containerId)).toEqual([ADA_SPACE]); + await wait(1); + expect(h.store.rows.get(ADA)).toBe(ADA_SPACE); + }); + + test("a missing space is created on first need, named for the person, and then assigned", async () => { + let exists = false; + const h = harness({ + probe: async () => { + if (!exists) throw new LearnedSkillsError("CONTAINER_NOT_FOUND", false); + throw new LearnedSkillsError("REVISION_NOT_FOUND", false); + }, + create: async () => { + exists = true; + return "created"; + }, + }); + expect(await h.spaces.state(ADA)).toEqual({ + state: "assigned", + containerId: ADA_SPACE, + }); + expect(h.creates).toEqual([ + { id: ADA_SPACE, name: "OpenBot: Ada Lovelace" }, + ]); + expect(h.store.rows.get(ADA)).toBe(ADA_SPACE); + }); + + test("concurrent requests for one person create the space once", async () => { + let exists = false; + let release: () => void = () => {}; + const gate = new Promise<void>((resolve) => { + release = resolve; + }); + const h = harness({ + probe: async () => { + if (!exists) throw new LearnedSkillsError("CONTAINER_NOT_FOUND", false); + return skillSnapshot(); + }, + create: async () => { + await gate; + exists = true; + return "created"; + }, + }); + const answers = Promise.all( + Array.from({ length: 10 }, () => h.spaces.state(ADA)), + ); + await wait(5); + release(); + for (const answer of await answers) + expect(answer).toEqual({ state: "assigned", containerId: ADA_SPACE }); + expect(h.creates).toHaveLength(1); + }); + + test("a space that is missing with no credential to make it is missing, and joins nothing", async () => { + const h = harness({ probe: failWith("CONTAINER_NOT_FOUND") }); + expect(await h.spaces.state(ADA)).toEqual({ + state: "missing", + containerId: null, + }); + }); + + test("a failed creation is missing, is not tried again for a minute, then is", async () => { + const h = harness({ + probe: failWith("CONTAINER_NOT_FOUND"), + create: async () => { + throw new Error("Intelligence refused the service token for Learning."); + }, + }); + expect((await h.spaces.state(ADA)).state).toBe("missing"); + expect((await h.spaces.state(ADA)).state).toBe("missing"); + expect(h.creates).toHaveLength(1); + expect(h.logs).toContainEqual( + expect.objectContaining({ + type: "learning-personal-space", + outcome: "create_failed", + containerId: ADA_SPACE, + }), + ); + h.advance(60_001); + await h.spaces.state(ADA); + expect(h.creates).toHaveLength(2); + }); + + test("a creation still running when the caller stops waiting is unverified with no container, and finishes anyway", async () => { + let exists = false; + const h = harness({ + probe: async () => { + if (!exists) throw new LearnedSkillsError("CONTAINER_NOT_FOUND", false); + return skillSnapshot(); + }, + create: async () => { + await wait(40); + exists = true; + return "created"; + }, + }); + expect(await h.spaces.state(ADA, AbortSignal.timeout(10))).toEqual({ + state: "unverified", + containerId: null, + }); + await wait(60); + expect(h.store.rows.get(ADA)).toBe(ADA_SPACE); + expect(await h.spaces.state(ADA)).toEqual({ + state: "assigned", + containerId: ADA_SPACE, + }); + expect(h.creates).toHaveLength(1); + }); + + test("a space made in another project is a failure, not a space", async () => { + const h = harness({ + probe: failWith("CONTAINER_NOT_FOUND"), + create: async () => "created", + }); + expect((await h.spaces.state(ADA)).state).toBe("missing"); + expect(h.store.rows.has(ADA)).toBe(false); + expect(h.logs).toContainEqual( + expect.objectContaining({ outcome: "create_failed" }), + ); + }); + + test.each(["UNSUPPORTED_SERVER", "TIMEOUT"] as const)( + "Intelligence unable to say (%s) still names the person's container, so a pass joins it when it is there", + async (code) => { + const h = harness({ probe: failWith(code) }); + expect(await h.spaces.state(ADA)).toEqual({ + state: classifyLearningSpaceProbe({ + code, + }) as LearningSpaceState["state"], + containerId: ADA_SPACE, + }); + }, + ); + + test("Intelligence unable to say, with a credential, makes the space (already there counts) and calls it assigned", async () => { + const h = harness({ + probe: failWith("UNSUPPORTED_SERVER"), + create: async () => "present", + }); + expect(await h.spaces.state(ADA)).toEqual({ + state: "assigned", + containerId: ADA_SPACE, + }); + expect(h.store.rows.get(ADA)).toBe(ADA_SPACE); + }); + + test("a space seen before is named without asking a credential again", async () => { + const h = harness({ + probe: failWith("UNSUPPORTED_SERVER"), + create: async () => "created", + }); + await h.store.confirm(ADA, ADA_SPACE); + expect(await h.spaces.state(ADA)).toEqual({ + state: "learning-off", + containerId: ADA_SPACE, + }); + expect(h.creates).toHaveLength(0); + }); + + test("a space removed in Intelligence is forgotten and made again", async () => { + let exists = false; + const h = harness({ + probe: async () => { + if (!exists) throw new LearnedSkillsError("CONTAINER_NOT_FOUND", false); + return skillSnapshot(); + }, + create: async () => { + exists = true; + return "created"; + }, + }); + await h.store.confirm(ADA, "stale-row"); + expect((await h.spaces.state(ADA)).state).toBe("assigned"); + expect(h.store.rows.get(ADA)).toBe(ADA_SPACE); + }); + + test("an answer is cached for a minute; an unverified one is not", async () => { + const h = harness(); + await h.spaces.state(ADA); + await h.spaces.state(ADA); + expect(h.probes).toHaveLength(1); + h.advance(60_001); + await h.spaces.state(ADA); + expect(h.probes).toHaveLength(2); + + const flaky = harness({ probe: failWith("NETWORK_ERROR") }); + await flaky.spaces.state(ADA); + await flaky.spaces.state(ADA); + expect(flaky.probes).toHaveLength(2); + }); + + test("settings that cannot be read are unverified with no container", async () => { + const h = harness({ + settings: async () => { + throw new Error("database down"); + }, + }); + expect(await h.spaces.state(ADA)).toEqual({ + state: "unverified", + containerId: null, + }); + }); + + test("the probe stops at its timeout, and when the caller's signal aborts", async () => { + const hangs = (call: Call) => + new Promise((_, reject) => { + call.signal?.addEventListener("abort", () => + reject(new LearnedSkillsError("TIMEOUT", true)), + ); + }); + const timed = harness({ probe: hangs, probeTimeoutMs: 20 }); + expect((await timed.spaces.state(ADA)).state).toBe("unverified"); + const caller = new AbortController(); + const cut = harness({ probe: hangs, probeTimeoutMs: 10_000 }); + const answer = cut.spaces.state(ADA, caller.signal); + await wait(5); + caller.abort(new Error("stop")); + expect((await answer).state).toBe("unverified"); + }); + + test("kick makes the space in the background and never throws", async () => { + let exists = false; + const h = harness({ + probe: async () => { + if (!exists) throw new LearnedSkillsError("CONTAINER_NOT_FOUND", false); + return skillSnapshot(); + }, + create: async () => { + exists = true; + return "created"; + }, + }); + h.spaces.kick(ADA); + await wait(5); + expect(h.store.rows.get(ADA)).toBe(ADA_SPACE); + }); +}); + +describe("classifyLearningSpaceProbe", () => { + test.each([ + ["REVISION_NOT_FOUND", "assigned"], + ["DELIVERY_DISABLED", "assigned"], + ["REVISION_REVOKED", "assigned"], + ["CONTAINER_NOT_FOUND", "missing"], + ["ENTITLEMENT_REQUIRED", "learning-off"], + ["AUTHORIZATION_FAILED", "learning-off"], + ["UNSUPPORTED_SERVER", "learning-off"], + ["TIMEOUT", "unverified"], + ["SOMETHING_NEW", "unverified"], + ] as const)("%s is %s", (code, state) => { + expect(classifyLearningSpaceProbe({ code })).toBe(state); + }); + + test("success is assigned", () => { + expect(classifyLearningSpaceProbe("ok")).toBe("assigned"); + }); +}); + +describe("the trajectory Learning space selector", () => { + const TRAJECTORY = "0b6c1c0e-6f0a-4f6e-9a55-1d1c2f3f4a5b"; + const PERSON = { id: ADA, name: "Ada" }; + const OTHER = { id: "person-grace", name: "Grace" }; + type State = PersonalLearningSpaces["state"]; + const answers = + (learning: LearningSpaceState): State => + async () => + learning; + /** A check that keeps going until its signal aborts, then answers what it knows. */ + const stopsOnAbort = + (learning: LearningSpaceState): State => + (_userId, signal) => + new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(learning)); + }); + + function selector( + options: { + state?: State; + isBoundTo?: (trajectoryId: string, userId: string) => Promise<boolean>; + budgetMs?: number; + } = {}, + ) { + const checks: { userId: string; signal?: AbortSignal }[] = []; + const state = + options.state ?? answers({ state: "assigned", containerId: ADA_SPACE }); + const select = createTrajectoryLearningSelector({ + isBoundTo: + options.isBoundTo ?? + (async (trajectoryId, userId) => + userId === PERSON.id && trajectoryId === TRAJECTORY), + personalSpace: { + state: (userId, signal) => { + checks.push({ userId, signal }); + return state(userId, signal); + }, + }, + budgetMs: options.budgetMs, + }); + const pick = async (signal?: AbortSignal, user = PERSON) => + select({ trajectoryId: TRAJECTORY, user }, signal); + return { pick, checks }; + } + + test("returns the person's own container, whichever Bot they later use", async () => { + const { pick, checks } = selector(); + expect(await pick()).toEqual([ADA_SPACE]); + expect(checks.map((check) => check.userId)).toEqual([ADA]); + }); + + test("returns the person's container end to end, created on the spot by the real space check", async () => { + let exists = false; + const h = harness({ + probe: async () => { + if (!exists) throw new LearnedSkillsError("CONTAINER_NOT_FOUND", false); + return skillSnapshot(); + }, + create: async () => { + exists = true; + return "created"; + }, + }); + const { pick } = selector({ state: h.spaces.state }); + expect(await pick()).toEqual([ADA_SPACE]); + expect(h.creates).toHaveLength(1); + }); + + test.each([ + { state: "unassigned", containerId: null }, + { state: "missing", containerId: null }, + { state: "unverified", containerId: null }, + ] as const)("$state with no container joins none", async (learning) => { + const { pick } = selector({ state: answers(learning) }); + expect(await pick()).toBeNull(); + }); + + test.each(["assigned", "learning-off", "unverified"] as const)( + "%s with the container returns it, so a pass joins the same space whatever one probe found", + async (state) => { + const { pick } = selector({ + state: answers({ state, containerId: ADA_SPACE }), + }); + expect(await pick()).toEqual([ADA_SPACE]); + }, + ); + + test("a trajectory bound to nobody, or to another person, returns null and is never checked", async () => { + const { pick, checks } = selector(); + expect(await pick(undefined, OTHER)).toBeNull(); + await wait(5); + expect(checks).toEqual([]); + }); + + test("a binding lookup that fails returns null", async () => { + const { pick, checks } = selector({ + isBoundTo: async () => { + throw new Error("database down"); + }, + }); + expect(await pick()).toBeNull(); + await wait(5); + expect(checks).toEqual([]); + }); + + test("a check that throws still answers, with no container", async () => { + const { pick } = selector({ + state: async () => { + throw new Error("boom"); + }, + }); + expect(await pick()).toBeNull(); + }); + + test("a check that stops when the budget nears its end still sends its container", async () => { + const { pick, checks } = selector({ + state: stopsOnAbort({ state: "unverified", containerId: ADA_SPACE }), + budgetMs: 30, + }); + expect(await pick()).toEqual([ADA_SPACE]); + expect(checks[0]?.signal?.aborted).toBe(true); + }); + + test("a check that never answers is cut at the budget with no container", async () => { + const { pick, checks } = selector({ + state: () => new Promise(() => {}), + budgetMs: 30, + }); + const started = performance.now(); + expect(await pick()).toBeNull(); + expect(performance.now() - started).toBeGreaterThanOrEqual(25); + expect(checks[0]?.signal?.aborted).toBe(true); + }); + + test("a binding lookup slower than the budget answers null and checks nothing", async () => { + const { pick, checks } = selector({ + isBoundTo: async () => { + await wait(60); + return true; + }, + budgetMs: 20, + }); + expect(await pick()).toBeNull(); + await wait(80); + expect(checks).toEqual([]); + }); + + test("the runtime giving up mid-check answers at once and stops the check", async () => { + const runtime = new AbortController(); + const { pick, checks } = selector({ + state: stopsOnAbort({ state: "unverified", containerId: ADA_SPACE }), + budgetMs: 10_000, + }); + const answer = pick(runtime.signal); + await wait(10); + runtime.abort(new Error("deadline")); + expect(await answer).toBeNull(); + expect(checks[0]?.signal?.aborted).toBe(true); + }); + + test("a signal already aborted answers without checking", async () => { + const runtime = new AbortController(); + runtime.abort(new Error("gone")); + const { pick, checks } = selector(); + expect(await pick(runtime.signal)).toBeNull(); + await wait(5); + expect(checks).toEqual([]); + }); +}); diff --git a/server/tests/learning-sharing.integration.test.ts b/server/tests/learning-sharing.integration.test.ts new file mode 100644 index 000000000..1646357e9 --- /dev/null +++ b/server/tests/learning-sharing.integration.test.ts @@ -0,0 +1,454 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + CopilotKitIntelligence, + LearnedSkillsError, +} from "@copilotkit/runtime/v2"; +import type { + LearningSettings, + PersonalLearningSharing, +} from "@openbot/shared/learning"; +import { inArray } from "drizzle-orm"; +import { createApp } from "../src/app"; +import type { AuditEventInput } from "../src/audit"; +import { loadConfig } from "../src/config"; +import { createDatabase } from "../src/db/client"; +import { users } from "../src/db/schema/core"; +import { personalLearningContainerId } from "../src/learning/personal-space"; +import { createLearningRuntime } from "../src/learning/runtime"; +import type { LearningSettingsStore } from "../src/learning/settings"; +import { + createLearningSharingStore, + createOpenSkillIndex, +} from "../src/learning/sharing"; +import { skillArchive } from "./fixtures/learned-skill-archive"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { testEnvironment } from "./support/environment"; + +/* + * Settings, Learning end to end: the routes a person's screen calls, the Postgres rows they write, the audit trail, and + * the open-Skill index a run reads, on two replicas sharing one database. Intelligence is faked: each person's space + * publishes a fixed set of Skills. + */ + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const prefix = `learning-sharing-${randomUUID()}`; +const ada = `${prefix}-ada`; +const grace = `${prefix}-grace`; +const config = loadConfig(testEnvironment()); +const roleRepository = { rolesForUser: async () => ["user" as const] }; +const audits: AuditEventInput[] = []; +const auditStore = { + insert: async (event: AuditEventInput) => { + audits.push(event); + }, +}; +const store = createLearningSharingStore(database); +let invalidations = 0; +let clock = 1_000_000; +/** Two replicas' indexes over one database; only the first is told about changes made through its routes. */ +const thisReplica = createOpenSkillIndex({ + read: () => store.openSources(), + now: () => clock, +}); +const otherReplica = createOpenSkillIndex({ + read: () => store.openSources(), + now: () => clock, +}); +const published: Record<string, ReturnType<typeof skillArchive>> = { + [personalLearningContainerId(grace)]: skillArchive("grace-r1", [ + { name: "grace-picked", description: "Picked by Grace." }, + { name: "grace-private", description: "Kept by Grace." }, + ]), + [personalLearningContainerId(ada)]: skillArchive("ada-r1", [ + { name: "ada-only" }, + ]), +}; +let deliveryUp = true; +const client = new CopilotKitIntelligence({ apiKey: "synthetic-never-sent" }); +client.getLearnedSkillsSnapshot = async ({ containerId }) => { + const answer = deliveryUp ? published[containerId] : undefined; + // Intelligence with its Learning delivery switched off. + if (!answer) throw new LearnedSkillsError("DELIVERY_DISABLED", false); + return answer; +}; +const settings: LearningSettings = { + enabled: true, + defaultTarget: null, + agents: {}, +}; +const runtimeFor = (index: typeof thisReplica) => + createLearningRuntime({ + store: { read: async () => settings } as unknown as LearningSettingsStore, + client, + freshnessWindowMs: 0, + people: { + containerFor: personalLearningContainerId, + openSkills: () => index.sources(), + }, + }); +const learning = runtimeFor(thisReplica); +const learningElsewhere = runtimeFor(otherReplica); + +const app = createApp( + config, + { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const cookie = headers.get("cookie") ?? ""; + const id = cookie.includes("person=ada") + ? ada + : cookie.includes("person=grace") + ? grace + : null; + return id + ? { user: { id, email: `${id}@example.test`, name: id } } + : null; + }, + }, + } as never, + roleRepository, + ...(Array.from({ length: 9 }) as never[]), + auditStore, + ...(Array.from({ length: 21 }) as never[]), + { + personalLearning: { + store, + skillsOf: (userId: string) => learning.personalSkills(userId), + invalidate: () => { + invalidations++; + thisReplica.invalidate(); + }, + }, + }, +); + +const as = (person: "ada" | "grace") => ({ cookie: `person=${person}` }); +const call = ( + path: string, + person: "ada" | "grace" | null, + init: { method?: string; json?: unknown } = {}, +) => + app.request(`http://openbot.test${path}`, { + method: init.method ?? "GET", + headers: { + ...(person ? as(person) : {}), + ...(init.json === undefined + ? {} + : { "content-type": "application/json" }), + }, + ...(init.json === undefined ? {} : { body: JSON.stringify(init.json) }), + }); +const read = async (person: "ada" | "grace") => + (await ( + await call("/api/settings/learning", person) + ).json()) as PersonalLearningSharing; +const offered = async ( + runtime: typeof learning, + person: string, +): Promise<string[]> => { + const run = await runtime.acquire("bot", undefined, person); + const json = run?.catalog.match(/\[.*\]/s)?.[0]; + return json + ? (JSON.parse(json) as { name: string }[]).map((skill) => skill.name) + : []; +}; +const sharingAudits = () => + audits.filter((event) => event.eventType.startsWith("learning.")); + +beforeAll(async () => { + await database.insert(users).values([ + { id: ada, email: `${ada}@example.test` }, + { id: grace, email: `${grace}@example.test` }, + ]); +}); + +afterAll(async () => { + await database.delete(users).where(inArray(users.id, [ada, grace])); + await database.$client.close(); +}); + +describe("Settings, Learning", () => { + test("needs a signed-in person", async () => { + expect((await call("/api/settings/learning", null)).status).toBe(401); + }); + + test("starts closed, listing the Skills in the person's own space, none open", async () => { + expect(await read("grace")).toEqual({ + mode: "closed", + skills: [ + { name: "grace-picked", description: "Picked by Grace.", open: false }, + { name: "grace-private", description: "Kept by Grace.", open: false }, + ], + }); + }); + + test("closed: only the person's own runs use their Skills", async () => { + expect(await offered(learning, grace)).toEqual([ + "grace-picked", + "grace-private", + ]); + expect(await offered(learning, ada)).toEqual(["ada-only"]); + }); + + test("open: every Bot uses them, at once on this replica, and on another within the index's TTL", async () => { + const before = invalidations; + const saved = await call("/api/settings/learning/mode", "grace", { + method: "PUT", + json: { mode: "open" }, + }); + expect(saved.status).toBe(200); + expect(invalidations).toBe(before + 1); + expect(await offered(learning, ada)).toEqual([ + "ada-only", + "grace-picked", + "grace-private", + ]); + // The other replica read its index before the change and keeps it for its TTL. + expect(await offered(learningElsewhere, ada)).toEqual([ + "ada-only", + "grace-picked", + "grace-private", + ]); + }); + + test("changing the choice writes one audit row naming the old and new choice; saving the same choice writes none", async () => { + expect(sharingAudits()).toEqual([ + expect.objectContaining({ + eventType: "learning.sharing_changed", + targetType: "learning_sharing", + targetId: grace, + actorUserId: grace, + payload: { from: "closed", to: "open" }, + }), + ]); + await call("/api/settings/learning/mode", "grace", { + method: "PUT", + json: { mode: "open" }, + }); + expect(sharingAudits()).toHaveLength(1); + }); + + test("choose: only the Skills the person opens are used by every Bot", async () => { + // The other replica has an index from the open step; let it age out first. + await offered(learningElsewhere, ada); + await call("/api/settings/learning/mode", "grace", { + method: "PUT", + json: { mode: "choose" }, + }); + expect(await offered(learning, ada)).toEqual(["ada-only"]); + const opened = await call( + "/api/settings/learning/skills/grace-picked", + "grace", + { method: "PUT", json: { open: true } }, + ); + expect(opened.status).toBe(200); + expect(await offered(learning, ada)).toEqual(["ada-only", "grace-picked"]); + expect((await read("grace")).skills).toEqual([ + { name: "grace-picked", description: "Picked by Grace.", open: true }, + { name: "grace-private", description: "Kept by Grace.", open: false }, + ]); + // Grace's own runs still have both. + expect(await offered(learning, grace)).toEqual([ + "grace-picked", + "grace-private", + ]); + }); + + test("another replica sees a change once its index's TTL has passed", async () => { + // Still the index it read while Grace's choice was open. + expect(await offered(learningElsewhere, ada)).toEqual([ + "ada-only", + "grace-picked", + "grace-private", + ]); + clock += 30_001; + expect(await offered(learningElsewhere, ada)).toEqual([ + "ada-only", + "grace-picked", + ]); + }); + + test("opening and closing a Skill each write an audit row; repeating one writes none", async () => { + await call("/api/settings/learning/skills/grace-picked", "grace", { + method: "PUT", + json: { open: true }, + }); + await call("/api/settings/learning/skills/grace-picked", "grace", { + method: "PUT", + json: { open: false }, + }); + const skillRows = sharingAudits().filter( + (event) => event.eventType !== "learning.sharing_changed", + ); + expect(skillRows).toEqual([ + expect.objectContaining({ + eventType: "learning.skill_opened", + targetType: "learned_skill", + targetId: "grace-picked", + actorUserId: grace, + payload: { skill: "grace-picked" }, + }), + expect.objectContaining({ + eventType: "learning.skill_closed", + targetId: "grace-picked", + payload: { skill: "grace-picked" }, + }), + ]); + expect(await offered(learning, ada)).toEqual(["ada-only"]); + }); + + test("a closed choice keeps the picks, so choosing again opens the same Skills", async () => { + await call("/api/settings/learning/skills/grace-picked", "grace", { + method: "PUT", + json: { open: true }, + }); + await call("/api/settings/learning/mode", "grace", { + method: "PUT", + json: { mode: "closed" }, + }); + expect(await offered(learning, ada)).toEqual(["ada-only"]); + await call("/api/settings/learning/mode", "grace", { + method: "PUT", + json: { mode: "choose" }, + }); + expect(await offered(learning, ada)).toEqual(["ada-only", "grace-picked"]); + }); + + test("a person's choice is theirs: another person's screen neither shows nor changes it", async () => { + expect((await read("ada")).mode).toBe("closed"); + await call("/api/settings/learning/mode", "ada", { + method: "PUT", + json: { mode: "open" }, + }); + expect((await read("grace")).mode).toBe("choose"); + }); + + test("refuses a choice that is not closed, open or choose, and a Skill name that is not one", async () => { + expect( + ( + await call("/api/settings/learning/mode", "grace", { + method: "PUT", + json: { mode: "everyone" }, + }) + ).status, + ).toBe(400); + for (const name of ["a/b", "x".repeat(201), " padded"]) { + expect( + ( + await call( + `/api/settings/learning/skills/${encodeURIComponent(name)}`, + "grace", + { method: "PUT", json: { open: true } }, + ) + ).status, + ).toBe(400); + } + expect( + ( + await call("/api/settings/learning/skills/fine", "grace", { + method: "PUT", + json: { open: "yes" }, + }) + ).status, + ).toBe(400); + }); + + test("Skills Intelligence does not deliver show as unavailable, and the choice still saves", async () => { + deliveryUp = false; + try { + expect(await read("grace")).toEqual({ + mode: "choose", + skills: null, + unavailable: "unavailable", + }); + } finally { + deliveryUp = true; + } + }); +}); + +describe("the open-Skill index", () => { + test("reads once per TTL, again at once after invalidate, and keeps its last answer when a read fails", async () => { + let reads = 0; + let fail = false; + let now = 0; + const index = createOpenSkillIndex({ + read: async () => { + reads++; + if (fail) throw new Error("database down"); + return [{ userId: "g", containerId: "user-g", skills: "all" }]; + }, + ttlMs: 1_000, + now: () => now, + }); + await index.sources(); + await index.sources(); + expect(reads).toBe(1); + now += 1_001; + await index.sources(); + expect(reads).toBe(2); + index.invalidate(); + await index.sources(); + expect(reads).toBe(3); + now += 1_001; + fail = true; + expect(await index.sources()).toEqual([ + { userId: "g", containerId: "user-g", skills: "all" }, + ]); + }); + + test("a read that began before a change never replaces what came after it", async () => { + let release: () => void = () => {}; + let call = 0; + const index = createOpenSkillIndex({ + read: async () => { + call++; + if (call === 1) { + await new Promise<void>((resolve) => { + release = resolve; + }); + return []; + } + return [{ userId: "g", containerId: "user-g", skills: "all" }]; + }, + }); + const stale = index.sources(); + index.invalidate(); + release(); + await stale; + expect(await index.sources()).toHaveLength(1); + }); + + test("lists people who opened all or picked some; closed people and empty picks are left out", async () => { + const sources = await store.openSources(); + const mine = sources.filter((source) => + [ada, grace].includes(source.userId), + ); + expect( + mine + .map((source) => ({ + userId: source.userId, + containerId: source.containerId, + skills: source.skills === "all" ? "all" : [...source.skills].sort(), + })) + .sort((a, b) => a.userId.localeCompare(b.userId)), + ).toEqual([ + { + userId: ada, + containerId: personalLearningContainerId(ada), + skills: "all", + }, + { + userId: grace, + containerId: personalLearningContainerId(grace), + skills: ["grace-picked"], + }, + ]); + await store.setSkillOpen(grace, "grace-picked", false); + expect( + (await store.openSources()).some((source) => source.userId === grace), + ).toBe(false); + }); +}); diff --git a/server/tests/organization-auth.test.ts b/server/tests/organization-auth.test.ts index de60ed0cf..a9630f240 100644 --- a/server/tests/organization-auth.test.ts +++ b/server/tests/organization-auth.test.ts @@ -5,7 +5,10 @@ import { betterAuth } from "better-auth"; import { memoryAdapter } from "better-auth/adapters/memory"; import { Hono } from "hono"; import { desktopAuthPage } from "../src/auth/native-browser"; -import { createOrganizationAuth } from "../src/auth/organization"; +import { + AuthorityUnavailable, + createOrganizationAuth, +} from "../src/auth/organization"; import { clearDesktopConnectionFailure, mountDesktopConnectionFailure, @@ -293,3 +296,87 @@ test.each(["google", "microsoft", "okta"])( expect(html).toContain("/api/auth/electron/transfer-user"); }, ); + +describe("quiet verification of a forwarded cookie", () => { + let status = 200; + let body: unknown = null; + let authority: ReturnType<typeof Bun.serve>; + let auth: ReturnType<typeof createOrganizationAuth>; + let cookieHeader: string; + const materialized: string[] = []; + + beforeEach(() => { + status = 200; + body = null; + materialized.length = 0; + authority = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => + status === 302 + ? new Response(null, { + status, + headers: { location: "https://elsewhere.example.test/" }, + }) + : status === 200 + ? Response.json(body) + : new Response("answer", { status }), + }); + auth = createOrganizationAuth({ + authorityUrl: authority.url.origin, + // The local store upserts by email and can answer with its own id. + materializeUser: async (user) => { + materialized.push(user.id); + return { ...user, id: `local-${user.id}` }; + }, + }); + const value = Buffer.from( + JSON.stringify({ + authority: authority.url.origin, + cookie: "better-auth.session_token=abc", + }), + ).toString("base64url"); + cookieHeader = `openbot.organization-session=${value}`; + }); + afterEach(() => { + authority.stop(true); + }); + + test.each([400, 404, 408, 429, 302])( + "an authority answering %d is unavailable, never a sign-out", + async (answer) => { + status = answer; + await expect( + auth.verifySessionQuietly(cookieHeader), + ).rejects.toBeInstanceOf(AuthorityUnavailable); + }, + ); + + test("a 2xx whose body is not an actor is unavailable", async () => { + body = { user: null }; + await expect( + auth.verifySessionQuietly(cookieHeader), + ).rejects.toBeInstanceOf(AuthorityUnavailable); + }); + + test.each([401, 403])( + "a %d from the authority is a sign-out", + async (answer) => { + status = answer; + expect(await auth.verifySessionQuietly(cookieHeader)).toBeNull(); + }, + ); + + test("a malformed cookie is a sign-out", async () => { + expect( + await auth.verifySessionQuietly("openbot.organization-session=x"), + ).toBeNull(); + }); + + test("a valid actor comes back as the local user", async () => { + body = { user: employee }; + const user = await auth.verifySessionQuietly(cookieHeader); + expect(user?.id).toBe("local-employee-1"); + expect(materialized).toEqual(["employee-1"]); + }); +}); diff --git a/server/tests/recorder-capture-pass.integration.test.ts b/server/tests/recorder-capture-pass.integration.test.ts new file mode 100644 index 000000000..e964dd088 --- /dev/null +++ b/server/tests/recorder-capture-pass.integration.test.ts @@ -0,0 +1,402 @@ +import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + CopilotKitIntelligence, + LearnedSkillsError, +} from "@copilotkit/runtime/v2"; +import type { LearningSettings } from "@openbot/shared/learning"; +import { + type ExchangeResponse, + RECORDER_SIGNED_OUT, + recorderPaths, +} from "@openbot/shared/recorder-api"; +import { RECORDER_EXTENSION_ID } from "@openbot/shared/recorder-extension"; +import { eq, inArray } from "drizzle-orm"; +import { createActorAgentResolver } from "../src/agents/agent-resolver"; +import { createApp } from "../src/app"; +import type { AuditEventInput } from "../src/audit"; +import { createStallGuard } from "../src/channels/stall-guard"; +import { loadConfig } from "../src/config"; +import { mountCopilotRuntime } from "../src/copilot"; +import { createDatabase } from "../src/db/client"; +import { users } from "../src/db/schema/core"; +import { learningPersonSpaces } from "../src/db/schema/learning"; +import { recorderTrajectoryBindings } from "../src/db/schema/recorder"; +import type { LearningContainerSpec } from "../src/learning/ensure-container"; +import { + createPersonalLearningSpaces, + createPersonSpaceStore, + personalLearningContainerId, +} from "../src/learning/personal-space"; +import { createLearningSettingsStore } from "../src/learning/settings"; +import { createRecorderExtension } from "../src/recorder/wiring"; +import { createRequestActor } from "../src/request-actor"; +import { skillSnapshot } from "./fixtures/learned-skills"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { testEnvironment } from "./support/environment"; + +/* + * The capture pass end to end, through the app index.ts builds: the Recorder's routes, the runtime mounted with the + * Recorder's gate and Learning space selector, and the request identity that answers the Recorder's bearer only here. + * Intelligence is the one thing faked: its trajectory join answers a grant and records what it was asked, its skills + * read says which Learning containers exist, and a service token's create makes one. + */ + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const prefix = `recorder-pass-${randomUUID()}`; +const owner = `${prefix}-owner`; +const stranger = `${prefix}-stranger`; +const ORIGIN = `chrome-extension://${RECORDER_EXTENSION_ID}`; +const OWNER_COOKIE = { cookie: "test-session=owner" }; + +const config = loadConfig( + testEnvironment({ + INTELLIGENCE_API_URL: "http://127.0.0.1:7100", + INTELLIGENCE_GATEWAY_WS_URL: "ws://127.0.0.1:7103", + }), +); +const intelligenceApi = config.runtime.intelligence.apiUrl.replace(/\/$/, ""); +const roleRepository = { rolesForUser: async () => ["user" as const] }; +const getSession = async ({ headers }: { headers: Headers }) => { + const cookie = headers.get("cookie") ?? ""; + const id = cookie.includes("test-session=owner") + ? owner + : cookie.includes("test-session=stranger") + ? stranger + : null; + return id + ? { user: { id, email: `${id}@example.test`, name: `Person ${id}` } } + : null; +}; +const learning: LearningSettings = { + enabled: true, + defaultTarget: { containerId: "openbot" }, + agents: {}, +}; +const audits: AuditEventInput[] = []; +const auditStore = { + insert: async (event: AuditEventInput) => { + audits.push(event); + }, +}; +const OWNER_SPACE = personalLearningContainerId(owner); +const STRANGER_SPACE = personalLearningContainerId(stranger); +/** The Learning containers Intelligence holds: the stranger's space exists, the owner's is made on first need. */ +const containers = new Set([STRANGER_SPACE]); +const created: LearningContainerSpec[] = []; +const recorder = createRecorderExtension({ + config, + database, + organizationAuth: undefined, + roleRepository, + peopleStore: { isRevoked: async () => false }, + personalSpaces: createPersonalLearningSpaces({ + readSettings: async () => learning, + snapshots: { + getLearnedSkillsSnapshot: async ({ + containerId, + }: { + containerId: string; + }) => { + if (!containers.has(containerId)) + throw new LearnedSkillsError("CONTAINER_NOT_FOUND", false); + return skillSnapshot(); + }, + } as never, + store: createPersonSpaceStore(database), + create: async (container) => { + created.push(container); + if (containers.has(container.id)) return "present"; + containers.add(container.id); + return "created"; + }, + log: () => {}, + }), + auditStore, +}); + +const guard = createStallGuard({ stallMs: 0 }); +const { identifyUser, identifyActor } = createRequestActor({ + singleUser: false, + resolveRecorder: recorder.resolve, + getSession: async (headers) => getSession({ headers }), + rolesForUser: (id) => roleRepository.rolesForUser(id), +}); +const runtime = mountCopilotRuntime( + config, + createActorAgentResolver({ + model: { provider: "openai", defaultModel: "unused" }, + loadAgents: async () => [], + resolveModelApiKey: async () => "synthetic-key", + stallGuard: guard, + }), + identifyUser, + identifyActor, + { + learningSettings: createLearningSettingsStore(undefined, { + containerId: "openbot", + }), + trajectoryCapture: recorder.trajectoryCapture, + }, +); +const app = createApp( + config, + { + handler: () => new Response(null, { status: 204 }), + api: { getSession }, + } as never, + roleRepository, + ...(Array.from({ length: 3 }) as never[]), + runtime.handler, + ...(Array.from({ length: 5 }) as never[]), + auditStore, + ...(Array.from({ length: 21 }) as never[]), + { recorder }, +); + +const call = (path: string, init: RequestInit & { json?: unknown } = {}) => { + const { json, headers, ...rest } = init; + return app.request(`http://openbot.test${path}`, { + ...rest, + headers: { + ...(json === undefined ? {} : { "content-type": "application/json" }), + ...(headers as Record<string, string>), + }, + ...(json === undefined ? {} : { body: JSON.stringify(json) }), + }); +}; + +/** What Intelligence's trajectory join was asked, in order. */ +const joins: Array<{ + trajectoryId: string; + appUserId: string; + learningContainerIds?: string[]; +}> = []; +const spies: Array<{ mockRestore(): void }> = []; +const tokens: Record<string, string> = {}; + +async function connectBrowser(person: string, installId: string) { + const minted = await call(recorderPaths.connectCodes, { + method: "POST", + headers: { + cookie: `test-session=${person === owner ? "owner" : "stranger"}`, + }, + json: { via: "web" }, + }); + const { code } = (await minted.json()) as { code: string }; + const exchanged = await call(recorderPaths.connections, { + method: "POST", + headers: { origin: ORIGIN }, + json: { code, label: "Chrome", installId }, + }); + expect(exchanged.status).toBe(201); + return ((await exchanged.json()) as ExchangeResponse).token; +} + +/** The capture pass exactly as the Recorder asks for it: its bearer and `{}`. No Bot is named. */ +function pass(trajectoryId: string, headers: Record<string, string>) { + return call(recorderPaths.trajectoryConnect(trajectoryId), { + method: "POST", + headers, + json: {}, + }); +} + +const as = (person: string) => ({ + authorization: `Bearer ${tokens[person]}`, +}); + +async function bindingOf(trajectoryId: string) { + return database + .select() + .from(recorderTrajectoryBindings) + .where(eq(recorderTrajectoryBindings.trajectoryId, trajectoryId)) + .then((rows) => rows.map(({ userId }) => ({ userId }))); +} + +beforeAll(async () => { + spies.push( + spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === `${intelligenceApi}/api/trajectories/join`) { + const body = JSON.parse(String(init?.body)) as (typeof joins)[number]; + joins.push(body); + return Response.json({ + joinToken: `join-${joins.length}`, + trajectoryId: body.trajectoryId, + }); + } + throw new Error(`No network in this test: ${url}`); + }), + // Nothing here may open a thread: a pass is not a run. + spyOn( + CopilotKitIntelligence.prototype, + "getOrCreateThread", + ).mockImplementation(async () => { + throw new Error("No thread is opened in this test."); + }), + ); + await database.insert(users).values([ + { id: owner, email: `${owner}@example.test`, name: "The Owner" }, + { id: stranger, email: `${stranger}@example.test`, name: "A Stranger" }, + ]); + tokens[owner] = await connectBrowser(owner, "1".repeat(32)); + tokens[stranger] = await connectBrowser(stranger, "2".repeat(32)); +}); + +afterAll(async () => { + for (const spy of spies) spy.mockRestore(); + guard.stop(); + await database.delete(users).where(inArray(users.id, [owner, stranger])); + await database.$client.close(); +}); + +describe("the capture pass", () => { + const mine = randomUUID(); + + test("a connected browser gets a pass with no Bot named, joined to the person's own Learning space", async () => { + const answer = await pass(mine, as(owner)); + + expect(answer.status).toBe(200); + expect(await answer.json()).toEqual({ + joinToken: "join-1", + realtime: { + clientUrl: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:7103\//), + topic: `trajectory:${mine}`, + }, + }); + expect(joins).toEqual([ + { + trajectoryId: mine, + appUserId: owner, + learningContainerIds: [OWNER_SPACE], + }, + ]); + expect(await bindingOf(mine)).toEqual([{ userId: owner }]); + }); + + test("the person's space was made once, on first need, and OpenBot remembers it", async () => { + expect(created.filter((spec) => spec.id === OWNER_SPACE)).toHaveLength(1); + expect( + await database + .select({ containerId: learningPersonSpaces.containerId }) + .from(learningPersonSpaces) + .where(eq(learningPersonSpaces.userId, owner)), + ).toEqual([{ containerId: OWNER_SPACE }]); + }); + + test("a reconnect asks for a fresh pass and gets one, joined to the same space", async () => { + const answer = await pass(mine, as(owner)); + + expect(answer.status).toBe(200); + expect(joins.at(-1)).toEqual({ + trajectoryId: mine, + appUserId: owner, + learningContainerIds: [OWNER_SPACE], + }); + expect(await bindingOf(mine)).toEqual([{ userId: owner }]); + }); + + test("the selector reads the binding: the person's own container for them, nothing for anyone else", async () => { + const select = recorder.trajectoryCapture.selectLearningContainers; + + expect( + await select({ trajectoryId: mine, user: { id: owner, name: "Owner" } }), + ).toEqual([OWNER_SPACE]); + expect( + await select({ + trajectoryId: mine, + user: { id: stranger, name: "Stranger" }, + }), + ).toBeNull(); + expect( + await select({ + trajectoryId: randomUUID(), + user: { id: owner, name: "Owner" }, + }), + ).toBeNull(); + }); + + test("the Recorder reads the person's own space, which its popup warns about", async () => { + const answer = await call(recorderPaths.learning, { headers: as(owner) }); + expect(answer.status).toBe(200); + expect(await answer.json()).toEqual({ + learning: { state: "assigned", containerId: OWNER_SPACE }, + }); + }); + + describe("is refused, and never reaches Intelligence,", () => { + let joined = 0; + const refusedWith = async ( + response: Response, + status: number, + code: string, + ) => { + expect(response.status).toBe(status); + expect(((await response.json()) as { code: string }).code).toBe(code); + expect(joins.length).toBe(joined); + }; + beforeAll(() => { + joined = joins.length; + }); + + test("for a trajectory id that is not a UUID", async () => { + await refusedWith( + await pass("not-a-uuid", as(owner)), + 400, + "INVALID_REQUEST", + ); + }); + + test("for a trajectory bound to someone else", async () => { + await refusedWith(await pass(mine, as(stranger)), 404, "NOT_FOUND"); + expect(await bindingOf(mine)).toEqual([{ userId: owner }]); + }); + + test("with neither a bearer nor a cookie", async () => { + await refusedWith(await pass(randomUUID(), {}), 401, "IDENTITY_REQUIRED"); + }); + + test("with only the person's signed-in session, because only a connected browser records", async () => { + await refusedWith( + await pass(randomUUID(), OWNER_COOKIE), + 401, + "IDENTITY_REQUIRED", + ); + }); + + test("with a bearer that no longer resolves, as signed out", async () => { + await refusedWith( + await pass(randomUUID(), { + authorization: `Bearer obr_${"z".repeat(43)}`, + }), + 401, + RECORDER_SIGNED_OUT, + ); + }); + + test("and each refusal is in the audit trail with its reason", () => { + const reasons = audits + .filter((event) => event.eventType === "recorder.trajectory_refused") + .map((event) => (event.payload as { reason: string }).reason); + expect(reasons).toEqual( + expect.arrayContaining(["invalid-id", "bound-elsewhere", "signed-out"]), + ); + expect(reasons).not.toContain("no-bot"); + expect(reasons).not.toContain("bot-forbidden"); + }); + }); + + test("the other person's own trajectory still gets its pass, into their own space", async () => { + const theirs = randomUUID(); + const answer = await pass(theirs, as(stranger)); + + expect(answer.status).toBe(200); + expect(joins.at(-1)).toEqual({ + trajectoryId: theirs, + appUserId: stranger, + learningContainerIds: [STRANGER_SPACE], + }); + expect(await bindingOf(theirs)).toEqual([{ userId: stranger }]); + }); +}); diff --git a/server/tests/recorder-config.test.ts b/server/tests/recorder-config.test.ts new file mode 100644 index 000000000..c96a34ba3 --- /dev/null +++ b/server/tests/recorder-config.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { loadConfig } from "../src/config"; + +const environment = { + DATABASE_URL: "postgres://openbot:openbot@127.0.0.1:5432/openbot", + KEY_ENCRYPTION_KEY: "b3BlbmJvdC1wcm9kdWN0aW9uLXRlc3Qta2V5LTMyMzI=", + INTELLIGENCE_API_URL: "https://api.intelligence.copilotkit.ai", + INTELLIGENCE_GATEWAY_WS_URL: "wss://realtime.intelligence.copilotkit.ai", + INTELLIGENCE_API_KEY: "test-project-key", + OPENBOT_SINGLE_USER: "true", +}; + +const first = "a".repeat(32); +const second = "abcdefghijklmnopabcdefghijklmnop"; + +test("a valid extra extension id parses", () => { + expect( + loadConfig({ ...environment, OPENBOT_RECORDER_EXTRA_EXTENSION_IDS: first }) + .recorder, + ).toEqual({ extraExtensionIds: [first] }); +}); + +test("an id that is not 32 letters a to p refuses boot and names the value", () => { + expect(() => + loadConfig({ ...environment, OPENBOT_RECORDER_EXTRA_EXTENSION_IDS: "abc" }), + ).toThrow("abc"); + expect(() => + loadConfig({ + ...environment, + OPENBOT_RECORDER_EXTRA_EXTENSION_IDS: `${first},${"z".repeat(32)}`, + }), + ).toThrow("z".repeat(32)); +}); + +test("ids with spaces after the comma are each trimmed", () => { + expect( + loadConfig({ + ...environment, + OPENBOT_RECORDER_EXTRA_EXTENSION_IDS: `${first}, ${second} `, + }).recorder, + ).toEqual({ extraExtensionIds: [first, second] }); +}); + +test("unset gives no extra ids", () => { + expect(loadConfig(environment).recorder).toEqual({ extraExtensionIds: [] }); +}); + +test("the example environment and the changelog name the variable and that a bad value stops boot", () => { + const example = readFileSync( + new URL("../../.env.example", import.meta.url), + "utf8", + ); + expect(example).toContain("# OPENBOT_RECORDER_EXTRA_EXTENSION_IDS="); + expect(example).toMatch(/Any\s+other value stops the server at boot/); + const [unreleased = ""] = readFileSync( + new URL("../../CHANGELOG.md", import.meta.url), + "utf8", + ) + .split("## Unreleased")[1] + ?.split(/^## /m) ?? [""]; + for (const term of [ + "0057_recorder_extension", + "OPENBOT_RECORDER_EXTRA_EXTENSION_IDS", + "stops the server at boot", + "Settings → Browsers", + "1.79.1", + "COPILOTKIT_OPENAI_API=responses", + ]) { + expect(unreleased).toContain(term); + } +}); diff --git a/server/tests/recorder-connections.integration.test.ts b/server/tests/recorder-connections.integration.test.ts new file mode 100644 index 000000000..043a6f6d7 --- /dev/null +++ b/server/tests/recorder-connections.integration.test.ts @@ -0,0 +1,298 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { RECORDER_EXTENSION_ID } from "@openbot/shared/recorder-extension"; +import { and, eq, inArray } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { users } from "../src/db/schema/core"; +import { + recorderConnectCodes, + recorderConnections, +} from "../src/db/schema/recorder"; +import { createRecorderConnectionStore } from "../src/recorder/connections"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const prefix = `recorder-connections-${randomUUID()}`; +const owner = `${prefix}-owner`; +const other = `${prefix}-other`; +const store = createRecorderConnectionStore(database); +const CHROME = "c".repeat(32); // an install id (Decision 18): 32 lowercase hex digits +const EDGE = "e".repeat(32); + +beforeAll(async () => { + await database.insert(users).values([ + { id: owner, email: `${owner}@example.test` }, + { id: other, email: `${other}@example.test` }, + ]); +}); +afterAll(async () => { + await database.delete(users).where(inArray(users.id, [owner, other])); + await database.$client.close(); +}); + +/** Mints a code, failing the test when the store refuses one. */ +async function mint( + on: ReturnType<typeof createRecorderConnectionStore>, + userId: string, + via: "web" | "desktop", + organizationSession: string | null = null, +) { + const minted = await on.mintCode(userId, via, organizationSession); + if (!minted) throw new Error("the store refused a connect code"); + return minted; +} + +async function connect(installId: string, label = "Chrome on macOS") { + const { code } = await mint(store, owner, "web"); + const made = await store.exchange({ + code, + label, + extensionId: RECORDER_EXTENSION_ID, + installId, + }); + if (!made) throw new Error("the exchange was refused"); + return made; +} + +test("a connect code works once and yields a token that resolves to its person", async () => { + const { code } = await mint(store, owner, "web"); + const first = await store.exchange({ + code, + label: "Chrome on macOS", + extensionId: RECORDER_EXTENSION_ID, + installId: CHROME, + }); + expect(first?.userId).toBe(owner); + expect(first?.token).toMatch(/^obr_[A-Za-z0-9_-]{43}$/); + expect(code).toMatch(/^obc_[A-Za-z0-9_-]{43}$/); + expect( + await store.exchange({ + code, + label: "again", + extensionId: RECORDER_EXTENSION_ID, + installId: CHROME, + }), + ).toBeNull(); + expect(await store.resolve(first!.token)).toEqual({ + connectionId: first!.connection.id, + userId: owner, + organizationSession: null, + }); +}); + +test("an expired code is refused", async () => { + const late = createRecorderConnectionStore(database, { + now: () => new Date(Date.now() + 6 * 60_000), + }); + const { code } = await mint(store, owner, "web"); + expect( + await late.exchange({ + code, + label: "x", + extensionId: RECORDER_EXTENSION_ID, + installId: CHROME, + }), + ).toBeNull(); +}); + +test("an unknown code is refused", async () => { + expect( + await store.exchange({ + code: `obc_${"A".repeat(43)}`, + label: "x", + extensionId: RECORDER_EXTENSION_ID, + installId: CHROME, + }), + ).toBeNull(); +}); + +test("only digests are stored", async () => { + const { code } = await mint(store, owner, "desktop"); + const made = await store.exchange({ + code, + label: "Edge on Windows", + extensionId: RECORDER_EXTENSION_ID, + installId: CHROME, + }); + const rows = await database + .select() + .from(recorderConnections) + .where(eq(recorderConnections.userId, owner)); + expect(JSON.stringify(rows)).not.toContain(made!.token); + expect( + JSON.stringify(await database.select().from(recorderConnectCodes)), + ).not.toContain(code); +}); + +test("an organization session travels from the code to the connection", async () => { + const { code } = await mint(store, owner, "web", "sealed-value"); + const made = await store.exchange({ + code, + label: "x", + extensionId: RECORDER_EXTENSION_ID, + installId: CHROME, + }); + expect((await store.resolve(made!.token))?.organizationSession).toBe( + "sealed-value", + ); +}); + +test("a revoked connection stops resolving and leaves the list", async () => { + const made = await connect(EDGE); + expect(await store.revoke(owner, made.connection.id)).toBe(true); + expect(await store.resolve(made.token)).toBeNull(); + expect((await store.list(owner)).map((c) => c.id)).not.toContain( + made.connection.id, + ); +}); + +test("last use is recorded at most once a minute", async () => { + let now = new Date(); + const clocked = createRecorderConnectionStore(database, { now: () => now }); + const { code } = await mint(clocked, owner, "web"); + const made = await clocked.exchange({ + code, + label: "x", + extensionId: RECORDER_EXTENSION_ID, + installId: CHROME, + }); + const lastUsed = async () => + ( + await database + .select({ at: recorderConnections.lastUsedAt }) + .from(recorderConnections) + .where(eq(recorderConnections.id, made!.connection.id)) + )[0]?.at; + expect(await lastUsed()).toBeNull(); + const first = now; + await clocked.resolve(made!.token); + expect((await lastUsed())?.getTime()).toBe(first.getTime()); + now = new Date(first.getTime() + 10_000); + await clocked.resolve(made!.token); + expect((await lastUsed())?.getTime()).toBe(first.getTime()); + now = new Date(first.getTime() + 61_000); + await clocked.resolve(made!.token); + expect((await lastUsed())?.getTime()).toBe(now.getTime()); +}); + +test("revoking someone else's connection does nothing", async () => { + const made = await connect(CHROME); + expect(await store.revoke(other, made.connection.id)).toBe(false); + expect(await store.resolve(made.token)).not.toBeNull(); +}); + +test("the same copy connecting again replaces its older connection; another copy does not", async () => { + const first = await connect(CHROME, "Chrome"); + const edge = await connect(EDGE, "Edge"); + const again = await connect(CHROME, "Chrome"); + expect(again.replaced).toBe(first.connection.id); + expect(edge.replaced).toBeNull(); + expect(await store.resolve(first.token)).toBeNull(); + expect(await store.resolve(edge.token)).not.toBeNull(); + expect((await store.list(owner)).map((c) => c.installId).sort()).toEqual([ + CHROME, + EDGE, + ]); +}); + +const sessionOf = async (connectionId: string) => + ( + await database + .select({ sealed: recorderConnections.organizationSession }) + .from(recorderConnections) + .where(eq(recorderConnections.id, connectionId)) + )[0]?.sealed; + +test("a used code is deleted with the organization session it held", async () => { + const { code } = await mint(store, owner, "web", "sealed-once"); + const made = await store.exchange({ + code, + label: "x", + extensionId: RECORDER_EXTENSION_ID, + installId: EDGE, + }); + expect(made).not.toBeNull(); + const kept = await database + .select({ sealed: recorderConnectCodes.organizationSession }) + .from(recorderConnectCodes) + .where(eq(recorderConnectCodes.userId, owner)); + expect(kept.map((row) => row.sealed)).not.toContain("sealed-once"); +}); + +test("revoking a connection, or replacing it, drops its organization session", async () => { + const revokedCode = await mint(store, owner, "web", "sealed-revoked"); + const revoked = await store.exchange({ + code: revokedCode.code, + label: "x", + extensionId: RECORDER_EXTENSION_ID, + installId: EDGE, + }); + expect(await sessionOf(revoked!.connection.id)).toBe("sealed-revoked"); + expect(await store.revoke(owner, revoked!.connection.id)).toBe(true); + expect(await sessionOf(revoked!.connection.id)).toBeNull(); + + const firstCode = await mint(store, owner, "web", "sealed-first"); + const first = await store.exchange({ + code: firstCode.code, + label: "x", + extensionId: RECORDER_EXTENSION_ID, + installId: EDGE, + }); + const againCode = await mint(store, owner, "web", "sealed-again"); + const again = await store.exchange({ + code: againCode.code, + label: "x", + extensionId: RECORDER_EXTENSION_ID, + installId: EDGE, + }); + expect(again!.replaced).toBe(first!.connection.id); + expect(await sessionOf(first!.connection.id)).toBeNull(); + expect(await sessionOf(again!.connection.id)).toBe("sealed-again"); +}); + +test("minting a code deletes every expired one, with its organization session", async () => { + await mint(store, other, "web", "sealed-expired"); + const late = createRecorderConnectionStore(database, { + now: () => new Date(Date.now() + 6 * 60_000), + }); + await mint(late, owner, "web"); + const expired = await database + .select({ sealed: recorderConnectCodes.organizationSession }) + .from(recorderConnectCodes) + .where( + and( + eq(recorderConnectCodes.userId, other), + eq(recorderConnectCodes.organizationSession, "sealed-expired"), + ), + ); + expect(expired).toEqual([]); +}); + +test("a person holding ten unexpired codes is refused another until one is used or expires", async () => { + const person = `${prefix}-busy`; + await database + .insert(users) + .values({ id: person, email: `${person}@example.test` }); + try { + const codes = []; + for (let index = 0; index < 10; index += 1) { + codes.push(await mint(store, person, "web")); + } + expect(await store.mintCode(person, "web")).toBeNull(); + // Another person is not held back by this one. + expect(await store.mintCode(other, "web")).not.toBeNull(); + await store.exchange({ + code: codes[0]!.code, + label: "x", + extensionId: RECORDER_EXTENSION_ID, + installId: "f".repeat(32), + }); + expect(await store.mintCode(person, "web")).not.toBeNull(); + const later = createRecorderConnectionStore(database, { + now: () => new Date(Date.now() + 6 * 60_000), + }); + expect(await later.mintCode(person, "web")).not.toBeNull(); + } finally { + await database.delete(users).where(eq(users.id, person)); + } +}); diff --git a/server/tests/recorder-guard.test.ts b/server/tests/recorder-guard.test.ts new file mode 100644 index 000000000..356ebd349 --- /dev/null +++ b/server/tests/recorder-guard.test.ts @@ -0,0 +1,706 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { Hono } from "hono"; +import type { AuditEventInput } from "../src/audit"; +import { DEV_ACTOR } from "../src/auth/dev-actor"; +import type { AuthenticatedActor } from "../src/auth/guards"; +import { AuthorityUnavailable } from "../src/auth/organization"; +import type { OpenBotRole } from "../src/auth/roles"; +import { + createRecorderResolver, + createRequireRecorder, + type OrganizationVerdictCache, + RECORDER_BEARER, + type RecorderMode, + type RecorderRefusalSink, +} from "../src/recorder/guard"; +import { createRecorderRefusalAudit } from "../src/recorder/refusal-audit"; +import type { RecorderVariables } from "../src/recorder/variables"; + +const TOKEN = `obr_${"a".repeat(43)}`; +const SECOND_TOKEN = `obr_${"b".repeat(43)}`; +const STRANGER_TOKEN = `obr_${"c".repeat(43)}`; +const SIGNED_OUT_SENTENCE = + "This browser is no longer connected to OpenBot. Connect it again."; +const ORGANIZATION_SENTENCE = + "Your organization sign-in ended. Connect this browser to OpenBot again."; + +type Refusal = Parameters<RecorderRefusalSink>[0]; +type UserRow = { + id: string; + email: string; + name: string | null; + image: string | null; +}; +type Connection = { + connectionId: string; + userId: string; + organizationSession: string | null; +}; + +const owner: UserRow = { + id: "owner", + email: "owner@openbot.test", + name: "Owner Person", + image: "https://example.test/owner.png", +}; + +function betterAuth( + overrides: Partial<Extract<RecorderMode, { kind: "better-auth" }>> = {}, +): RecorderMode { + return { + kind: "better-auth", + roles: { rolesForUser: async () => ["user"] }, + isRevoked: async () => false, + allowedEmailDomains: [], + ...overrides, + }; +} + +function fixture( + mode: RecorderMode, + options: { + connections?: Record<string, Connection>; + users?: UserRow[]; + verdicts?: OrganizationVerdictCache; + } = {}, +) { + const connections = new Map( + Object.entries( + options.connections ?? { + [TOKEN]: { + connectionId: "connection-1", + userId: owner.id, + organizationSession: null, + }, + }, + ), + ); + const users = new Map( + (options.users ?? [owner]).map((user) => [user.id, user]), + ); + const refusals: Refusal[] = []; + const clock = { now: 1_000_000 }; + const resolve = createRecorderResolver({ + connections: { resolve: async (token) => connections.get(token) ?? null }, + mode, + user: async (userId) => users.get(userId) ?? null, + now: () => clock.now, + ...(options.verdicts ? { verdicts: options.verdicts } : {}), + }); + const onRefused: RecorderRefusalSink = (refusal) => { + refusals.push(refusal); + }; + const app = new Hono<{ Variables: RecorderVariables }>(); + app.get("/x", createRequireRecorder(resolve, onRefused), (c) => + c.json(c.var.actor), + ); + app.get("/connection", createRequireRecorder(resolve, onRefused), (c) => + c.json({ connectionId: c.var.recorderConnectionId }), + ); + const call = (headers: Record<string, string> = {}, path = "/x") => + app.request(`http://openbot.test${path}`, { headers }); + const bearer = (token = TOKEN) => ({ authorization: `Bearer ${token}` }); + return { app, call, bearer, refusals, clock, connections, users, resolve }; +} + +const digestOf = (token: string) => + createHash("sha256").update(token).digest("hex").slice(0, 16); + +describe("the recorder bearer guard", () => { + test("a valid bearer is answered as its person", async () => { + const { call, bearer, refusals } = fixture(betterAuth()); + + const response = await call(bearer()); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + id: owner.id, + email: owner.email, + name: owner.name, + image: owner.image, + role: "user", + }); + expect(refusals).toEqual([]); + }); + + test("the guard sets the connection the bearer belongs to", async () => { + const { call, bearer } = fixture(betterAuth()); + + const response = await call(bearer(), "/connection"); + + expect(await response.json()).toEqual({ connectionId: "connection-1" }); + }); + + test("an administrator role beats a user role, as the session guard decides", async () => { + const { call, bearer } = fixture( + betterAuth({ roles: { rolesForUser: async () => ["user", "admin"] } }), + ); + + const response = await call(bearer()); + + expect((await response.json()).role).toBe("admin"); + }); + + test("a malformed bearer is signed out and reported as malformed", async () => { + const { call, refusals } = fixture(betterAuth()); + + const response = await call({ authorization: "Bearer obr_short" }); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: SIGNED_OUT_SENTENCE, + code: "RECORDER_SIGNED_OUT", + reason: "revoked", + }); + expect(refusals).toEqual([ + { reason: "malformed", path: "/x", tokenDigest: digestOf("obr_short") }, + ]); + }); + + test("an unknown token is signed out with the connect-again sentence", async () => { + const { call, bearer, refusals } = fixture(betterAuth()); + + const response = await call(bearer(STRANGER_TOKEN)); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: SIGNED_OUT_SENTENCE, + code: "RECORDER_SIGNED_OUT", + reason: "revoked", + }); + expect(refusals).toEqual([ + { reason: "unknown", path: "/x", tokenDigest: digestOf(STRANGER_TOKEN) }, + ]); + }); + + test("the sink is handed a short digest, never the token", async () => { + const { call, bearer, refusals } = fixture(betterAuth()); + + await call(bearer(STRANGER_TOKEN)); + + const [refusal] = refusals; + expect(refusal?.tokenDigest).toMatch(/^[0-9a-f]{16}$/); + expect(JSON.stringify(refusals)).not.toContain(STRANGER_TOKEN); + }); + + test("a token whose person no longer has a user row is unknown", async () => { + const { call, bearer, refusals } = fixture(betterAuth(), { users: [] }); + + const response = await call(bearer()); + + expect(response.status).toBe(401); + expect((await response.json()).reason).toBe("revoked"); + expect(refusals.map((refusal) => refusal.reason)).toEqual(["unknown"]); + }); + + test("no Authorization header on a recorder-only route is signed out with no digest", async () => { + const { call, refusals } = fixture(betterAuth()); + + const response = await call(); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: SIGNED_OUT_SENTENCE, + code: "RECORDER_SIGNED_OUT", + reason: "revoked", + }); + expect(refusals).toEqual([ + { reason: "unknown", path: "/x", tokenDigest: null }, + ]); + }); + + test("a person removed from the deployment is signed out as removed", async () => { + const asked: string[] = []; + const { call, bearer, refusals } = fixture( + betterAuth({ + isRevoked: async (email) => { + asked.push(email); + return email === owner.email; + }, + }), + ); + + const response = await call(bearer()); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: SIGNED_OUT_SENTENCE, + code: "RECORDER_SIGNED_OUT", + reason: "removed", + }); + expect(asked).toEqual([owner.email]); + expect(refusals).toEqual([ + { + reason: "removed", + path: "/x", + tokenDigest: digestOf(TOKEN), + userId: owner.id, + }, + ]); + }); + + test("a person whose email domain is no longer allowed is signed out as removed", async () => { + const { call, bearer, refusals } = fixture( + betterAuth({ allowedEmailDomains: ["company.test"] }), + ); + + const response = await call(bearer()); + + expect(response.status).toBe(401); + expect((await response.json()).reason).toBe("removed"); + expect(refusals.map((refusal) => refusal.reason)).toEqual(["removed"]); + }); + + test("a person in an allowed email domain is admitted", async () => { + const { call, bearer } = fixture( + betterAuth({ allowedEmailDomains: ["openbot.test"] }), + ); + + expect((await call(bearer())).status).toBe(200); + }); + + test("a person with no role is forbidden", async () => { + const { call, bearer, refusals } = fixture( + betterAuth({ roles: { rolesForUser: async () => [] } }), + ); + + const response = await call(bearer()); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "Authorization required." }); + expect(refusals).toEqual([ + { + reason: "forbidden", + path: "/x", + tokenDigest: digestOf(TOKEN), + userId: owner.id, + }, + ]); + }); + + test("the actor's name is the email when the user row's name is null or blank", async () => { + const unnamed = { ...owner, id: "unnamed", name: null }; + const blank = { + ...owner, + id: "blank", + email: "blank@openbot.test", + name: " ", + }; + const { call, bearer } = fixture(betterAuth(), { + users: [unnamed, blank], + connections: { + [TOKEN]: { + connectionId: "connection-1", + userId: unnamed.id, + organizationSession: null, + }, + [SECOND_TOKEN]: { + connectionId: "connection-2", + userId: blank.id, + organizationSession: null, + }, + }, + }); + + expect((await (await call(bearer())).json()).name).toBe(unnamed.email); + expect((await (await call(bearer(SECOND_TOKEN))).json()).name).toBe( + blank.email, + ); + }); + + test("the resolver answers null when the request carries no recorder bearer", async () => { + const { resolve } = fixture(betterAuth()); + + expect(await resolve(new Request("http://openbot.test/x"))).toBeNull(); + expect( + await resolve( + new Request("http://openbot.test/x", { + headers: { authorization: "Bearer someone-elses-token" }, + }), + ), + ).toBeNull(); + }); + + test("the bearer pattern takes exactly a recorder token", () => { + expect(RECORDER_BEARER.exec(`Bearer ${TOKEN}`)?.[1]).toBe(TOKEN); + expect(RECORDER_BEARER.test(`Bearer ${TOKEN}x`)).toBe(false); + expect(RECORDER_BEARER.test("Bearer obr_short")).toBe(false); + }); +}); + +describe("single-user mode", () => { + const devUser: UserRow = { + id: DEV_ACTOR.id, + email: DEV_ACTOR.email, + name: DEV_ACTOR.email, + image: null, + }; + + test("admits a token whose person is the local person as DEV_ACTOR", async () => { + const { call, bearer } = fixture( + { kind: "single-user" }, + { + users: [devUser], + connections: { + [TOKEN]: { + connectionId: "connection-1", + userId: DEV_ACTOR.id, + organizationSession: null, + }, + }, + }, + ); + + const response = await call(bearer()); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ...DEV_ACTOR, + name: DEV_ACTOR.email, + }); + }); + + test("refuses a token for any other person as unknown", async () => { + const { call, bearer, refusals } = fixture({ kind: "single-user" }); + + const response = await call(bearer()); + + expect(response.status).toBe(401); + expect((await response.json()).reason).toBe("revoked"); + expect(refusals).toEqual([ + { + reason: "unknown", + path: "/x", + tokenDigest: digestOf(TOKEN), + userId: owner.id, + }, + ]); + }); +}); + +describe("organization mode", () => { + function organization( + answer: (cookie: string) => Promise<AuthenticatedActor | null>, + verdicts?: OrganizationVerdictCache, + ) { + const asked: string[] = []; + const unsealed: string[] = []; + const mode: RecorderMode = { + kind: "organization", + session: async (cookie) => { + asked.push(cookie); + return answer(cookie); + }, + unseal: async (value) => { + unsealed.push(value); + return value.replace("sealed-", ""); + }, + }; + const built = fixture(mode, { + connections: { + [TOKEN]: { + connectionId: "connection-1", + userId: owner.id, + organizationSession: "sealed-session-one", + }, + [SECOND_TOKEN]: { + connectionId: "connection-2", + userId: owner.id, + organizationSession: "sealed-session-two", + }, + }, + ...(verdicts ? { verdicts } : {}), + }); + return { ...built, asked, unsealed }; + } + + const signedIn = + (role: OpenBotRole = "user") => + async () => ({ + id: owner.id, + email: owner.email, + name: "Name At The Authority", + role, + }); + + test("unseals the stored session and asks with the organization session cookie", async () => { + const { call, bearer, asked, unsealed } = organization(signedIn("admin")); + + const response = await call(bearer()); + + expect(response.status).toBe(200); + expect(unsealed).toEqual(["sealed-session-one"]); + expect(asked).toEqual(["openbot.organization-session=session-one"]); + expect(await response.json()).toEqual({ + id: owner.id, + email: owner.email, + name: owner.name, + image: owner.image, + role: "admin", + }); + }); + + test("a session the organization no longer knows is signed out as organization", async () => { + const { call, bearer, refusals } = organization(async () => null); + + const response = await call(bearer()); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: ORGANIZATION_SENTENCE, + code: "RECORDER_SIGNED_OUT", + reason: "organization", + }); + expect(refusals).toEqual([ + { + reason: "organization", + path: "/x", + tokenDigest: digestOf(TOKEN), + userId: owner.id, + }, + ]); + }); + + test("a connection with no stored organization session is signed out as organization", async () => { + const { call, bearer, connections, asked } = organization(signedIn()); + connections.set(TOKEN, { + connectionId: "connection-1", + userId: owner.id, + organizationSession: null, + }); + + const response = await call(bearer()); + + expect(response.status).toBe(401); + expect((await response.json()).reason).toBe("organization"); + expect(asked).toEqual([]); + }); + + test("a session that belongs to somebody else is signed out as organization", async () => { + const { call, bearer } = organization(async () => ({ + id: "somebody-else", + email: "else@openbot.test", + role: "admin", + })); + + const response = await call(bearer()); + + expect(response.status).toBe(401); + expect((await response.json()).reason).toBe("organization"); + }); + + test("an organization that cannot answer is a 503 the Recorder retries, never cached and never reported", async () => { + let unavailable = true; + const { call, bearer, refusals, asked } = organization(async () => { + if (unavailable) throw new AuthorityUnavailable("down"); + return signedIn()(); + }); + + const first = await call(bearer()); + + expect(first.status).toBe(503); + expect(await first.json()).toEqual({ + error: "Your organization could not answer. Try again in a moment.", + code: "RECORDER_AUTHORITY_UNAVAILABLE", + }); + expect(refusals).toEqual([]); + + unavailable = false; + const second = await call(bearer()); + + expect(second.status).toBe(200); + expect(asked).toHaveLength(2); + }); + + test("an error that is not the organization being unavailable is not hidden as one", async () => { + const { call, bearer, refusals } = organization(async () => { + throw new Error("materialize failed"); + }); + const original = console.error; + console.error = () => {}; + try { + const response = await call(bearer()); + + expect(response.status).toBe(500); + expect(refusals).toEqual([]); + } finally { + console.error = original; + } + }); + + test("an actor is cached for 60 s per connection", async () => { + const { call, bearer, asked, clock } = organization(signedIn()); + + await call(bearer()); + clock.now += 59_000; + expect((await call(bearer())).status).toBe(200); + expect(asked).toHaveLength(1); + + await call(bearer(SECOND_TOKEN)); + expect(asked).toEqual([ + "openbot.organization-session=session-one", + "openbot.organization-session=session-two", + ]); + + clock.now += 2_000; + expect((await call(bearer())).status).toBe(200); + expect(asked).toHaveLength(3); + }); + + test("a refusal is cached for 60 s per connection", async () => { + let signedOut = true; + const { call, bearer, asked, clock } = organization(async () => + signedOut ? null : signedIn()(), + ); + + expect((await call(bearer())).status).toBe(401); + signedOut = false; + clock.now += 30_000; + expect((await call(bearer())).status).toBe(401); + expect(asked).toHaveLength(1); + + clock.now += 31_000; + expect((await call(bearer())).status).toBe(200); + expect(asked).toHaveLength(2); + }); + + test("a verdict past its 60 s is dropped when the next one is kept, so the cache does not grow", async () => { + const verdicts: OrganizationVerdictCache = new Map(); + const { call, bearer, clock } = organization(signedIn(), verdicts); + + await call(bearer()); + expect([...verdicts.keys()]).toEqual(["connection-1"]); + clock.now += 61_000; + await call(bearer(SECOND_TOKEN)); + expect([...verdicts.keys()]).toEqual(["connection-2"]); + + // A verdict asked for again moves to the end, so the oldest stays first and the expired ones are a prefix. + clock.now += 30_000; + await call(bearer()); + expect([...verdicts.keys()]).toEqual(["connection-2", "connection-1"]); + clock.now += 31_000; + await call(bearer(SECOND_TOKEN)); + expect([...verdicts.keys()]).toEqual(["connection-1", "connection-2"]); + clock.now += 61_000; + await call(bearer()); + expect([...verdicts.keys()]).toEqual(["connection-1"]); + }); + + test("a revoked connection is refused at once, whatever the cache holds", async () => { + const { call, bearer, connections, refusals } = organization(signedIn()); + + expect((await call(bearer())).status).toBe(200); + connections.delete(TOKEN); + + const response = await call(bearer()); + + expect(response.status).toBe(401); + expect(refusals.map((refusal) => refusal.reason)).toEqual(["unknown"]); + }); +}); + +describe("the refusal audit", () => { + function audit() { + const rows: AuditEventInput[] = []; + const clock = { now: 5_000_000 }; + const sink = createRecorderRefusalAudit( + { + insert: async (event) => { + rows.push(event); + }, + }, + { now: () => clock.now }, + ); + return { rows, clock, sink }; + } + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + const refusal = (overrides: Partial<Refusal> = {}): Refusal => ({ + reason: "unknown", + path: "/api/recorder/me", + tokenDigest: "0123456789abcdef", + ...overrides, + }); + + test("two refusals for one token digest and reason within a minute write one row", async () => { + const { rows, clock, sink } = audit(); + + sink(refusal()); + clock.now += 30_000; + sink(refusal()); + await settle(); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + eventType: "recorder.bearer_refused", + targetId: "0123456789abcdef", + payload: { reason: "unknown", path: "/api/recorder/me" }, + }); + }); + + test("a different reason writes a second row", async () => { + const { rows, sink } = audit(); + + sink(refusal({ reason: "unknown" })); + sink(refusal({ reason: "removed", userId: owner.id })); + await settle(); + + expect(rows.map((row) => row.payload.reason)).toEqual([ + "unknown", + "removed", + ]); + expect(rows[1]?.actorUserId).toBe(owner.id); + }); + + test("another token digest writes its own row", async () => { + const { rows, sink } = audit(); + + sink(refusal()); + sink(refusal({ tokenDigest: "fedcba9876543210" })); + await settle(); + + expect(rows.map((row) => row.targetId)).toEqual([ + "0123456789abcdef", + "fedcba9876543210", + ]); + }); + + test("the same refusal writes again once its minute has passed", async () => { + const { rows, clock, sink } = audit(); + + sink(refusal()); + clock.now += 61_000; + sink(refusal()); + await settle(); + + expect(rows).toHaveLength(2); + }); + + test("a failed write is logged and never thrown at the request", async () => { + const logged: unknown[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { + logged.push(args); + }; + try { + const sink = createRecorderRefusalAudit({ + insert: async () => { + throw new Error("trail unavailable"); + }, + }); + + expect(() => sink(refusal())).not.toThrow(); + await settle(); + + expect(JSON.stringify(logged)).toContain("trail unavailable"); + } finally { + console.error = original; + } + }); + + test("with no audit store nothing is written and nothing throws", () => { + const sink = createRecorderRefusalAudit(undefined); + + expect(() => sink(refusal())).not.toThrow(); + }); +}); diff --git a/server/tests/recorder-organization.integration.test.ts b/server/tests/recorder-organization.integration.test.ts new file mode 100644 index 000000000..498f44b3e --- /dev/null +++ b/server/tests/recorder-organization.integration.test.ts @@ -0,0 +1,295 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + type ExchangeResponse, + RECORDER_AUTHORITY_UNAVAILABLE, + RECORDER_SIGNED_OUT, +} from "@openbot/shared/recorder-api"; +import { RECORDER_EXTENSION_ID } from "@openbot/shared/recorder-extension"; +import { eq } from "drizzle-orm"; +import { createApp } from "../src/app"; +import type { AuditEventInput } from "../src/audit"; +import { createOrganizationAuth } from "../src/auth/organization"; +import { organizationUserStore } from "../src/auth/organization-store"; +import { loadConfig } from "../src/config"; +import { createDatabase } from "../src/db/client"; +import { users } from "../src/db/schema/core"; +import { recorderConnections } from "../src/db/schema/recorder"; +import { clearDesktopConnectionFailure } from "../src/desktop-connection-failure"; +import { createRecorderExtension } from "../src/recorder/wiring"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const prefix = `recorder-organization-${randomUUID()}`; +const person = { + id: `${prefix}-person`, + email: `${prefix}@example.test`, + name: "Organization Person", + image: null, + role: "user" as const, +}; +const ORIGIN = `chrome-extension://${RECORDER_EXTENSION_ID}`; +const APP = "http://127.0.0.1:3010"; +const HOST_TOKEN = "recorder-organization-host-token"; +const SESSION_COOKIE = "better-auth.session_token=fixture-session"; + +type Answer = () => Response | Promise<Response>; +const signedIn: Answer = () => Response.json({ user: person }); +let answer: Answer = signedIn; +const authorityFetch = (request: Request) => { + expect(new URL(request.url).pathname).toBe("/api/me"); + if (request.headers.get("cookie") !== SESSION_COOKIE) + return new Response(null, { status: 401 }); + return answer(); +}; +let authority = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: authorityFetch, +}); +const authorityPort = authority.port; + +// The sign-in verdict is cached for 60 s per connection by the guard's clock, which defaults to the +// Date.now captured when the resolver is built: build it after this wrapper is installed. +const realNow = Date.now; +let skewMs = 0; +const pastTheCache = () => { + skewMs += 61_000; +}; + +const config = loadConfig({ + DATABASE_URL: "postgres://openbot:openbot@127.0.0.1:5432/openbot", + KEY_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + INTELLIGENCE_API_URL: "http://127.0.0.1:7100", + INTELLIGENCE_GATEWAY_WS_URL: "ws://127.0.0.1:7103", + INTELLIGENCE_API_KEY: "tenant-api-key", + COPILOTKIT_LICENSE_TOKEN: "license-token", + OPENBOT_ORGANIZATION_AUTH_URL: `http://127.0.0.1:${authorityPort}`, +}); +const audits: AuditEventInput[] = []; +const auditStore = { + insert: async (event: AuditEventInput) => { + audits.push(event); + }, +}; +const roleRepository = { rolesForUser: async () => ["user" as const] }; + +let app: ReturnType<typeof createApp>; + +const call = (path: string, init: RequestInit & { json?: unknown } = {}) => { + const { json, headers, ...rest } = init; + return app.request(`${APP}${path}`, { + ...rest, + headers: { + ...(json === undefined ? {} : { "content-type": "application/json" }), + ...(headers as Record<string, string>), + }, + ...(json === undefined ? {} : { body: JSON.stringify(json) }), + }); +}; +const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); +const desktopFailure = async () => + ( + await call("/api/desktop/connection-failure", { + headers: { "x-openbot-desktop-host-token": HOST_TOKEN }, + }) + ).json(); + +/** The real handoff: the authority cookie becomes the local organization cookie. */ +async function organizationCookie() { + const start = await call("/api/auth/organization/session", { + method: "POST", + headers: { origin: APP }, + json: { cookie: SESSION_COOKIE }, + }); + expect(start.status).toBe(200); + const { ticket } = (await start.json()) as { ticket: string }; + const consumed = await call( + `/api/auth/organization/session?ticket=${ticket}`, + ); + expect(consumed.status).toBe(303); + const cookie = consumed.headers.get("set-cookie")?.split(";")[0]; + expect(cookie).toStartWith("openbot.organization-session="); + return cookie as string; +} + +async function connect(cookie: string, installId: string) { + const minted = await call("/api/recorder/connect-codes", { + method: "POST", + headers: { cookie }, + json: { via: "web" }, + }); + expect(minted.status).toBe(201); + const { code } = (await minted.json()) as { code: string }; + const exchanged = await call("/api/recorder/connections", { + method: "POST", + headers: { origin: ORIGIN }, + json: { code, label: "Chrome on macOS", installId }, + }); + expect(exchanged.status).toBe(201); + return (await exchanged.json()) as ExchangeResponse; +} + +const clearOrganizationFailure = () => + clearDesktopConnectionFailure("organization"); +beforeEach(() => { + clearOrganizationFailure(); + answer = signedIn; +}); +afterEach(clearOrganizationFailure); + +beforeAll(() => { + Date.now = () => realNow() + skewMs; + const organizationAuth = createOrganizationAuth({ + authorityUrl: config.organizationAuthUrl as string, + materializeUser: organizationUserStore(database), + }); + const recorder = createRecorderExtension({ + config, + database, + organizationAuth, + roleRepository, + peopleStore: { isRevoked: async () => false }, + personalSpaces: { + containerFor: (userId) => `space-of-${userId}`, + state: async () => ({ state: "unassigned", containerId: null }), + kick: () => {}, + }, + auditStore, + }); + app = createApp( + config, + organizationAuth, + roleRepository, + ...(Array.from({ length: 9 }) as never[]), + auditStore, + ...(Array.from({ length: 14 }) as never[]), + HOST_TOKEN, + ...(Array.from({ length: 6 }) as never[]), + { recorder }, + ); +}); +afterAll(async () => { + Date.now = realNow; + authority.stop(true); + await database.delete(users).where(eq(users.email, person.email)); + await database.$client.close(); +}); + +test("an odd answer from the organization's sign-in server never signs the Recorder out, and a 401 or 403 does", async () => { + const cookie = await organizationCookie(); + const { token } = await connect(cookie, "a".repeat(32)); + const me = () => call("/api/recorder/me", { headers: bearer(token) }); + expect((await me()).status).toBe(200); + + const odd: Array<[string, Answer]> = [ + ["404", () => new Response("missing", { status: 404 })], + ["408", () => new Response(null, { status: 408 })], + ["429", () => new Response(null, { status: 429 })], + [ + "302", + () => + new Response(null, { + status: 302, + headers: { location: "http://127.0.0.1:1/login" }, + }), + ], + ["200 that is not JSON", () => new Response("<html>sign in</html>")], + ["200 that is not an actor", () => Response.json({ user: { id: 7 } })], + ["503", () => new Response(null, { status: 503 })], + ]; + for (const [label, odd_answer] of odd) { + pastTheCache(); + answer = odd_answer; + const refused = await me(); + expect(refused.status, label).toBe(503); + expect(await refused.json(), label).toEqual({ + error: "Your organization could not answer. Try again in a moment.", + code: RECORDER_AUTHORITY_UNAVAILABLE, + }); + answer = signedIn; + expect((await me()).status, `${label}, then answering again`).toBe(200); + } + + // A closed port: the sign-in server is not there at all. + pastTheCache(); + authority.stop(true); + const closed = await me(); + expect(closed.status).toBe(503); + expect(((await closed.json()) as { code: string }).code).toBe( + RECORDER_AUTHORITY_UNAVAILABLE, + ); + authority = Bun.serve({ + hostname: "127.0.0.1", + port: authorityPort, + fetch: authorityFetch, + }); + expect((await me()).status).toBe(200); + + // Only an explicit refusal signs the Recorder out, 401 and 403 alike. + for (const status of [401, 403]) { + pastTheCache(); + answer = () => new Response(null, { status }); + const signedOut = await me(); + expect(signedOut.status, String(status)).toBe(401); + expect(await signedOut.json(), String(status)).toMatchObject({ + code: RECORDER_SIGNED_OUT, + reason: "organization", + }); + answer = signedIn; + pastTheCache(); + expect((await me()).status, `after ${status}`).toBe(200); + } +}); + +test("a person the organization removed is refused once the cached answer ages out, and the re-check leaves Desktop's failure state alone", async () => { + const cookie = await organizationCookie(); + const { token } = await connect(cookie, "b".repeat(32)); + const me = () => call("/api/recorder/me", { headers: bearer(token) }); + expect((await me()).status).toBe(200); + + answer = () => new Response(null, { status: 401 }); + // Still inside the 60 s cache: the earlier answer stands. + expect((await me()).status).toBe(200); + pastTheCache(); + expect(await desktopFailure()).toBeNull(); + const removed = await me(); + expect(removed.status).toBe(401); + expect(await removed.json()).toMatchObject({ + code: RECORDER_SIGNED_OUT, + reason: "organization", + }); + // The Recorder's own re-check records no Desktop connection failure. + expect(await desktopFailure()).toBeNull(); + + // The same removed person asking with their cookie is today's recorded failure, unchanged. + const withCookie = await call("/api/recorder/connections", { + headers: { cookie }, + }); + expect(withCookie.status).toBe(401); + expect(await desktopFailure()).toEqual({ + connection: "organization", + code: "organization_authentication_failed", + }); +}); + +test("the stored organization session is sealed, never the raw cookie", async () => { + const cookie = await organizationCookie(); + const { connection } = await connect(cookie, "c".repeat(32)); + const [row] = await database + .select({ sealed: recorderConnections.organizationSession }) + .from(recorderConnections) + .where(eq(recorderConnections.id, connection.id)); + expect(row?.sealed).toBeTruthy(); + const value = cookie.slice("openbot.organization-session=".length); + expect(row?.sealed).not.toContain(value); + expect(row?.sealed).not.toContain("better-auth.session_token"); + expect(row?.sealed).not.toContain(cookie); +}); diff --git a/server/tests/recorder-routes.integration.test.ts b/server/tests/recorder-routes.integration.test.ts new file mode 100644 index 000000000..eb54178e5 --- /dev/null +++ b/server/tests/recorder-routes.integration.test.ts @@ -0,0 +1,438 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + type ExchangeResponse, + LEARNING_SPACE_STATES, + RECORDER_SIGNED_OUT, +} from "@openbot/shared/recorder-api"; +import { RECORDER_EXTENSION_ID } from "@openbot/shared/recorder-extension"; +import { inArray } from "drizzle-orm"; +import { createApp } from "../src/app"; +import type { AuditEventInput } from "../src/audit"; +import { loadConfig } from "../src/config"; +import { createDatabase } from "../src/db/client"; +import { users } from "../src/db/schema/core"; +import { createRecorderExtension } from "../src/recorder/wiring"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { testEnvironment } from "./support/environment"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const prefix = `recorder-routes-${randomUUID()}`; +const owner = `${prefix}-owner`; +const busy = `${prefix}-busy`; +const ORIGIN = `chrome-extension://${RECORDER_EXTENSION_ID}`; +const INSTALL = "c".repeat(32); +const OTHER_EXTENSION = `chrome-extension://${"a".repeat(32)}`; + +let transcribed = 0; +const transcriber = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => { + transcribed += 1; + return Response.json({ text: "hello from the fake" }); + }, +}); + +type Harness = ReturnType<typeof build>; + +function build( + extra: Record<string, string | undefined> = {}, + options: { kickThrows?: boolean } = {}, +) { + const config = loadConfig( + testEnvironment({ + TRANSCRIPTION_PROVIDER: "openai-compatible", + TRANSCRIPTION_BASE_URL: `http://127.0.0.1:${transcriber.port}`, + TRANSCRIPTION_MODEL: "fake", + ...extra, + }), + ); + const audits: AuditEventInput[] = []; + const auditStore = { + insert: async (event: AuditEventInput) => { + audits.push(event); + }, + }; + const roleRepository = { rolesForUser: async () => ["user" as const] }; + /** The person's own Learning space, faked: what the Recorder is told, and whose browsers connected. */ + const kicked: string[] = []; + const recorder = createRecorderExtension({ + config, + database, + organizationAuth: undefined, + roleRepository, + peopleStore: { isRevoked: async () => false }, + personalSpaces: { + containerFor: (userId) => `space-of-${userId}`, + state: async (userId) => ({ + state: "assigned", + containerId: `space-of-${userId}`, + }), + kick: (userId) => { + kicked.push(userId); + if (options.kickThrows) throw new Error("credential store down"); + }, + }, + auditStore, + }); + const app = createApp( + config, + { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const cookie = headers.get("cookie") ?? ""; + const id = cookie.includes("test-session=owner") + ? owner + : cookie.includes("test-session=busy") + ? busy + : null; + return id + ? { user: { id, email: `${id}@example.test`, name: "The Owner" } } + : null; + }, + }, + } as never, + roleRepository, + ...(Array.from({ length: 9 }) as never[]), + auditStore, + ...(Array.from({ length: 21 }) as never[]), + { recorder }, + ); + return { app, audits, config, kicked }; +} + +const call = ( + { app }: Harness, + path: string, + init: RequestInit & { json?: unknown } = {}, +) => { + const { json, headers, ...rest } = init; + return app.request(`http://openbot.test${path}`, { + ...rest, + headers: { + ...(json === undefined ? {} : { "content-type": "application/json" }), + ...(headers as Record<string, string>), + }, + ...(json === undefined ? {} : { body: JSON.stringify(json) }), + }); +}; +const COOKIE = { cookie: "test-session=owner" }; + +async function connect( + harness: Harness, + installId = INSTALL, +): Promise<ExchangeResponse> { + const minted = await call(harness, "/api/recorder/connect-codes", { + method: "POST", + headers: COOKIE, + json: { via: "web" }, + }); + const { code } = (await minted.json()) as { code: string }; + const exchanged = await call(harness, "/api/recorder/connections", { + method: "POST", + headers: { origin: ORIGIN }, + json: { code, label: "Chrome on macOS", installId }, + }); + expect(exchanged.status).toBe(201); + return exchanged.json(); +} +const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); + +beforeAll(async () => { + await database.insert(users).values([ + { id: owner, email: `${owner}@example.test`, name: "The Owner" }, + { id: busy, email: `${busy}@example.test`, name: "The Owner" }, + ]); +}); +afterAll(async () => { + await database.delete(users).where(inArray(users.id, [owner, busy])); + await database.$client.close(); + await transcriber.stop(true); +}); + +describe("connecting", () => { + test("a connect code needs JSON and a signed-in person", async () => { + const h = build(); + const form = await call(h, "/api/recorder/connect-codes", { + method: "POST", + headers: { ...COOKIE, "content-type": "text/plain" }, + body: '{"via":"web"}', + }); + expect(form.status).toBe(415); + const anonymous = await call(h, "/api/recorder/connect-codes", { + method: "POST", + json: { via: "web" }, + }); + expect(anonymous.status).toBe(401); + const minted = await call(h, "/api/recorder/connect-codes", { + method: "POST", + headers: COOKIE, + json: { via: "web" }, + }); + expect(minted.status).toBe(201); + expect(((await minted.json()) as { code: string }).code).toMatch(/^obc_/); + }); + + test("a person who already holds ten unused codes is told to wait", async () => { + const h = build(); + const mint = () => + call(h, "/api/recorder/connect-codes", { + method: "POST", + headers: { cookie: "test-session=busy" }, + json: { via: "web" }, + }); + for (let index = 0; index < 10; index += 1) { + expect((await mint()).status).toBe(201); + } + const refused = await mint(); + expect(refused.status).toBe(429); + expect(await refused.json()).toEqual({ + error: + "Too many connection codes were asked for at once. Wait a few minutes and try again.", + }); + }); + + test("a body over 2 KiB is refused", async () => { + const h = build(); + const big = await call(h, "/api/recorder/connect-codes", { + method: "POST", + headers: COOKIE, + json: { via: "web", padding: "x".repeat(3000) }, + }); + expect(big.status).toBe(413); + }); + + test("only the Recorder's own origin may finish connecting", async () => { + const h = build(); + const minted = await call(h, "/api/recorder/connect-codes", { + method: "POST", + headers: COOKIE, + json: { via: "web" }, + }); + const { code } = (await minted.json()) as { code: string }; + const body = { code, label: "x", installId: INSTALL }; + for (const origin of ["https://evil.example.test", OTHER_EXTENSION]) { + const refused = await call(h, "/api/recorder/connections", { + method: "POST", + headers: { origin }, + json: body, + }); + expect(refused.status).toBe(403); + } + expect( + h.audits.filter((a) => a.eventType === "recorder.connect_refused"), + ).toHaveLength(2); + const missing = await call(h, "/api/recorder/connections", { + method: "POST", + headers: { origin: ORIGIN }, + json: { code, label: "x" }, + }); + expect(missing.status).toBe(400); + const done = await call(h, "/api/recorder/connections", { + method: "POST", + headers: { origin: ORIGIN }, + json: body, + }); + expect(done.status).toBe(201); + const made = (await done.json()) as ExchangeResponse; + expect(made.connection.installId).toBe(INSTALL); + expect(made.user.id).toBe(owner); + expect( + h.audits.filter((a) => a.eventType === "recorder.connected"), + ).toHaveLength(1); + const again = await call(h, "/api/recorder/connections", { + method: "POST", + headers: { origin: ORIGIN }, + json: body, + }); + expect(again.status).toBe(400); + }); + + test("connecting still succeeds when starting the person's Learning space fails", async () => { + const h = build({}, { kickThrows: true }); + const made = await connect(h, "7".repeat(32)); + expect(made.token).toMatch(/^obr_/); + expect(h.kicked).toEqual([owner]); + }); + + test("the same copy connecting again replaces its older connection", async () => { + const h = build(); + const first = await connect(h, "d".repeat(32)); + const second = await connect(h, "d".repeat(32)); + const replaced = h.audits.filter((a) => a.eventType === "recorder.revoked"); + expect(replaced).toHaveLength(1); + expect(replaced[0]?.payload).toEqual({ + connectionId: first.connection.id, + reason: "replaced", + }); + expect( + (await call(h, "/api/recorder/me", { headers: bearer(first.token) })) + .status, + ).toBe(401); + expect( + (await call(h, "/api/recorder/me", { headers: bearer(second.token) })) + .status, + ).toBe(200); + }); + + test("extra extension ids are listed after the fixed one and accepted", async () => { + const extra = "b".repeat(32); + const plain = build(); + const refused = await call(plain, "/api/recorder/connections", { + method: "POST", + headers: { origin: `chrome-extension://${extra}` }, + json: { code: "obc_x", label: "x", installId: INSTALL }, + }); + expect(refused.status).toBe(403); + const h = build({ OPENBOT_RECORDER_EXTRA_EXTENSION_IDS: extra }); + const listed = await call(h, "/api/recorder/connections", { + headers: COOKIE, + }); + expect( + ((await listed.json()) as { extensionIds: string[] }).extensionIds, + ).toEqual([RECORDER_EXTENSION_ID, extra]); + const minted = await call(h, "/api/recorder/connect-codes", { + method: "POST", + headers: COOKIE, + json: { via: "desktop" }, + }); + const { code } = (await minted.json()) as { code: string }; + const accepted = await call(h, "/api/recorder/connections", { + method: "POST", + headers: { origin: `chrome-extension://${extra}/` }, + json: { code, label: "Fork", installId: "f".repeat(32) }, + }); + expect(accepted.status).toBe(201); + }); +}); + +describe("using the bearer", () => { + test("me, the person's own Learning space and the connections list", async () => { + const h = build(); + const made = await connect(h, "1".repeat(32)); + // Connecting the browser starts making the person's space, so it is there before the first recording. + expect(h.kicked).toEqual([owner]); + const me = await call(h, "/api/recorder/me", { + headers: bearer(made.token), + }); + expect(me.status).toBe(200); + expect(await me.json()).toEqual({ + user: { id: owner, email: `${owner}@example.test`, name: "The Owner" }, + connectionId: made.connection.id, + }); + + const learning = await call(h, "/api/recorder/learning", { + headers: bearer(made.token), + }); + expect(learning.status).toBe(200); + const answer = (await learning.json()) as { + learning: { state: string; containerId: string | null }; + }; + expect(LEARNING_SPACE_STATES as readonly string[]).toContain( + answer.learning.state, + ); + expect(answer.learning).toEqual({ + state: "assigned", + containerId: `space-of-${owner}`, + }); + // There is no Bot list any more: a recording is the person's, not a Bot's. + expect( + (await call(h, "/api/recorder/bots", { headers: bearer(made.token) })) + .status, + ).toBe(404); + + const listed = (await ( + await call(h, "/api/recorder/connections", { headers: COOKIE }) + ).json()) as { + connections: { id: string }[]; + extensionIds: string[]; + }; + expect(listed.connections.map((c) => c.id)).toContain(made.connection.id); + expect(listed.extensionIds).toEqual([RECORDER_EXTENSION_ID]); + }); + + test("disconnecting signs the browser out", async () => { + const h = build(); + const made = await connect(h, "3".repeat(32)); + const gone = await call(h, "/api/recorder/connections/current", { + method: "DELETE", + headers: bearer(made.token), + }); + expect(gone.status).toBe(204); + expect( + h.audits.find((a) => a.eventType === "recorder.revoked")?.payload, + ).toMatchObject({ connectionId: made.connection.id }); + const after = await call(h, "/api/recorder/me", { + headers: bearer(made.token), + }); + expect(after.status).toBe(401); + expect(((await after.json()) as { code: string }).code).toBe( + RECORDER_SIGNED_OUT, + ); + expect( + h.audits.filter((a) => a.eventType === "recorder.bearer_refused"), + ).toHaveLength(1); + }); + + test("a person removes one of their connections from the list", async () => { + const h = build(); + const made = await connect(h, "4".repeat(32)); + const removed = await call( + h, + `/api/recorder/connections/${made.connection.id}`, + { method: "DELETE", headers: COOKIE }, + ); + expect(removed.status).toBe(204); + const missing = await call(h, "/api/recorder/connections/nope", { + method: "DELETE", + headers: COOKIE, + }); + expect(missing.status).toBe(404); + expect( + (await call(h, "/api/recorder/me", { headers: bearer(made.token) })) + .status, + ).toBe(401); + }); + + test("the bearer cannot transcribe speech, even with the dictation header", async () => { + const h = build(); + const made = await connect(h, "5".repeat(32)); + const form = new FormData(); + form.set( + "file", + new File([new Uint8Array(8)], "recording.webm", { type: "audio/webm" }), + ); + const answer = await h.app.request( + "http://openbot.test/api/audio/transcriptions", + { + method: "POST", + headers: { ...bearer(made.token), "x-openbot-dictation": "1" }, + body: form, + }, + ); + expect(answer.status).toBe(401); + expect(await answer.json()).toEqual({ error: "Authentication required." }); + expect(transcribed).toBe(0); + }); + + test("OpenBot takes no recording from the Recorder, not even with its bearer", async () => { + const h = build(); + const made = await connect(h, "6".repeat(32)); + for (const [method, path] of [ + ["POST", "/api/recorder/demonstrations"], + ["GET", "/api/recorder/demonstrations/open?botId=mine"], + ["POST", "/api/recorder/demonstrations/x/actions"], + ["POST", "/api/recorder/demonstrations/x/media"], + ["PUT", "/api/recorder/demonstrations/x/media/m/chunks/0"], + ]) { + const answer = await call(h, path, { + method, + headers: bearer(made.token), + ...(method === "GET" ? {} : { json: {} }), + }); + expect([method, path, answer.status]).toEqual([method, path, 404]); + } + }); +}); diff --git a/server/tests/recorder-schema.integration.test.ts b/server/tests/recorder-schema.integration.test.ts new file mode 100644 index 000000000..364b25962 --- /dev/null +++ b/server/tests/recorder-schema.integration.test.ts @@ -0,0 +1,257 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray, sql } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { users } from "../src/db/schema/core"; +import { + learningOpenSkills, + learningPersonSpaces, + learningSkillSharing, +} from "../src/db/schema/learning"; +import { + recorderConnectCodes, + recorderConnections, + recorderTrajectoryBindings, +} from "../src/db/schema/recorder"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const prefix = `recorder-${randomUUID()}`; +const alice = `${prefix}-alice`; +const bob = `${prefix}-bob`; +let counter = 0; +const next = () => `${prefix}-${counter++}`; + +async function rejects(promise: Promise<unknown>, pattern: RegExp) { + let message = ""; + try { + await promise; + } catch (error) { + const cause = (error as { cause?: unknown }).cause; + message = `${String(error)} ${String(cause ?? "")} ${JSON.stringify(error)}`; + } + expect(message).toMatch(pattern); +} + +function connection( + userId: string, + overrides: Partial<typeof recorderConnections.$inferInsert> = {}, +) { + return { + id: next(), + userId, + tokenHash: next(), + label: "Chrome", + via: "web" as const, + extensionId: "abcdefghijklmnopabcdefghijklmnop", + installId: next(), + ...overrides, + }; +} + +beforeAll(async () => { + await database.insert(users).values([ + { id: alice, email: `${alice}@example.test` }, + { id: bob, email: `${bob}@example.test` }, + ]); +}); + +afterAll(async () => { + await database.delete(users).where(inArray(users.id, [alice, bob])); + await database.$client.close(); +}); + +test("no name in the migration is longer than PostgreSQL keeps", async () => { + const migration = await Bun.file( + new URL("../drizzle/0057_recorder_extension.sql", import.meta.url), + ).text(); + const quoted = [...migration.matchAll(/"([a-z0-9_]+)"/g)].map( + (match) => match[1] as string, + ); + expect(quoted.length).toBeGreaterThan(0); + for (const name of quoted) expect(name.length).toBeLessThanOrEqual(63); +}); + +test("a connection token hash is unique", async () => { + const tokenHash = next(); + await database + .insert(recorderConnections) + .values(connection(alice, { tokenHash })); + await rejects( + database.insert(recorderConnections).values(connection(bob, { tokenHash })), + /token_hash|unique/i, + ); +}); + +test("one live connection per person and installed copy", async () => { + const installId = next(); + await database + .insert(recorderConnections) + .values(connection(alice, { installId })); + await rejects( + database + .insert(recorderConnections) + .values(connection(alice, { installId })), + /recorder_connections_live_install_idx/, + ); + await database + .insert(recorderConnections) + .values(connection(alice, { installId, revokedAt: new Date() })); + await database.insert(recorderConnections).values(connection(alice)); +}); + +test("deleting a person removes their connections and connect codes", async () => { + const carol = `${prefix}-carol`; + await database + .insert(users) + .values({ id: carol, email: `${carol}@example.test` }); + await database.insert(recorderConnections).values(connection(carol)); + await database.insert(recorderConnectCodes).values({ + codeHash: next(), + userId: carol, + via: "desktop", + expiresAt: new Date(Date.now() + 60_000), + }); + await database.delete(users).where(eq(users.id, carol)); + expect( + await database + .select() + .from(recorderConnections) + .where(eq(recorderConnections.userId, carol)), + ).toEqual([]); + expect( + await database + .select() + .from(recorderConnectCodes) + .where(eq(recorderConnectCodes.userId, carol)), + ).toEqual([]); +}); + +test("a trajectory binds to one person, by its UUID, and names no Bot", async () => { + const trajectoryId = randomUUID(); + await database + .insert(recorderTrajectoryBindings) + .values({ trajectoryId, userId: alice }); + await rejects( + database + .insert(recorderTrajectoryBindings) + .values({ trajectoryId, userId: bob }), + /recorder_trajectory_bindings_pkey|duplicate key/i, + ); + await rejects( + database + .insert(recorderTrajectoryBindings) + .values({ trajectoryId: "not-a-uuid", userId: alice }), + /uuid/i, + ); + const [row] = await database + .select() + .from(recorderTrajectoryBindings) + .where(eq(recorderTrajectoryBindings.trajectoryId, trajectoryId)); + expect(row).toMatchObject({ trajectoryId, userId: alice }); + expect(Object.keys(row ?? {}).sort()).toEqual([ + "createdAt", + "trajectoryId", + "userId", + ]); + expect(row?.createdAt).toBeInstanceOf(Date); + const columns = (await database.execute( + sql`select column_name from information_schema.columns where table_name = 'recorder_trajectory_bindings' order by column_name`, + )) as unknown as { column_name: string }[]; + expect(columns.map((column) => column.column_name)).toEqual([ + "created_at", + "trajectory_id", + "user_id", + ]); +}); + +test("deleting a person removes their trajectory bindings, Learning space row and sharing choice", async () => { + const dave = `${prefix}-dave`; + await database + .insert(users) + .values({ id: dave, email: `${dave}@example.test` }); + await database + .insert(recorderTrajectoryBindings) + .values({ trajectoryId: randomUUID(), userId: dave }); + await database + .insert(learningPersonSpaces) + .values({ userId: dave, containerId: `${prefix}-space-dave` }); + await database + .insert(learningSkillSharing) + .values({ userId: dave, mode: "choose" }); + await database + .insert(learningOpenSkills) + .values({ userId: dave, skillName: "refund-order" }); + await database.delete(users).where(eq(users.id, dave)); + for (const table of [ + recorderTrajectoryBindings, + learningPersonSpaces, + learningSkillSharing, + learningOpenSkills, + ]) { + expect( + await database.select().from(table).where(eq(table.userId, dave)), + ).toEqual([]); + } +}); + +test("a sharing choice is closed, open or choose, and closed by default", async () => { + await database.insert(learningSkillSharing).values({ userId: alice }); + const [row] = await database + .select() + .from(learningSkillSharing) + .where(eq(learningSkillSharing.userId, alice)); + expect(row?.mode).toBe("closed"); + await rejects( + database + .update(learningSkillSharing) + .set({ mode: "everyone" as never }) + .where(eq(learningSkillSharing.userId, alice)), + /learning_skill_sharing_mode_check|check constraint/i, + ); + await database + .delete(learningSkillSharing) + .where(eq(learningSkillSharing.userId, alice)); +}); + +test("one Learning space per person, and no two people share one", async () => { + const space = `${prefix}-space`; + await database + .insert(learningPersonSpaces) + .values({ userId: alice, containerId: space }); + await rejects( + database + .insert(learningPersonSpaces) + .values({ userId: alice, containerId: `${space}-2` }), + /learning_person_spaces_pkey|duplicate key/i, + ); + await rejects( + database + .insert(learningPersonSpaces) + .values({ userId: bob, containerId: space }), + /learning_person_spaces_container_id_unique|duplicate key/i, + ); + await database + .delete(learningPersonSpaces) + .where(eq(learningPersonSpaces.userId, alice)); +}); + +test("the migration holds only the Recorder's connections and trajectory bindings, and each person's Learning space and sharing", async () => { + const migration = await Bun.file( + new URL("../drizzle/0057_recorder_extension.sql", import.meta.url), + ).text(); + const created = [...migration.matchAll(/CREATE TABLE "([a-z0-9_]+)"/g)].map( + (match) => match[1], + ); + expect(created.sort()).toEqual([ + "learning_open_skills", + "learning_person_spaces", + "learning_skill_sharing", + "recorder_connect_codes", + "recorder_connections", + "recorder_trajectory_bindings", + ]); + expect(migration).not.toMatch(/ALTER TABLE "demonstrations"|DROP /); + // A recording is the person's: no Bot column, and no reference to the agents table. + expect(migration).not.toMatch(/bot_id|"agents"/); +}); diff --git a/server/tests/recorder-scope.integration.test.ts b/server/tests/recorder-scope.integration.test.ts new file mode 100644 index 000000000..9f7dc5480 --- /dev/null +++ b/server/tests/recorder-scope.integration.test.ts @@ -0,0 +1,307 @@ +import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { CopilotKitIntelligence } from "@copilotkit/runtime/v2"; +import type { ExchangeResponse } from "@openbot/shared/recorder-api"; +import { RECORDER_EXTENSION_ID } from "@openbot/shared/recorder-extension"; +import { count, eq, inArray } from "drizzle-orm"; +import { GATED_ROUTES, runtimeRun } from "../src/admin/gate"; +import { createActorAgentResolver } from "../src/agents/agent-resolver"; +import { createApp } from "../src/app"; +import type { AuditEventInput } from "../src/audit"; +import { createStallGuard } from "../src/channels/stall-guard"; +import { loadConfig } from "../src/config"; +import { mountCopilotRuntime } from "../src/copilot"; +import { createDatabase } from "../src/db/client"; +import { agents, users } from "../src/db/schema/core"; +import { routines } from "../src/db/schema/coworker"; +import { demonstrations } from "../src/db/schema/demonstrations"; +import { + learningOpenSkills, + learningSkillSharing, +} from "../src/db/schema/learning"; +import { skills } from "../src/db/schema/plugins"; +import { createDemonstrationRecorder } from "../src/demonstrations/recording"; +import { createDemonstrationStore } from "../src/demonstrations/store"; +import { createLearningSettingsStore } from "../src/learning/settings"; +import { createLearningSharingStore } from "../src/learning/sharing"; +import { createRecorderExtension } from "../src/recorder/wiring"; +import { createRequestActor } from "../src/request-actor"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; +import { testEnvironment } from "./support/environment"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const prefix = `recorder-scope-${randomUUID()}`; +const owner = `${prefix}-owner`; +const ORIGIN = `chrome-extension://${RECORDER_EXTENSION_ID}`; +const BOT = `${prefix}-bot`; +const COOKIE = { cookie: "test-session=owner" }; + +const config = loadConfig(testEnvironment()); +const roleRepository = { rolesForUser: async () => ["user" as const] }; +const getSession = async ({ headers }: { headers: Headers }) => + headers.get("cookie")?.includes("test-session=owner") + ? { + user: { + id: owner, + email: `${owner}@example.test`, + name: "The Owner", + }, + } + : null; +const bot = { id: BOT, name: "Bot", hidden: false, deletedAt: null }; +const agentProfileStore = { + list: async () => [bot], + get: async (_actor: unknown, id: string) => (id === BOT ? bot : null), +} as never; +const auditStore = { insert: async (_event: AuditEventInput) => {} }; + +const demonstrationStore = createDemonstrationStore(database); +const demonstrationRecorder = createDemonstrationRecorder({ + store: demonstrationStore, + ownsBot: async () => true, + ownsSkill: async () => true, +}); +const recorder = createRecorderExtension({ + config, + database, + organizationAuth: undefined, + roleRepository, + peopleStore: { isRevoked: async () => false }, + personalSpaces: { + containerFor: (userId) => `space-of-${userId}`, + state: async () => ({ state: "unassigned", containerId: null }), + kick: () => {}, + }, + auditStore, +}); + +// The agent resolver records every actor a run asks it to load Bots for, and serves the Bot to every +// one of them, anonymous included: the worst case, a Bot anyone can reach, so only identity refuses. +const loadedFor: string[] = []; +// Who Intelligence was asked to open a thread for. Reaching it is a run starting. +const threadsFor: string[] = []; +// Every fetch, refused: nothing in this file may dial Intelligence (or anything else). +const dialled: string[] = []; +const spies: Array<{ mockRestore(): void }> = []; +const guard = createStallGuard({ stallMs: 0 }); +const { identifyUser, identifyActor } = createRequestActor({ + singleUser: false, + resolveRecorder: recorder.resolve, + getSession: async (headers) => getSession({ headers }), + rolesForUser: (id) => roleRepository.rolesForUser(id), +}); +const learningSettings = createLearningSettingsStore(undefined, { + containerId: "general", +}); +const runtime = mountCopilotRuntime( + config, + createActorAgentResolver({ + model: { provider: "openai", defaultModel: "unused" }, + loadAgents: async (actor) => { + loadedFor.push(actor.id); + return [ + { id: BOT, name: "Bot", type: "built_in", systemPrompt: "Work." }, + ]; + }, + resolveModelApiKey: async () => "synthetic-key", + stallGuard: guard, + }), + identifyUser, + identifyActor, + { learningSettings, trajectoryCapture: recorder.trajectoryCapture }, +); + +const app = createApp( + config, + { + handler: () => new Response(null, { status: 204 }), + api: { getSession }, + } as never, + roleRepository, + ...(Array.from({ length: 3 }) as never[]), + runtime.handler, + ...(Array.from({ length: 2 }) as never[]), + agentProfileStore, + ...(Array.from({ length: 2 }) as never[]), + auditStore, + ...(Array.from({ length: 21 }) as never[]), + { + recorder, + personalLearning: { + store: createLearningSharingStore(database), + skillsOf: async () => ({ skills: [] }), + invalidate: () => {}, + }, + approvals: {} as never, + demonstrations: { + store: demonstrationStore, + recorder: demonstrationRecorder, + }, + }, +); + +const call = (path: string, init: RequestInit & { json?: unknown } = {}) => { + const { json, headers, ...rest } = init; + return app.request(`http://openbot.test${path}`, { + ...rest, + headers: { + ...(json === undefined ? {} : { "content-type": "application/json" }), + ...(headers as Record<string, string>), + }, + ...(json === undefined ? {} : { body: JSON.stringify(json) }), + }); +}; + +async function counts() { + const of = async ( + table: + | typeof demonstrations + | typeof routines + | typeof skills + | typeof learningSkillSharing + | typeof learningOpenSkills, + ) => (await database.select({ n: count() }).from(table))[0]?.n ?? 0; + return { + demonstrations: await of(demonstrations), + routines: await of(routines), + skills: await of(skills), + sharing: await of(learningSkillSharing), + openSkills: await of(learningOpenSkills), + }; +} + +let token = ""; +let stoppedId = ""; + +beforeAll(async () => { + spies.push( + spyOn( + CopilotKitIntelligence.prototype, + "getOrCreateThread", + ).mockImplementation(async (input) => { + threadsFor.push(input.userId); + throw new Error("No thread is opened in this test."); + }), + // The Learning assignment asks whether the thread exists first: it does not. + spyOn(CopilotKitIntelligence.prototype, "getThread").mockImplementation( + async () => { + const error = new Error("Missing synthetic Thread"); + error.name = "PlatformRequestError"; + Object.assign(error, { status: 404 }); + throw error; + }, + ), + spyOn(globalThis, "fetch").mockImplementation(async (input) => { + dialled.push(input instanceof Request ? input.url : String(input)); + throw new Error("No network in this test."); + }), + ); + await database + .insert(users) + .values({ id: owner, email: `${owner}@example.test`, name: "The Owner" }); + await database + .insert(agents) + .values({ id: BOT, name: "Bot", type: "built_in", configuration: {} }); + const minted = await call("/api/recorder/connect-codes", { + method: "POST", + headers: COOKIE, + json: { via: "web" }, + }); + const { code } = (await minted.json()) as { code: string }; + const exchanged = await call("/api/recorder/connections", { + method: "POST", + headers: { origin: ORIGIN }, + json: { code, label: "Chrome on macOS", installId: "e".repeat(32) }, + }); + expect(exchanged.status).toBe(201); + token = ((await exchanged.json()) as ExchangeResponse).token; + const started = await demonstrationStore.start(owner, BOT, "Seeded"); + await demonstrationStore.stop(owner, started.id); + stoppedId = started.id; +}); +afterAll(async () => { + for (const spy of spies) spy.mockRestore(); + guard.stop(); + await database + .delete(demonstrations) + .where(inArray(demonstrations.ownerUserId, [owner])); + await database.delete(agents).where(eq(agents.id, BOT)); + await database.delete(users).where(inArray(users.id, [owner])); + await database.$client.close(); +}); + +describe("a recorder bearer works only on the Recorder's own routes", () => { + test("the bearer is valid where it belongs", async () => { + const me = await call("/api/recorder/me", { + headers: { authorization: `Bearer ${token}` }, + }); + expect(me.status).toBe(200); + }); + + test("every other route answers 401 and changes nothing", async () => { + const before = await counts(); + const routes: Array<[string, string, unknown?]> = [ + ["GET", `/api/demonstrations?botId=${BOT}`], + ["POST", "/api/demonstrations", { botId: BOT, title: "Sneaky" }], + ["POST", `/api/demonstrations/${stoppedId}/draft`, {}], + ["POST", `/api/demonstrations/${stoppedId}/published`, {}], + ["POST", `/api/demonstrations/${stoppedId}/schedule`, {}], + ["DELETE", `/api/demonstrations/${stoppedId}`], + ["GET", "/api/agents"], + ["POST", "/api/agents", { name: "Sneaky" }], + ["GET", "/api/approvals"], + // Who else's Bots use the person's Skills is theirs to change in Settings, never the Recorder's. + ["GET", "/api/settings/learning"], + ["PUT", "/api/settings/learning/mode", { mode: "open" }], + ["PUT", "/api/settings/learning/skills/refund", { open: true }], + ]; + for (const [method, path, json] of routes) { + const answer = await call(path, { + method, + headers: { authorization: `Bearer ${token}` }, + ...(json === undefined ? {} : { json }), + }); + expect([method, path, answer.status]).toEqual([method, path, 401]); + expect(((await answer.json()) as { error: string }).error).toBe( + "Authentication required.", + ); + } + expect(await counts()).toEqual(before); + }); + + test("a bearer on a Bot's run starts no run", async () => { + const run = (headers: Record<string, string>) => + call(`/api/copilotkit/agent/${BOT}/run`, { + method: "POST", + headers, + json: { + threadId: "t", + runId: "r", + state: {}, + messages: [], + tools: [], + context: [], + forwardedProps: {}, + }, + }); + // The control: the person's own session reaches the Bot and starts a run as them. + await run(COOKIE); + expect(loadedFor).toContain(owner); + expect(threadsFor).toEqual([owner]); + const loaded = loadedFor.length; + const answer = await run({ authorization: `Bearer ${token}` }); + expect(answer.ok).toBe(false); + expect(threadsFor).toEqual([owner]); + expect(loadedFor.slice(loaded)).not.toContain(owner); + const intelligence = config.runtime.intelligence.apiUrl; + expect(dialled.filter((url) => url.startsWith(intelligence))).toEqual([]); + }); + + test("no gate rule matches a recorder path", () => { + for (const path of ["/api/recorder/me", "/api/recorder/learning"]) { + expect(runtimeRun(path)).toBeNull(); + const matched = GATED_ROUTES.filter((rule) => rule.path.test(path)); + expect([path, matched.length]).toEqual([path, 0]); + } + }); +}); diff --git a/server/tests/recorder-single-user.integration.test.ts b/server/tests/recorder-single-user.integration.test.ts new file mode 100644 index 000000000..ab5ff09fc --- /dev/null +++ b/server/tests/recorder-single-user.integration.test.ts @@ -0,0 +1,148 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type { ExchangeResponse } from "@openbot/shared/recorder-api"; +import { RECORDER_EXTENSION_ID } from "@openbot/shared/recorder-extension"; +import { eq, inArray } from "drizzle-orm"; +import { createApp } from "../src/app"; +import type { AuditEventInput } from "../src/audit"; +import { DEV_ACTOR, initializeDevActorUser } from "../src/auth/dev-actor"; +import { loadConfig } from "../src/config"; +import { createDatabase } from "../src/db/client"; +import { users } from "../src/db/schema/core"; +import { recorderConnections } from "../src/db/schema/recorder"; +import { personalLearningContainerId } from "../src/learning/personal-space"; +import { RECORDER_BEARER } from "../src/recorder/guard"; +import { createRecorderExtension } from "../src/recorder/wiring"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const other = `recorder-single-${randomUUID()}-other`; +const ORIGIN = `chrome-extension://${RECORDER_EXTENSION_ID}`; + +const config = loadConfig({ + DATABASE_URL: "postgres://openbot:openbot@127.0.0.1:5432/openbot", + KEY_ENCRYPTION_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + INTELLIGENCE_API_URL: "http://127.0.0.1:7100", + INTELLIGENCE_GATEWAY_WS_URL: "ws://127.0.0.1:7103", + INTELLIGENCE_API_KEY: "tenant-api-key", + COPILOTKIT_LICENSE_TOKEN: "license-token", + OPENBOT_SINGLE_USER: "true", +}); +const auditStore = { insert: async (_event: AuditEventInput) => {} }; +const roleRepository = { rolesForUser: async () => ["admin" as const] }; +const recorder = createRecorderExtension({ + config, + database, + organizationAuth: undefined, + roleRepository, + peopleStore: { isRevoked: async () => false }, + personalSpaces: { + containerFor: personalLearningContainerId, + state: async (userId) => ({ + state: "assigned", + containerId: personalLearningContainerId(userId), + }), + kick: () => {}, + }, + auditStore, +}); +const app = createApp( + config, + undefined, + roleRepository, + ...(Array.from({ length: 9 }) as never[]), + auditStore, + ...(Array.from({ length: 21 }) as never[]), + { recorder }, +); + +const call = (path: string, init: RequestInit & { json?: unknown } = {}) => { + const { json, headers, ...rest } = init; + return app.request(`http://openbot.test${path}`, { + ...rest, + headers: { + ...(json === undefined ? {} : { "content-type": "application/json" }), + ...(headers as Record<string, string>), + }, + ...(json === undefined ? {} : { body: JSON.stringify(json) }), + }); +}; + +beforeAll(async () => { + expect(config.singleUser).toBe(true); + await initializeDevActorUser(database, true); + await database + .insert(users) + .values({ id: other, email: `${other}@example.test`, name: "Other" }); +}); +afterAll(async () => { + await database + .delete(recorderConnections) + .where(eq(recorderConnections.userId, DEV_ACTOR.id)); + await database.delete(users).where(inArray(users.id, [other])); + await database.$client.close(); +}); + +test("in single-user mode a connect code, an exchange and the bearer all resolve to the one local person", async () => { + const minted = await call("/api/recorder/connect-codes", { + method: "POST", + json: { via: "web" }, + }); + expect(minted.status).toBe(201); + const { code } = (await minted.json()) as { code: string }; + const exchanged = await call("/api/recorder/connections", { + method: "POST", + headers: { origin: ORIGIN }, + json: { code, label: "Chrome on macOS", installId: "f".repeat(32) }, + }); + expect(exchanged.status).toBe(201); + const { token } = (await exchanged.json()) as ExchangeResponse; + const headers = { authorization: `Bearer ${token}` }; + const me = await call("/api/recorder/me", { headers }); + expect(me.status).toBe(200); + expect(((await me.json()) as { user: { id: string } }).user.id).toBe( + "dev-local-user", + ); + // The one local person's recordings go into their own Learning space. + const learning = await call("/api/recorder/learning", { headers }); + expect(learning.status).toBe(200); + expect(await learning.json()).toEqual({ + learning: { + state: "assigned", + containerId: personalLearningContainerId("dev-local-user"), + }, + }); +}); + +/** A live connection stored the way the connection store stores one: a well-formed token and its SHA-256. */ +async function seedConnection(userId: string, installId: string) { + const token = `obr_${randomBytes(32).toString("base64url")}`; + // Well-formed, so the resolver reads the row instead of refusing the shape of the token. + expect(RECORDER_BEARER.test(`Bearer ${token}`)).toBe(true); + await database.insert(recorderConnections).values({ + id: randomUUID(), + userId, + tokenHash: createHash("sha256").update(token).digest("hex"), + label: "Seeded", + via: "web", + extensionId: RECORDER_EXTENSION_ID, + installId, + }); + return token; +} + +test("a connection that belongs to another person is refused", async () => { + // The control: a row built the same way for the one local person is accepted, so a refusal below + // can only come from whose row it is. + const mine = await seedConnection(DEV_ACTOR.id, "8".repeat(32)); + const accepted = await call("/api/recorder/me", { + headers: { authorization: `Bearer ${mine}` }, + }); + expect(accepted.status).toBe(200); + + const theirs = await seedConnection(other, "9".repeat(32)); + const refused = await call("/api/recorder/me", { + headers: { authorization: `Bearer ${theirs}` }, + }); + expect(refused.status).toBe(401); +}); diff --git a/server/tests/recorder-trajectory-gate.test.ts b/server/tests/recorder-trajectory-gate.test.ts new file mode 100644 index 000000000..0ca13c76f --- /dev/null +++ b/server/tests/recorder-trajectory-gate.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, test } from "bun:test"; +import { + RECORDER_SIGNED_OUT, + TRAJECTORY_GATE_REFUSALS, +} from "@openbot/shared/recorder-api"; +import type { AuditEventInput } from "../src/audit"; +import type { + RecorderActor, + RecorderResolution, + RecorderResolver, +} from "../src/recorder/guard"; +import { + createTrajectoryConnectHook, + createTrajectoryRefusalAudit, + isTrajectoryConnectPath, +} from "../src/recorder/trajectory-gate"; + +const TRAJECTORY = "0b6c1c0e-6f0a-4f6e-9a55-1d1c2f3f4a5b"; +const PERSON = "person-1"; +const OTHER = "person-2"; +const BOT = "bot-1"; + +type Refusal = Parameters< + Parameters<typeof createTrajectoryConnectHook>[0]["audit"] +>[0]; + +const actor: RecorderActor = { + id: PERSON, + name: "Ada", + email: "ada@example.com", + image: null, + role: "user", + connectionId: "connection-1", +}; + +function setup( + options: { + bearer?: RecorderResolution; + /** Existing bindings: trajectory id to person. */ + bound?: Record<string, string>; + } = {}, +) { + const bearer = "bearer" in options ? options.bearer : { actor }; + const bound = new Map(Object.entries(options.bound ?? {})); + const asked: string[] = []; + const refusals: Refusal[] = []; + const resolveRecorder: RecorderResolver = async () => bearer ?? null; + const hook = createTrajectoryConnectHook({ + resolveRecorder, + bind: async ({ trajectoryId, userId }) => { + asked.push(`bind ${trajectoryId}:${userId}`); + const existing = bound.get(trajectoryId); + if (existing === undefined) bound.set(trajectoryId, userId); + return existing === undefined || existing === userId; + }, + audit: (refusal) => refusals.push(refusal), + }); + const run = async ( + route: Parameters<typeof hook>[0]["route"], + headers: Record<string, string> = {}, + ) => { + const request = new Request("http://127.0.0.1/api/copilotkit/x", { + method: "POST", + headers, + }); + try { + await hook({ + request, + path: "/api/copilotkit/x", + runtime: {} as never, + route, + }); + return null; + } catch (thrown) { + if (thrown instanceof Response) return thrown; + throw thrown; + } + }; + const connect = ( + trajectoryId = TRAJECTORY, + headers?: Record<string, string>, + ) => run({ method: "trajectory/connect", trajectoryId }, headers); + return { run, connect, asked, refusals, bound }; +} + +async function bodyOf(response: Response | null) { + return (await response?.json()) as Record<string, unknown>; +} + +describe("createTrajectoryConnectHook", () => { + test("passes every other route without a question", async () => { + const { run, asked, refusals } = setup(); + + expect(await run({ method: "agent/run", agentId: BOT })).toBeNull(); + expect(await run({ method: "info" })).toBeNull(); + expect(asked).toEqual([]); + expect(refusals).toEqual([]); + }); + + test("answers 400 for an id that is not a UUID before any question", async () => { + const { connect, asked, refusals } = setup(); + + const response = await connect("not-a-uuid"); + + expect(response?.status).toBe(400); + expect(response?.headers.get("cache-control")).toBe("no-store"); + expect(await bodyOf(response)).toEqual({ + code: TRAJECTORY_GATE_REFUSALS.invalidId.code, + message: TRAJECTORY_GATE_REFUSALS.invalidId.message, + }); + expect(asked).toEqual([]); + expect(refusals.map((refusal) => refusal.reason)).toEqual(["invalid-id"]); + }); + + test("answers 401 RECORDER_SIGNED_OUT with the reason for a bearer that does not resolve", async () => { + for (const [refused, reason] of [ + ["unknown", "revoked"], + ["malformed", "revoked"], + ["removed", "removed"], + ["organization", "organization"], + ] as const) { + const { connect, asked, refusals } = setup({ + bearer: { refused, userId: PERSON }, + }); + + const response = await connect(); + + expect(response?.status).toBe(401); + expect(response?.headers.get("cache-control")).toBe("no-store"); + const body = await bodyOf(response); + expect(body.code).toBe(RECORDER_SIGNED_OUT); + expect(body.reason).toBe(reason); + expect(typeof body.message).toBe("string"); + expect(asked).toEqual([]); + expect(refusals).toEqual([ + { reason: "signed-out", trajectoryId: TRAJECTORY, userId: PERSON }, + ]); + } + }); + + test("answers 403 FORBIDDEN for a person without a role", async () => { + const { connect, refusals } = setup({ + bearer: { refused: "forbidden", userId: PERSON }, + }); + + const response = await connect(); + + expect(response?.status).toBe(403); + expect(await bodyOf(response)).toEqual({ + code: TRAJECTORY_GATE_REFUSALS.forbidden.code, + message: TRAJECTORY_GATE_REFUSALS.forbidden.message, + }); + expect(refusals.map((refusal) => refusal.reason)).toEqual(["forbidden"]); + }); + + test("answers 503 when the organization could not answer, never a sign-out", async () => { + const { connect, refusals } = setup({ bearer: { unavailable: true } }); + + const response = await connect(); + + expect(response?.status).toBe(503); + expect(response?.headers.get("cache-control")).toBe("no-store"); + expect(await bodyOf(response)).toEqual({ + code: TRAJECTORY_GATE_REFUSALS.authorityUnavailable.code, + message: TRAJECTORY_GATE_REFUSALS.authorityUnavailable.message, + }); + expect(refusals.map((refusal) => refusal.reason)).toEqual(["unavailable"]); + }); + + test("answers 401 IDENTITY_REQUIRED without a Recorder bearer, whatever else the request carries", async () => { + const { connect, asked, refusals } = setup({ bearer: null }); + + const response = await connect(TRAJECTORY, { + cookie: "better-auth.session_token=signed-in", + }); + + expect(response?.status).toBe(401); + expect(await bodyOf(response)).toEqual({ + code: "IDENTITY_REQUIRED", + message: "Connect this browser to OpenBot to record.", + }); + expect(asked).toEqual([]); + expect(refusals).toEqual([ + { reason: "signed-out", trajectoryId: TRAJECTORY, userId: null }, + ]); + }); + + test("needs no Bot: a pass with no Bot named binds the trajectory to the person", async () => { + const { connect, asked, refusals, bound } = setup(); + + expect(await connect()).toBeNull(); + expect(asked).toEqual([`bind ${TRAJECTORY}:${PERSON}`]); + expect(bound.get(TRAJECTORY)).toBe(PERSON); + expect(refusals).toEqual([]); + }); + + test("ignores a Bot header an older Recorder still sends: the trajectory is the person's", async () => { + const { connect, bound, refusals } = setup(); + + expect( + await connect(TRAJECTORY, { + "x-openbot-recorder-bot": "someone-elses-bot", + }), + ).toBeNull(); + expect(bound.get(TRAJECTORY)).toBe(PERSON); + expect(refusals).toEqual([]); + }); + + test("lets the same person through again, for a reconnect's fresh pass", async () => { + const { connect, refusals } = setup({ + bound: { [TRAJECTORY]: PERSON }, + }); + + expect(await connect()).toBeNull(); + expect(await connect()).toBeNull(); + expect(refusals).toEqual([]); + }); + + test("answers 404 for a trajectory bound to another person", async () => { + const { connect, refusals, bound } = setup({ + bound: { [TRAJECTORY]: OTHER }, + }); + + const response = await connect(); + + expect(response?.status).toBe(404); + expect(response?.headers.get("cache-control")).toBe("no-store"); + expect(await bodyOf(response)).toEqual({ + code: "NOT_FOUND", + message: "This trajectory belongs to another recording.", + }); + expect(bound.get(TRAJECTORY)).toBe(OTHER); + expect(refusals).toEqual([ + { reason: "bound-elsewhere", trajectoryId: TRAJECTORY, userId: PERSON }, + ]); + }); +}); + +describe("isTrajectoryConnectPath", () => { + test("matches the runtime's unanchored shape", () => { + expect( + isTrajectoryConnectPath( + `/api/copilotkit/trajectory/${TRAJECTORY}/connect`, + ), + ).toBe(true); + expect( + isTrajectoryConnectPath( + `/api/copilotkit/x/trajectory/${TRAJECTORY}/connect`, + ), + ).toBe(true); + expect( + isTrajectoryConnectPath( + `/api/copilotkit/trajectory/${TRAJECTORY}/connect/`, + ), + ).toBe(true); + expect(isTrajectoryConnectPath("/api/copilotkit/agent/a/run")).toBe(false); + expect( + isTrajectoryConnectPath(`/api/copilotkit/trajectory/${TRAJECTORY}/stop`), + ).toBe(false); + }); + + test("honours a base path the way the runtime does", () => { + const path = `/api/copilotkit/trajectory/${TRAJECTORY}/connect`; + + expect(isTrajectoryConnectPath(path, "/api/copilotkit")).toBe(true); + expect(isTrajectoryConnectPath(path, "/elsewhere")).toBe(false); + expect(isTrajectoryConnectPath(path, "/api/copilot")).toBe(false); + }); +}); + +describe("createTrajectoryRefusalAudit", () => { + function audit() { + const rows: AuditEventInput[] = []; + const clock = { now: 9_000_000 }; + const sink = createTrajectoryRefusalAudit( + { + insert: async (event) => { + rows.push(event); + }, + }, + { now: () => clock.now }, + ); + return { rows, clock, sink }; + } + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + const refusal = (overrides: Partial<Refusal> = {}): Refusal => ({ + reason: "not-open", + trajectoryId: TRAJECTORY, + userId: PERSON, + ...overrides, + }); + + test("writes one row per person, trajectory and reason in a minute", async () => { + const { rows, clock, sink } = audit(); + + sink(refusal()); + clock.now += 30_000; + sink(refusal()); + await settle(); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + eventType: "recorder.trajectory_refused", + targetId: TRAJECTORY, + actorUserId: PERSON, + payload: { reason: "not-open" }, + }); + }); + + test("writes again after the minute, and for another reason or person", async () => { + const { rows, clock, sink } = audit(); + + sink(refusal()); + sink(refusal({ reason: "signed-out" })); + sink(refusal({ userId: OTHER })); + clock.now += 60_000; + sink(refusal()); + await settle(); + + expect(rows).toHaveLength(4); + }); + + test("a refusal nobody can be named for is the deployment's", async () => { + const { rows, sink } = audit(); + + sink(refusal({ userId: null })); + await settle(); + + expect(rows[0]?.actorUserId).toBeUndefined(); + expect(rows[0]?.initiator).toEqual({ kind: "deployment" }); + }); + + test("anonymous callers write one row per reason a minute however many trajectory ids they invent", async () => { + const { rows, clock, sink } = audit(); + const invent = (reason: Refusal["reason"]) => { + for (let i = 0; i < 50; i++) { + sink( + refusal({ + userId: null, + reason, + trajectoryId: `00000000-0000-4000-8000-${String(i).padStart(12, "0")}`, + }), + ); + } + }; + + invent("not-open"); + invent("signed-out"); + await settle(); + expect(rows).toHaveLength(2); + + clock.now += 60_000; + invent("not-open"); + await settle(); + expect(rows).toHaveLength(3); + }); + + test("a known person still dedupes per trajectory id", async () => { + const { rows, sink } = audit(); + + sink(refusal({ trajectoryId: "a" })); + sink(refusal({ trajectoryId: "b" })); + sink(refusal({ trajectoryId: "a" })); + await settle(); + + expect(rows).toHaveLength(2); + }); + + test("a store that cannot be written to is not fatal", async () => { + const sink = createTrajectoryRefusalAudit({ + insert: async () => { + throw new Error("database down"); + }, + }); + + expect(() => sink(refusal())).not.toThrow(); + await settle(); + }); + + test("writes nothing without an audit store", () => { + expect(() => + createTrajectoryRefusalAudit(undefined)(refusal()), + ).not.toThrow(); + }); +}); diff --git a/server/tests/request-actor.test.ts b/server/tests/request-actor.test.ts new file mode 100644 index 000000000..36fe59ba6 --- /dev/null +++ b/server/tests/request-actor.test.ts @@ -0,0 +1,246 @@ +// server/tests/request-actor.test.ts +import { describe, expect, test } from "bun:test"; +import { DEV_ACTOR } from "../src/auth/dev-actor"; +import type { OpenBotRole } from "../src/auth/roles"; +import type { RecorderResolver } from "../src/recorder/guard"; +import { actorName, createRequestActor } from "../src/request-actor"; + +type User = { + id: string; + email?: string | null; + name?: string | null; + role?: OpenBotRole | null; +}; + +function actorFor(options: { + singleUser?: boolean; + user?: User; + roles?: OpenBotRole[]; + resolveRecorder?: RecorderResolver; +}) { + const calls = { getSession: 0, rolesForUser: 0 }; + const functions = createRequestActor({ + resolveRecorder: options.resolveRecorder, + singleUser: options.singleUser ?? false, + async getSession() { + calls.getSession += 1; + return options.user ? { user: options.user } : null; + }, + async rolesForUser() { + calls.rolesForUser += 1; + return options.roles ?? []; + }, + }); + return { ...functions, calls }; +} + +const request = () => new Request("http://127.0.0.1/api/copilotkit"); + +describe("createRequestActor", () => { + test("single-user mode answers every request as the dev actor without asking for a session", async () => { + const { resolveRequestActor, calls } = actorFor({ singleUser: true }); + expect(await resolveRequestActor(request())).toEqual({ + id: DEV_ACTOR.id, + name: DEV_ACTOR.email, + role: DEV_ACTOR.role, + }); + expect(calls.getSession).toBe(0); + }); + + test("a session's user is answered with its own role and a name of name, email, then id", async () => { + const named = actorFor({ + user: { id: "u1", name: "Ada", email: "ada@example.test", role: "admin" }, + }); + expect(await named.resolveRequestActor(request())).toEqual({ + id: "u1", + name: "Ada", + role: "admin", + }); + expect(named.calls.rolesForUser).toBe(0); + + const emailOnly = actorFor({ + user: { id: "u2", email: "bo@example.test", role: "user" }, + }); + expect((await emailOnly.resolveRequestActor(request())).name).toBe( + "bo@example.test", + ); + + const idOnly = actorFor({ user: { id: "u3", role: "user" } }); + expect((await idOnly.resolveRequestActor(request())).name).toBe("u3"); + }); + + test("a blank name falls back to the email, never reaching the runtime blank", async () => { + for (const name of ["", " "]) { + const blank = actorFor({ + user: { id: "u9", name, email: "di@example.test", role: "user" }, + }); + expect((await blank.resolveRequestActor(request())).name).toBe( + "di@example.test", + ); + } + expect(actorName({ id: "u10", name: " ", email: null })).toBe("u10"); + expect( + actorName({ id: "u10", name: " Ada ", email: "a@example.test" }), + ).toBe("Ada"); + }); + + test("a user with no role takes it from the role repository, admin winning over user", async () => { + const both = actorFor({ user: { id: "u4" }, roles: ["user", "admin"] }); + expect((await both.resolveRequestActor(request())).role).toBe("admin"); + expect(both.calls.rolesForUser).toBe(1); + + const plain = actorFor({ user: { id: "u5" }, roles: ["user"] }); + expect((await plain.resolveRequestActor(request())).role).toBe("user"); + }); + + test("a request with no session throws, and so does identifyUser", async () => { + const { resolveRequestActor, identifyUser } = actorFor({}); + await expect(resolveRequestActor(request())).rejects.toThrow( + "A CopilotKit run requires a signed-in user.", + ); + await expect(identifyUser(request())).rejects.toThrow( + "A CopilotKit run requires a signed-in user.", + ); + }); + + test("a user with no authorized role throws", async () => { + const { resolveRequestActor } = actorFor({ user: { id: "u6" }, roles: [] }); + await expect(resolveRequestActor(request())).rejects.toThrow( + "A CopilotKit run requires an authorized user.", + ); + }); + + test("identifyUser returns the id and name only", async () => { + const { identifyUser } = actorFor({ + user: { id: "u7", name: "Cy", role: "user" }, + }); + expect(await identifyUser(request())).toEqual({ id: "u7", name: "Cy" }); + }); + + test("identifyActor returns id and role, and the anonymous actor when resolving throws", async () => { + const signedIn = actorFor({ user: { id: "u8", role: "admin" } }); + expect(await signedIn.identifyActor(request())).toEqual({ + id: "u8", + role: "admin", + }); + const anonymous = actorFor({}); + expect(await anonymous.identifyActor(request())).toEqual({ + id: "", + role: "user", + }); + }); + + describe("a Recorder bearer", () => { + const bearer = `Bearer obr_${"a".repeat(43)}`; + const passRequest = (pathname = "/api/copilotkit/trajectory/t-1/connect") => + new Request(`http://127.0.0.1${pathname}`, { + method: "POST", + headers: { authorization: bearer }, + }); + const recorderActor = { + id: "r1", + name: "Rae", + email: "rae@example.test", + image: null, + role: "user" as const, + connectionId: "c1", + }; + + test("is answered on the capture pass through the resolver, before single-user mode", async () => { + for (const singleUser of [false, true]) { + const seen: Request[] = []; + const { resolveRequestActor, calls } = actorFor({ + singleUser, + resolveRecorder: async (request) => { + seen.push(request); + return { actor: recorderActor }; + }, + }); + expect(await resolveRequestActor(passRequest())).toEqual({ + id: "r1", + name: "Rae", + role: "user", + }); + expect(seen).toHaveLength(1); + expect(calls.getSession).toBe(0); + } + }); + + test("is refused on any other path without asking the resolver", async () => { + for (const singleUser of [false, true]) { + let asked = 0; + const { resolveRequestActor, identifyActor } = actorFor({ + singleUser, + resolveRecorder: async () => { + asked += 1; + return { actor: recorderActor }; + }, + }); + for (const path of [ + "/api/copilotkit/agent/bot/run", + "/api/copilotkit/trajectory/t-1/connect/extra", + "/api/copilotkit/trajectory/connect", + // The id the runtime would decode: one that does not decode is no trajectory. + "/api/copilotkit/trajectory/%E0%A4%A/connect", + ]) { + await expect(resolveRequestActor(passRequest(path))).rejects.toThrow( + "A CopilotKit run requires a signed-in user.", + ); + expect(await identifyActor(passRequest(path))).toEqual({ + id: "", + role: "user", + }); + } + expect(asked).toBe(0); + } + }); + + test("is refused when the resolver refuses, is unavailable or has no answer", async () => { + for (const resolution of [ + { refused: "unknown" as const }, + { refused: "organization" as const, userId: "r1" }, + { unavailable: true as const }, + null, + ]) { + const { resolveRequestActor } = actorFor({ + user: { id: "u1", role: "admin" }, + resolveRecorder: async () => resolution, + }); + await expect(resolveRequestActor(passRequest())).rejects.toThrow( + "A CopilotKit run requires a signed-in user.", + ); + } + }); + + test("never falls through to the cookie session or the dev actor", async () => { + const { resolveRequestActor, calls } = actorFor({ + singleUser: true, + resolveRecorder: async () => ({ refused: "removed" }), + }); + await expect(resolveRequestActor(passRequest())).rejects.toThrow(); + expect(calls.getSession).toBe(0); + }); + + test("with no resolver wired is refused", async () => { + const { resolveRequestActor } = actorFor({ + user: { id: "u1", role: "admin" }, + }); + await expect(resolveRequestActor(passRequest())).rejects.toThrow( + "A CopilotKit run requires a signed-in user.", + ); + }); + }); + + test("a request without a Recorder bearer is unchanged when a resolver is wired", async () => { + let asked = 0; + const { resolveRequestActor } = actorFor({ + singleUser: true, + resolveRecorder: async () => { + asked += 1; + return null; + }, + }); + expect((await resolveRequestActor(request())).id).toBe(DEV_ACTOR.id); + expect(asked).toBe(0); + }); +}); diff --git a/server/tests/tool-selection.integration.test.ts b/server/tests/tool-selection.integration.test.ts index 99137c5a6..a8fda88c7 100644 --- a/server/tests/tool-selection.integration.test.ts +++ b/server/tests/tool-selection.integration.test.ts @@ -500,7 +500,13 @@ describe("a built-in Bot", () => { } )?.messages ?? [] ) - .filter((message) => message.role === "system") + /* + * Chat Completions carries the prompt of a reasoning model such as gpt-5.5 as a `developer` + * message, where the Responses API the runtime used before 1.78 arrived here as `system`. + */ + .filter( + (message) => message.role === "system" || message.role === "developer", + ) .map((message) => String(message.content)) .join("\n"); /* @@ -512,6 +518,37 @@ describe("a built-in Bot", () => { expect(system).toContain("drive"); expect(system).not.toContain("slack: tool_0"); }); + + test("behind OPENAI_BASE_URL, the run goes to the gateway's Chat Completions endpoint", async () => { + /* + * Since runtime 1.78 an `openai/...` model whose OPENAI_BASE_URL is not an OpenAI or Azure host + * is sent to `/chat/completions`, not `/responses`. That is the only route Desktop's own model + * proxy serves (`mountProviderOAuthProxy`), the one routing and tool selection already call + * (`chatCompletionsUrl`), and what most OpenAI-compatible servers implement, so a runtime that + * went back to `/responses` would break built-in Bots behind any of them. + */ + answerWith(["drive-audit"]); + const agents = await buildAgents( + [builtIn], + model, + "test-key", + undefined, + async () => granted, + undefined, + undefined, + undefined, + selection(), + ); + await ask(agents.analyst as never, "read the Drive doc"); + + const runs = llm + .getRequests() + .filter((entry) => + Array.isArray((entry.body as { tools?: unknown })?.tools), + ); + expect(runs.length).toBeGreaterThan(0); + for (const run of runs) expect(run.path).toBe("/chat/completions"); + }); }); describe("a remote Bot", () => { diff --git a/shared/demonstration-rules.test.ts b/shared/demonstration-rules.test.ts new file mode 100644 index 000000000..b6364d32e --- /dev/null +++ b/shared/demonstration-rules.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { + describeDemonstrationTarget, + gestureForRecording, + SENSITIVE_FIELD, + safeDemonstrationUrl, +} from "./demonstration-rules"; + +beforeAll(() => GlobalRegistrator.register()); +afterAll(() => GlobalRegistrator.unregister()); + +function render(html: string) { + document.body.innerHTML = html; +} + +test("passwords, pasted text and printable key values are never part of a recorded gesture", () => { + expect( + gestureForRecording({ type: "text", text: "secret-password" }), + ).toEqual({ kind: "type" }); + expect( + gestureForRecording({ + type: "key", + event: "down", + key: "s", + code: "KeyS", + text: "s", + }), + ).toEqual({ kind: "type" }); +}); +test("pointer movement and releases are not false completed actions", () => { + expect( + gestureForRecording({ type: "mouse", event: "moved", x: 1, y: 2 }), + ).toBeNull(); + expect( + gestureForRecording({ + type: "key", + event: "up", + key: "Enter", + code: "Enter", + }), + ).toBeNull(); +}); +test("URL provenance omits credentials, query tokens and fragments", () => { + expect( + safeDemonstrationUrl( + "https://user:password@example.test/form?token=secret#secret", + ), + ).toBe("https://example.test/form"); +}); + +test("a click on a label's text is a click on the control it names", () => { + render(`<label for="s">Small</label><input id="s" type="radio" name="size">`); + expect( + describeDemonstrationTarget({ + point: null, + element: document.querySelector("label"), + }), + ).toEqual({ role: "radio", name: "Small", sensitive: false }); +}); + +test("a field is sensitive when only its separate label says so", () => { + render(`<label for="c">Security code</label><input id="c" name="field7">`); + expect( + describeDemonstrationTarget({ + point: null, + element: document.querySelector("input"), + }), + ).toEqual({ role: "textbox", name: "[sensitive field]", sensitive: true }); +}); + +test("a field labelled only Card number is sensitive", () => { + render(`<label for="n">Card number</label><input id="n" name="f7">`); + expect( + describeDemonstrationTarget({ + point: null, + element: document.querySelector("input"), + }).sensitive, + ).toBe(true); + expect(SENSITIVE_FIELD.test("Card number")).toBe(true); + expect(SENSITIVE_FIELD.test("Cardholder name")).toBe(true); + expect(SENSITIVE_FIELD.test("Card notes")).toBe(false); +}); + +test("emails and long tokens in names are masked", () => { + render( + `<button aria-label="Email ana@example.test about abcdefghijklmnopqrstuvwxyz0123456789">x</button>`, + ); + expect( + describeDemonstrationTarget({ + point: null, + element: document.querySelector("button"), + }).name, + ).toBe("Email [redacted] about [redacted]"); +}); + +test("words that contain pin or otp are not sensitive", () => { + render(`<input aria-label="Shipping topping hotpot">`); + expect( + describeDemonstrationTarget({ + point: null, + element: document.querySelector("input"), + }).sensitive, + ).toBe(false); +}); + +test("a key gesture carries a typed demonstration key", () => { + // `code` is required by InputMessage (`screencast.ts` `InputMessage`'s `key` variant), so GestureInput keeps it required too. + expect( + gestureForRecording({ + type: "key", + event: "down", + key: "Enter", + code: "Enter", + }), + ).toEqual({ kind: "key", key: "Enter" }); + expect( + gestureForRecording({ + type: "key", + event: "down", + key: "F5", + code: "F5", + }), + ).toBeNull(); +}); + +test("the function runs on its own, as Playwright serialises it", () => { + const isolated = new Function( + `return (${describeDemonstrationTarget.toString()})`, + )() as typeof describeDemonstrationTarget; + render(`<a href="/x">Invoices</a>`); + expect( + isolated({ point: null, element: document.querySelector("a") }), + ).toEqual({ role: "link", name: "Invoices", sensitive: false }); +}); + +test("the in-page copy of the sensitive rule is the exported one", () => { + expect(describeDemonstrationTarget.toString()).toContain( + SENSITIVE_FIELD.source, + ); +}); diff --git a/shared/demonstration-rules.ts b/shared/demonstration-rules.ts new file mode 100644 index 000000000..c74419946 --- /dev/null +++ b/shared/demonstration-rules.ts @@ -0,0 +1,184 @@ +const DEMONSTRATION_KEYS = [ + "Enter", + "Tab", + "Escape", + "Backspace", + "Delete", + "ArrowDown", + "ArrowUp", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "PageDown", + "PageUp", +] as const; +type DemonstrationKey = (typeof DEMONSTRATION_KEYS)[number]; +function isDemonstrationKey(key: string): key is DemonstrationKey { + return (DEMONSTRATION_KEYS as readonly string[]).includes(key); +} + +/** Short words only as whole words: "pin" is in "topping" and "shipping", "otp" in "hotpot". */ +export const SENSITIVE_FIELD = + /password|secret|token|one.time|verification|credit.card|security.code|passcode|card.?(?:number|num|no)(?![a-z])|cardholder|card.?verification|(?:^|[^a-z])(?:cc-|(?:cvc|cvv|csc|pin|otp)(?![a-z]))/i; + +type DemonstrationTarget = { + role: string; + name: string; + sensitive: boolean; +}; + +/** Structural twin of the cloud browser's `InputMessage`, so the shared rules need no import. */ +export type GestureInput = + | { + type: "mouse"; + event: "pressed" | "released" | "moved"; + x: number; + y: number; + button?: "left" | "right" | "middle"; + clickCount?: number; + modifiers?: number; + } + | { + type: "wheel"; + x: number; + y: number; + deltaX: number; + deltaY: number; + modifiers?: number; + } + | { + type: "key"; + event: "down" | "up"; + key: string; + code: string; + text?: string; + windowsVirtualKeyCode?: number; + modifiers?: number; + } + | { type: "text"; text: string }; + +export type RecordedGesture = { + kind: "click" | "type" | "key" | "scroll"; + key?: DemonstrationKey; + deltaY?: number; +}; + +export function gestureForRecording( + input: GestureInput, +): RecordedGesture | null { + if (input.type === "mouse") + return input.event === "released" && input.button !== "right" + ? { kind: "click" } + : null; + if (input.type === "wheel") + return { + kind: "scroll", + deltaY: Math.max(-2000, Math.min(2000, input.deltaY)), + }; + if (input.type === "text") return { kind: "type" }; + if (input.event !== "down") return null; + if (input.key.length === 1 || (input.text && input.key !== "Enter")) + return { kind: "type" }; + if (isDemonstrationKey(input.key)) return { kind: "key", key: input.key }; + return null; +} + +export function safeDemonstrationUrl(raw: string): string { + try { + const url = new URL(raw); + if (!["http:", "https:"].includes(url.protocol)) return "about:blank"; + const path = url.pathname + .split("/") + .map((part) => + part.length > 40 || /token|secret|password|credential/i.test(part) + ? "[redacted]" + : part, + ) + .join("/"); + return `${url.origin}${path}`; + } catch { + return "about:blank"; + } +} + +/** + * Never reads a field's value, page body, clipboard or image. Playwright serialises this function + * with `Function.prototype.toString()` into `page.evaluate`, so it uses nothing declared outside it. + */ +export function describeDemonstrationTarget(input: { + point: { x: number; y: number } | null; + element?: Element | null; +}): DemonstrationTarget { + const hit = + input.element ?? + (input.point + ? document.elementFromPoint(input.point.x, input.point.y) + : document.activeElement); + const closest = + hit?.closest( + "button,a,input,textarea,select,label,[role],[contenteditable=true]", + ) ?? hit; + // A click on a label's text is a click on the control it names: "Small", a radio, not a label. + const element = + closest instanceof HTMLLabelElement + ? (closest.control ?? closest) + : closest; + if (!element) return { role: "page", name: "Page", sensitive: false }; + const tag = element.tagName.toLowerCase(); + const inputType = + element instanceof HTMLInputElement ? element.type.toLowerCase() : ""; + const role = + element.getAttribute("role") || + ( + { + checkbox: "checkbox", + radio: "radio", + button: "button", + submit: "button", + reset: "button", + image: "button", + range: "slider", + file: "button", + } as Record<string, string> + )[inputType] || + ( + { + button: "button", + a: "link", + input: "textbox", + textarea: "textbox", + select: "combobox", + } as Record<string, string> + )[tag] || + tag; + const label = + element.getAttribute("aria-label") || + (element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ? Array.from(element.labels ?? []) + .map((entry) => entry.textContent ?? "") + .join(" ") + : "") || + (tag === "button" || tag === "a" ? element.textContent : "") || + element.getAttribute("placeholder") || + role; + const sensitive = + element.getAttribute("type") === "password" || + /password|secret|token|one.time|verification|credit.card|security.code|passcode|card.?(?:number|num|no)(?![a-z])|cardholder|card.?verification|(?:^|[^a-z])(?:cc-|(?:cvc|cvv|csc|pin|otp)(?![a-z]))/i.test( + [ + label, + element.getAttribute("name"), + element.getAttribute("autocomplete"), + ].join(" "), + ); + const name = sensitive + ? "[sensitive field]" + : (label ?? "") + .replace(/[A-Za-z0-9_+/=-]{32,}/g, "[redacted]") + .replace(/[^\s@]+@[^\s@]+/g, "[redacted]") + .trim() + .slice(0, 120); + return { role: role.slice(0, 40), name: name || role, sensitive }; +} diff --git a/shared/learning.ts b/shared/learning.ts index 9ee4aac03..9e00aaed4 100644 --- a/shared/learning.ts +++ b/shared/learning.ts @@ -75,3 +75,54 @@ export function isLearningContainerId(value: unknown): value is string { LEARNING_CONTAINER_ID.test(value) ); } + +/** + * Who else's Bots use the Skills learned in a person's own Learning space. `closed`, the default: only their own Bots. + * `open`: every Bot in this OpenBot. `choose`: every Bot, for the Skills the person opens one by one. + */ +export const LEARNING_SHARING_MODES = ["closed", "open", "choose"] as const; +export type LearningSharingMode = (typeof LEARNING_SHARING_MODES)[number]; +export function isLearningSharingMode( + value: unknown, +): value is LearningSharingMode { + return ( + typeof value === "string" && + (LEARNING_SHARING_MODES as readonly string[]).includes(value) + ); +} + +/** A learned Skill's name as Intelligence publishes it: 1 to 200 characters, no slash and no control characters. */ +export function isLearnedSkillName(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 200 && + value.trim() === value && + value !== "." && + value !== ".." && + !value.includes("/") && + !value.includes("\\") && + ![...value].some( + (character) => + character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127, + ) + ); +} + +/** One Skill published in the person's own Learning space, and whether they opened it to every Bot (under `choose`). */ +export type PersonalLearnedSkill = { + name: string; + description: string; + open: boolean; +}; + +/** Settings, Learning: the person's sharing choice and the Skills in their own space. */ +export type PersonalLearningSharing = { + mode: LearningSharingMode; + /** The Skills published in the person's own space, read the way a run reads them (none yet is an empty list), or null + * when they could not be read, and then `unavailable` says why. */ + skills: PersonalLearnedSkill[] | null; + /** `off`: Automatic Learning is off in this OpenBot. `unavailable`: Intelligence did not deliver them (its Learning + * delivery is off, the space does not exist yet, or it could not be reached). */ + unavailable?: "off" | "unavailable"; +}; diff --git a/shared/package.json b/shared/package.json index 225955e48..b649ea077 100644 --- a/shared/package.json +++ b/shared/package.json @@ -11,7 +11,12 @@ "scripts": { "typecheck": "tsc --noEmit" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { + "@happy-dom/global-registrator": "^20.11.2", + "happy-dom": "^20.11.2", "typescript": "^5.9.3" } } diff --git a/shared/recorder-api.test.ts b/shared/recorder-api.test.ts new file mode 100644 index 000000000..c88699e5e --- /dev/null +++ b/shared/recorder-api.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { + connectCodeRequestSchema, + exchangeRequestSchema, + joinedLearningContainer, + recorderPaths, + TRAJECTORY_GATE_REFUSALS, +} from "./recorder-api"; + +const INSTALL_ID = "0123456789abcdef0123456789abcdef"; + +describe("recorder request schemas", () => { + test("each schema accepts its documented example and rejects an extra field", () => { + const cases: [ + { safeParse: (v: unknown) => { success: boolean } }, + Record<string, unknown>, + ][] = [ + [connectCodeRequestSchema, { via: "web" }], + [ + exchangeRequestSchema, + { code: "obc_abc", label: "Work laptop", installId: INSTALL_ID }, + ], + ]; + for (const [schema, example] of cases) { + expect(schema.safeParse(example).success).toBe(true); + expect(schema.safeParse({ ...example, extra: 1 }).success).toBe(false); + } + }); + + test("exchangeRequestSchema refuses an installId that is not 32 lowercase hex digits", () => { + const base = { code: "obc_abc", label: "Work laptop" }; + for (const installId of [ + "0123456789ABCDEF0123456789ABCDEF", + "0123456789abcdef", + `${INSTALL_ID}0`, + "g123456789abcdef0123456789abcdef", + ]) { + expect( + exchangeRequestSchema.safeParse({ ...base, installId }).success, + ).toBe(false); + } + }); +}); + +describe("recorder constants and paths", () => { + test("trajectoryConnect encodes the space", () => { + expect(recorderPaths.trajectoryConnect("a b")).toBe( + "/api/copilotkit/trajectory/a%20b/connect", + ); + }); + + test("only the capture pass leaves /api/recorder: OpenBot serves the Recorder nothing else", () => { + const samples: Record<string, string> = { + connectCodes: recorderPaths.connectCodes, + connections: recorderPaths.connections, + connection: recorderPaths.connection("c"), + currentConnection: recorderPaths.currentConnection, + me: recorderPaths.me, + learning: recorderPaths.learning, + trajectoryConnect: recorderPaths.trajectoryConnect("t"), + }; + expect(Object.keys(samples).sort()).toEqual( + Object.keys(recorderPaths).sort(), + ); + for (const [name, path] of Object.entries(samples)) { + expect(path.startsWith("/api/recorder/")).toBe( + name !== "trajectoryConnect", + ); + } + }); + + test("the gate refusals carry the documented statuses", () => { + expect( + Object.values(TRAJECTORY_GATE_REFUSALS).map((r) => r.status), + ).toEqual([400, 403, 503]); + }); +}); + +describe("joinedLearningContainer", () => { + test("a pass joins the person's container unless there is none or it does not exist and could not be made", () => { + const cases: [ + Parameters<typeof joinedLearningContainer>[0], + string | null, + ][] = [ + [{ state: "assigned", containerId: "c1" }, "c1"], + // OpenBot could not check (Learning delivery off, or Intelligence not answering): the pass still names it. + [{ state: "learning-off", containerId: "c1" }, "c1"], + [{ state: "unverified", containerId: "c1" }, "c1"], + [{ state: "unverified", containerId: null }, null], + [{ state: "unassigned", containerId: null }, null], + [{ state: "missing", containerId: "c1" }, null], + [{ state: "unjoined", containerId: "c1" }, null], + ]; + for (const [learning, joined] of cases) + expect(joinedLearningContainer(learning)).toBe(joined); + }); +}); diff --git a/shared/recorder-api.ts b/shared/recorder-api.ts new file mode 100644 index 000000000..800083cb9 --- /dev/null +++ b/shared/recorder-api.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; + +export const RECORDER_TOKEN_PREFIX = "obr_"; +export const RECORDER_CODE_PREFIX = "obc_"; +export const RECORDER_CONNECT_CODE_TTL_MS = 5 * 60_000; +/** The `code` on every 401 the recorder bearer gets. Only this code means "signed out". */ +export const RECORDER_SIGNED_OUT = "RECORDER_SIGNED_OUT"; +/** The `code` on the 503 the recorder guard and the trajectory gate answer when the organization's sign-in server could not + * answer for now (Decision 19): transient, never a sign-out; the caller retries. */ +export const RECORDER_AUTHORITY_UNAVAILABLE = "RECORDER_AUTHORITY_UNAVAILABLE"; +/** The refusals OpenBot's trajectory gate shares with the Recorder's pass table (rows 1, 3 and 4a). */ +export const TRAJECTORY_GATE_REFUSALS = { + invalidId: { + status: 400, + code: "INVALID_REQUEST", + message: "Trajectory ID must be a UUID.", + }, + forbidden: { + status: 403, + code: "FORBIDDEN", + message: "Authorization required.", + }, + authorityUnavailable: { + status: 503, + code: RECORDER_AUTHORITY_UNAVAILABLE, + message: "Your organization could not answer. Try again in a moment.", + }, +} as const; + +export type RecorderConnectVia = "web" | "desktop"; +export type RecorderUser = { id: string; name: string; email: string }; +export type RecorderConnection = { + id: string; + label: string; + via: RecorderConnectVia; + extensionId: string; + /** The connecting copy's install id for this OpenBot origin (Decision 18): 32 lowercase hex digits. */ + installId: string; + createdAt: string; + lastUsedAt: string | null; +}; +const RECORDER_INSTALL_ID = /^[0-9a-f]{32}$/; +export type ConnectCodeResponse = { code: string; expiresAt: string }; +export type RecorderConnectionsResponse = { + connections: RecorderConnection[]; + extensionIds: string[]; +}; +export type ExchangeResponse = { + token: string; + connection: RecorderConnection; + user: RecorderUser; +}; +export type RecorderMe = { user: RecorderUser; connectionId: string }; +/** Decision 10, for the person's own Learning space: assigned (it exists), unassigned (Automatic Learning is off, so + * there is no space), missing (it does not exist and could not be made), learning-off (Intelligence's Learning delivery + * is off, unlicensed or unavailable, so existence was not checked), unverified (Intelligence could not be asked, or the + * space was still being made), unjoined (the runtime's 5 s deadline passed and it joined no space). */ +export const LEARNING_SPACE_STATES = [ + "assigned", + "unassigned", + "missing", + "learning-off", + "unverified", + "unjoined", +] as const; +/** Whether the person's recordings land in their own Learning space (Decision 10, Task 15). */ +export type LearningSpaceState = { + state: (typeof LEARNING_SPACE_STATES)[number]; + containerId: string | null; +}; +/** + * The Learning container a capture pass joins for this answer, or null when it joins none. Only no space at all, or one + * that does not exist and could not be made, joins none: an answer Intelligence could not give (unverified, or Learning + * delivery off) still names the person's container, so one failed check does not change where a trajectory lands. The + * server's selector joins by this, and the Recorder warns that Learning will not see a recording by it. + */ +export function joinedLearningContainer( + learning: LearningSpaceState, +): string | null { + const joins = + learning.state === "assigned" || + learning.state === "unverified" || + learning.state === "learning-off"; + return joins && typeof learning.containerId === "string" + ? learning.containerId + : null; +} +/** `GET /api/recorder/learning`: the person's own Learning space, which every recording they make joins. */ +export type RecorderLearningResponse = { learning: LearningSpaceState }; +export type TrajectoryGrant = { + joinToken: string; + realtime: { clientUrl: string; topic: string }; +}; +export type RecorderErrorBody = { + error: string; + code?: string; + reason?: "revoked" | "removed" | "organization"; +}; + +export const connectCodeRequestSchema = z + .object({ via: z.enum(["web", "desktop"]) }) + .strict(); +export const exchangeRequestSchema = z + .object({ + code: z.string().startsWith(RECORDER_CODE_PREFIX).max(80), + label: z.string().trim().min(1).max(80), + installId: z.string().regex(RECORDER_INSTALL_ID), + }) + .strict(); + +const id = (value: string) => encodeURIComponent(value); +export const recorderPaths = { + connectCodes: "/api/recorder/connect-codes", + connections: "/api/recorder/connections", + connection: (connectionId: string) => + `/api/recorder/connections/${id(connectionId)}`, + currentConnection: "/api/recorder/connections/current", + me: "/api/recorder/me", + learning: "/api/recorder/learning", + trajectoryConnect: (t: string) => + `/api/copilotkit/trajectory/${id(t)}/connect`, +} as const; diff --git a/shared/recorder-extension.json b/shared/recorder-extension.json new file mode 100644 index 000000000..b36455ba7 --- /dev/null +++ b/shared/recorder-extension.json @@ -0,0 +1,4 @@ +{ + "nativeHost": "ai.copilotkit.openbot.recorder", + "extensionId": "icpcnhgjghecmklnjbccdgdicklblcgd" +} diff --git a/shared/recorder-extension.test.ts b/shared/recorder-extension.test.ts new file mode 100644 index 000000000..d33ce1fa5 --- /dev/null +++ b/shared/recorder-extension.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + extensionIdFromOrigin, + RECORDER_EXTENSION_ID, + recorderExtensionIds, +} from "./recorder-extension"; + +/** Chrome's extension id: SHA-256 of the DER public key, first 32 hex digits mapped 0-f -> a-p. */ +async function extensionIdFromPublicKey(base64Der: string): Promise<string> { + const der = Uint8Array.from(atob(base64Der.trim()), (character) => + character.charCodeAt(0), + ); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", der)); + const hex = Array.from(digest, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + return Array.from(hex.slice(0, 32), (digit) => + String.fromCharCode(97 + Number.parseInt(digit, 16)), + ).join(""); +} + +const publicKey = readFileSync( + new URL("../extension/keys/public-key.txt", import.meta.url), + "utf8", +).trim(); +const id = "icpcnhgjghecmklnjbccdgdicklblcgd"; + +test("the committed public key derives the one extension id", async () => { + expect(await extensionIdFromPublicKey(publicKey)).toBe(RECORDER_EXTENSION_ID); + expect(RECORDER_EXTENSION_ID).toBe(id); +}); + +test("the derivation matches a vector computed with openssl", async () => { + // printf openbot | base64 | base64 -d | openssl dgst -sha256 -binary | xxd -p -c 64 | cut -c1-32 | tr '0-9a-f' 'a-p' + expect(await extensionIdFromPublicKey("b3BlbmJvdA==")).toBe( + "fffgjjbhnklfhmeochieojhodiegmggn", + ); +}); + +test("the fixed id comes first and extras are deduplicated", () => { + expect(recorderExtensionIds()).toEqual([id]); + expect(recorderExtensionIds({ extra: ["a".repeat(32), id] })).toEqual([ + id, + "a".repeat(32), + ]); +}); + +test("an extension origin maps to its id with or without the trailing slash", () => { + expect(extensionIdFromOrigin(`chrome-extension://${id}`, [id])).toBe(id); + expect(extensionIdFromOrigin(`chrome-extension://${id}/`, [id])).toBe(id); +}); + +test("other origins and unknown ids map to nothing", () => { + expect(extensionIdFromOrigin("https://x.test", [id])).toBeNull(); + expect( + extensionIdFromOrigin(`chrome-extension://${"b".repeat(32)}`, [id]), + ).toBeNull(); + expect(extensionIdFromOrigin(null, [id])).toBeNull(); + expect(extensionIdFromOrigin(undefined, [id])).toBeNull(); +}); diff --git a/shared/recorder-extension.ts b/shared/recorder-extension.ts new file mode 100644 index 000000000..4a0b0512d --- /dev/null +++ b/shared/recorder-extension.ts @@ -0,0 +1,28 @@ +import config from "./recorder-extension.json"; + +export const RECORDER_NATIVE_HOST: string = config.nativeHost; + +/** The Recorder's one id, in every build, in Chrome and Edge. */ +export const RECORDER_EXTENSION_ID: string = config.extensionId; + +/** The fixed id first, then each extra id (forks and self-signed builds), deduplicated. */ +export function recorderExtensionIds(options?: { + extra?: readonly string[]; +}): string[] { + return [...new Set([RECORDER_EXTENSION_ID, ...(options?.extra ?? [])])]; +} + +const EXTENSION_ORIGIN = /^chrome-extension:\/\/([a-p]{32})\/?$/; + +/** + * `chrome-extension://<id>` with or without the trailing slash, for an id in `ids`. Edge's native messaging page shows + * its `allowed_origins` in the `chrome-extension://<id>/` form: + * https://learn.microsoft.com/en-us/microsoft-edge/extensions/developer-guide/native-messaging + */ +export function extensionIdFromOrigin( + origin: string | null | undefined, + ids: readonly string[], +): string | null { + const match = origin ? EXTENSION_ORIGIN.exec(origin) : null; + return match && ids.includes(match[1]) ? match[1] : null; +} diff --git a/shared/recorder-masking.test.ts b/shared/recorder-masking.test.ts new file mode 100644 index 000000000..1c317effa --- /dev/null +++ b/shared/recorder-masking.test.ts @@ -0,0 +1,1669 @@ +import { expect, test } from "bun:test"; +import { + holdsTypedSecret, + isCredentialKey, + type JsonValue, + isSensitiveTarget, + luhnValid, + maskBody, + maskCollectorEvent, + maskHeaders, + maskNetworkEvent, + normaliseSecret, + REDACTED, + sanitiseForIntelligence, + scrubText, + scrubTranscript, + scrubUrl, + sha256HexSync, + trimEvent, +} from "./recorder-masking"; + +// Looks inside a masked event's JSON value by path, the way the assertions below read it. +// biome-ignore lint/suspicious/noExplicitAny: a probe over arbitrary event JSON +type Probe = any; +const at = ( + event: { value: unknown } | null | undefined, + ...path: string[] +): Probe => path.reduce((node: Probe, key) => node?.[key], event?.value); + +const digestsOf = (...values: string[]) => + new Set(values.map((v) => sha256HexSync(normaliseSecret(v)))); + +test("credential headers keep their name and lose their value", () => { + expect( + maskHeaders({ + Authorization: "Bearer abc", + Cookie: "s=1", + "Set-Cookie": "s=1", + "X-Api-Key": "k", + Accept: "application/json", + }), + ).toEqual({ + Authorization: REDACTED, + Cookie: REDACTED, + "Set-Cookie": REDACTED, + "X-Api-Key": REDACTED, + Accept: "application/json", + }); +}); + +test("credential keys follow the collector's rules plus payment-card keys", () => { + for (const key of [ + "password", + "apiKey", + "subscriptionKey", + "client_secret", + "accessToken", + "id_token", + "x-csrf-token", + "cookie", + "token", + "otp", + "pin", + "cvv", + "cvc", + "cardNumber", + "cc_number", + ]) + expect([key, isCredentialKey(key)]).toEqual([key, true]); + for (const key of [ + "tokenExpiry", + "token_in", + "sessionIds", + "passwordPolicy", + "pinned", + "shipping", + "page", + "count", + ]) + expect([key, isCredentialKey(key)]).toEqual([key, false]); +}); + +test("tokens in URLs are scrubbed, ordinary query values and long readable paths are kept", () => { + expect( + scrubUrl( + "https://u:p@api.example.test/v1/a/eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.c2ln?access_token=abc&page=2#frag", + ), + ).toBe( + `https://api.example.test/v1/a/${REDACTED}?access_token=${REDACTED}&page=2`, + ); + const readable = + "https://docs.example.test/docs/getting-started/installation-guide-for-enterprise-customers"; + expect(scrubUrl(readable)).toBe(readable); +}); + +test("JSON bodies lose credential values at any depth and keep shape", () => { + const body = JSON.stringify({ + user: { email: "ana@example.test", password: "hunter22" }, + meta: { refreshToken: "r", count: 3 }, + }); + expect(JSON.parse(maskBody(body, "application/json"))).toEqual({ + user: { email: REDACTED, password: REDACTED }, + meta: { refreshToken: REDACTED, count: 3 }, + }); +}); + +test("a value typed into a sensitive field is scrubbed wherever it reappears, even 3 digits long", async () => { + const digests = digestsOf("4111111111111111", "737"); + expect( + JSON.parse( + maskBody( + '{"n":"4111111111111111","s":"737","q":"shoes"}', + "application/json", + { digests }, + ), + ), + ).toEqual({ n: REDACTED, s: REDACTED, q: "shoes" }); + expect( + maskBody( + "card=4111111111111111&q=shoes&s=737", + "application/x-www-form-urlencoded", + { digests }, + ), + ).toBe(`card=${REDACTED}&q=shoes&s=${REDACTED}`); +}); + +test("numbers in JSON are matched against digests too", async () => { + const digests = digestsOf("737"); + expect( + JSON.parse( + maskBody('{"code":737,"qty":2}', "application/json", { digests }), + ), + ).toEqual({ code: REDACTED, qty: 2 }); +}); + +test("card numbers are scrubbed in any format, without a digest, when they pass the Luhn check", () => { + expect(luhnValid("4111111111111111")).toBe(true); + expect(luhnValid("4111111111111112")).toBe(false); + expect( + scrubText( + "card 4111 1111 1111 1111 and 4111-1111-1111-1111 and order 1234567890123", + ), + ).toBe(`card ${REDACTED} and ${REDACTED} and order 1234567890123`); + expect( + JSON.parse(maskBody('{"pan":4111111111111111}', "application/json")), + ).toEqual({ pan: REDACTED }); +}); + +test("a Luhn-valid timestamp or id is redacted too, by design (Decision 15)", () => { + // 1760000000008 is a millisecond timestamp that happens to pass Luhn; losing it is the price of never leaking a card. + expect(luhnValid("1760000000008")).toBe(true); + expect(luhnValid("1760000000007")).toBe(false); + expect( + JSON.parse( + maskBody('{"at":1760000000008,"n":1760000000007}', "application/json"), + ), + ).toEqual({ at: REDACTED, n: 1760000000007 }); +}); + +test("query values are replaced with a literal [redacted], never percent-encoded", () => { + expect(scrubUrl("https://x.test/a?token=abc&q=a%20b")).toBe( + `https://x.test/a?token=${REDACTED}&q=a%20b`, + ); +}); + +test("a scrubbed path segment keeps a literal [redacted] and re-encodes only what is around it", () => { + expect(scrubUrl("https://x.test/users/ana%40example.test/notes%20one")).toBe( + `https://x.test/users/${REDACTED}/notes%20one`, + ); + expect( + scrubUrl("https://x.test/a/eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.c2ln/b"), + ).not.toContain("%5B"); +}); + +test("headers keep their names and case; non-credential values are scrubbed as text", () => { + expect( + maskHeaders({ "X-Note": "mail ana@example.test", "x-api-key": "k" }), + ).toEqual({ "X-Note": `mail ${REDACTED}`, "x-api-key": REDACTED }); +}); + +test("normaliseSecret trims, and reduces digits with spaces or dashes to digits", () => { + expect(normaliseSecret(" 4111 1111-1111 1111 ")).toBe("4111111111111111"); + expect(normaliseSecret(" Hunter 2 ")).toBe("Hunter 2"); +}); + +test("a space-formatted secret typed into a sensitive field matches its digits-only form in a body", async () => { + const digests = digestsOf("4111 1111 1111 1111"); + expect( + maskBody('{"x":"4111111111111111"}', "application/json", { digests }), + ).toBe(`{"x":"${REDACTED}"}`); +}); + +test("collector input events from sensitive fields lose their value", () => { + const event = { + type: "CUSTOM" as const, + name: "input", + timestamp: 1, + value: { + target: { + tag: "input", + value: "1234", + attributes: { autocomplete: "one-time-code" }, + }, + url: "https://x.test/a?code=1", + }, + }; + const masked = maskCollectorEvent(event, { sensitive: false }); + expect(at(masked, "target", "value")).toBe(REDACTED); +}); + +test("a sensitive target loses its value, its mirrored value attribute, its text and its selected values", () => { + const event = { + type: "CUSTOM" as const, + name: "click", + timestamp: 1, + value: { + target: { + tag: "input", + value: "4111111111111111", + text: "4111111111111111", + selectedValues: ["x"], + attributes: { + name: "f7", + value: "4111111111111111", + "aria-valuetext": "4111111111111111", + }, + }, + }, + }; + const target = at(maskCollectorEvent(event, { sensitive: true }), "target"); + expect(target.value).toBe(REDACTED); + expect(target.text).toBe(REDACTED); + expect(target.attributes.value).toBe(REDACTED); + expect(target.attributes["aria-valuetext"]).toBe(REDACTED); + expect(target.attributes.name).toBe("f7"); + expect("selectedValues" in target).toBe(false); +}); + +test("an edited field's copies of what was typed take the value's verdict: a copy a keystroke behind keeps no part of a card", () => { + // End-to-end run 1's strings: the chat box's value held a card, its text the same message a keystroke earlier, whose 15 + // digits are no card on their own. A page that copies what is typed into another attribute, or into what names the + // field, copies it a render behind too. + const typed = + "Please look up this deal's history and then get it approved. Card on file for the invoice: 4111 1111 1111 1111"; + const behind = typed.slice(0, -1); + const masked = + "Please look up this deal's history and then get it approved. Card on file for the invoice: [redacted]"; + const edit = (target: { [key: string]: JsonValue }) => + at( + maskCollectorEvent( + { + type: "CUSTOM" as const, + name: "input", + timestamp: 1, + value: { eventType: "input", target }, + }, + { sensitive: false }, + ), + "target", + ); + const attributes = { + rows: "1", + "data-testid": "copilot-chat-textarea", + placeholder: "Type a message...", + }; + const chatBox = edit({ + tag: "textarea", + text: behind, + value: typed, + attributes: { + ...attributes, + "data-draft": behind, + title: behind.slice(-30), + "aria-valuetext": behind, + }, + label: behind, + name: behind, + }); + expect(chatBox.value).toBe(masked); + expect(chatBox.text).toBe(masked); + expect(chatBox.attributes).toEqual({ + ...attributes, + "data-draft": REDACTED, + title: REDACTED, + "aria-valuetext": masked, + }); + expect([chatBox.label, chatBox.name]).toEqual([REDACTED, REDACTED]); + // An editable region's text is its value; an input has no text, and its value attribute is what React last rendered. + expect( + edit({ + tag: "div", + text: behind, + value: typed, + attributes: { contenteditable: "true" }, + }).text, + ).toBe(masked); + const input = edit({ + tag: "input", + text: "", + value: typed, + attributes: { type: "text", value: behind }, + }); + expect([input.text, input.attributes.value]).toEqual(["", masked]); + // A select's text lists its options, and a field whose value the masking left whole keeps every copy as it was. + const select = edit({ + tag: "select", + text: "Visa ending 1111 Mastercard ending 4444", + value: "4111 1111 1111 1111", + }); + expect(select.text).toBe("Visa ending 1111 Mastercard ending 4444"); + const plain = edit({ + tag: "textarea", + text: "Please look up this deal's histor", + value: "Please look up this deal's history", + attributes: { + ...attributes, + "data-draft": "Please look up this deal's histor", + }, + }); + expect(plain.text).toBe("Please look up this deal's histor"); + expect(plain.attributes["data-draft"]).toBe( + "Please look up this deal's histor", + ); +}); + +test("a contenteditable field's text is redacted when it is sensitive", () => { + const event = { + type: "CUSTOM" as const, + name: "input", + timestamp: 1, + value: { + target: { + tag: "div", + value: "737", + text: "737", + attributes: { contenteditable: "true", "aria-label": "Security code" }, + }, + }, + }; + const target = at(maskCollectorEvent(event, { sensitive: false }), "target"); + expect([target.value, target.text]).toEqual([REDACTED, REDACTED]); +}); + +test("emails and long tokens are scrubbed from every string in an event", () => { + expect( + scrubText("mail ana@example.test key abcdefghijklmnopqrstuvwxyz012345"), + ).toBe(`mail ${REDACTED} key ${REDACTED}`); +}); + +test("network events mask headers, bodies and URLs together", () => { + const event = { + type: "CUSTOM" as const, + name: "network", + timestamp: 1, + value: { + method: "POST", + url: "https://api.x.test/login?sig=abcdefghijklmnopqrstuvwxyz0123456789", + status: 200, + request: { + headers: { authorization: "Bearer t" }, + body: { status: "complete", text: '{"password":"p"}' }, + }, + response: { + headers: { "set-cookie": "s=1", "content-type": "application/json" }, + body: { status: "complete", text: '{"token":"t","ok":true}' }, + }, + }, + }; + const v = at(maskNetworkEvent(event)); + expect(v.url).toBe(`https://api.x.test/login?sig=${REDACTED}`); + expect(v.request.headers.authorization).toBe(REDACTED); + expect(JSON.parse(v.request.body.text)).toEqual({ password: REDACTED }); + expect(JSON.parse(v.response.body.text)).toEqual({ + token: REDACTED, + ok: true, + }); +}); + +test("an event over budget is trimmed body-first and says so", () => { + const big = { + type: "CUSTOM" as const, + name: "network", + timestamp: 1, + value: { + response: { body: { status: "complete", text: "a".repeat(40_000) } }, + }, + }; + const { event, trimmed } = trimEvent(big, 15 * 1024); + expect(trimmed).toBe(true); + expect( + new TextEncoder().encode(JSON.stringify(event)).byteLength, + ).toBeLessThanOrEqual(15 * 1024); + expect(at(event, "response", "body", "status")).toBe("truncated"); +}); + +const eventBytes = (event: unknown) => + new TextEncoder().encode(JSON.stringify(event)).byteLength; + +test("a large request is cut first and its small response survives whole, with the event filling its budget", () => { + const response = '{"ok":true,"id":"a1b2c3d4"}'; + expect(response).toHaveLength(27); + const upload = { + type: "CUSTOM" as const, + name: "network", + timestamp: 1, + value: { + url: "https://api.x.test/upload", + request: { + method: "POST", + body: { status: "complete", text: "a".repeat(2_600_000) }, + }, + response: { status: 200, body: { status: "complete", text: response } }, + }, + }; + const { event, trimmed } = trimEvent(upload, 15 * 1024); + expect(trimmed).toBe(true); + expect(at(event, "response", "body")).toEqual({ + status: "complete", + text: response, + }); + expect(at(event, "request", "body", "status")).toBe("truncated"); + expect(at(event, "request", "body", "truncated")).toBe(true); + // The cut leaves the event as close to the budget as one more character would overshoot. + expect(eventBytes(event)).toBeLessThanOrEqual(15 * 1024); + expect(eventBytes(event)).toBeGreaterThan(15 * 1024 - 2); +}); + +test("two large bodies are cut to the same length, and neither is emptied", () => { + const both = { + type: "CUSTOM" as const, + name: "network", + timestamp: 1, + value: { + request: { body: { status: "complete", text: "q".repeat(60_000) } }, + response: { body: { status: "complete", text: "r".repeat(40_000) } }, + }, + }; + const { event } = trimEvent(both, 15 * 1024); + const request: string = at(event, "request", "body", "text"); + const responseText: string = at(event, "response", "body", "text"); + expect(request.length).toBe(responseText.length); + expect(request.length).toBeGreaterThan(7_000); + expect(at(event, "response", "body", "status")).toBe("truncated"); + expect(eventBytes(event)).toBeLessThanOrEqual(15 * 1024); +}); + +test("an input event with a huge value is trimmed by shortening the value and text", () => { + const big = { + type: "CUSTOM" as const, + name: "input", + timestamp: 1, + value: { + target: { + tag: "textarea", + value: "b".repeat(40_000), + text: "b".repeat(40_000), + }, + }, + }; + const { event, trimmed } = trimEvent(big, 15 * 1024); + expect(trimmed).toBe(true); + expect( + new TextEncoder().encode(JSON.stringify(event)).byteLength, + ).toBeLessThanOrEqual(15 * 1024); + expect(at(event, "truncated")).toBe(true); +}); + +test("NUL characters never reach Intelligence", () => { + expect(sanitiseForIntelligence({ "a\u0000b": ["x\u0000y", 1] })).toEqual({ + "a�b": ["x�y", 1], + }); +}); + +test("a lone surrogate never reaches Intelligence, so a split emoji cannot crash the gateway's decoder", () => { + // "\ud83d" alone is what textContent.slice(0, 1000) leaves when it cuts "😀" in half; JSON.stringify writes it as a lone escape. + const out = sanitiseForIntelligence({ + text: "ab\ud83d", + "k\udc00": ["\ud83d\ude00 ok"], + }); + expect(out).toEqual({ text: "ab\ufffd", "k\ufffd": ["\ud83d\ude00 ok"] }); + expect(JSON.stringify(out)).not.toMatch( + /\\ud[89ab][0-9a-f]{2}(?!\\ud[c-f])/i, + ); +}); + +test("trimming never splits a surrogate pair", () => { + const big = { + type: "CUSTOM" as const, + name: "input", + timestamp: 1, + value: { + target: { + tag: "textarea", + value: "😀".repeat(20_000), + text: "😀".repeat(20_000), + }, + }, + }; + const { event } = trimEvent(big, 15 * 1024); + const target = at(event, "target"); + for (const text of [target.value, target.text]) + expect(text.isWellFormed()).toBe(true); +}); + +test("a collector event whose text was cut mid-emoji is made well-formed by the masking", () => { + const event = { + type: "CUSTOM" as const, + name: "click", + timestamp: 1, + value: { target: { tag: "button", text: "Save \ud83d" } }, + }; + expect( + at(maskCollectorEvent(event, { sensitive: false }), "target", "text"), + ).toBe("Save \ufffd"); +}); + +test("sha256HexSync matches the platform's SHA-256, and needs no crypto.subtle", async () => { + for (const value of ["", "abc", "4111111111111111", "😀 x".repeat(100)]) { + const platform = Buffer.from( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)), + ).toString("hex"); + expect(sha256HexSync(value)).toBe(platform); + } + expect(sha256HexSync("abc")).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + const platform737 = Buffer.from( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode("737")), + ).toString("hex"); + const subtle = Object.getOwnPropertyDescriptor(globalThis.crypto, "subtle"); + Object.defineProperty(globalThis.crypto, "subtle", { + value: undefined, + configurable: true, + }); // an insecure http page + try { + expect(sha256HexSync("737")).toBe(platform737); + } finally { + if (subtle) Object.defineProperty(globalThis.crypto, "subtle", subtle); + // subtle is a getter on Crypto's prototype; deleting the own override restores it + else delete (globalThis.crypto as { subtle?: unknown }).subtle; + } + expect(typeof globalThis.crypto.subtle?.digest).toBe("function"); +}); + +test("a hyphenated UUID is redacted by the long-token rule, by design (Decision 15)", () => { + expect(scrubText("deal 3f2b8c1e-9a4d-4e6b-8f1a-2c3d4e5f6a7b")).toBe( + `deal ${REDACTED}`, + ); +}); + +test("sensitivity is read from autocomplete, name, aria-label, placeholder and label text", () => { + expect(isSensitiveTarget({ attributes: { autocomplete: "cc-csc" } })).toBe( + true, + ); + expect(isSensitiveTarget({ attributes: { name: "shipping_topping" } })).toBe( + false, + ); + expect( + isSensitiveTarget({ attributes: { "aria-label": "Verification code" } }), + ).toBe(true); + expect(isSensitiveTarget({ label: "Card number" })).toBe(true); +}); + +test("sanitiseForIntelligence replaces every NUL, not only the first", () => { + expect( + sanitiseForIntelligence({ "a\u0000b\u0000c": ["x\u0000y\u0000z"] }), + ).toEqual({ "a�b�c": ["x�y�z"] }); +}); + +test("maskBody redacts a JSON integer too large to check after rounding", () => { + expect( + JSON.parse( + maskBody('{"pan":6250941006528599123,"n":42}', "application/json"), + ), + ).toEqual({ pan: REDACTED, n: 42 }); +}); + +test("isCredentialKey decodes each escape on its own, like the collector", () => { + expect(isCredentialKey("client%5Fsecret%zz")).toBe(true); + expect(isCredentialKey("access%5Ftoken%")).toBe(true); +}); + +/** Milliseconds a synchronous call takes. */ +const elapsedMs = (run: () => unknown): number => { + const start = performance.now(); + run(); + return performance.now() - start; +}; + +/** Deterministic pseudo-random bytes, so a timing test masks realistic base64 rather than one repeated letter. */ +const noise = (length: number): Uint8Array => { + const bytes = new Uint8Array(length); + let seed = 1; + for (let i = 0; i < length; i++) { + seed = (Math.imul(seed, 1_103_515_245) + 12_345) >>> 0; + bytes[i] = seed >>> 24; + } + return bytes; +}; + +test("a 2 MB run without whitespace is scrubbed in linear time", () => { + const text = "x".repeat(2_000_000); + let out = ""; + expect(elapsedMs(() => (out = scrubText(text)))).toBeLessThan(5000); + expect(out === text).toBe(true); +}); + +test("a JSON body holding a 2 MiB data URI is masked in linear time", () => { + // 1.5 MiB of bytes is exactly 2 MiB of base64. + const image = `data:image/png;base64,${Buffer.from(noise(1_572_864)).toString("base64")}`; + const body = JSON.stringify({ name: "photo.png", image }); + let out = ""; + expect( + elapsedMs(() => (out = maskBody(body, "application/json"))), + ).toBeLessThan(5000); + expect(JSON.parse(out).name).toBe("photo.png"); +}); + +test("a 200,000-digit run is scrubbed in linear time when digests are present", async () => { + const digests = digestsOf("737"); + const text = "1".repeat(200_000); + let out = ""; + expect(elapsedMs(() => (out = scrubText(text, { digests })))).toBeLessThan( + 5000, + ); + expect(out === text).toBe(true); +}); + +test("a 2 MB run of one-digit groups is scrubbed in linear time when digests are present", async () => { + const digests = digestsOf("737"); + const text = "1 ".repeat(1_000_000); + let out = ""; + expect(elapsedMs(() => (out = scrubText(text, { digests })))).toBeLessThan( + 5000, + ); + expect(out === text).toBe(true); +}); + +test("a long dotless run full of eyJ is scrubbed in linear time, and a JWT after other letters is still found", () => { + expect(elapsedMs(() => scrubText("eyJ".repeat(700_000)))).toBeLessThan(5000); + expect(scrubText("xeyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.c2ln end")).toBe( + `x${REDACTED} end`, + ); +}); + +test("a card number beside an expiry, a security code or a quantity is still redacted", () => { + expect(scrubText("Card 4111 1111 1111 1111 12/28")).toBe( + `Card ${REDACTED} 12/28`, + ); + expect(scrubText("4111111111111111 737")).toBe(`${REDACTED} 737`); + expect(scrubText("qty 2 4111 1111 1111 1111")).toBe(`qty 2 ${REDACTED}`); + expect(scrubText("3782 822463 10005 737")).toBe(`${REDACTED} 737`); + expect(scrubText("4111111111111111 5555555555554444")).toBe( + `${REDACTED} ${REDACTED}`, + ); +}); + +test("a card number beside an expiry is redacted when its digest is known too", async () => { + const digests = digestsOf("4111 1111 1111 1111"); + expect(scrubText("Card 4111 1111 1111 1111 12/28", { digests })).toBe( + `Card ${REDACTED} 12/28`, + ); +}); + +test("a typed number that fails Luhn is found by its digest inside a longer run of digit groups", async () => { + const digests = digestsOf("4111 1111 1111 1112", "123456"); + expect(scrubText("Card 4111 1111 1111 1112 12/28", { digests })).toBe( + `Card ${REDACTED} 12/28`, + ); + expect(scrubText("ref 9 123 456 7", { digests })).toBe(`ref 9 ${REDACTED} 7`); +}); + +test("short numbers in a row are not mistaken for a card number", () => { + expect(scrubText("Page 1 2 3 4 5 6 7 8 9 10 11 12")).toBe( + "Page 1 2 3 4 5 6 7 8 9 10 11 12", + ); +}); + +test("a secret beside punctuation or inside markup is still found by its digest", async () => { + const digests = digestsOf("737", "hunter22"); + expect(scrubText("Invalid code: 737.", { digests })).toBe( + `Invalid code: ${REDACTED}.`, + ); + expect(scrubText("<td>737</td>", { digests })).toBe(`<td>${REDACTED}</td>`); + expect(scrubText("(737)", { digests })).toBe(`(${REDACTED})`); + expect(scrubText("<b>hunter22</b>", { digests })).toBe(`<b>${REDACTED}</b>`); + expect( + JSON.parse( + maskBody('{"error":"Invalid code: 737."}', "application/json", { + digests, + }), + ), + ).toEqual({ error: `Invalid code: ${REDACTED}.` }); +}); + +test("a secret that contains a period still matches its digest", async () => { + const digests = digestsOf("p.ss"); + expect(scrubText("password p.ss was rejected", { digests })).toBe( + `password ${REDACTED} was rejected`, + ); +}); + +test("a header value that is not a string is scrubbed as its string form", () => { + const event = { + type: "CUSTOM" as const, + name: "network", + timestamp: 1, + value: { + method: "GET", + url: "https://x.test/a", + request: { headers: { "content-length": 12 } }, + response: { headers: { "content-length": 2 } }, + }, + }; + let masked: ReturnType<typeof maskNetworkEvent> | undefined; + expect(() => { + masked = maskNetworkEvent(event); + }).not.toThrow(); + expect(at(masked, "request", "headers", "content-length")).toBe("12"); + expect(at(masked, "response", "headers", "content-length")).toBe("2"); +}); + +// A realistic sign-in and payment: what a form post or a JSON body carries. None of these may reach Intelligence. +const PASSWORD = "hunter2"; +const PIN = "4921"; +const CARD = "4111 1111 1111 1111"; +const SESSION_TOKEN = "sess_9f8e7d6c"; +const leaks = (text: string) => + [PASSWORD, PIN, "4111", SESSION_TOKEN].filter((secret) => + text.includes(secret), + ); + +test("a form body is masked pair by pair whatever type it was declared with, or none", () => { + const body = new URLSearchParams({ + username: "alice", + password: PASSWORD, + pin: PIN, + card_number: CARD, + session_token: SESSION_TOKEN, + remember: "1", + }).toString(); + for (const type of [ + undefined, + "text/plain;charset=UTF-8", + "application/x-www-form-urlencoded;charset=UTF-8", + ]) { + const masked = maskBody(body, type); + expect(leaks(masked)).toEqual([]); + expect(masked).toBe( + "username=alice&password=[redacted]&pin=[redacted]&card_number=[redacted]&session_token=[redacted]&remember=1", + ); + } +}); + +test("a form body cut short keeps none of its credentials", () => { + const body = `username=alice&password=${PASSWORD}&pin=${PIN}&session_token=${SESSION_TOKEN}¬es=${"a".repeat(64)}`; + const masked = maskBody(body.slice(0, body.length - 20), undefined); + expect(leaks(masked)).toEqual([]); + expect(masked.startsWith("username=alice&password=[redacted]")).toBe(true); +}); + +test("a long token posted as plain text is still redacted, though it looks like one form pair", () => { + expect( + maskBody("c2VjcmV0LXRva2VuLWZvci10aGUtYXBpLTEyMzQ1Njc4OTA=", "text/plain"), + ).toBe(REDACTED); + expect(maskBody("total = 3 apples", "text/plain")).toBe("total = 3 apples"); +}); + +test("JSON cut short keeps none of its credentials: each credential-named key loses its value, to the end of the text", () => { + const whole = JSON.stringify({ + user: { name: "alice", password: PASSWORD }, + session_token: SESSION_TOKEN, + payment: { pin: PIN, card_number: CARD, cvv: 737 }, + note: "kept", + rows: "x".repeat(1000), + }); + const cut = whole.slice(0, whole.indexOf("xxxxx") + 5); + for (const type of ["application/json; charset=utf-8", undefined]) { + const masked = maskBody(cut, type); + expect(leaks(masked)).toEqual([]); + expect(masked).not.toContain("737"); + expect(masked).toContain('"name":"alice"'); + expect(masked).toContain('"password":"[redacted]"'); + expect(masked).toContain('"note":"kept"'); + } +}); + +test("JSON cut inside a credential's value, or inside a credential-named object, keeps none of it", () => { + expect( + leaks(maskBody(`{"user":"alice","password":"${PASSWORD}`, undefined)), + ).toEqual([]); + const nested = maskBody( + `{"user":"alice","credentials":{"username":"alice","answer":"blue-sky","pin":"${PIN}"`, + "application/json", + ); + expect(nested).toBe('{"user":"alice","credentials":"[redacted]"'); + // An escaped quote inside a value never ends the string early, so a key-shaped run inside it stays text. + const escaped = maskBody( + `[{"pass\\u0077ord":"${PASSWORD}","memo":"say \\"pin\\": open"},{"session_token": "${SESSION_TOKEN}`, + "application/json", + ); + expect(escaped).toBe( + '[{"pass\\u0077ord":"[redacted]","memo":"say \\"pin\\": open"},{"session_token": "[redacted]"', + ); +}); + +test("JSON cut short is matched against digests and card numbers too", () => { + const masked = maskBody( + `{"memo":"my code is 737","echo":"hunter 22","total":4111111111111111,"rows":["a`, + "application/json", + { digests: digestsOf("737", "hunter 22") }, + ); + expect(masked).toBe( + '{"memo":"my code is [redacted]","echo":"[redacted]","total":[redacted],"rows":["a', + ); +}); + +test("JSON of 2 MiB cut inside a string is masked in linear time", () => { + const cut = `{"password":"${PASSWORD}","rows":[${'"row", '.repeat(300_000)}"${"x".repeat(100_000)}`; + let out = ""; + expect( + elapsedMs(() => (out = maskBody(cut, "application/json"))), + ).toBeLessThan(5000); + expect(leaks(out)).toEqual([]); +}); + +test("a spoken card number is scrubbed however the speech service wrote its digits", () => { + // As Chrome's speech services wrote "4242 4242 4242 4242" read aloud: thousands separators, a stray currency sign or + // dash, commas with spaces, and the plain groups an on-device model writes. + expect(scrubTranscript("my card number is $4,2424,2424,2424,242")).toBe( + `my card number is $${REDACTED}`, + ); + expect(scrubTranscript("my card number is 4,242-42424,2424,242")).toBe( + `my card number is ${REDACTED}`, + ); + expect(scrubTranscript("My card number is 4242 4242 4242 4242.")).toBe( + `My card number is ${REDACTED}.`, + ); + expect(scrubTranscript("it is 4242, 4242, 4242, 4242 thanks")).toBe( + `it is ${REDACTED} thanks`, + ); + expect( + scrubTranscript("card 4,242-42424,2424,242 expires 12 28, code 123"), + ).toBe(`card ${REDACTED} expires ${REDACTED}, code ${REDACTED}`); +}); + +test("a spoken number the speech service cut short or ran together is scrubbed too, whatever its checksum", () => { + // What Chrome wrote for "4 0 1 2, 8 8 8 8, 8 8 8 8, 1 8 8 1" read aloud in a real run: digits dropped or merged, so no + // longer a valid card number, or cut off at Stop. + const said: Array<[string, string]> = [ + ["my card number is 4012-888888-1881", `my card number is ${REDACTED}`], + ["my card number is 4012-8888", `my card number is ${REDACTED}`], + ["my card number is 4012 8888 8888 1881", `my card number is ${REDACTED}`], + ["my card number is 4012-8888-8888-1881", `my card number is ${REDACTED}`], + [ + "my card number is $4,012,888,888,881,881", + `my card number is $${REDACTED}`, + ], + // Six digits or more go with no word before them, however they are split. + ["it is 4012-888888-1881 thanks", `it is ${REDACTED} thanks`], + ["then 4012 88 and done", `then ${REDACTED} and done`], + ["type 4 0 1 2, 8 8 then", `type ${REDACTED} then`], + ["type 4 0 1 2 - 8 8 then", `type ${REDACTED} then`], + ["that is $4,2424,2424 there", `that is $${REDACTED} there`], + ["40128888 is the one", `${REDACTED} is the one`], + ["try 4012.8888.", `try ${REDACTED}.`], + ["call 555 123 4567", `call ${REDACTED}`], + ["ref ab4012888 now", `ref ab${REDACTED} now`], + ]; + for (const [text, masked] of said) expect(scrubTranscript(text)).toBe(masked); +}); + +test("after a word that names a secret, every number that follows in the phrase goes, however short", () => { + const said: Array<[string, string]> = [ + ["my PIN is 1234", `my PIN is ${REDACTED}`], + ["the CVV is 123", `the CVV is ${REDACTED}`], + ["CVC 4 2 1 please", `CVC ${REDACTED} please`], + ["the security code is 421", `the security code is ${REDACTED}`], + ["my card number is 4012", `my card number is ${REDACTED}`], + ["debit card ending 1881", `debit card ending ${REDACTED}`], + ["credit limit 500", `credit limit ${REDACTED}`], + ["account 77 please", `account ${REDACTED} please`], + ["routing number 12", `routing number ${REDACTED}`], + ["IBAN GB 82 WEST", `IBAN GB ${REDACTED} WEST`], + ["SSN is 123", `SSN is ${REDACTED}`], + [ + "my social security number is 123 45", + `my social security number is ${REDACTED}`, + ], + ["the passcode is 0000", `the passcode is ${REDACTED}`], + ["my pass code is 12", `my pass code is ${REDACTED}`], + ["my password is hunter2", `my password is ${REDACTED}`], + ["the OTP is 48", `the OTP is ${REDACTED}`], + ["verification code 4821", `verification code ${REDACTED}`], + [ + "my card number is 4012 and then order 1043", + `my card number is ${REDACTED} and then order ${REDACTED}`, + ], + // A number said before the word is not after it. + ["order 1043 on my card", "order 1043 on my card"], + ]; + for (const [text, masked] of said) expect(scrubTranscript(text)).toBe(masked); +}); + +test("a number said in the phrase after one that names a secret goes too, as Chrome splits a phrase at a pause", () => { + expect(scrubTranscript("4012", { before: "my card number is" })).toBe( + REDACTED, + ); + expect(scrubTranscript("it is 12 34", { before: "and my PIN" })).toBe( + `it is ${REDACTED}`, + ); + expect( + scrubTranscript("order 1043", { before: "I opened the order page" }), + ).toBe("order 1043"); +}); + +test("ordinary numbers in speech are kept", () => { + for (const text of [ + "order 1043, 2 items for $12.50 in 3 minutes", + "step 3 then step 4", + "open order 1043 and refund 2 items", + "12345 is five digits", + "on October 18, 2026 the parcel came", + "it shipped December 3 2026", + "discard 3 drafts and add 2 to the cart", + "I pinned 2 tabs", + ]) + expect(scrubTranscript(text)).toBe(text); +}); + +test("a spoken transcript keeps ordinary numbers and loses what the text masking always takes", () => { + expect(scrubTranscript("order 1043, 2 items for $12.50 in 3 minutes")).toBe( + "order 1043, 2 items for $12.50 in 3 minutes", + ); + expect(scrubTranscript("write to ana@example.test now")).toBe( + `write to ${REDACTED} now`, + ); +}); + +test("a value typed into a sensitive field is scrubbed when it is spoken, whatever the separators", () => { + const digests = new Set([sha256HexSync("737"), sha256HexSync("hunter2")]); + expect(scrubTranscript("the code is 7,3,7 ok", { digests })).toBe( + `the code is ${REDACTED} ok`, + ); + expect(scrubTranscript("the code is 7 3 7", { digests })).toBe( + `the code is ${REDACTED}`, + ); + expect(scrubTranscript("my password is hunter2", { digests })).toBe( + `my password is ${REDACTED}`, + ); + expect(scrubTranscript("I need 7,38 of them", { digests })).toBe( + "I need 7,38 of them", + ); +}); + +// ---- One-time codes -------------------------------------------------------------------------- + +/** The 2FA field of a real sign-in, as the collector described it: no autocomplete=one-time-code, and the code mirrored into + * the value attribute. */ +const SIGN_IN_CODE_FIELD = { + id: "codeEntry", + type: "text", + placeholder: "Enter 6-digit code", + "data-test-id": "two-factor-code-input", + "aria-labelledby": "FormControl-label2", + value: "880261", +}; +const SIGN_IN_PAGE = "https://app.example.test/login/confirm-to-login"; + +test("the 2FA code field of a real sign-in loses the code in its value and its value attribute", () => { + const event = { + type: "CUSTOM" as const, + name: "input", + timestamp: 1, + value: { + eventType: "input", + target: { + tag: "input", + role: null, + action: null, + text: "", + attributes: { ...SIGN_IN_CODE_FIELD }, + value: "880261", + }, + url: SIGN_IN_PAGE, + route: "/login/confirm-to-login", + }, + }; + const masked = maskCollectorEvent(event, { sensitive: false }); + expect(JSON.stringify(masked)).not.toContain("880261"); + expect(at(masked, "target", "value")).toBe(REDACTED); + expect(at(masked, "target", "attributes", "value")).toBe(REDACTED); + expect(at(masked, "target", "attributes", "placeholder")).toBe( + "Enter 6-digit code", + ); +}); + +test("a one-time code field is known by its id, name, placeholder, label, test id or class, without autocomplete", () => { + const sensitive = (attributes: Record<string, string>, label?: string) => + isSensitiveTarget({ tag: "input", attributes, label }); + expect(sensitive({ "data-test-id": "two-factor-code-input" })).toBe(true); + expect(sensitive({ placeholder: "Enter 6-digit code" })).toBe(true); + expect(sensitive({ id: "otpCode" })).toBe(true); + expect(sensitive({ name: "mfa_code" })).toBe(true); + expect(sensitive({ name: "totpPin" })).toBe(true); + expect(sensitive({ name: "otc" })).toBe(true); + expect(sensitive({ "data-testid": "2fa-input" })).toBe(true); + expect(sensitive({ "data-testid": "2FA-input" })).toBe(true); + expect(sensitive({ id: "2FACode" })).toBe(true); + expect(sensitive({ id: "sixDigitCode" })).toBe(true); + expect(sensitive({ "data-qa": "sms-code" })).toBe(true); + expect(sensitive({ class: "form-control otp-input" })).toBe(true); + expect(sensitive({ "aria-label": "Authentication code" })).toBe(true); + expect(sensitive({ title: "Two-step verification" })).toBe(true); + expect(sensitive({ name: "auth_code" })).toBe(true); + expect(sensitive({ name: "loginCode" })).toBe(true); + expect(sensitive({ name: "confirmationCode" })).toBe(true); + expect(sensitive({ id: "recovery-code" })).toBe(true); + expect(sensitive({}, "Code from your authenticator app")).toBe(true); + expect(sensitive({}, "Multi-factor code")).toBe(true); + expect(sensitive({}, "Enter the 6-digit code we sent to your phone")).toBe( + true, + ); + expect( + isSensitiveTarget({ + tag: "input", + attributes: { inputmode: "numeric", maxlength: "6" }, + label: "Type what we texted to your phone", + }), + ).toBe(true); + // A bare "code" is a one-time code on a sign-in page, and not elsewhere. + expect(sensitive({ id: "codeEntry" })).toBe(false); + expect( + isSensitiveTarget( + { tag: "input", attributes: { id: "codeEntry" } }, + { url: SIGN_IN_PAGE }, + ), + ).toBe(true); + expect( + isSensitiveTarget( + { tag: "input", attributes: { name: "code" } }, + { url: "https://shop.example.test/cart" }, + ), + ).toBe(false); +}); + +test("a short numeric input is a one-time code field by its shape alone", () => { + const sensitive = (attributes: Record<string, string>) => + isSensitiveTarget({ tag: "input", attributes }); + expect(sensitive({ inputmode: "numeric", maxlength: "6" })).toBe(true); + expect(sensitive({ inputmode: "numeric", maxlength: "4" })).toBe(true); + expect(sensitive({ inputmode: "numeric", maxlength: "8" })).toBe(true); + expect(sensitive({ pattern: "[0-9]*", maxlength: "6" })).toBe(true); + expect(sensitive({ pattern: "\\d{6}" })).toBe(true); + expect(sensitive({ pattern: "[0-9]{4,8}" })).toBe(true); + expect(sensitive({ type: "tel", pattern: "[0-9 ]*", maxlength: "7" })).toBe( + true, + ); + // Outside 4 to 8 digits, or not numeric, the shape says nothing. + expect(sensitive({ inputmode: "numeric", maxlength: "3" })).toBe(false); + expect(sensitive({ inputmode: "numeric", maxlength: "9" })).toBe(false); + expect(sensitive({ inputmode: "numeric" })).toBe(false); + expect(sensitive({ maxlength: "6" })).toBe(false); + expect(sensitive({ inputmode: "decimal", maxlength: "6" })).toBe(false); +}); + +test("one numeric box of a code typed a digit per box is sensitive on its own, and a one-letter box is not", () => { + const sensitive = (attributes: Record<string, string>) => + isSensitiveTarget({ tag: "input", attributes }); + expect(sensitive({ maxlength: "1", inputmode: "numeric" })).toBe(true); + expect(sensitive({ maxlength: "1", type: "tel" })).toBe(true); + expect(sensitive({ maxlength: "1", pattern: "[0-9]*" })).toBe(true); + // A middle initial, and a one-digit count, keep their values. + expect( + sensitive({ maxlength: "1", type: "text", name: "middleInitial" }), + ).toBe(false); + expect(sensitive({ maxlength: "1", inputmode: "numeric", name: "qty" })).toBe( + false, + ); +}); + +test("ordinary codes and short numbers are not one-time codes: zip, postal, promo, coupon and country codes are kept", () => { + const cases: Array<[Record<string, string>, string?]> = [ + [{ name: "zipCode", inputmode: "numeric", maxlength: "5" }], + [{ placeholder: "ZIP code", pattern: "[0-9]{5}" }], + [{ inputmode: "numeric", maxlength: "5" }, "5-digit ZIP code"], + [{ name: "postal_code", maxlength: "8" }, "Postal code"], + [{ autocomplete: "postal-code", inputmode: "numeric", maxlength: "6" }], + [{ name: "promoCode" }, "Promo code"], + [{ id: "coupon-code" }, "Coupon code"], + [{ name: "countryCode", type: "tel", maxlength: "4" }, "Country code"], + [{ "aria-label": "Discount code" }], + [{ name: "qty", inputmode: "numeric", maxlength: "4" }, "Quantity"], + [{ inputmode: "numeric", maxlength: "4", placeholder: "YYYY" }], + [{ name: "phone_ext", type: "tel", maxlength: "5" }, "Extension"], + ]; + for (const [attributes, label] of cases) + expect([ + attributes, + label, + isSensitiveTarget({ tag: "input", attributes, label }), + ]).toEqual([attributes, label, false]); + // Not on a sign-in page either, where a bare "code" counts. + expect( + isSensitiveTarget( + { tag: "input", attributes: { name: "promoCode" } }, + { url: "https://shop.example.test/login" }, + ), + ).toBe(false); +}); + +/** An edit of one of the two code fields on the end-to-end run's security page (its fixture shop's /security), exactly as + * the step reached Intelligence in run 1b and on Edge: no autocomplete, no code-like name and no numeric shape; the label + * says only that the code was texted, or sent to the phone. */ +const securityPageEdit = (field: { + id: string; + form: string; + label: string; + code: string; + y: number; +}) => ({ + type: "CUSTOM" as const, + name: "input", + timestamp: 1_792_548_306_243, + value: { + box: { x: 273, y: field.y, width: 720, height: 40 }, + url: "http://127.0.0.1:7420/security", + page: { + url: "http://127.0.0.1:7420/security", + title: "Security - Corner Shop admin", + }, + step: "input", + route: "/security", + target: { + tag: "input", + name: field.label, + role: "textbox", + text: "", + value: field.code, + action: null, + inputType: "text", + attributes: { id: field.id, type: "text" }, + }, + viewport: { width: 1280, height: 860 }, + eventType: "input", + selectors: [ + { by: "id", selector: `#${field.id}` }, + { by: "role", selector: `role=textbox[name="${field.label}"]` }, + { by: "css", selector: `#${field.form} > input` }, + ], + }, +}); +const TEXTED_CODE = { + id: "sms", + form: "otp-b", + label: "Enter the code we texted you", + code: "582046", + y: 411, +}; +const PHONE_CODE = { + id: "pc", + form: "otp-b2", + label: "Enter what we sent to your phone", + code: "315827", + y: 611, +}; + +test("the run's codes typed into fields that say only they were texted or sent to the phone lose their value, wherever the step is masked", () => { + for (const field of [TEXTED_CODE, PHONE_CODE]) { + const heard: string[][] = []; + const masked = maskCollectorEvent(securityPageEdit(field), { + sensitive: false, + onSensitive: (typed) => heard.push(typed), + }); + expect(JSON.stringify(masked)).not.toContain(field.code); + expect(at(masked, "target", "value")).toBe(REDACTED); + // The field is still named and found as the page drew it. + expect(at(masked, "target", "name")).toBe(field.label); + expect(at(masked, "selectors", "0", "selector")).toBe(`#${field.id}`); + // What it held is handed on, so the service worker masks it in everything after (the save's body and answer). + expect(heard).toEqual([[field.code]]); + } +}); + +test("a field that says what it takes was sent to the person takes a one-time code when it holds 4 to 8 digits, is numeric, takes 4 to 8 characters or sits on a verification page", () => { + const sensitive = ( + label: string, + value?: string, + attributes: Record<string, string> = { type: "text" }, + url = "http://127.0.0.1:7420/security", + ) => isSensitiveTarget({ tag: "input", attributes, label, value }, { url }); + // The run's own two fields, as the person typed into them. + expect(sensitive(TEXTED_CODE.label, TEXTED_CODE.code)).toBe(true); + expect(sensitive(PHONE_CODE.label, PHONE_CODE.code)).toBe(true); + expect(sensitive(PHONE_CODE.label, "315 827")).toBe(true); + expect(sensitive("We've emailed you a code", "4821")).toBe(true); + expect(sensitive("Enter the code from the text message", "90412")).toBe(true); + expect(sensitive("Type the number we just texted you", "77310215")).toBe( + true, + ); + expect(sensitive("Check your phone for what we sent", "662041")).toBe(true); + // Before anything is typed, the field's shape or page says so. + expect( + sensitive(TEXTED_CODE.label, "", { type: "text", inputmode: "numeric" }), + ).toBe(true); + expect( + sensitive(PHONE_CODE.label, "", { type: "text", maxlength: "6" }), + ).toBe(true); + expect( + sensitive(PHONE_CODE.label, "", { pattern: "[0-9]*" }, "https://x.test/"), + ).toBe(true); + expect( + sensitive(PHONE_CODE.label, "", {}, "https://bank.example.test/verify"), + ).toBe(true); + // Nothing that says so: an empty field, words, a phone number, a longer number. + expect(sensitive(PHONE_CODE.label, "")).toBe(false); + expect(sensitive(PHONE_CODE.label, "it never came")).toBe(false); + expect(sensitive(TEXTED_CODE.label, "4155551234")).toBe(false); + expect( + sensitive("We texted a code to this number", "+44 7700 900123", { + type: "tel", + }), + ).toBe(false); + // What else a site sends a person is kept: an order, tracking, invoice or booking number, a postcode. + expect(sensitive("Enter the order number we emailed you", "1043")).toBe( + false, + ); + expect(sensitive("Tracking number sent to your email", "48213377")).toBe( + false, + ); + expect(sensitive("Invoice number we sent you", "20931")).toBe(false); + expect(sensitive("Booking reference we emailed you", "5521")).toBe(false); + expect(sensitive("Zip code we have sent to your phone", "94107")).toBe(false); + expect(sensitive("How many items were sent to you", "1200")).toBe(false); + // Words that say nothing was sent keep their value. + expect(sensitive("Internal memo", "5821")).toBe(false); + expect(sensitive("Note to support", "1043")).toBe(false); +}); + +test("a passphrase typed into a sensitive field and pasted whole into an ordinary one goes whole, though no word of it matches", () => { + const passphrase = "correct horse battery staple"; + const pasted = { + type: "CUSTOM" as const, + name: "input", + timestamp: 1, + value: { + url: "http://127.0.0.1:7420/account", + target: { + tag: "input", + name: "Internal memo", + text: "", + value: passphrase, + attributes: { id: "memo", name: "memo", "data-last": passphrase }, + }, + }, + }; + const masked = maskCollectorEvent( + pasted, + { sensitive: false }, + { digests: digestsOf(passphrase) }, + ); + expect(at(masked, "target", "value")).toBe(REDACTED); + expect(JSON.stringify(masked)).not.toContain("horse"); + // A memo that only holds words of it, or none, is kept. + const other = structuredClone(pasted); + other.value.target.value = "battery staple order"; + other.value.target.attributes["data-last"] = ""; + expect( + at( + maskCollectorEvent( + other, + { sensitive: false }, + { digests: digestsOf(passphrase) }, + ), + "target", + "value", + ), + ).toBe("battery staple order"); +}); + +test("a button that names a one-time code keeps its label", () => { + const event = { + type: "CUSTOM" as const, + name: "click", + timestamp: 1, + value: { + target: { + tag: "button", + text: "Verify code", + attributes: { "data-test-id": "two-factor-submit" }, + }, + url: SIGN_IN_PAGE, + }, + }; + expect( + at(maskCollectorEvent(event, { sensitive: false }), "target", "text"), + ).toBe("Verify code"); + const submit = { + ...event, + value: { + target: { + tag: "input", + value: "Verify", + attributes: { type: "submit", value: "Verify", name: "verify" }, + }, + }, + }; + expect( + at(maskCollectorEvent(submit, { sensitive: false }), "target", "value"), + ).toBe("Verify"); +}); + +test("a button, link, label or toggle that names a password keeps its text; the field it names loses its value", () => { + // The final end-to-end run's clicks as the content script described them (rows ...95033, ...68032 and ...68058), whose + // text reached Intelligence as [redacted], and the checkbox and label a show-password toggle is as often drawn with. + const click = (target: Record<string, unknown>, url: string) => ({ + type: "CUSTOM" as const, + name: "click", + timestamp: 1, + value: { target, url } as JsonValue, + }); + const named = [ + click( + { + tag: "button", + name: "Retrieve password", + role: null, + text: "Retrieve password", + input: "pointer", + label: "Retrieve password", + action: null, + ariaRole: "button", + attributes: { id: "form_submit", type: "submit", class: "radius" }, + }, + "https://the-internet.herokuapp.com/forgot_password", + ), + click( + { + tag: "button", + name: "Show password", + role: null, + text: "Show password", + input: "pointer", + label: "Show password", + action: null, + ariaRole: "button", + attributes: { + id: "show-pw", + type: "button", + class: "ghost", + style: "margin-top:6px", + }, + }, + "http://127.0.0.1:7420/checkout?order=1043", + ), + click( + { + tag: "a", + name: "Reset password link", + role: null, + text: "Reset password link", + input: "pointer", + label: "Reset password link", + action: null, + ariaRole: "link", + attributes: { + id: "reset", + href: "/reset?token=[redacted]&email=admin%40cornershop.example", + }, + }, + "http://127.0.0.1:7420/account?welcome=1", + ), + click( + { + tag: "input", + text: "", + value: "on", + checked: true, + label: "Show password", + attributes: { id: "show-password", type: "checkbox" }, + }, + "https://x.test/sign-in", + ), + click( + { + tag: "label", + text: "Your admin password", + label: "Your admin password", + attributes: { for: "admin-password" }, + }, + "http://127.0.0.1:7420/checkout?order=1043", + ), + ]; + for (const event of named) { + const typed: string[][] = []; + const masked = maskCollectorEvent(event, { + sensitive: false, + onSensitive: (values) => typed.push(values), + }); + expect(masked).toEqual(event); + expect(typed).toEqual([]); + } + // The field itself, as a password and after Show password made it a text field, loses its value. + for (const type of ["password", "text"]) { + const field = click( + { + tag: "input", + name: "Your admin password", + text: "", + value: "Kestrel-Pw-58213!", + attributes: { + id: "admin-password", + name: "password", + type, + autocomplete: "current-password", + value: "Kestrel-Pw-58213!", + }, + }, + "http://127.0.0.1:7420/checkout?order=1043", + ); + const target = at( + maskCollectorEvent(field, { sensitive: false }), + "target", + ); + expect([target.value, target.attributes.value]).toEqual([ + REDACTED, + REDACTED, + ]); + } +}); + +test("every attribute that echoes a sensitive field's value is redacted, for one-time code, password and card fields alike", () => { + const echoes = (attributes: Record<string, string>, value: string) => { + const event = { + type: "CUSTOM" as const, + name: "input", + timestamp: 1, + value: { + target: { + tag: "input", + value, + attributes: { + ...attributes, + value, + "data-initial-value": value, + "ng-reflect-model": value, + title: `Current: ${value}`, + dir: "ltr", + }, + }, + }, + }; + return maskCollectorEvent(event, { sensitive: false }); + }; + for (const [attributes, value] of [ + [{ "data-test-id": "two-factor-code-input" }, "880261"], + [{ type: "password" }, "Hunter-22"], + [{ autocomplete: "cc-number" }, "4000 0566 5566 5556"], + [{ name: "cardnumber" }, "4000056655665556"], + ] as const) { + const masked = echoes(attributes, value); + expect([value, JSON.stringify(masked).includes(value)]).toEqual([ + value, + false, + ]); + expect(at(masked, "target", "attributes", "dir")).toBe("ltr"); + } +}); + +test("one-time code keys lose their value in JSON, form and cut-short bodies, in any case or style", () => { + const keys = [ + "verificationCode", + "verification_code", + "VerificationCode", + "VERIFICATION_CODE", + "verification-code", + "otp", + "otpCode", + "OTPCode", + "otp_code", + "mfaCode", + "mfa_code", + "totp", + "totpCode", + "twoFactorCode", + "two_factor_code", + "2faCode", + "smsCode", + "sms_code", + "authCode", + "auth_code", + "authenticationCode", + "authorization_code", + "passcode", + "securityCode", + "loginCode", + "accessCode", + "confirmationCode", + "oneTimeCode", + "one_time_password", + "verifyCode", + "recoveryCode", + "backup_codes", + "code_verifier", + "mfaToken", + "x-otp", + ]; + for (const key of keys) + expect([key, isCredentialKey(key)]).toEqual([key, true]); + for (const key of [ + "zipCode", + "postal_code", + "promoCode", + "couponCode", + "countryCode", + "errorCode", + "statusCode", + "otpLength", + "mfaEnabled", + "verificationCodeExpiresAt", + "twoFactorMethod", + "verificationStatus", + "code", + ]) + expect([key, isCredentialKey(key)]).toEqual([key, false]); +}); + +test("the verification code a real sign-in posted is redacted from its body", () => { + expect(maskBody('{"verificationCode":"880261"}', "application/json")).toBe( + `{"verificationCode":"${REDACTED}"}`, + ); + expect(maskBody("verificationCode=880261", undefined)).toBe( + `verificationCode=${REDACTED}`, + ); + expect(maskBody('{"verificationCode":"880261","rememb', undefined)).toBe( + `{"verificationCode":"${REDACTED}","rememb`, + ); +}); + +test("a bare code key goes when it holds 4 to 10 digits, or on a sign-in request, and an ordinary response code stays", () => { + const json = (body: object, url?: string) => + JSON.parse(maskBody(JSON.stringify(body), "application/json", { url })); + expect(json({ code: "880261" })).toEqual({ code: REDACTED }); + expect(json({ code: 880261 })).toEqual({ code: REDACTED }); + expect(json({ code: "8802" })).toEqual({ code: REDACTED }); + expect(json({ code: "8802610000" })).toEqual({ code: REDACTED }); + expect(json({ Code: "880 261" })).toEqual({ Code: REDACTED }); + expect(json({ data: { code: "880261" } })).toEqual({ + data: { code: REDACTED }, + }); + expect(json({ code: "NOT_FOUND", message: "No such deal" })).toEqual({ + code: "NOT_FOUND", + message: "No such deal", + }); + expect(json({ code: 404 })).toEqual({ code: 404 }); + expect(json({ code: "ABC123" })).toEqual({ code: "ABC123" }); + expect(json({ code: "88026100001" })).toEqual({ code: "88026100001" }); + for (const url of [ + "https://app.example.test/api/login/verify", + "https://id.example.test/oauth/token", + "https://app.example.test/api/v1/mfa/challenge", + "https://app.example.test/auth/callback", + "https://app.example.test/api/two-factor", + ]) + expect([url, json({ code: "ABC123" }, url)]).toEqual([ + url, + { code: REDACTED }, + ]); + expect(maskBody("code=880261&remember=1", undefined)).toBe( + `code=${REDACTED}&remember=1`, + ); + expect(maskBody("code=NOT_FOUND", undefined)).toBe("code=NOT_FOUND"); + expect( + maskBody( + "grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA", + undefined, + { + url: "https://id.example.test/oauth/token", + }, + ), + ).toBe(`grant_type=authorization_code&code=${REDACTED}`); + expect(maskBody('{"code":"880261","note":"x', undefined)).toBe( + `{"code":${JSON.stringify(REDACTED)},"note":"x`, + ); + expect(maskBody('{"code":"NOT_FOUND","note":"x', undefined)).toBe( + '{"code":"NOT_FOUND","note":"x', + ); +}); + +test("a network event to a sign-in endpoint loses a bare code in its request and its response, whatever it holds", () => { + const masked = maskNetworkEvent({ + type: "CUSTOM", + name: "network", + timestamp: 1, + value: { + url: "https://app.example.test/api/login/verify", + request: { + headers: { "content-type": "application/json" }, + body: { text: '{"code":"Q7XK2M"}', status: "captured" }, + }, + response: { + headers: { "content-type": "application/json" }, + body: { text: '{"code":"INVALID"}', status: "captured" }, + }, + }, + }); + expect(JSON.stringify(masked)).not.toContain("Q7XK2M"); + expect(JSON.stringify(masked)).not.toContain("INVALID"); +}); + +test("an OAuth callback's code and state are redacted from its URL, and an ordinary code query is kept", () => { + expect( + scrubUrl( + "https://app.example.test/oauth/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xyzABC&next=/home", + ), + ).toBe( + `https://app.example.test/oauth/callback?code=${REDACTED}&state=${REDACTED}&next=/home`, + ); + // An OAuth redirect by its shape, wherever it lands. + expect(scrubUrl("https://app.example.test/?code=ab12&state=s9")).toBe( + `https://app.example.test/?code=${REDACTED}&state=${REDACTED}`, + ); + expect(scrubUrl("https://app.example.test/verify?code=880261")).toBe( + `https://app.example.test/verify?code=${REDACTED}`, + ); + expect(scrubUrl("https://app.example.test/deals?code=880261")).toBe( + `https://app.example.test/deals?code=${REDACTED}`, + ); + const ordinary = "https://shop.example.test/search?code=summer&page=2"; + expect(scrubUrl(ordinary)).toBe(ordinary); +}); + +test("a body holds what was typed into a sensitive field wherever the masking takes it, and the page's own calls do not", () => { + // The final run's checkout (trajectory 7983e67d): the admin password, a 9 added at its end, and the card in groups of + // four, hashed as the content script hashes each keystroke's value; then bodies the shop logged that run. + const keystrokes = (text: string) => + [...text].map((_, index) => text.slice(0, index + 1)); + const typed = digestsOf( + ...keystrokes("Kestrel-Pw-58213!9"), + ...keystrokes("4929 7311 2846 5036"), + ); + const refund = + '{"order":1043,"fullName":"","reason":"","password":"Kestrel-Pw-58213!9","cardNumber":"4929 7311 2846 5036","notes":"","reference":"","amount":58.2}'; + const json = "application/json"; + // The refund: both are under keys the masking redacts whatever they hold. + expect(holdsTypedSecret(refund, json, typed)).toBe(true); + expect(maskBody(refund, json, { digests: typed })).not.toContain("Kestrel"); + // The account form, the password typed into its memo. + expect( + holdsTypedSecret( + "displayName=Shop+Admin&phone=&supportNote=&memo=memo+Kestrel-Pw-58213%21", + "application/x-www-form-urlencoded", + typed, + ), + ).toBe(true); + // The page's telemetry, its read of a conversation and its read receipt hold nothing typed. + for (const body of [ + '{"type":"click","sid":"e3bvgvb5rxa","path":"/checkout"}', + '{"event":"ui_click","sid":"e3bvgvb5rxa","el":"BUTTON","at":1792561212746}', + '{"operationName":"Conversation","query":"query Conversation($id: ID!) { conversation(id: $id) { messages { body } } }","variables":{"id":"42"}}', + '{"upTo":7}', + ]) + expect(holdsTypedSecret(body, json, typed)).toBe(false); + // Read only before the masking, and only for what this tab's person typed. + expect( + holdsTypedSecret(maskBody(refund, json, { digests: typed }), json, typed), + ).toBe(false); + expect(holdsTypedSecret(refund, json, new Set())).toBe(false); +}); diff --git a/shared/recorder-masking.ts b/shared/recorder-masking.ts new file mode 100644 index 000000000..be555c2cc --- /dev/null +++ b/shared/recorder-masking.ts @@ -0,0 +1,1540 @@ +// Masking for everything the Recorder captures, applied in the browser before anything leaves it. Intelligence does no +// server-side redaction, so these functions are the only defence for what reaches it. DOM-free and import-free: the same +// file runs in the service worker, content scripts, the MAIN-world bundle, the offscreen document and Bun. + +/** The collector's own marker (`@copilotkit/learning` 0.0.1 `src/types.ts` REDACTED), so the two agree. */ +export const REDACTED = "[redacted]"; +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; +export type CapturedEvent = { + type: "CUSTOM"; + name: string; + timestamp: number; + value: JsonValue; +}; +/** `url` is the address a body was sent to: on a sign-in, verification or token request a bare `code` key is a secret + * whatever it holds. */ +type MaskOptions = { digests?: ReadonlySet<string>; url?: string }; +/** MaskOptions as the body maskers read them, with the request's address already judged. */ +type BodyOptions = MaskOptions & { authFlow?: boolean }; +type Obj = { [key: string]: JsonValue }; + +/** A 3-digit security code is a secret too, so digests of values this short still match. */ +export const MIN_DIGEST_LENGTH = 3; +/** Intelligence's per-event limit. */ +export const MAX_EVENT_BYTES = 16 * 1024; +/** Intelligence refuses timestamps at or above 253,402,300,800,000. */ +export const MAX_TIMESTAMP_MS = 253_402_300_800_000 - 1; +/** Leaves room for the seq and envelope the streamer adds to each event. */ +export const EVENT_BUDGET_BYTES = 15 * 1024; + +/** Copy of `shared/demonstration-rules.ts` SENSITIVE_FIELD (this file has no imports); `shared/sensitive-rule.test.ts` keeps the two equal. */ +export const SENSITIVE_FIELD = + /password|secret|token|one.time|verification|credit.card|security.code|passcode|card.?(?:number|num|no)(?![a-z])|cardholder|card.?verification|(?:^|[^a-z])(?:cc-|(?:cvc|cvv|csc|pin|otp)(?![a-z]))/i; + +// The next three are copied exactly from `@copilotkit/learning` 0.0.1 `src/redact.ts` (`CREDENTIAL`, `CREDENTIAL_WORD`, `METADATA`). +/** Matches anywhere in a key's joined lowercase words (collector `CREDENTIAL`). */ +const CREDENTIAL = + /password|passwd|passphrase|passcode|credential|api(key|token)|privatekey|subscriptionkey|(access|refresh|auth|session)token|clientsecret/; +/** Matches only as whole words, as the last word, or as the entire key (collector `CREDENTIAL_WORD`). */ +const CREDENTIAL_WORD = + / (secret|id ?token)s? | (authorization|cookies?|jwt|bearer ?token|[cx]srf ?token) $|^ (pwd|pass|token|otp) $/; +/** Metadata about a credential is not the credential (collector `METADATA`), checked first. */ +const METADATA = + / (ids?|at|in|expires?|expiry|length|count|enabled|required|policy|hint|type) $/; +/** OpenBot's addition: payment-card keys that are the entire key (plan Task 3, Decision 15). */ +const CARD_KEY = /^ (pin|cvv|cvc|csc) $/; +/** OpenBot's addition: payment-card keys matched on the joined words (plan Task 3, Decision 15). */ +const CARD_WORDS = /cardnumber|ccnumber|cardverification/; +/** OpenBot's addition: one-time and verification code keys, matched on the joined words (`verificationCode`, `mfa_code`, + * `two_factor_code`, `smsCode`, `authCode`, `code_verifier`), never an ordinary code (`zipCode`, `promoCode`, `errorCode`). */ +const ONE_TIME_CODE_WORDS = + /(?:verification|verify|verif|otp|totp|hotp|mfa|2fa|twofactor|2factor|twostep|2step|multifactor|sms|auth|authentication|authenticator|authorization|login|signin|logon|security|access|confirmation|onetime|recovery|backup)(?:code|pin|otp)|onetimepass|codeverifier|(?:mfa|2fa|twofactor|otp|totp)token/; +/** OpenBot's addition: one-time code keys that are the last word or the entire key (`otp`, `totp`, `x-otp`). */ +const ONE_TIME_CODE_LAST = / (?:otp|totp|hotp|otc)s? $/; +/** A bare `code` key: a one-time code when it holds 4 to 10 digits, or on a sign-in request (`codeHoldsSecret`). */ +const BARE_CODE = /^ codes? $/; +/** 4 to 10 digits: what a one-time code sent by text, email or an authenticator app holds. */ +const CODE_DIGITS = /^\d{4,10}$/; +/** A sign-in, verification, token or OAuth address, read from its host and path split into words (`authFlowUrl`); never + * "author". */ +const AUTH_URL_WORDS = + / (?:auth(?!or)[a-z\d]*|oauth[a-z\d]*|login|logon|log in|signin|sign in|sso|saml|oidc|openid|mfa|2 ?fa|two ?factor|2 ?step|two ?step|otp|totp|verif[a-z]*|tokens?|callback|challenge) /; +/** Headers whose values are credentials whatever their name looks like (plan Task 3 `CREDENTIAL_HEADERS`). */ +const CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", + "apikey", + "x-auth-token", + "x-csrf-token", + "x-xsrf-token", + "x-amz-security-token", + "private-token", + "x-token", +]); +/** JSON web tokens, three dot-separated base64url parts (plan Task 3 `scrubText`): the same matches as + * `/eyJ[\w-]+\.[\w-]+\.[\w-]*\/g`, tried once per run of `[\w-]` so a long dotless run full of `eyJ` stays linear. Group 1 is + * what precedes the run's first `eyJ` and is put back; each lookahead and backreference pair is atomic, so no part is re-scanned. */ +const JWT = + /(?<![\w-])((?:(?!eyJ)[\w-])*)eyJ(?=([\w-]+))\2\.(?=([\w-]+))\3\.[\w-]*/g; +/** The value after an auth scheme word (plan Task 3 `scrubText`). */ +const AUTH_SCHEME = /\b(bearer|basic|token)(\s+)([^\s"',;]+)/gi; +/** Long unbroken runs; no `/` in the class so a readable path or slug survives (plan Task 3 `scrubText`). */ +const LONG_RUN = /[A-Za-z0-9_+=-]{32,}/g; +/** Email addresses, bounded by RFC 5321's 64-octet local part and 255-octet domain so a long run without whitespace is + * scanned in linear time (plan Task 3 `scrubText`). */ +const EMAIL = /[^\s@"'<>]{1,64}@[^\s@"'<>]{1,255}\.[A-Za-z]{2,}/g; +/** A whole run of digit groups separated by single spaces or dashes, touching no word character: card number candidates + * (plan Task 3 `scrubText`). */ +const CARD_GROUPS = /(?<!\w)\d+(?:[ -]\d+)*(?!\w)/g; +/** A card number is 13 to 19 digits (ISO/IEC 7812-1, the range plan Task 3 `scrubText` checks). */ +const CARD_MIN_DIGITS = 13; +const CARD_MAX_DIGITS = 19; +/** The shortest group in a written card number (4-4-4-4, 4-6-5, 4-6-4, 4-4-4-4-3), so a row of page numbers is never one. */ +const CARD_MIN_GROUP_DIGITS = 3; +/** A run of digit groups separated by single spaces or dashes, re-joined before hashing (plan Task 3 `scrubText`); anchored at + * the start of a digit run so a long run of digits is scanned once, not once per digit. */ +const DIGIT_GROUPS = /(?<!\d)\d+(?:[ -]\d+)+/g; +/** The most consecutive digit groups hashed as one digest candidate (a card written 4-4-4-4-3 is five). */ +const MAX_DIGEST_GROUPS = 6; +/** The most digest answers one `scrubText` call remembers (see `digestMatcher`). */ +const DIGEST_MEMO_LIMIT = 4096; +/** Where one token ends when hashing digest candidates (plan Task 3 `scrubText`). */ +const TOKEN_CHARS = /[^\s,;&=:"'{}[\]]+/g; +/** A second, finer split for digest candidates, so a secret beside a full stop, a bracket or markup is found too ("737.", + * "<td>737</td>"); the first split still finds a secret that contains these characters ("p.ss"). */ +const TOKEN_CHARS_FINE = /[^\s,;&=:"'{}[\]().<>/\\?!|*`]+/g; +/** Only digits, spaces and dashes: normalised to digits (plan Task 3 `normaliseSecret`). */ +const DIGITS_AND_SEPARATORS = /^[\d\s-]+$/; +/** Form bodies are masked pair by pair (plan Task 3 maskBody). */ +const FORM_CONTENT_TYPE = /application\/x-www-form-urlencoded/i; +/** A first `key=` with no whitespace in the key: a form body, whatever type it was declared with (a string a page posts + * as text/plain, or a URLSearchParams body whose type the page-level hook could not see). */ +const FORM_START = /^[^\s=&]+=/; +const LINE_BREAK = /[\r\n]/; +/** application/json and every +json type. */ +const JSON_CONTENT_TYPE = /[/+]json\s*(?:;|$)/i; +/** Text that opens like a JSON object or array: once cut short it no longer parses, whatever type it was declared with. */ +const JSON_START = /^\s*[[{]/; +/** What a redacted JSON value becomes in JSON text. */ +const REDACTED_JSON = JSON.stringify(REDACTED); +/** Input event target fields that hold what the person typed or chose (plan Task 3 `maskCollectorEvent`). */ +const TARGET_VALUE_FIELDS = ["value", "text"] as const; +/** Attributes that hold a field's value by name (`value`, `aria-valuetext`, `aria-valuenow`, `data-value`, + * `data-initial-value`, `ng-reflect-value`); a sensitive field loses each, and any other attribute that echoes the value. */ +const VALUE_ATTRIBUTE = /value/i; +/** The shortest stretch of an edited field's content another attribute must hold to be a copy of it, as an attribute a page + * set from what was typed a keystroke or a render earlier is: long enough that a short attribute (`rows="1"`) is never one. */ +const COPY_MIN_LENGTH = 8; +/** Intelligence rejects a NUL at any depth. */ +const NUL = "\u0000"; +/** Longest `target.value` and `target.text` kept when trimming (plan Task 3 `trimEvent`). */ +const TRIMMED_TARGET_TEXT = 200; + +const isObject = (value: unknown): value is Obj => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** An attribute that repeats one of `typed` (a sensitive field's value): contains it, spaced or not, or for a value shorter + * than MIN_DIGEST_LENGTH is it. */ +const echoes = (attribute: unknown, typed: readonly string[]): boolean => { + if (typeof attribute !== "string" || attribute === REDACTED) return false; + return typed.some((value) => { + const trimmed = value.trim(); + const normalised = normaliseSecret(trimmed); + return normalised.length >= MIN_DIGEST_LENGTH + ? attribute.includes(trimmed) || + normaliseSecret(attribute).includes(normalised) + : attribute.trim() === trimmed; + }); +}; + +/** An attribute that copies what was typed into a field: it holds the whole of it (`echoes`), or a stretch of it at least + * COPY_MIN_LENGTH long, as a copy made a keystroke earlier does. */ +const copies = (attribute: unknown, typed: string): boolean => { + if (typeof attribute !== "string" || attribute === REDACTED) return false; + const copy = attribute.trim(); + return ( + echoes(attribute, [typed]) || + (copy.length >= COPY_MIN_LENGTH && typed.includes(copy)) + ); +}; + +/** A field whose text and value attributes are copies of what is typed into it: an input (React keeps its `value` + * attribute at the value it last rendered), a textarea (its text is its default value, which React keeps at the value it + * last rendered, a keystroke behind the one an input event reads) and an editable region (its text is its value). Never a + * select: its text lists every option. */ +const mirrorsValue = ( + tag: string | undefined, + attributes: Record<string, string> | undefined, +): boolean => { + const name = tag?.toLowerCase(); + const editable = attributes?.contenteditable; + return ( + name === "input" || + name === "textarea" || + (typeof editable === "string" && editable.toLowerCase() !== "false") + ); +}; + +const decodeQuietly = (text: string): string => { + try { + return decodeURIComponent(text); + } catch { + return text; + } +}; + +export function normaliseSecret(value: string): string { + const trimmed = value.trim(); + return DIGITS_AND_SEPARATORS.test(trimmed) + ? trimmed.replace(/[\s-]/g, "") + : trimmed; +} + +/** A key's lowercase words, one space apart and one space at each end, each escape decoded on its own (collector rule). */ +const keyWords = (key: string): string => + ` ${key + .replace(/%([\da-f]{2})/gi, (_, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ) + .replace(/([a-z\d])(?=[A-Z])/g, "$1 ") + .toLowerCase() + .split(/[^a-z\d]+/) + .filter(Boolean) + .join(" ")} `; + +export function isCredentialKey(key: string): boolean { + const words = keyWords(key); + if (METADATA.test(words)) return false; + const joined = words.replace(/ /g, ""); + return ( + CREDENTIAL.test(joined) || + CREDENTIAL_WORD.test(words) || + CARD_KEY.test(words) || + CARD_WORDS.test(joined) || + ONE_TIME_CODE_WORDS.test(joined) || + ONE_TIME_CODE_LAST.test(words) + ); +} + +const isBareCodeKey = (key: string): boolean => BARE_CODE.test(keyWords(key)); +const isStateKey = (key: string): boolean => keyWords(key) === " state "; + +/** What a bare `code` key holds is a one-time code: 4 to 10 digits (spaces and dashes aside, or a list of them), or anything + * at all on a sign-in, verification or token request, so an ordinary `"code":"NOT_FOUND"` survives elsewhere. */ +const codeHoldsSecret = ( + value: JsonValue | undefined, + authFlow: boolean | undefined, +): boolean => { + if (authFlow) return true; + if (typeof value === "string") + return CODE_DIGITS.test(normaliseSecret(value)); + if (typeof value === "number") return CODE_DIGITS.test(String(value)); + return ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => codeHoldsSecret(item, false)) + ); +}; + +/** Text split into lowercase words, one space apart and one space at each end (`codeEntry` and `code-entry` are both + * " code entry ", `6DigitCode` is " 6 digit code ", `2FACode` is " 2fa code "). */ +const fieldWords = (text: string): string => + ` ${text + .replace(/([a-z])(?=[A-Z])/g, "$1 ") + .replace(/(\d)(?=[A-Z][a-z])/g, "$1 ") + .replace(/([A-Z]+)(?=[A-Z][a-z])/g, "$1 ") + .toLowerCase() + .split(/[^a-z\d]+/) + .filter(Boolean) + .join(" ")} `; + +/** True for a sign-in, verification, token or OAuth address, read from its host and path. */ +export function authFlowUrl(url: string | undefined): boolean { + if (!url) return false; + let where: string; + try { + const parsed = new URL(url); + where = `${parsed.hostname} ${parsed.pathname}`; + } catch { + where = url.replace(/[?#].*$/, ""); + } + return AUTH_URL_WORDS.test(fieldWords(decodeQuietly(where))); +} + +function isCredentialHeader(name: string): boolean { + return isCredentialKey(name) || CREDENTIAL_HEADERS.has(name.toLowerCase()); +} + +export function luhnValid(digits: string): boolean { + if (!/^\d+$/.test(digits)) return false; + let sum = 0; + let double = false; + for (let i = digits.length - 1; i >= 0; i--) { + let digit = digits.charCodeAt(i) - 48; + if (double) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + double = !double; + } + return sum % 10 === 0; +} + +/** SHA-256 round constants (FIPS 180-4 section 4.2.2). */ +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +/** SHA-256 (FIPS 180-4) in plain JavaScript: synchronous and identical in every context, including insecure http pages + * where `crypto.subtle` does not exist. */ +export function sha256HexSync(value: string): string { + const bytes = new TextEncoder().encode(value); + const padded = new Uint8Array(((bytes.length + 9 + 63) >> 6) << 6); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32( + padded.length - 8, + Math.floor((bytes.length * 8) / 0x100000000), + ); + view.setUint32(padded.length - 4, (bytes.length * 8) >>> 0); + const h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, + ]); + const w = new Uint32Array(64); + const word = (list: ArrayLike<number>, i: number) => list[i] ?? 0; + const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4); + for (let i = 16; i < 64; i++) { + const w15 = word(w, i - 15); + const w2 = word(w, i - 2); + const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3); + const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10); + w[i] = (word(w, i - 16) + s0 + word(w, i - 7) + s1) >>> 0; + } + const state = Array.from(h); + let [a = 0, b = 0, c = 0, d = 0, e = 0, f = 0, g = 0, hh = 0] = state; + for (let i = 0; i < 64; i++) { + const t1 = + (hh + + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + + ((e & f) ^ (~e & g)) + + word(K, i) + + word(w, i)) >>> + 0; + const t2 = + ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + + ((a & b) ^ (a & c) ^ (b & c))) >>> + 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + const next = [a, b, c, d, e, f, g, hh]; + for (let i = 0; i < 8; i++) h[i] = (word(h, i) + word(next, i)) >>> 0; + } + return Array.from(h, (part) => part.toString(16).padStart(8, "0")).join(""); +} + +const matchesDigest = (value: string, options?: MaskOptions): boolean => { + const digests = options?.digests; + if (!digests || digests.size === 0) return false; + const normalised = normaliseSecret(value); + return ( + normalised.length >= MIN_DIGEST_LENGTH && + digests.has(sha256HexSync(normalised)) + ); +}; + +/** One group of a digit run and where it sits in the run, as character offsets. */ +type DigitGroup = { digits: string; start: number; end: number }; + +/** Splits a run matched by `CARD_GROUPS` or `DIGIT_GROUPS`, whose groups are always one separator apart. */ +const digitGroupsOf = (run: string): DigitGroup[] => { + const groups: DigitGroup[] = []; + let start = 0; + for (const digits of run.split(/[ -]/)) { + groups.push({ digits, start, end: start + digits.length }); + start += digits.length + 1; + } + return groups; +}; + +/** Replaces each `[start, end)` span of `text` with REDACTED; overlapping spans become one marker, disjoint ones stay apart. */ +const redactSpans = (text: string, spans: Array<[number, number]>): string => { + if (spans.length === 0) return text; + spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + let out = ""; + let cursor = 0; + for (const [start, end] of spans) { + if (start < cursor) { + // Overlaps the span just redacted: widen that marker instead of adding another. + cursor = Math.max(cursor, end); + continue; + } + out += text.slice(cursor, start) + REDACTED; + cursor = end; + } + return out + text.slice(cursor); +}; + +const isCardNumber = (digits: string): boolean => + digits.length >= CARD_MIN_DIGITS && + digits.length <= CARD_MAX_DIGITS && + luhnValid(digits); + +/** A whole run that is a card number goes; otherwise every stretch of consecutive groups that could be a written card number + * (each group at least 3 digits, 13 to 19 digits in all) is tested on its own, so a card beside an expiry, a security code or + * a quantity is still found. A run of one group is a card number or nothing, as before. */ +const scrubCardRun = (run: string): string => { + if (isCardNumber(run.replace(/[ -]/g, ""))) return REDACTED; + const groups = digitGroupsOf(run); + if (groups.length === 1) return run; + const spans: Array<[number, number]> = []; + for (let first = 0; first < groups.length; first++) { + const head = groups[first]; + if (!head) continue; + let digits = ""; + for (let last = first; last < groups.length; last++) { + const group = groups[last]; + if (!group || group.digits.length < CARD_MIN_GROUP_DIGITS) break; + digits += group.digits; + if (digits.length > CARD_MAX_DIGITS) break; + if (isCardNumber(digits)) spans.push([head.start, group.end]); + } + } + return redactSpans(run, spans); +}; + +/** `matchesDigest` for one `scrubText` call, hashing each distinct candidate once, so repeated words and numbers cost one hash. + * The digests cannot change during a synchronous call, so a remembered answer is never stale; at most `DIGEST_MEMO_LIMIT` + * answers are kept, so a body of distinct tokens cannot grow it without bound. */ +const digestMatcher = (options: MaskOptions): ((value: string) => boolean) => { + const known = new Map<string, boolean>(); + return (value) => { + const remembered = known.get(value); + if (remembered !== undefined) return remembered; + const hit = matchesDigest(value, options); + if (known.size < DIGEST_MEMO_LIMIT) known.set(value, hit); + return hit; + }; +}; + +/** A whole run whose digits match a digest goes; otherwise every stretch of up to `MAX_DIGEST_GROUPS` consecutive groups is + * hashed on its own, so a typed number is found beside other numbers ("4111 1111 1111 1112 12/28"). */ +const scrubDigestRun = ( + run: string, + matches: (value: string) => boolean, +): string => { + if (matches(run.replace(/[ -]/g, ""))) return REDACTED; + const groups = digitGroupsOf(run); + const spans: Array<[number, number]> = []; + for (let first = 0; first < groups.length; first++) { + const head = groups[first]; + if (!head) continue; + let digits = ""; + const stop = Math.min(groups.length, first + MAX_DIGEST_GROUPS); + for (let last = first; last < stop; last++) { + const group = groups[last]; + if (!group) break; + digits += group.digits; + if (matches(digits)) spans.push([head.start, group.end]); + } + } + return redactSpans(run, spans); +}; + +export function scrubText(text: string, options?: MaskOptions): string { + let out = text + .replace(JWT, `$1${REDACTED}`) + .replace(AUTH_SCHEME, `$1$2${REDACTED}`) + .replace(LONG_RUN, (run) => + /[A-Za-z]/.test(run) && /\d/.test(run) ? REDACTED : run, + ) + .replace(EMAIL, REDACTED) + .replace(CARD_GROUPS, (run) => scrubCardRun(run)); + if (options?.digests && options.digests.size > 0) { + const matches = digestMatcher(options); + const redactToken = (token: string) => (matches(token) ? REDACTED : token); + out = out + .replace(DIGIT_GROUPS, (run) => scrubDigestRun(run, matches)) + .replace(TOKEN_CHARS, redactToken) + .replace(TOKEN_CHARS_FINE, redactToken); + } + return out; +} + +/** What joins the numerals of a number read aloud, as a speech service writes it: a comma, a full stop or a dash, alone or + * with spaces around it, or spaces alone ("4012-888888-1881", "4 0 1 2, 8 8 8 8", "$4,2424,2424,2424,242"). */ +const SPOKEN_GAP = String.raw`(?:\s*[,.\-\u2013]\s*|\s+)`; +/** A number read aloud: numerals, in any script, joined by `SPOKEN_GAP`, and the name of a month said just before it. */ +const SPOKEN_NUMBER = new RegExp( + String.raw`(\b(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+)?(?<!\p{Nd})(\p{Nd}+(?:${SPOKEN_GAP}\p{Nd}+)*)`, + "giu", +); +/** A speech service drops digits of a number read aloud and runs others together, so a card, account or phone number + * comes back neither whole nor valid: any spoken number of this many digits goes, whatever its checksum. */ +const SPOKEN_DIGITS_REDACTED = 6; +/** Words that say a secret number follows: every number after one, to the end of the phrase, goes however short. */ +const SPOKEN_SECRET = + /\b(?:cards?|credit|debit|accounts?|routing|iban|ssn|social\s+security|pin|cvv\d?|cvc|security\s+codes?|pass[\s-]?codes?|pass[\s-]?words?|otp|verification\s+codes?)\b/i; +/** One word; after a `SPOKEN_SECRET` word, one with a numeral in it goes whole ("hunter2"). */ +const SPOKEN_WORD = /[\p{L}\p{N}]+/gu; +const HAS_NUMERAL = /\p{Nd}/u; +/** Markers `SPOKEN_WORD` left one gap apart ("[redacted] [redacted] [redacted]" for "4 0 1"), folded into one. */ +const REDACTED_RUN = new RegExp( + `${REDACTED.replace(/[[\]]/g, "\\$&")}(?:${SPOKEN_GAP}${REDACTED.replace(/[[\]]/g, "\\$&")})+`, + "g", +); +/** A date said in full ("October 18, 2026") keeps its day and year, the one ordinary run of six digits in speech. */ +const DAY_AND_YEAR = /^\d{1,2},?\s+(?:1[89]|20)\d\d$/; + +/** A spoken number before any `SPOKEN_SECRET` word: one of `SPOKEN_DIGITS_REDACTED` digits or more goes whole, a date said + * in full excepted; a shorter one loses each stretch of its groups that is a value typed into a sensitive field. */ +const scrubSpokenRun = ( + run: string, + afterMonth: boolean, + matches: ((value: string) => boolean) | null, +): string => { + const digits = run.replace(/\P{Nd}/gu, ""); + if (digits.length >= SPOKEN_DIGITS_REDACTED) + return afterMonth && DAY_AND_YEAR.test(run) ? run : REDACTED; + if (matches === null || digits.length < MIN_DIGEST_LENGTH) return run; + const groups = [...run.matchAll(/\d+/g)].map((match) => ({ + digits: match[0], + start: match.index, + end: match.index + match[0].length, + })); + const spans: Array<[number, number]> = []; + for (let first = 0; first < groups.length; first++) { + const head = groups[first]; + if (!head) continue; + let typed = ""; + for (let last = first; last < groups.length; last++) { + const group = groups[last]; + if (!group) break; + typed += group.digits; + if (typed.length >= MIN_DIGEST_LENGTH && matches(typed)) + spans.push([head.start, group.end]); + } + } + return redactSpans(run, spans); +}; + +/** `scrubText` for what a person said while recording, as the browser's speech recognition wrote it. A speech service + * writes a number read aloud with thousands separators, stray signs and dashes, and drops or runs together its digits, so + * a spoken number of six digits or more goes whatever it adds up to, every number after a word that names a secret (in + * this phrase, or in `before`, the phrase said just before it) goes however short, and a shorter one loses a value typed + * into a sensitive field. Then the text masking runs as it does on everything else. */ +export function scrubTranscript( + text: string, + options?: MaskOptions & { before?: string }, +): string { + const matches = + options?.digests && options.digests.size > 0 + ? digestMatcher(options) + : null; + const secret = SPOKEN_SECRET.exec(text); + const from = + options?.before && SPOKEN_SECRET.test(options.before) + ? 0 + : secret + ? secret.index + secret[0].length + : text.length; + const head = text.slice(0, from); + const tail = text + .slice(from) + .replace(SPOKEN_WORD, (word) => (HAS_NUMERAL.test(word) ? REDACTED : word)) + .replace(REDACTED_RUN, REDACTED); + return scrubText( + head.replace( + SPOKEN_NUMBER, + (_, month: string | undefined, run: string) => + (month ?? "") + scrubSpokenRun(run, month !== undefined, matches), + ) + tail, + options, + ); +} + +export function maskHeaders( + headers: Record<string, string>, + options?: MaskOptions, +): Record<string, string> { + const out: Record<string, string> = {}; + // A header value read from captured JSON is not always a string (a numeric content-length), so it is scrubbed as one. + for (const [name, value] of Object.entries(headers)) + out[name] = isCredentialHeader(name) + ? REDACTED + : scrubText(String(value), options); + return out; +} + +const scrubSegment = (segment: string, options?: MaskOptions): string => { + const decoded = decodeQuietly(segment); + const scrubbed = scrubText(decoded, options); + if (scrubbed === decoded) return segment; + return scrubbed.split(REDACTED).map(encodeURIComponent).join(REDACTED); +}; + +/** One `key=value` pair of a query or form body. `oauth` is set for the query of an OAuth callback or sign-in address, whose + * `state` is a secret too. */ +const scrubQueryPair = ( + pair: string, + options?: BodyOptions, + oauth = false, +): string => { + const at = pair.indexOf("="); + if (at === -1) { + const decoded = decodeQuietly(pair); + return scrubText(decoded, options) === decoded && + !matchesDigest(decoded, options) + ? pair + : REDACTED; + } + const key = pair.slice(0, at); + const name = decodeQuietly(key); + const value = decodeQuietly(pair.slice(at + 1).replace(/\+/g, " ")); + const redact = + isCredentialKey(name) || + (isBareCodeKey(name) && + codeHoldsSecret(value, options?.authFlow || oauth)) || + (oauth && isStateKey(name)) || + scrubText(value, options) !== value || + matchesDigest(value, options); + return redact ? `${key}=${REDACTED}` : pair; +}; + +const pairKey = (pair: string): string => { + const at = pair.indexOf("="); + return decodeQuietly(at === -1 ? pair : pair.slice(0, at)); +}; + +/** A URL's path segments and query pairs are masked one by one. On a sign-in, verification, token or OAuth address, or in a + * query shaped like an OAuth redirect (a `code` beside a `state`), `code` and `state` are secrets whatever they hold. */ +export function scrubUrl(url: string, options?: MaskOptions): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return scrubText(url, options); + } + if (!parsed.host) return scrubText(url.replace(/#.*$/, ""), options); + const path = parsed.pathname + .split("/") + .map((segment) => scrubSegment(segment, options)) + .join("/"); + const query = parsed.search.slice(1); + const pairs = query ? query.split("&") : []; + const oauth = + authFlowUrl(url) || + (pairs.some((pair) => isBareCodeKey(pairKey(pair))) && + pairs.some((pair) => isStateKey(pairKey(pair)))); + const scrubbedQuery = query + ? `?${pairs.map((pair) => scrubQueryPair(pair, options, oauth)).join("&")}` + : ""; + return `${parsed.protocol}//${parsed.host}${path}${scrubbedQuery}`; +} + +const NO_KEYS: ReadonlySet<string> = new Set(); + +const maskJson = ( + value: JsonValue, + options?: BodyOptions, + keep: ReadonlySet<string> = NO_KEYS, +): JsonValue => { + if (typeof value === "string") + return matchesDigest(value, options) ? REDACTED : scrubText(value, options); + if (typeof value === "number") { + const digits = String(value); + // JSON.parse already rounded an integer past 2^53, so it can no longer be checked; redact it. + const unverifiable = + Number.isInteger(value) && + !Number.isSafeInteger(value) && + Math.abs(value) < 1e19; + return unverifiable || + matchesDigest(digits, options) || + (/^\d{13,19}$/.test(digits) && luhnValid(digits)) + ? REDACTED + : value; + } + if (Array.isArray(value)) + return value.map((item) => maskJson(item, options, keep)); + if (isObject(value)) { + const out: Obj = {}; + for (const [key, item] of Object.entries(value)) + out[key] = + keep.has(key) && typeof item === "string" + ? item + : isCredentialKey(key) || + (isBareCodeKey(key) && codeHoldsSecret(item, options?.authFlow)) + ? REDACTED + : maskJson(item, options, keep); + return out; + } + return value; +}; + +/** A body's JSON masking for a value that is already JSON (an agent's state, a run's input), leaving the string values + * of the `keep` keys as they are: the ids that join one AG-UI event to another, which the long-run rule would take. */ +export function maskJsonValue( + value: JsonValue, + options?: MaskOptions, + keep: ReadonlySet<string> = NO_KEYS, +): JsonValue { + try { + return maskJson(value, options, keep); + } catch { + return REDACTED; + } +} + +/** One pair of a form body, as a query pair is masked; a key that is itself a secret (a long token posted bare, base64 + * padding and all) takes the whole pair. */ +const maskFormPair = (pair: string, options?: BodyOptions): string => { + const at = pair.indexOf("="); + if (at > 0) { + const key = decodeQuietly(pair.slice(0, at).replace(/\+/g, " ")); + if (scrubText(key, options) !== key || matchesDigest(key, options)) + return REDACTED; + } + return scrubQueryPair(pair, options); +}; + +const maskForm = (text: string, options?: BodyOptions): string => + text + .split("&") + .map((pair) => maskFormPair(pair, options)) + .join("&"); + +const looksLikeForm = (text: string): boolean => + FORM_START.test(text) && !LINE_BREAK.test(text.trimEnd()); + +const isJsonSpace = (code: number): boolean => + code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; + +/** The index of the quote that closes the string opening at `start`, or -1 when the text ends first. */ +const closingQuote = (text: string, start: number): number => { + for (let i = start + 1; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code === 0x5c) i++; + else if (code === 0x22) return i; + } + return -1; +}; + +/** Where the JSON value starting at `start` ends: past a string's closing quote, past the bracket that closes an object or + * array, or after a number's or literal's last character; the end of the text when the text ends first. */ +const valueEnd = (text: string, start: number): number => { + const first = text.charCodeAt(start); + if (first === 0x22) { + const close = closingQuote(text, start); + return close === -1 ? text.length : close + 1; + } + if (first === 0x7b || first === 0x5b) { + let depth = 0; + for (let i = start; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code === 0x22) { + const close = closingQuote(text, i); + if (close === -1) return text.length; + i = close; + } else if (code === 0x7b || code === 0x5b) depth++; + else if ((code === 0x7d || code === 0x5d) && --depth === 0) return i + 1; + } + return text.length; + } + let i = start; + for (; i < text.length; i++) { + const code = text.charCodeAt(i); + if (isJsonSpace(code) || code === 0x2c || code === 0x7d || code === 0x5d) + break; + } + return i; +}; + +/** A JSON string token's text with its escapes decoded; a token the text ends inside is read as if it closed there. */ +const jsonStringValue = (token: string, closed: boolean): string => { + try { + return JSON.parse(closed ? token : `${token}"`) as string; + } catch { + return token.slice(1, closed ? -1 : undefined); + } +}; + +/** The string or number a JSON value starting at `start` holds, read as far as the text goes; undefined for anything else. */ +const jsonLiteralAt = (text: string, start: number): JsonValue | undefined => { + const token = text.slice(start, valueEnd(text, start)); + if (token.charCodeAt(0) === 0x22) + return jsonStringValue(token, closingQuote(text, start) !== -1); + const number = Number(token); + return /^-?\d/.test(token) && Number.isFinite(number) ? number : undefined; +}; + +/** JSON that does not parse, above all a body cut at NETWORK_BODY_READ_MAX_BYTES before it was masked. It is read token by + * token from its start: the value of every credential-named key, and of a bare `code` key that holds a one-time code, is + * redacted (a whole object or array too, up to its end or the end of the text), a string that matches a digest is redacted + * whole, and the result is scrubbed as text. Linear. */ +const maskJsonText = (text: string, options?: BodyOptions): string => { + const hasDigests = (options?.digests?.size ?? 0) > 0; + const out: string[] = []; + let copied = 0; + const redact = (start: number, end: number) => { + out.push(text.slice(copied, start), REDACTED_JSON); + copied = end; + }; + let i = 0; + while (i < text.length) { + if (text.charCodeAt(i) !== 0x22) { + i++; + continue; + } + const close = closingQuote(text, i); + const end = close === -1 ? text.length : close + 1; + let next = end; + while (next < text.length && isJsonSpace(text.charCodeAt(next))) next++; + if (close !== -1 && text.charCodeAt(next) === 0x3a) { + let value = next + 1; + while (value < text.length && isJsonSpace(text.charCodeAt(value))) + value++; + const key = jsonStringValue(text.slice(i, end), true); + if ( + value < text.length && + (isCredentialKey(key) || + (isBareCodeKey(key) && + codeHoldsSecret(jsonLiteralAt(text, value), options?.authFlow))) + ) { + const after = valueEnd(text, value); + redact(value, after); + i = after; + } else i = value; + continue; + } + if ( + hasDigests && + matchesDigest(jsonStringValue(text.slice(i, end), close !== -1), options) + ) + redact(i, end); + i = end; + } + out.push(text.slice(copied)); + return scrubText(out.join(""), options); +}; + +/** A body is masked by its shape first and its declared type second: JSON whole; JSON that no longer parses (a body cut + * short) key by key; a form body pair by pair, declared or not; anything else as text. `options.url` is where it was sent. */ +export function maskBody( + text: string, + contentType: string | undefined, + options?: MaskOptions, +): string { + return maskBodyText(text, contentType, { + ...options, + authFlow: authFlowUrl(options?.url), + }); +} + +function maskBodyText( + text: string, + contentType: string | undefined, + options: BodyOptions, +): string { + try { + let parsed: JsonValue | undefined; + try { + parsed = JSON.parse(text) as JsonValue; + } catch { + parsed = undefined; + } + if (parsed !== undefined && typeof parsed === "object") + return JSON.stringify(maskJson(parsed, options)); + if (JSON_START.test(text)) return maskJsonText(text, options); + if ( + (contentType && FORM_CONTENT_TYPE.test(contentType)) || + looksLikeForm(text) + ) + return maskForm(text, options); + if (contentType && JSON_CONTENT_TYPE.test(contentType)) + return maskJsonText(text, options); + return scrubText(text, options); + } catch { + return REDACTED; + } +} + +/** + * Whether a body, before it is masked, holds one of `typed`: the digests of what the person typed into sensitive fields. + * It is read as `maskBody` reads it and looked through where the masking looks for a typed secret (a JSON string or + * number, a form key or value, a word or a run of digits in text), so each value it finds is one the masking redacts, by + * its digest or first as a credential's value or a card number. Only the answer leaves: a save whose body this holds + * carries what the person entered, though all of it reads [redacted]. + */ +export function holdsTypedSecret( + text: string, + contentType: string | undefined, + typed: ReadonlySet<string>, +): boolean { + if (typed.size === 0) return false; + const matches = digestMatcher({ digests: typed }); + /** `scrubText`'s candidates: a run of digit groups, or a word. */ + const inWords = (value: string): boolean => + (value.match(DIGIT_GROUPS) ?? []).some( + (run) => scrubDigestRun(run, matches) !== run, + ) || + (value.match(TOKEN_CHARS) ?? []).some(matches) || + (value.match(TOKEN_CHARS_FINE) ?? []).some(matches); + const inValue = (value: string): boolean => matches(value) || inWords(value); + const inJson = (value: JsonValue): boolean => { + if (typeof value === "string") return inValue(value); + if (typeof value === "number") return matches(String(value)); + if (Array.isArray(value)) return value.some(inJson); + return isObject(value) && Object.values(value).some(inJson); + }; + const inJsonText = (body: string): boolean => { + if (inWords(body)) return true; + for (let at = body.indexOf('"'); at !== -1; ) { + const close = closingQuote(body, at); + const end = close === -1 ? body.length : close + 1; + if (matches(jsonStringValue(body.slice(at, end), close !== -1))) + return true; + at = close === -1 ? -1 : body.indexOf('"', end); + } + return false; + }; + const inPair = (pair: string): boolean => { + const at = pair.indexOf("="); + if (at === -1) return inValue(decodeQuietly(pair)); + const key = decodeQuietly(pair.slice(0, at).replace(/\+/g, " ")); + const value = decodeQuietly(pair.slice(at + 1).replace(/\+/g, " ")); + return (at > 0 && inValue(key)) || inValue(value); + }; + try { + let parsed: JsonValue | undefined; + try { + parsed = JSON.parse(text) as JsonValue; + } catch { + parsed = undefined; + } + if (parsed !== undefined && typeof parsed === "object") + return inJson(parsed); + if (JSON_START.test(text)) return inJsonText(text); + if ( + (contentType && FORM_CONTENT_TYPE.test(contentType)) || + looksLikeForm(text) + ) + return text.split("&").some(inPair); + if (contentType && JSON_CONTENT_TYPE.test(contentType)) + return inJsonText(text); + return inWords(text); + } catch { + return false; + } +} + +/** A field whose name, label, placeholder, test id or class says it takes a one-time code: OTP, 2FA, two-factor, MFA, + * verification, passcode, security, auth, SMS, login or confirmation code, a code from an authenticator. */ +const ONE_TIME_CODE_FIELD = + / (?:otp|otc|totp|hotp|one ?time|2 ?fa|2 ?step|two ?step|2 ?factor|two ?factor|multi ?factor|mfa|2sv|pass ?codes?|verif[a-z]*|security ?codes?|auth ?codes?|authentication ?codes?|authenticator|authorization ?codes?|access ?codes?|sms ?codes?|log ?in ?codes?|sign ?in ?codes?|confirmation ?codes?|recovery ?codes?|backup ?codes?) /; +/** Codes that are not secrets: a "zip code", "promo code" or "country code" is no one-time code, whatever else it says. */ +const ORDINARY_CODE = + / (?:zip|postal|post|promo|promotion|promotional|coupon|discount|voucher|gift|referral|invite|invitation|country|area|dial|dialing|dialling|calling|region|language|lang|locale|currency|product|item|sku|article|order|tracking|reference|source|embed|html|qr|bar|error|status|response|reason|tax|vat|tariff|hs|sic|naics|icd|cpt|cost|gl|project|course|class|department|dept|store|branch|airport|iata|station|color|colour|hex|swift|bic|sort|bank) codes?(?= )/g; +/** "code" as a word, once the ordinary codes are taken out. */ +const CODE_WORD = / codes? /; +/** "6-digit", "six digit", "8 characters": the length a one-time code field asks for. */ +const DIGIT_COUNT = + / (?:\d+|four|five|six|seven|eight) ?(?:digit|character|char)s? /; +/** Short numbers that are not secrets, which veto a field's numeric shape: a postcode, a year, a quantity, a phone extension. + * A phone number is not among them: "the code we texted to your phone" names a one-time code. */ +const ORDINARY_NUMBER = + / (?:zip|zipcode|postal|postcode|post code|year|years|yyyy|yy|month|mm|day|dd|date|dob|birth[a-z]*|bday[a-z]*|age|qty|quantity|amount|price|cost|total|count|tip|percent[a-z]*|weight|height|width|length|size|page|sku|product|item|order|tracking|house|street|address[a-z]*|apt|suite|building|floor|unit|extension|ext|country|area|dial[a-z]*|promo[a-z]*|coupon|discount|voucher|gift|referral|invite|currency|number of) /; +/** Wording that says the field takes something the site just sent the person by text, email or message: "the code we + * texted you", "what we sent to your phone", "we've emailed you a code", "the code in the text message". Chromium's autofill + * (`ONE_TIME_CODE`: otp, verification code, 2fa, six digit), Bitwarden's `TotpFieldNames` and Proton Pass's `TWO_FA` and + * `OTP` dictionaries name the code; none names the delivery, which is all a field like these says. */ +const DELIVERED_TO_PERSON = + / (?:(?:we|we ve|we have|we just|we ll|we will|just|was|were|been|has been|have been|that|which|being) (?:just )?(?:sent|texted|txted|emailed|e mailed|messaged|mailed|smsed)|(?:sent|texted|txted|emailed|e mailed|messaged|mailed|delivered) (?:(?:it|this|them|you|a|an|the|code|codes|pin|number|digits|just) )*(?:to (?:you|your|\d)|you |your )|(?:by|via|in (?:the|your|a|an)|from (?:the|your|a|an)|over) (?:text|txt|sms|email|e mail)(?: message)? )/; +/** What a site sends a person besides a one-time code, by name, which vetoes `DELIVERED_TO_PERSON`: an order, tracking, + * invoice or booking number we emailed you is no secret. */ +const ORDINARY_DELIVERED = + / (?:invoice|receipt|reference|ref|ticket|case|booking|reservation|itinerary|tracking|order|shipment|parcel|package|link|address|url|how many|how much|items|units|times) /; +/** A short numeric value, 4 to 8 digits (spaces and dashes aside), the lengths a code field takes: the shape of a one-time + * code as typed, and never a phone number. */ +const isCodeValue = (value: string | undefined): boolean => + typeof value === "string" && /^\d{4,8}$/.test(normaliseSecret(value)); +/** A `pattern` that admits digits only (`[0-9]*`, `\d{6}`, `[0-9 ]*`, `^[0-9]{4,8}$`); group 1 and 2 are its length bounds. */ +const DIGITS_PATTERN = + /^\^?(?:\\d|\[(?:0-9|\\d) ?\])(?:[*+]|\{(\d+)(?:,(\d*))?\})\$?$/; +/** The shortest and longest one-time code a numeric field's length says it takes. */ +const CODE_FIELD_MIN_LENGTH = 4; +const CODE_FIELD_MAX_LENGTH = 8; +/** Inputs a person types a code into; a button's `value` is its label, never a secret. */ +const TEXT_INPUT_TYPES: ReadonlySet<string> = new Set([ + "", + "text", + "tel", + "number", + "password", + "search", +]); +/** Attributes that name a field without being read by SENSITIVE_FIELD: test ids and the class name, read for one-time codes + * only (a "token" class names a tag picker as often as a secret). */ +const FIELD_HINT_ATTRIBUTE = + /^(?:class|data-(?:test-?id|test|testing-?id|qa|cy|e2e|automation-?id|tid|hook))$/i; + +/** How many characters a numeric field (inputmode numeric, type tel or a digits-only pattern) takes, by its maxlength or its + * pattern; NaN for a field that is not numeric or says no length. */ +const numericFieldLength = (attributes: Record<string, string>): number => { + const pattern = DIGITS_PATTERN.exec((attributes.pattern ?? "").trim()); + const numeric = + attributes.inputmode?.toLowerCase() === "numeric" || + attributes.type?.toLowerCase() === "tel" || + pattern !== null; + if (!numeric) return Number.NaN; + const declared = Number.parseInt(attributes.maxlength ?? "", 10); + return Number.isFinite(declared) + ? declared + : Number.parseInt(pattern?.[2] || pattern?.[1] || "", 10); +}; + +/** A short numeric input, 4 to 8 long: a one-time code field by its shape. */ +const hasCodeShape = (attributes: Record<string, string>): boolean => { + const length = numericFieldLength(attributes); + return length >= CODE_FIELD_MIN_LENGTH && length <= CODE_FIELD_MAX_LENGTH; +}; + +/** A field that takes a one-time code, though it lacks autocomplete=one-time-code: its words name one; or it says "code" + * (no zip, promo or country code) and asks for a number of digits, has a code's shape or sits on a sign-in page; or it says + * the site just sent the person what it takes ("the code we texted you", "what we sent to your phone"), names no ordinary + * thing sent, and holds 4 to 8 digits, is numeric, takes 4 to 8 characters or sits on a sign-in or verification page; or + * it has a code's shape (a short numeric input) or is one numeric box of a code typed a digit per box, and names no ordinary + * number. Text inputs, text areas and editable regions only. */ +const isOneTimeCodeField = ( + target: { + attributes?: Record<string, string>; + label?: string; + tag?: string; + value?: string; + }, + url: string | undefined, +): boolean => { + const attributes = target.attributes ?? {}; + const tag = target.tag?.toLowerCase(); + const editable = + tag === "textarea" || + (attributes.contenteditable !== undefined && + attributes.contenteditable !== "false"); + const input = + (tag === undefined || tag === "input") && + TEXT_INPUT_TYPES.has((attributes.type ?? "").toLowerCase()); + if (!input && !editable) return false; + const words = fieldWords( + [ + target.label, + attributes["aria-label"], + attributes.placeholder, + attributes.title, + attributes.name, + attributes.id, + attributes.autocomplete, + ...Object.entries(attributes) + .filter(([name]) => FIELD_HINT_ATTRIBUTE.test(name)) + .map(([, value]) => value), + ] + .filter((part): part is string => typeof part === "string") + .join(" ") + .slice(0, 2000), + ); + if (ONE_TIME_CODE_FIELD.test(words)) return true; + if ( + DELIVERED_TO_PERSON.test(words) && + !ORDINARY_DELIVERED.test(words) && + !ORDINARY_NUMBER.test(words) && + words.replace(ORDINARY_CODE, "") === words + ) { + // A phone field ("we texted a code to this number", type=tel) is no code field: tel is not a code's shape here. + const length = Number.parseInt(attributes.maxlength ?? "", 10); + if ( + isCodeValue(target.value) || + attributes.inputmode?.toLowerCase() === "numeric" || + DIGITS_PATTERN.test((attributes.pattern ?? "").trim()) || + (length >= CODE_FIELD_MIN_LENGTH && length <= CODE_FIELD_MAX_LENGTH) || + authFlowUrl(url) + ) + return true; + } + if (!input) return false; + const shape = hasCodeShape(attributes); + if ( + CODE_WORD.test(words.replace(ORDINARY_CODE, "")) && + (DIGIT_COUNT.test(words) || shape || authFlowUrl(url)) + ) + return true; + // One box of a code typed a digit per box: a numeric input one character long. The content script knows its row of + // boxes; this holds for a box judged on its own. + return ( + (shape || numericFieldLength(attributes) === 1) && + !ORDINARY_NUMBER.test(words) + ); +}; + +/** Inputs drawn as a button or a choice: what they hold is the page's own caption or option, never what the person typed. */ +const NAMING_INPUT_TYPES: ReadonlySet<string> = new Set([ + "submit", + "button", + "reset", + "image", + "checkbox", + "radio", + "range", + "color", +]); +/** The roles of an element the person types into (CopilotKit's `TEXT_ENTRY` in clicks.ts). */ +const TEXT_ENTRY_ROLE = /^(?:textbox|searchbox|combobox)$/i; + +/** Whether the element holds a value the person enters: an input (one drawn as a button or a choice aside), a text area, a + * select, an editable region or a text-entry role, as CopilotKit's capture tells one (`holdsValue` in clicks.ts). Only such + * an element can hold a secret; a button, a link or a label only names one ("Show password", "Reset password link", + * "Retrieve password"). One described with no tag is judged as a field. */ +export function holdsValue(target: { + tag?: string; + attributes?: Record<string, string>; +}): boolean { + const tag = target.tag?.toLowerCase(); + if (tag === undefined) return true; + const attributes = target.attributes ?? {}; + if (tag === "input") + return !NAMING_INPUT_TYPES.has( + (attributes.type ?? "").trim().toLowerCase(), + ); + if (tag === "textarea" || tag === "select") return true; + const editable = attributes.contenteditable; + if (typeof editable === "string" && editable.toLowerCase() !== "false") + return true; + return TEXT_ENTRY_ROLE.test( + (attributes.role ?? "").trim().split(/\s+/)[0] ?? "", + ); +} + +/** A field whose value is a secret: a password, a card, a security or one-time code. `context.url` is the page it is on; + * `target.value`, what it holds, tells a one-time code field that names no code by its shape. Only an element that holds a + * value (`holdsValue`) is one: a control that names a secret holds none. */ +export function isSensitiveTarget( + target: { + attributes?: Record<string, string>; + label?: string; + text?: string; + tag?: string; + value?: string; + }, + context?: { url?: string }, +): boolean { + if (!holdsValue(target)) return false; + const attributes = target.attributes ?? {}; + if (attributes.type === "password") return true; + const words = [ + target.label, + attributes["aria-label"], + attributes.placeholder, + attributes.title, + attributes.name, + attributes.id, + attributes.autocomplete, + ] + .filter((part): part is string => typeof part === "string") + .join(" "); + return ( + SENSITIVE_FIELD.test(words) || isOneTimeCodeField(target, context?.url) + ); +} + +export function sanitiseForIntelligence(value: JsonValue): JsonValue { + const clean = (text: string) => text.replaceAll(NUL, "\uFFFD").toWellFormed(); + if (typeof value === "string") return clean(value); + if (Array.isArray(value)) return value.map(sanitiseForIntelligence); + if (isObject(value)) { + const out: Obj = {}; + for (const [key, item] of Object.entries(value)) + out[clean(key)] = sanitiseForIntelligence(item); + return out; + } + return value; +} + +const scrubStrings = ( + value: JsonValue, + options: MaskOptions | undefined, + key?: string, +): JsonValue => { + if (typeof value === "string") { + if (value === REDACTED) return value; + return key === "url" || key === "referrer" + ? scrubUrl(value, options) + : scrubText(value, options); + } + if (Array.isArray(value)) + return value.map((item) => scrubStrings(item, options, key)); + if (isObject(value)) { + const out: Obj = {}; + for (const [name, item] of Object.entries(value)) { + out[name] = + key === "attributes" && isCredentialKey(name.replace(/^data-/i, "")) + ? REDACTED + : scrubStrings(item, options, name); + } + return out; + } + return value; +}; + +/** A field a person types text into, whose accessible name comes from its label, aria-label or placeholder and never from + * what it holds: a text-like input or a text area (an editable region is named by its content). */ +const namedByLabel = ( + tag: string | undefined, + attributes: Record<string, string> | undefined, +): boolean => + tag?.toLowerCase() === "textarea" || + (tag?.toLowerCase() === "input" && + TEXT_INPUT_TYPES.has((attributes?.type ?? "").toLowerCase())); + +/** `context.sensitive`: the content script judged the field sensitive. `context.onSensitive` hears what a field judged + * sensitive here held before it was redacted, so the service worker can mask it wherever it appears later. */ +export function maskCollectorEvent( + event: CapturedEvent, + context: { sensitive: boolean; onSensitive?: (typed: string[]) => void }, + options?: MaskOptions, +): CapturedEvent | null { + if (typeof event !== "object" || event === null) return null; + const clone = sanitiseForIntelligence( + event as unknown as JsonValue, + ) as unknown as CapturedEvent; + const value = clone.value; + if (isObject(value) && isObject(value.target)) { + const target = value.target; + const attributes = isObject(target.attributes) + ? (target.attributes as Record<string, string>) + : undefined; + const tag = typeof target.tag === "string" ? target.tag : undefined; + // CopilotKit's capture gives only a click a label; an edit's field is named by the accessible name the targeting adds, + // which for a text field is its label text, so the rule reads the same words for both. + const label = + typeof target.label === "string" + ? target.label + : namedByLabel(tag, attributes) && typeof target.name === "string" + ? target.name + : undefined; + const url = typeof value.url === "string" ? value.url : undefined; + const held = + typeof target.value === "string" && target.value !== REDACTED + ? target.value + : undefined; + if ( + context.sensitive || + isSensitiveTarget({ attributes, label, tag, value: held }, { url }) + ) { + // Every copy of the value goes: the live value and text, the value attributes, and any attribute that echoes it. + const typed = TARGET_VALUE_FIELDS.map((field) => target[field]).filter( + (item): item is string => + typeof item === "string" && item.trim() !== "" && item !== REDACTED, + ); + if (typed.length > 0) context.onSensitive?.(typed); + for (const field of TARGET_VALUE_FIELDS) + if (field in target) target[field] = REDACTED; + if (attributes) + for (const [name, item] of Object.entries(attributes)) + if (VALUE_ATTRIBUTE.test(name) || echoes(item, typed)) + attributes[name] = REDACTED; + delete target.selectedValues; + } else if ( + mirrorsValue(tag, attributes) && + typeof target.value === "string" + ) { + // The masking took part of what was typed (a card, an email, a token), now or before the event came here: every + // copy of the field's content takes the same verdict, so a copy that still held a keystroke less of it (the text of + // React's chat box held a card's first 15 digits, which are no card on their own) never leaves. The text and the + // value attributes are the field's own copies; any other attribute, and the field's name, goes whole if it holds a + // stretch of what was typed. + const typed = target.value; + // A secret pasted whole is one value, though it holds spaces (a passphrase), and no single word of it matches. + const masked = matchesDigest(typed, options) + ? REDACTED + : scrubText(typed, options); + if (masked !== typed || masked.includes(REDACTED)) { + target.value = masked; + if (typeof target.text === "string" && target.text !== "") + target.text = masked; + if (attributes) + for (const [name, item] of Object.entries(attributes)) + if (typeof item === "string" && item !== "") + if (VALUE_ATTRIBUTE.test(name)) attributes[name] = masked; + else if (copies(item, typed)) attributes[name] = REDACTED; + for (const field of ["label", "name"] as const) + if (copies(target[field], typed)) target[field] = REDACTED; + } + } + if (Array.isArray(target.files)) { + target.files = target.files.map((file) => + isObject(file) && typeof file.name === "string" + ? { ...file, name: scrubText(file.name, options) } + : file, + ); + } + } + return { ...clone, value: scrubStrings(value, options) }; +} + +const headerValue = ( + headers: JsonValue | undefined, + name: string, +): string | undefined => { + if (!isObject(headers)) return undefined; + for (const [key, item] of Object.entries(headers)) + if (key.toLowerCase() === name && typeof item === "string") return item; + return undefined; +}; + +const maskExchange = ( + part: JsonValue | undefined, + options: BodyOptions, +): JsonValue | undefined => { + if (!isObject(part)) return part; + const out: Obj = { ...part }; + if (typeof out.url === "string") out.url = scrubUrl(out.url, options); + if (isObject(out.headers)) + out.headers = maskHeaders(out.headers as Record<string, string>, options); + if (isObject(out.body) && typeof out.body.text === "string") { + out.body = { + ...out.body, + text: maskBodyText( + out.body.text, + headerValue(part.headers, "content-type"), + options, + ), + }; + } + return out; +}; + +export function maskNetworkEvent( + event: CapturedEvent, + options?: MaskOptions, +): CapturedEvent { + const clone = sanitiseForIntelligence( + event as unknown as JsonValue, + ) as unknown as CapturedEvent; + if (!isObject(clone.value)) return clone; + const value: Obj = { ...clone.value }; + // Both bodies of a sign-in, verification or token call are read as such, by the address asked or the one answering. + const exchange: BodyOptions = { + ...options, + authFlow: + (typeof value.url === "string" && authFlowUrl(value.url)) || + (isObject(value.response) && + typeof value.response.url === "string" && + authFlowUrl(value.response.url)), + }; + if (typeof value.url === "string") value.url = scrubUrl(value.url, options); + if (typeof value.route === "string") + value.route = scrubText(value.route, options); + if (value.request !== undefined) + value.request = maskExchange(value.request, exchange) ?? null; + if (value.response !== undefined) + value.response = maskExchange(value.response, exchange) ?? null; + return { ...clone, value }; +} + +const byteLength = (event: CapturedEvent) => + new TextEncoder().encode(JSON.stringify(event)).byteLength; + +/** Cuts at a code unit count without leaving the first half of a surrogate pair at the end. */ +const cut = (text: string, length: number): string => { + const end = + length > 0 && + text.charCodeAt(length - 1) >= 0xd800 && + text.charCodeAt(length - 1) <= 0xdbff + ? length - 1 + : length; + return text.slice(0, end); +}; + +/** + * Cuts the request and response bodies to one length, the longest that fits the event in `maxBytes`. A body shorter + * than that length stays whole, so the larger body is always cut first: a small response survives a large upload, + * and two large bodies end the same length. Called only on an event already over its budget. + */ +const truncateBodies = (event: CapturedEvent, maxBytes: number): void => { + const value = event.value; + if (!isObject(value)) return; + const bodies: { exchange: Obj; body: Obj; text: string }[] = []; + for (const side of ["request", "response"] as const) { + const exchange = value[side]; + if (!isObject(exchange)) continue; + const body = exchange.body; + if (isObject(body) && typeof body.text === "string") + bodies.push({ exchange, body, text: body.text }); + } + if (bodies.length === 0) return; + const capAt = (length: number) => { + for (const { exchange, body, text } of bodies) + exchange.body = + text.length > length + ? { + ...body, + text: cut(text, length), + status: "truncated", + truncated: true, + } + : body; + }; + // The event with every body whole is over budget: the answer is below the longest body's length. + let fits = -1; + let low = 0; + let high = Math.max(...bodies.map(({ text }) => text.length)) - 1; + while (low <= high) { + const length = Math.floor((low + high) / 2); + capAt(length); + if (byteLength(event) <= maxBytes) { + fits = length; + low = length + 1; + } else high = length - 1; + } + // Nothing fits even with the bodies emptied: they stay empty, and the caller cuts further. + capAt(Math.max(fits, 0)); +}; + +/** One string in an event's value, where it sits so it can be cut in place, and the field that holds it: an array's + * items are its field's, so the names a JSON Schema's `required` lists are `required`. */ +type StringLeaf = { + holder: { [key: string]: JsonValue } | JsonValue[]; + key: string | number; + text: string; + field: string | null; +}; + +const stringLeaves = ( + value: JsonValue, + out: StringLeaf[] = [], + field: string | null = null, +): StringLeaf[] => { + const visit = ( + holder: StringLeaf["holder"], + key: string | number, + item: JsonValue, + itemField: string | null, + ) => { + if (typeof item === "string") + out.push({ holder, key, text: item, field: itemField }); + else stringLeaves(item, out, itemField); + }; + if (Array.isArray(value)) + for (const [index, item] of value.entries()) + visit(value, index, item, field); + else if (isObject(value)) + for (const [key, item] of Object.entries(value)) + visit(value, key, item, key); + return out; +}; + +/** + * Cuts the strings in the event's value to one length, the longest that fits the event in `maxBytes`, as + * truncateBodies does for bodies: a string shorter than that length stays whole, so the longest (an agent's message, a + * tool's arguments, a long description) are cut first and its numbers stay. A string of a field `keep` names (an id, a + * name) is never cut, at any depth. Called only on an event still over its budget. + */ +const truncateStrings = ( + event: CapturedEvent, + maxBytes: number, + keep: ReadonlySet<string>, +): void => { + const value = event.value; + if (!isObject(value)) return; + value.truncated = true; + const leaves = stringLeaves(value).filter( + ({ field }) => field === null || !keep.has(field), + ); + if (leaves.length === 0) return; + const capAt = (length: number) => { + for (const { holder, key, text } of leaves) + (holder as Record<string | number, JsonValue>)[key] = + text.length > length ? cut(text, length) : text; + }; + let fits = -1; + let low = 0; + let high = Math.max(...leaves.map(({ text }) => text.length)) - 1; + while (low <= high) { + const length = Math.floor((low + high) / 2); + capAt(length); + if (byteLength(event) <= maxBytes) { + fits = length; + low = length + 1; + } else high = length - 1; + } + capAt(Math.max(fits, 0)); +}; + +/** `strings`: before giving up on an event still over budget, cut its longest strings to fit (truncateStrings), as the + * agent events do so that a long message keeps its ids and the start of its text. `keep`, with `strings`: the fields + * whose strings are never cut, at any depth (the agent events' ids, names and enum-like fields). */ +export function trimEvent( + event: CapturedEvent, + maxBytes: number, + options: { strings?: boolean; keep?: ReadonlySet<string> } = {}, +): { event: CapturedEvent; trimmed: boolean } { + const work = sanitiseForIntelligence( + event as unknown as JsonValue, + ) as unknown as CapturedEvent; + if (byteLength(work) <= maxBytes) return { event: work, trimmed: false }; + truncateBodies(work, maxBytes); + if ( + byteLength(work) > maxBytes && + isObject(work.value) && + isObject(work.value.target) + ) { + const target = work.value.target; + for (const field of TARGET_VALUE_FIELDS) { + if (typeof target[field] === "string") + target[field] = cut(target[field] as string, TRIMMED_TARGET_TEXT); + } + work.value.truncated = true; + } + if (options.strings && byteLength(work) > maxBytes) + truncateStrings(work, maxBytes, options.keep ?? new Set()); + if (byteLength(work) > maxBytes) { + const minimal: Obj = { trimmed: true }; + if (isObject(work.value) && typeof work.value.url === "string") + minimal.url = scrubUrl(work.value.url); + return { event: { ...work, value: minimal }, trimmed: true }; + } + return { + event: sanitiseForIntelligence( + work as unknown as JsonValue, + ) as unknown as CapturedEvent, + trimmed: true, + }; +} diff --git a/shared/sensitive-rule.test.ts b/shared/sensitive-rule.test.ts new file mode 100644 index 000000000..a400c167a --- /dev/null +++ b/shared/sensitive-rule.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { SENSITIVE_FIELD as rulesCopy } from "./demonstration-rules"; +import { + isSensitiveTarget, + SENSITIVE_FIELD as maskingCopy, +} from "./recorder-masking"; + +test("the two copies of the sensitive-field rule are the same literal", () => { + expect(maskingCopy.source).toBe(rulesCopy.source); + expect(maskingCopy.flags).toBe(rulesCopy.flags); +}); + +test("both rules agree on every label", () => { + const labels = [ + "Card number", + "Security code", + "Password", + "Shipping", + "Topping", + "cvc", + "Card notes", + ]; + for (const label of labels) { + expect(isSensitiveTarget({ label })).toBe(rulesCopy.test(label)); + } +}); diff --git a/tests/desktop-release-tree.test.ts b/tests/desktop-release-tree.test.ts new file mode 100644 index 000000000..aa3f5fdab --- /dev/null +++ b/tests/desktop-release-tree.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const repositoryRoot = join(import.meta.dir, ".."); + +// Desktop installs a release from GitHub's tag tarball, keeping only the top-level entries and the +// single files that deployment.rs names, then runs `bun install --frozen-lockfile --ignore-scripts`. +function copiedLists(): { entries: string[]; files: string[] } { + const source = readFileSync( + join(repositoryRoot, "desktop", "src-tauri", "src", "deployment.rs"), + "utf8", + ) + .split("\n") + .map((line) => line.replace(/\/\/.*$/, "")) + .join("\n"); + const literals = (constant: string): string[] => { + const block = source.match( + new RegExp(`pub const ${constant}: \\[&str; \\d+\\] = \\[([^\\]]*)\\]`), + ); + expect(block).not.toBeNull(); + return [...(block?.[1] ?? "").matchAll(/"([^"]*)"/g)].map( + (match) => match[1] ?? "", + ); + }; + return { + entries: [...literals("REQUIRED"), ...literals("ALSO_COPIED")], + files: literals("ALSO_COPIED_FILES"), + }; +} + +// A workspace reaches a deployment whole, or as its manifest alone, which is all the install needs. +function copiedWorkspace(workspace: string): boolean { + const { entries, files } = copiedLists(); + return ( + entries.includes(workspace.split("/")[0] ?? "") || + files.includes(`${workspace}/package.json`) + ); +} + +function lockfileWorkspaceKeys(): string[] { + const lock = readFileSync(join(repositoryRoot, "bun.lock"), "utf8"); + const start = lock.indexOf('"workspaces": {'); + expect(start).toBeGreaterThan(-1); + const end = lock.indexOf("\n },", start); + expect(end).toBeGreaterThan(start); + return [...lock.slice(start, end).matchAll(/^ {4}"([^"]*)": \{/gm)].map( + (match) => match[1] ?? "", + ); +} + +function tracked(path: string): boolean { + return ( + execFileSync("git", ["ls-files", "--cached", "-z", "--", path], { + cwd: repositoryRoot, + }).length > 0 + ); +} + +describe("OpenBot Desktop's copied tree", () => { + test("every root workspace is in Desktop's copied tree", () => { + const manifest = JSON.parse( + readFileSync(join(repositoryRoot, "package.json"), "utf8"), + ) as { workspaces: string[] }; + for (const workspace of manifest.workspaces) { + expect({ workspace, copied: copiedWorkspace(workspace) }).toEqual({ + workspace, + copied: true, + }); + } + }); + + test("every workspace the lockfile names is in Desktop's copied tree", () => { + const keys = lockfileWorkspaceKeys().filter((key) => key !== ""); + expect(keys.length).toBeGreaterThan(0); + + for (const key of keys) { + expect({ key, copied: copiedWorkspace(key) }).toEqual({ + key, + copied: true, + }); + expect(tracked(join(key, "package.json"))).toBe(true); + } + }); + + test("every dependency patch package.json names is in Desktop's copied tree", () => { + const manifest = JSON.parse( + readFileSync(join(repositoryRoot, "package.json"), "utf8"), + ) as { patchedDependencies?: Record<string, string> }; + const { entries, files } = copiedLists(); + for (const patch of Object.values(manifest.patchedDependencies ?? {})) { + const copied = + entries.includes(patch.split("/")[0] ?? "") || files.includes(patch); + expect({ patch, copied, tracked: tracked(patch) }).toEqual({ + patch, + copied: true, + tracked: true, + }); + } + }); +}); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index 7f9df2d60..188a39c0b 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -28,7 +28,7 @@ function packagesStartedBy(script: string, workspaces: string[]): string[] { } describe("OpenBot workspace", () => { - test("defines the app, server, worker, shared and toolkit packages", () => { + test("defines the app, server, worker, shared, toolkit and extension packages", () => { const manifest = rootManifest(); const names: Record<string, string> = { app: "app", @@ -36,6 +36,7 @@ describe("OpenBot workspace", () => { worker: "worker", shared: "@openbot/shared", toolkit: "@openbot/toolkit", + extension: "@openbot/recorder-extension", }; expect(manifest.workspaces).toEqual(Object.keys(names)); diff --git a/toolkit/package.json b/toolkit/package.json index e5e36c2a5..288a71934 100644 --- a/toolkit/package.json +++ b/toolkit/package.json @@ -10,8 +10,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@ag-ui/client": "1.0.1", - "@copilotkit/runtime": "1.77.0", + "@ag-ui/client": "1.0.2", + "@copilotkit/runtime": "1.79.1", "@openbot/shared": "workspace:*", "rxjs": "7.8.1", "zod": "^4.4.3"