diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91fcfabc5..599c4103a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,14 @@ parallel compatibility paths, raise the issue with a concrete recommendation and tradeoffs before implementing the disputed behavior. Continue independent work while the decision is pending. Do not silently preserve obsolete private designs. +Core Web leads with operations: Monitor (Overview, Core metrics, Agent metrics, +Sandbox metrics, Session log), Resources and Platform. Pages use the shared +components in `apps/web/src/components` and the tokens in `apps/web/src/styles`, +described in `apps/web/DESIGN.md`. Keep explanations behind help tips, but keep +errors, warnings and safety notices visible. Browser-derived metrics state their +coverage, keep missing values missing, bound their fan-out and time, report a failed +read as failed and never imply deployment-wide or billing totals. + For subsequent alignment and milestone closure batches, the main thread coordinates design, shared interface agreements, file ownership, integration and merge. First reconcile main and the boards, then list remaining mandatory milestone work, diff --git a/apps/web/.impeccable/design.json b/apps/web/.impeccable/design.json new file mode 100644 index 000000000..9cd52cf93 --- /dev/null +++ b/apps/web/.impeccable/design.json @@ -0,0 +1,692 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-09-24T09:59:21.057068+00:00", + "title": "Design System: Parsar Core Console", + "extensions": { + "colorMeta": { + "ink": { + "role": "neutral", + "displayName": "Ledger Ink", + "canonical": "#37352f", + "cssVar": "--fg", + "dark": "#d4d4d4", + "tonalRamp": [ + "#292823", + "#4a483f", + "#6b675c", + "#8c8778", + "#a8a499", + "#c4c2ba", + "#e0dfdb", + "#f3f3f1" + ] + }, + "ink-muted": { + "role": "neutral", + "displayName": "Graphite", + "canonical": "#787774", + "cssVar": "--fg-muted", + "dark": "#9b9b9b", + "tonalRamp": [ + "#272726", + "#464544", + "#656462", + "#848380", + "#a2a19f", + "#c0c0be", + "#dededd", + "#f2f2f2" + ] + }, + "ink-subtle": { + "role": "neutral", + "displayName": "Pencil", + "canonical": "#9b9a97", + "cssVar": "--fg-subtle", + "dark": "#7b7b7b", + "tonalRamp": [ + "#272726", + "#464644", + "#656462", + "#848380", + "#a2a29f", + "#c0c0be", + "#dededd", + "#f2f2f2" + ] + }, + "sidebar-ink": { + "role": "neutral", + "displayName": "Sidebar Ink", + "canonical": "#5f5e5a", + "cssVar": "--sidebar-fg", + "dark": "#bdbdbd", + "tonalRamp": [ + "#272725", + "#474643", + "#666561", + "#85847f", + "#a3a29e", + "#c1c0be", + "#dfdedd", + "#f3f2f2" + ] + }, + "surface": { + "role": "neutral", + "displayName": "Paper", + "canonical": "#ffffff", + "cssVar": "--surface", + "dark": "#191919", + "tonalRamp": [ + "#262626", + "#454545", + "#636363", + "#828282", + "#a1a1a1", + "#bfbfbf", + "#dedede", + "#f2f2f2" + ] + }, + "surface-subtle": { + "role": "neutral", + "displayName": "Margin Gray", + "canonical": "#fafafa", + "cssVar": "--surface-subtle", + "dark": "#202020", + "tonalRamp": [ + "#262626", + "#454545", + "#636363", + "#828282", + "#a1a1a1", + "#bfbfbf", + "#dedede", + "#f2f2f2" + ] + }, + "surface-muted": { + "role": "neutral", + "displayName": "Well Gray", + "canonical": "#f1f1f0", + "cssVar": "--surface-muted", + "dark": "#2a2a2a", + "tonalRamp": [ + "#282825", + "#474742", + "#676760", + "#86867e", + "#a4a49d", + "#c1c1bd", + "#dfdfdd", + "#f3f3f2" + ] + }, + "line": { + "role": "neutral", + "displayName": "Hairline", + "canonical": "#e9e9ec", + "cssVar": "--line", + "dark": "rgb(255 255 255 / 9%)", + "tonalRamp": [ + "#232329", + "#40404a", + "#5c5c6b", + "#79798b", + "#9a9aa8", + "#bbbbc4", + "#dbdbe0", + "#f1f1f3" + ] + }, + "line-muted": { + "role": "neutral", + "displayName": "Faint Rule", + "canonical": "#efeff1", + "cssVar": "--line-muted", + "dark": "rgb(255 255 255 / 6%)", + "tonalRamp": [ + "#242429", + "#404049", + "#5d5d6a", + "#7a7a8a", + "#9a9aa7", + "#bbbbc3", + "#dcdce0", + "#f1f1f3" + ] + }, + "line-strong": { + "role": "neutral", + "displayName": "Firm Rule", + "canonical": "#d6d7dc", + "cssVar": "--line-strong", + "dark": "rgb(255 255 255 / 16%)", + "tonalRamp": [ + "#232429", + "#3f414a", + "#5c5e6b", + "#787b8c", + "#999ca8", + "#babcc4", + "#dbdce0", + "#f1f2f3" + ] + }, + "hover": { + "role": "neutral", + "displayName": "Hover Wash", + "canonical": "rgb(55 53 47 / 3%)", + "cssVar": "--hover", + "dark": "rgb(255 255 255 / 5.5%)", + "tonalRamp": [] + }, + "pressed": { + "role": "neutral", + "displayName": "Pressed Wash", + "canonical": "rgb(55 53 47 / 6%)", + "cssVar": "--pressed", + "dark": "rgb(255 255 255 / 10%)", + "tonalRamp": [] + }, + "tile": { + "role": "neutral", + "displayName": "Tile Wash", + "canonical": "rgb(55 53 47 / 7%)", + "cssVar": "--tile", + "dark": "rgb(255 255 255 / 10%)", + "tonalRamp": [] + }, + "accent": { + "role": "primary", + "displayName": "Parsar Indigo", + "canonical": "#4f46e5", + "cssVar": "--accent", + "dark": "#8b90f6", + "tonalRamp": [ + "#0d0943", + "#171179", + "#2119ae", + "#2f24e0", + "#625ae8", + "#958fef", + "#c8c5f7", + "#eae9fc" + ] + }, + "accent-emphasis": { + "role": "primary", + "displayName": "Pressed Indigo", + "canonical": "#4338ca", + "cssVar": "--accent-emphasis", + "dark": "#a5a9f8", + "tonalRamp": [ + "#13103c", + "#231d6d", + "#332a9d", + "#453aca", + "#726ad7", + "#a09ae4", + "#cecbf1", + "#ecebfa" + ] + }, + "accent-fg": { + "role": "primary", + "displayName": "On Indigo", + "canonical": "#ffffff", + "cssVar": "--accent-fg", + "dark": "#14142b", + "tonalRamp": [ + "#262626", + "#454545", + "#636363", + "#828282", + "#a1a1a1", + "#bfbfbf", + "#dedede", + "#f2f2f2" + ] + }, + "success": { + "role": "signal", + "displayName": "Healthy Green", + "canonical": "#16a34a", + "cssVar": "--success", + "dark": "#3fb950", + "tonalRamp": [ + "#09431f", + "#107937", + "#18af50", + "#23e169", + "#59e98e", + "#8ff0b3", + "#c5f7d7", + "#e9fcf0" + ] + }, + "warning": { + "role": "signal", + "displayName": "Caution Amber", + "canonical": "#d97706", + "cssVar": "--warning", + "dark": "#f5a524", + "tonalRamp": [ + "#4a2902", + "#864904", + "#c26a05", + "#f88a0c", + "#faa747", + "#fcc483", + "#fde0be", + "#fef3e6" + ] + }, + "danger": { + "role": "signal", + "displayName": "Fault Red", + "canonical": "#dc2626", + "cssVar": "--danger", + "dark": "#f05252", + "tonalRamp": [ + "#420b0b", + "#771313", + "#ab1c1c", + "#dc2828", + "#e55d5d", + "#ed9191", + "#f6c6c6", + "#fbe9e9" + ] + }, + "status-queued": { + "role": "signal", + "displayName": "Queued Gray", + "canonical": "#9a9ca4", + "cssVar": "--status-queued", + "dark": "#71737b", + "tonalRamp": [ + "#242528", + "#414348", + "#5e6069", + "#7c7e89", + "#9c9ea6", + "#bcbdc3", + "#dcdde0", + "#f2f2f3" + ] + }, + "status-idle": { + "role": "signal", + "displayName": "Idle Gray", + "canonical": "#b4b4b9", + "cssVar": "--status-idle", + "dark": "#5e5e62", + "tonalRamp": [ + "#252528", + "#424247", + "#606067", + "#7e7e86", + "#9d9da4", + "#bdbdc1", + "#dddddf", + "#f2f2f3" + ] + }, + "series-1": { + "role": "data", + "displayName": "Cobalt", + "canonical": "#2a78d6", + "cssVar": "--series-1", + "dark": "#3987e5", + "tonalRamp": [ + "#0c2440", + "#164173", + "#205da7", + "#2d7ad7", + "#619be1", + "#94bbea", + "#c7dcf4", + "#eaf1fb" + ] + }, + "series-2": { + "role": "data", + "displayName": "Persimmon", + "canonical": "#eb6834", + "cssVar": "--series-2", + "dark": "#d95926", + "tonalRamp": [ + "#461907", + "#7d2c0c", + "#b54012", + "#e9561c", + "#ee7f53", + "#f4a98b", + "#f9d2c3", + "#fdeee8" + ] + }, + "series-3": { + "role": "data", + "displayName": "Jade", + "canonical": "#1baf7a", + "cssVar": "--series-3", + "dark": "#199e70", + "tonalRamp": [ + "#0a422e", + "#127753", + "#1bac78", + "#27de9c", + "#5ce6b4", + "#91eecd", + "#c6f6e5", + "#e9fcf5" + ] + }, + "series-4": { + "role": "data", + "displayName": "Saffron", + "canonical": "#eda100", + "cssVar": "--series-4", + "dark": "#c98500", + "tonalRamp": [ + "#4c3400", + "#8a5e00", + "#c78700", + "#ffaf05", + "#ffc242", + "#ffd680", + "#ffeabd", + "#fff7e5" + ] + }, + "series-5": { + "role": "data", + "displayName": "Rose", + "canonical": "#e87ba4", + "cssVar": "--series-5", + "dark": "#d55181", + "tonalRamp": [ + "#410b20", + "#751439", + "#a91e52", + "#da2a6c", + "#e35e90", + "#ec92b4", + "#f5c7d8", + "#fbe9f0" + ] + }, + "series-6": { + "role": "data", + "displayName": "Forest", + "canonical": "#008300", + "cssVar": "--series-6", + "dark": "#008300", + "tonalRamp": [ + "#004c00", + "#008a00", + "#00c700", + "#05ff05", + "#42ff42", + "#80ff80", + "#bdffbd", + "#e5ffe5" + ] + }, + "series-other": { + "role": "data", + "displayName": "Series Other", + "canonical": "#a3a3a8", + "cssVar": "--series-other", + "dark": "#6d6d72", + "tonalRamp": [ + "#252527", + "#434347", + "#616166", + "#7f7f86", + "#9e9ea3", + "#bdbdc1", + "#dddddf", + "#f2f2f3" + ] + }, + "meter-track": { + "role": "data", + "displayName": "Meter Track", + "canonical": "rgb(55 53 47 / 8%)", + "cssVar": "--meter-track", + "dark": "rgb(255 255 255 / 9%)", + "tonalRamp": [] + } + }, + "typographyMeta": { + "display": { + "displayName": "KPI Figure", + "purpose": "KPI strip values only; tabular numerals." + }, + "headline": { + "displayName": "Page Title", + "purpose": "One per page in the 64px header." + }, + "title": { + "displayName": "Section Title", + "purpose": "Section headings; chart captions step down to 13px/600." + }, + "body": { + "displayName": "Body", + "purpose": "Table cells, controls, fields; document base is 14px/20px." + }, + "label": { + "displayName": "Label", + "purpose": "KPI labels, status labels, text actions, segmented options." + }, + "mono": { + "displayName": "Mono", + "purpose": "Identifiers and code in Graphite." + } + }, + "shadows": [ + { + "name": "shadow-control", + "value": "0 1px 2px rgb(0 0 0 / 6%)", + "dark": "0 1px 2px rgb(0 0 0 / 40%)", + "purpose": "Raised controls only: primary/outline buttons, fields, active segment, active filter tab, selected fleet target." + }, + { + "name": "shadow-floating", + "value": "0 1px 2px rgb(24 24 27 / 4%), 0 8px 24px -12px rgb(24 24 27 / 18%)", + "dark": "0 1px 2px rgb(0 0 0 / 30%), 0 8px 24px -12px rgb(0 0 0 / 55%)", + "purpose": "Help-tip popovers, chart tooltips, menus and dialogs." + } + ], + "motion": [ + { + "name": "ease-settle", + "value": "cubic-bezier(0.22, 1, 0.36, 1)", + "purpose": "Default easing for colour, background and opacity state changes (150ms)." + }, + { + "name": "ease-spring", + "value": "cubic-bezier(0.34, 1.56, 0.64, 1)", + "purpose": "Button press scale to 0.97 (120ms)." + }, + { + "name": "page-in", + "value": "opacity 0 to 1, 160ms ease-settle", + "purpose": "Page change settle; no choreography. Disabled under reduced motion." + }, + { + "name": "help-tip-in", + "value": "opacity 0 to 1, 120ms ease-settle", + "purpose": "Help-tip popover appearance." + }, + { + "name": "refetch-dim", + "value": "opacity 0.62, 180ms ease-settle", + "purpose": "Page body while refetching." + } + ], + "breakpoints": [ + { + "name": "min-desktop", + "value": "960px" + }, + { + "name": "stack-charts", + "value": "1180px" + } + ] + }, + "components": [ + { + "name": "Primary Button", + "kind": "button", + "refersTo": "button-primary", + "description": "The one filled action on a page; indigo is reserved for this and selection.", + "html": "", + "css": ".ds-btn { display:inline-flex; height:28px; align-items:center; gap:6px; padding:0 10px; font:500 13px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; border:1px solid transparent; border-radius:6px; cursor:pointer; transition: background-color 150ms cubic-bezier(0.22,1,0.36,1), border-color 150ms cubic-bezier(0.22,1,0.36,1), transform 120ms cubic-bezier(0.34,1.56,0.64,1); } .ds-btn-primary { color: var(--accent-fg, #fff); background: var(--accent, #4f46e5); border-color: var(--accent, #4f46e5); box-shadow: 0 1px 2px rgb(0 0 0 / 6%); } .ds-btn-primary:hover { background: var(--accent-emphasis, #4338ca); border-color: var(--accent-emphasis, #4338ca); } .ds-btn:focus-visible { outline:none; border-color: var(--accent, #4f46e5); box-shadow: 0 0 0 1px var(--accent, #4f46e5); } .ds-btn:active { transform: scale(0.97); }" + }, + { + "name": "Outline Button (Refresh)", + "kind": "button", + "refersTo": "button-outline", + "description": "Default page-header action; Refresh keeps its last-updated time in the tooltip.", + "html": "", + "css": ".ds-btn-outline { display:inline-flex; height:28px; align-items:center; gap:6px; padding:0 10px; color: var(--fg, #37352f); font:500 13px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--surface, #fff); border:1px solid var(--line-strong, #d6d7dc); border-radius:6px; box-shadow: 0 1px 2px rgb(0 0 0 / 6%); cursor:pointer; transition: background-color 150ms cubic-bezier(0.22,1,0.36,1); } .ds-btn-outline:hover { background: var(--hover, rgb(55 53 47 / 3%)); } .ds-btn-outline:focus-visible { outline:none; border-color: var(--accent, #4f46e5); box-shadow: 0 0 0 1px var(--accent, #4f46e5); }" + }, + { + "name": "Text Field", + "kind": "input", + "refersTo": "input-field", + "description": "28px form field with a firm rule border and indigo focus ring.", + "html": "", + "css": ".ds-input { width:240px; min-height:28px; padding:4px 8px; color: var(--fg, #37352f); font:400 13px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--surface, #fff); border:1px solid var(--line-strong, #d6d7dc); border-radius:6px; box-shadow: 0 1px 2px rgb(0 0 0 / 6%); } .ds-input:focus { outline:none; border-color: var(--accent, #4f46e5); box-shadow: 0 0 0 1px var(--accent, #4f46e5); }" + }, + { + "name": "Segmented Range Control", + "kind": "chip", + "refersTo": "segmented-option", + "description": "The single style for time ranges and mode switches.", + "html": "
", + "css": ".ds-seg { display:inline-flex; gap:2px; padding:2px; background: var(--surface-muted, #f1f1f0); border-radius:7px; } .ds-seg button { height:24px; padding:0 10px; color: var(--fg-muted, #787774); font:500 12.5px -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Segoe UI', sans-serif; background:transparent; border:0; border-radius:5px; cursor:pointer; transition: color 150ms cubic-bezier(0.22,1,0.36,1), background-color 150ms cubic-bezier(0.22,1,0.36,1); } .ds-seg button:hover { color: var(--fg, #37352f); } .ds-seg button[aria-checked='true'] { color: var(--fg, #37352f); background: var(--surface, #fff); box-shadow: 0 1px 2px rgb(0 0 0 / 6%); } .ds-seg button:focus-visible { outline:2px solid var(--accent, #4f46e5); outline-offset:2px; }" + }, + { + "name": "Sidebar Navigation", + "kind": "nav", + "refersTo": "nav-item", + "description": "Grouped sidebar items on the subtle ground; active item takes the pressed wash.", + "html": "", + "css": ".ds-nav { width:212px; padding:10px; color: var(--sidebar-fg, #5f5e5a); background: var(--surface-subtle, #fafafa); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-nav-label { margin:0; padding:14px 8px 4px; color: var(--fg-muted, #787774); font-size:12px; line-height:16px; } .ds-nav button { display:flex; align-items:center; gap:8px; width:100%; height:30px; margin:1px 0; padding:0 8px; color:inherit; font-size:14px; background:transparent; border:0; border-radius:6px; cursor:pointer; transition: background-color 150ms cubic-bezier(0.22,1,0.36,1); } .ds-nav button:hover { background: var(--hover, rgb(55 53 47 / 3%)); } .ds-nav button.active { color: var(--fg, #37352f); font-weight:500; background: var(--pressed, rgb(55 53 47 / 6%)); } .ds-nav button:focus-visible { outline:none; box-shadow: 0 0 0 1px var(--accent, #4f46e5); }" + }, + { + "name": "KPI Strip", + "kind": "card", + "refersTo": "kpi-cell", + "description": "One hairline frame of figures divided by internal rules, each label with a help tip.", + "html": "
Service ?
Degraded
Sandbox slots
12 / 16
Reported tokens
4.16M
P95 latency
—
", + "css": ".ds-kpis { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); margin:0; overflow:hidden; border:1px solid var(--line, #e9e9ec); border-radius:8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-kpi { display:flex; flex-direction:column; gap:6px; padding:14px 16px 16px; box-shadow: inset -1px 0 var(--line, #e9e9ec); } .ds-kpi dt { display:flex; align-items:center; gap:4px; color: var(--fg-muted, #787774); font-size:12.5px; line-height:18px; } .ds-q { display:inline-grid; place-items:center; width:13px; height:13px; border:1.2px solid var(--fg-subtle, #9b9a97); border-radius:50%; color: var(--fg-subtle, #9b9a97); font-size:9px; } .ds-kpi dd { display:flex; align-items:center; gap:7px; margin:0; color: var(--fg, #37352f); font-size:24px; font-weight:600; line-height:28px; letter-spacing:-0.02em; font-variant-numeric: tabular-nums; } .ds-tone { width:8px; height:8px; border-radius:50%; } .ds-tone.warn { background: var(--warning, #d97706); }" + }, + { + "name": "Status Dot", + "kind": "custom", + "refersTo": "status-dot", + "description": "A 7px dot plus a plain label; never colour alone, never a pill.", + "html": "Running Needs action Failed Idle", + "css": ".ds-status { display:inline-flex; align-items:center; gap:6px; margin-right:12px; color: var(--fg, #37352f); font:400 12.5px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-status i { width:7px; height:7px; border-radius:50%; background: var(--fg-subtle, #9b9a97); } .ds-status.ok i { background: var(--success, #16a34a); } .ds-status.warn i { background: var(--warning, #d97706); } .ds-status.danger i { background: var(--danger, #dc2626); } .ds-status.idle i { background: var(--status-idle, #b4b4b9); }" + }, + { + "name": "Meter", + "kind": "custom", + "refersTo": "meter", + "description": "Ratio against a limit; series fill until 80%, amber to 95%, red beyond.", + "html": "
Active sandboxes12 / 16
gpu-worker-027 / 8
", + "css": ".ds-meter-row { display:grid; grid-template-columns: 120px 1fr auto; align-items:center; gap:12px; margin:6px 0; color: var(--fg-muted, #787774); font:400 12.5px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-meter-row b { color: var(--fg, #37352f); font-weight:500; font-variant-numeric: tabular-nums; } .ds-meter { position:relative; display:block; height:6px; overflow:hidden; background: var(--meter-track, rgb(55 53 47 / 8%)); border-radius:999px; } .ds-meter > span { position:absolute; inset:0 auto 0 0; background: var(--meter-fill, #2a78d6); border-radius:999px; } .ds-meter.warn > span { background: var(--warning, #d97706); }" + }, + { + "name": "Data Table", + "kind": "custom", + "refersTo": "table-row", + "description": "Hairline-framed table with sticky subtle header, 44px rows, right-aligned tabular numerics.", + "html": "
AgentRequestsError rate
Contract checker571.8%
Code reviewer20—
", + "css": ".ds-table { overflow:auto; border:1px solid var(--line, #e9e9ec); border-radius:8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ds-table table { width:100%; border-collapse:collapse; font-size:13px; line-height:18px; } .ds-table th { height:34px; padding:0 12px; color: var(--fg-muted, #787774); font-size:12px; font-weight:500; text-align:left; background: var(--surface-subtle, #fafafa); border-bottom:1px solid var(--line, #e9e9ec); } .ds-table td { height:44px; padding:6px 12px; color: var(--fg, #37352f); border-bottom:1px solid var(--line-muted, #efeff1); } .ds-table tr:last-child td { border-bottom:0; } .ds-table tbody tr:hover { background: var(--hover, rgb(55 53 47 / 3%)); } .ds-table td strong { font-weight:500; } .ds-table tbody tr:hover strong { color: var(--accent, #4f46e5); } .ds-table .n { text-align:right; font-variant-numeric: tabular-nums; }" + } + ], + "narrative": { + "northStar": "The Operator's Ledger", + "overview": "The console is an operations back office, not a developer showroom. Every screen reads like a ledger page: one header rule, one strip of figures, then ruled sections of evidence (charts, bar lists, tables) sitting on white. Structure comes from 1px hairlines and whitespace, never from floating cards; colour is spent on data and state, almost never on decoration. The one indigo accent means \"you selected this\" or \"this is the primary action,\" and nothing else.\n\nDensity is deliberately high and calm: 13px body, 44px table rows, 28px controls, tabular figures in every column. The system is bilingual (zh-CN and English) and ships light and dark themes on the same token names; dark swaps values, never structure. It honours reduced motion and treats keyboard focus as a first-class state (2px indigo outline).\n\nThe data contract is part of the look. Core reports only what it observes, so the interface shows absence honestly: an em dash, a gap in a line, the word \"Unavailable\". An explained figure keeps its explanation one click away behind a circled question mark, so the page stays a ledger rather than a leaflet.", + "keyCharacteristics": [ + "Neutral warm-gray ink on white, one indigo accent for selection and primary actions only.", + "Hairline frames divided by internal rules; no nested cards, no decorative shadows.", + "A six-slot categorical palette for data, bound to the entity, not its rank.", + "Tabular numerals everywhere a number can line up.", + "Status is always a dot plus a plain-language label.", + "Explanations live behind \"?\" help tips; errors, warnings and safety notices stay visible." + ], + "rules": [ + { + "name": "The One Voice Rule", + "body": "Indigo is for selection and primary actions only. Data never wears the accent; charts, bar lists and meters draw from `--series-1..6`, `--series-other` and `--meter-fill`.", + "section": "colors" + }, + { + "name": "The Entity Owns Its Colour Rule", + "body": "A categorical colour follows the entity (model, tool, node), never its rank. An entity keeps its slot while visible; only slots of entities that left the view are reused. Anything beyond six series collapses into Series Other.", + "section": "colors" + }, + { + "name": "The Signal Is Not Decoration Rule", + "body": "Green, amber and red appear only when they report a state. A healthy meter stays in the series ramp; it turns amber or red only when the value crosses its threshold.", + "section": "colors" + }, + { + "name": "The Columns Line Up Rule", + "body": "Every figure that can share a column uses tabular numerals (`font-variant-numeric: tabular-nums`): KPI values, numeric table cells (right-aligned), bar-list values, axis ticks, tooltip values, counts.", + "section": "typography" + }, + { + "name": "The Honest Figure Rule", + "body": "Missing data renders as \"—\", a gap in the line, or \"Unavailable\"; never as 0. Compact numbers keep two decimals only when the integer part is a single digit (\"1.04M\"), otherwise one (\"415.7万\"); values under 10,000 print whole. Durations read \"850 ms / 12.4 s / 4m 12s / 3h 5m\".", + "section": "typography" + }, + { + "name": "The Plain Vocabulary Rule", + "body": "zh-CN copy uses one term per concept: 沙箱 (sandbox), 运行时 (runtime), 已上报 (reported), 活跃 (active), 提供方 (provider). Time ranges read \"1 小时 / 6 小时 / 24 小时 / 7 天\" (English \"1h / 6h / 24h / 7d\"), always in the one segmented control style.", + "section": "typography" + }, + { + "name": "The One Page Grammar Rule", + "body": "Every page, new or legacy, uses PageHeader, PageBody and Section. No page invents its own header height, gutter or section rhythm.", + "section": "layout" + }, + { + "name": "The One Frame Rule", + "body": "Charts and KPI figures sit in one hairline frame divided by internal rules (inset 1px lines), never in nested cards. A frame never contains another bordered, shadowed container.", + "section": "elevation" + } + ], + "dos": [ + "Do put every explanation of a figure, section or page behind a circled \"?\" help tip; keep errors, warnings and safety notices (deletion confirmation, uncertain writes, secrets) visible on the page.", + "Do reserve Parsar Indigo for selection, focus and primary actions; draw data from `--series-1..6` and `--series-other`, and meters from `--meter-fill`.", + "Do place charts and KPI figures in one hairline frame (8px) divided by 1px internal rules.", + "Do render missing data as \"—\", a chart gap, or \"Unavailable\".", + "Do show status as a 7px dot plus a plain label.", + "Do use tabular numerals for every aligned figure and right-align numeric columns.", + "Do use the one segmented control for time ranges, labelled \"1 小时 / 6 小时 / 24 小时 / 7 天\".", + "Do keep compact numbers at two decimals only when the integer part is one digit.", + "Do build every page from PageHeader, PageBody and Section with the 24px gutter and 28px section gap." + ], + "donts": [ + "Don't add lines of small explanatory print under headings, KPIs or charts.", + "Don't colour data, bars, lines or meters with the indigo accent.", + "Don't nest cards inside frames or lift sections with shadows; shadows are for raised controls and floating layers only.", + "Don't render an unreported value as 0 or draw a missing interval as a zero line.", + "Don't use coloured status pills or colour-only status.", + "Don't reassign a categorical colour by rank when data re-sorts.", + "Don't add uppercase letter-spaced micro-labels or eyebrow lines above headings; a section is named by its title alone.", + "Don't mix synonyms in zh-CN copy (for example alternating 沙盒 with 沙箱)." + ] + } +} \ No newline at end of file diff --git a/apps/web/.impeccable/surfaces/src-app-tsx.md b/apps/web/.impeccable/surfaces/src-app-tsx.md new file mode 100644 index 000000000..2da2a95df --- /dev/null +++ b/apps/web/.impeccable/surfaces/src-app-tsx.md @@ -0,0 +1,25 @@ +--- +version: 1 +slug: "src-app-tsx" +primary_target: "src/App.tsx" +related_targets: ["src/ConsoleApp.tsx"] +--- + +# Administrator console (operate) + +Scope: the signed-in console shell and every page behind it, plus the first-run step. Visitor mode: Operate. +Audience: the administrator of one Parsar Core deployment. Task: judge health, capacity, usage and failures; inspect and delete or copy project assets; manage projects, keys and nodes. +Constraints: Web API only (`/core/v1/admin`, `/core/v1/sandbox`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark. + +Information architecture: Monitor (Overview, Agent metrics, Sandbox metrics, Session log) · Resources (Agents, Environment templates, Skills, Files, Vaults) · Platform (Projects and keys, Nodes, System). + +Unresolved: deployment configuration wizard waits for backend fields. + +## Direction contract + +THESIS: One calm instrument panel for a whole deployment; every screen speaks one component language so the administrator reads state, not layout. Refuses the assembled dashboard of mismatched widgets and loading spinners. +OWN-WORLD: Beautiful UI's foundation: cool near-white canvas, white cards drawn by a hairline ring and smooth layered shadow, neutral ink ramp, pill buttons (ink primary), Inter with CJK system fallback, tabular numerals, semantic tints as condiment; Parsar indigo as the only accent, for selection, links and data. +STORY: The administrator lands on health, sees what needs attention, drills into a project, Session or node, and acts (delete, copy, issue, revoke) without waiting on a spinner. +FIRST VIEWPORT: Page header with title, filters and refresh on one line; KPI strip; the page's primary card (chart grid, table or topology); nothing above the fold is a loader. +FORM: User-pinned world (Beautiful UI + Parsar indigo, 2026-09-24); concept roll skipped because a user-pinned direction beats the roll. +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md new file mode 100644 index 000000000..f81fba4a8 --- /dev/null +++ b/apps/web/DESIGN.md @@ -0,0 +1,637 @@ +--- +name: Parsar Core Console +description: The management console for one self-hosted Parsar Core deployment; projects, their assets and keys, health and capacity, on raised cards over a quiet canvas. +colors: + ink: "#37352f" + ink-muted: "#787774" + ink-subtle: "#9b9a97" + sidebar-ink: "#5f5e5a" + surface: "#ffffff" + surface-subtle: "#fafafa" + surface-muted: "#f1f1f0" + canvas: "#f5f5f6" + card-border: "rgb(20 20 30 / 11%)" + line: "#e9e9ec" + line-muted: "#efeff1" + line-strong: "#d6d7dc" + hover: "rgb(55 53 47 / 3%)" + pressed: "rgb(55 53 47 / 6%)" + tile: "rgb(55 53 47 / 7%)" + accent: "#4f46e5" + accent-emphasis: "#4338ca" + accent-fg: "#ffffff" + data: "#4f46e5" + success: "#16a34a" + warning: "#d97706" + danger: "#dc2626" + status-queued: "#9a9ca4" + status-idle: "#b4b4b9" + series-1: "#2a78d6" + series-2: "#eb6834" + series-3: "#1baf7a" + series-4: "#eda100" + series-5: "#e87ba4" + series-6: "#008300" + series-other: "#a3a3a8" + meter-fill: "color-mix(in srgb, #37352f 62%, transparent)" + meter-track: "rgb(55 53 47 / 8%)" +typography: + metric: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "30px" + fontWeight: 600 + lineHeight: "36px" + letterSpacing: "-0.025em" + fontFeature: "\"tnum\"" + display: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "24px" + fontWeight: 500 + lineHeight: "28px" + letterSpacing: "-0.01em" + fontFeature: "\"tnum\"" + headline: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "20px" + fontWeight: 600 + lineHeight: "26px" + letterSpacing: "-0.02em" + title: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "15px" + fontWeight: 600 + lineHeight: "22px" + letterSpacing: "-0.01em" + body: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "13px" + fontWeight: 400 + lineHeight: "18px" + label: + fontFamily: "-apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Hiragino Sans GB\", \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", \"Helvetica Neue\", Helvetica, Arial, sans-serif" + fontSize: "12.5px" + fontWeight: 500 + lineHeight: "18px" + mono: + fontFamily: "ui-monospace, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", \"Noto Sans Mono\", monospace" + fontSize: "11.5px" + fontWeight: 400 +rounded: + hairline: "4px" + control: "6px" + control-inner: "5px" + segment: "7px" + popover: "8px" + frame: "12px" + pill: "999px" +spacing: + xs: "4px" + sm: "8px" + md: "12px" + lg: "16px" + gutter: "28px" + section: "28px" +components: + card: + backgroundColor: "{colors.surface}" + rounded: "{rounded.frame}" + button-primary: + backgroundColor: "{colors.accent}" + textColor: "{colors.accent-fg}" + rounded: "{rounded.control}" + padding: "0 10px" + height: "28px" + typography: "{typography.label}" + button-primary-hover: + backgroundColor: "{colors.accent-emphasis}" + button-outline: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.control}" + padding: "0 10px" + height: "28px" + button-outline-hover: + backgroundColor: "{colors.hover}" + button-danger: + backgroundColor: "{colors.danger}" + textColor: "#ffffff" + rounded: "{rounded.control}" + padding: "0 10px" + height: "28px" + button-ghost: + textColor: "{colors.ink-muted}" + rounded: "{rounded.control}" + padding: "0 10px" + height: "28px" + refresh-button: + textColor: "{colors.ink-muted}" + rounded: "{rounded.segment}" + size: "32px" + back-button: + textColor: "{colors.ink-muted}" + rounded: "{rounded.control}" + size: "28px" + text-action: + textColor: "{colors.ink-muted}" + typography: "{typography.label}" + input-field: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.control}" + padding: "4px 8px" + height: "28px" + search-field: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.control}" + height: "28px" + select: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.control}" + padding: "0 28px 0 9px" + height: "28px" + segmented-track: + backgroundColor: "{colors.surface-muted}" + rounded: "{rounded.segment}" + padding: "2px" + segmented-option: + textColor: "{colors.ink-muted}" + rounded: "{rounded.control-inner}" + padding: "0 10px" + height: "24px" + segmented-option-active: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + nav-item: + textColor: "{colors.sidebar-ink}" + rounded: "{rounded.control}" + padding: "0 8px" + height: "30px" + nav-item-active: + backgroundColor: "{colors.pressed}" + textColor: "{colors.ink}" + metric-tile: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + typography: "{typography.metric}" + rounded: "{rounded.frame}" + padding: "16px 18px 18px" + kpi-cell: + textColor: "{colors.ink}" + typography: "{typography.display}" + padding: "14px 16px 16px" + table-header: + backgroundColor: "{colors.surface-subtle}" + textColor: "{colors.ink-muted}" + padding: "0 12px" + height: "34px" + table-row: + textColor: "{colors.ink}" + typography: "{typography.body}" + padding: "6px 12px" + height: "44px" + empty-state: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink-muted}" + rounded: "{rounded.frame}" + padding: "40px 24px" + help-tip: + textColor: "{colors.ink-subtle}" + rounded: "{rounded.pill}" + size: "18px" + modal: + backgroundColor: "{colors.surface}" + textColor: "{colors.ink}" + rounded: "{rounded.popover}" + meter: + backgroundColor: "{colors.meter-track}" + rounded: "{rounded.pill}" + height: "6px" +--- + +# Design System: Parsar Core Console + +## Overview + +**Creative North Star: "The Operator's Ledger"** + +The console is a management tool, not a developer showroom. Every screen reads like +a ledger page laid on a desk: a quiet canvas, one header, then raised white cards +holding the evidence (figures, charts, tables). Structure comes from the card edge, +1px internal rules and whitespace; there are no cards inside cards. Colour is spent +on problems and on the data itself, almost never on decoration. The indigo accent +means "you selected this", "this is the primary action" or, as `--data`, "this is +the single measured quantity". + +Density is deliberately high and calm: 13px body, 44px table rows, 28px controls, +tabular figures in every column. The system is bilingual (zh-CN and English) and +ships light and dark themes on the same token names; dark swaps values, never +structure. It honours reduced motion and treats keyboard focus as a first-class +state (2px indigo outline). + +The data contract is part of the look. Core reports only what it observes, so the +interface shows absence honestly: an em dash, a gap in a line, the word +"Unavailable" or "Unknown". Explanations stay one click away behind a circled +question mark, so the page stays a ledger rather than a leaflet. + +**Key Characteristics:** +- White cards with a faint border and shadow on a light-gray canvas, beside a white + sidebar; the page header is solid canvas. +- One indigo voice for selection, primary actions and single-series data (`--data`). +- Meters are neutral ink; green, amber and red appear only when something is wrong + or a state needs reporting. +- A six-slot categorical palette for multi-series data, bound to the entity, not its + rank. +- One list grammar on every resource page: project filter, search, count, name with + compact ID, creator, row actions. +- Tabular numerals everywhere a number can line up. +- Status is always a dot plus a plain-language label. +- Explanations live behind "?" help tips; errors, warnings and safety notices stay + visible. + +## Colors + +A restrained neutral ledger with one indigo voice, three signal colours and a +separate categorical palette that belongs to multi-series data alone. + +### Primary +- **Parsar Indigo** (accent): keyboard focus outlines and rings, primary buttons, + hover on name links and text actions, text selection wash. Deepens to **Pressed + Indigo** (accent-emphasis) on primary hover. It is the brand colour shared with + the public Parsar landing. +- **Data** (`--data`, an alias of accent): the one measured series of a chart that + has only one, such as Sessions created per hour on Overview, drawn as a tint + (62% into the surface) rather than full strength. + +### Neutral +- **Ledger Ink** (ink): primary text, figures, table cells, headings. +- **Graphite** (ink-muted): secondary text, column headers, KPI labels, axis ticks, + inactive controls, row actions at rest. +- **Pencil** (ink-subtle): help-tip glyphs, crosshairs, untoned dots. +- **Sidebar Ink** (sidebar-ink): navigation text. +- **Canvas** (canvas): the ground of the main column (`#121212` in dark). +- **Paper** (surface): cards, tables, KPI strips, chart grids, empty states, + dialogs, popovers, the sidebar. +- **Card Edge** (card-border): the 1px border of raised cards. +- **Margin Gray** (surface-subtle): table header band, coverage notes. +- **Well Gray** (surface-muted): segmented-control track, secondary buttons. +- **Hairline** (line): internal dividers of cards, chart gridlines, dialog rules. +- **Faint Rule** (line-muted): row dividers inside tables. +- **Firm Rule** (line-strong): control borders (inputs, selects, search, outline + buttons) and the pending-key notice. +- **Hover / Pressed / Tile washes**: translucent ink at 3% / 6% / 7% for hover, the + active navigation item, and count pills. + +### Signal +- **Healthy Green** (success), **Caution Amber** (warning), **Fault Red** (danger): + status dots, KPI and tile tone dots, meter fills past their thresholds, error text, + error notices, destructive buttons and the hover of destructive row actions. +- **Queued Gray** (status-queued) is the pending KPI tone; **Idle Gray** + (status-idle) marks idle and neutral status dots. A running or pending status dot + uses Series 1. + +### Data (categorical) +- **Series 1–6** (Cobalt, Persimmon, Jade, Saffron, Rose, Forest) and **Series + Other**: lines, stacked bars and legend keys of multi-series charts (requests by + model, calls by tool, average against P95 duration, Runtime trends). Dark theme + re-tunes each slot under the same name. +- **Meter Fill** (meter-fill): ink at 62%, the healthy fill of every meter. +- **Meter Track** (meter-track): the empty rail under meters. + +### Named Rules +**The Colour Only for Problems Rule.** A healthy state is drawn in ink. Meters fill +in neutral ink and turn amber or red only past their thresholds; tone dots appear +only on figures that report a state. + +**The One Voice Rule.** Indigo is for selection, focus, primary actions and the +single `--data` series. Multi-series charts draw from `--series-1..6` and +`--series-other`, never from the accent. + +**The Entity Owns Its Colour Rule.** A categorical colour follows the entity (model, +tool), never its rank. An entity keeps its slot while visible; only slots of +entities that left the view are reused. Anything beyond six series collapses into +Series Other. + +## Typography + +**Display Font:** system UI sans (-apple-system, Segoe UI, with PingFang SC / +Microsoft YaHei / Noto Sans SC for Chinese) +**Body Font:** the same system stack +**Label/Mono Font:** ui-monospace / SF Mono / Menlo for identifiers and code + +**Character:** One quiet system sans in several weights, sized for dense reading; +hierarchy comes from weight and a tight scale, not from a second typeface. Mono +appears only for machine identifiers, key prefixes, models and commands. + +### Hierarchy +- **Metric** (600, 30px, 36px, -0.025em, tabular): the four Overview tiles; the + largest type in the console. +- **Display** (500, 24px, 28px, -0.01em, tabular): KPI strip figures. +- **Headline** (600, 20px, 26px, -0.02em): the page title in the 64px page header; + one per page. Detail pages put the back button before it. +- **Title** (600, 15px, 22px, -0.01em): section headings. Card headings and + empty-state titles step down to 600 at 14px. +- **Body** (400, 13px, 18px): table cells, controls, form fields, dialog text. The + document base is 14px/20px; help popovers run 12.5px/19px. +- **Label** (500, 12.5px, 18px): KPI labels (400), status labels, text actions, + segmented options; column headers 500 at 12px; fact labels 12px Graphite; axis + ticks 11px; the list count 12px. +- **Mono** (400, 11.5px): IDs and code in tables and name cells, in Graphite. + +### Named Rules +**The Columns Line Up Rule.** Every figure that can share a column uses tabular +numerals: KPI and tile values, numeric table cells (right-aligned), legend totals, +axis ticks, tooltip values, counts. + +**The Honest Figure Rule.** Missing data renders as "—", a gap in the line, +"Unavailable" or "Unknown"; never as 0. Compact numbers keep two decimals only when +the integer part is a single digit ("1.04M"), otherwise one ("415.7万"); values under +10,000 print whole. Durations read "850 ms / 12.4 s / 4m 12s / 3h 5m". + +**The Plain Vocabulary Rule.** zh-CN copy uses one term per concept: 项目 +(project), 沙箱 (sandbox), 运行时 (runtime), 创建者 (creator), 已上报 (reported), +活跃 (active), 提供方 (provider). API terms stay in English (Agent, Session, Turn, +Skill, Vault, Credential, API key). Time ranges read "1 小时 / 6 小时 / 24 小时 / +7 天" (English "1h / 6h / 24h / 7d"), always in the one segmented control style. + +## Layout + +A fixed 232px white sidebar beside a full-height main column on the canvas; below +640px the sidebar collapses to a 52px icon rail. The desktop minimum is 960px. +Every page uses the same frame: a 64px header (title, optional help tip, actions +on the right) in solid canvas, then a scrolling body +padded `20px 28px 48px` with sections stacked 28px apart. Inside a section the +heading row sits 12px above its content. + +The recurring shapes in the body are the KPI strip (auto-fit columns, min 158px; +three per row below 1180px), chart grids (two equal columns, single below 1180px), +full-width table cards, and a fact row on detail pages. Overview has its own +arrangement: four metric tiles, Session activity beside the fleet topology (Core +in the middle, nodes left and right, solid lines online and dashed offline; Core +and each node open an anchored popover with a two-column glance and links to +their pages), then the attention table and usage by project, each on its own +card with a 16px gap. Popovers are the overlay card (14px radius, overlay shadow, +16px padding): a 14px title, 12px labels over 13px values, links at a ruled foot. Nodes itself is a plain list with a detail page. + +Spacing follows a 4px base: 4, 8, 12, 16, 28 (page gutter and section gap). +Controls are 28px tall, segmented options 24px, table rows 44px (32px compact), +table headers 34px. + +**The One Page Grammar Rule.** Every page uses PageHeader, PageBody and Section +from `components/console-ui.tsx`, and every resource list uses the list grammar +from `components/list-ui.tsx`. No page invents its own header height, gutter, +section rhythm or toolbar. + +## Elevation & Depth + +Depth comes from the canvas-to-card step, not from stacked shadows. + +### Shadow Vocabulary +- **Card** (no shadow; a 1px `card-border` edge at 11% ink): KPI strips, table + frames, chart grids, Overview cards, the Session transcript and the deployment + panel. Cards are flat; no page surface is translucent or blurred. +- **Control lift** (`0 1px 2px rgb(0 0 0 / 6%)`): primary and outline buttons, + inputs, selects, the search field, the active segment. +- **Floating** (`0 1px 2px rgb(24 24 27 / 4%), 0 8px 24px -12px rgb(24 24 27 / + 18%)`): help-tip popovers, chart tooltips, menus and dialogs. + +### Named Rules +**The One Card Rule.** Figures, charts and tables sit in one card divided by 1px +internal rules. A card never contains another bordered, shadowed container; an +empty list is itself one card. + +## Shapes + +12px corners on cards, tables, KPI strips, chart grids, empty states, coverage +notes and the pending-key notice; 8px on dialogs and help popovers; 7px on the +segmented track, the refresh button and chart tooltips; 6px on buttons, inputs, +selects and the search field; 5px on inner segments; 4px on small inline marks and +flags; full pills for meters and count badges; circles for status dots (7px), KPI +tone dots (8px) and tile dots (10px). Legend keys are 9px squares with 2px corners, +or 12×2px strokes for line series. Borders are always 1px. + +## Components + +### Buttons +Compact and quiet; the primary button is the only filled accent in a header. +- **Shape:** 6px corners, 28px tall, 0 10px padding, 13px/500 label, optional 14px + Lucide icon. +- **Primary:** Parsar Indigo fill, white text, control lift; deepens on hover. Used + for the one affirmative header action (Create project) and for the submit button + of non-destructive dialogs (create, rename, issue, copy, continue). +- **Outline:** Paper face, Firm Rule border, control lift; hover takes the ink wash. + Used for every action in a card or section header (Issue key, Manage nodes, + Session log, Projects and keys), Copy on detail pages, Cancel in dialogs and + empty-state actions. +- **Danger:** Fault Red fill, white text. Used for Delete on detail pages and for + the confirm button of every destructive dialog. +- **Ghost:** transparent with Graphite text; darkens on hover. +- **Focus / Press:** focus draws an indigo border plus 1px indigo ring; press scales + to 0.97. +- **Text action:** borderless Graphite 12.5px/500 that takes the hover wash; used + only for per-row actions in tables (Copy to project, Delete) and links in a + popover's foot, never in a header. A destructive text action turns red on hover. + +### Refresh button +A 32px ghost icon button with the refresh glyph. Controls that scope the whole page +(project filter, time range) come before it; on detail pages it leads, followed by +Copy and Delete. It spins while reading; its tooltip carries the last update time +instead of a visible timestamp. + +### Segmented control +The single style for ranges, order and status filters. A Well Gray track (2px +padding, 8px corners) holds 26px options in Graphite; the chosen option sits on a +Paper thumb with control lift and Ledger Ink text, and the thumb glides to a new +choice (Motion shared layout). Options may carry a tabular count. It is a +radiogroup with arrow-key movement. + +### Selects and the project filter +Selects are 28px Paper fields with a Firm Rule border, control lift and a drawn +chevron; focus swaps the border to indigo with a 1px ring. The project filter is a +select whose first option is **All projects**; archived projects are listed with +"· archived". + +### List grammar +Every resource list, the Session log and the project list share one grammar: +- **ListToolbar**: on project-scoped lists the project filter first, then the + SearchField (280px, search icon, Paper, Firm Rule border), then any further + filters (segmented status or order, selects); the count sits on the right in 12px + Graphite ("12 total", "3 of 12", "40 loaded" when more exist). +- **Project column**: shown only while All projects is selected, right after the + name; archived projects are muted. +- **NameCell**: the first column. The name at 500 weight (a link that turns indigo + on hover when the row opens a detail page; a muted fallback such as "Untitled" + when the resource has no name) with the compact ID underneath in 11.5px mono. The + ID's copy button appears on row hover or focus; the full ID lives in its tooltip. +- **Creator column**: the last column before the actions, headed "Creator" with a + help tip. It shows the creating key's name (its prefix when unnamed) with a small + "Revoked" flag for revoked keys, "Admin copy" in Graphite for an administrator's + copy, "Unknown" in Graphite when Core has no record, and "—" while loading or when + the lookup failed. +- **RowActions**: text actions right-aligned at the end of the row, 16px apart: + Copy to…, then Delete (red on hover). A row click opens the detail page; action + clicks do not. +- **Partial failure**: when some projects fail to load, one red line names them + above the table; the other projects still show. +- **Empty state**: a solid card (Paper, 1px Hairline, 12px, 40px 24px padding) with + an optional 20px outline icon, a 14px/600 title, an optional one-line description + and an optional action. "No matches" offers Clear search. +- **Load more**: an outline button centred under its table when more rows exist. + +### Detail pages +- The page header starts with a **back button** (28px ghost icon button, arrow-left, + Graphite) before the title; the actions on the right start with Refresh, continue + with outline actions such as Copy, and end with Delete (danger). +- Under the header, **resource-facts** lays out the facts as a grid of up to four + label/value pairs per row (12px Graphite label over a 13px value, 14px by 40px + gaps, two columns below 900px). It starts with the ID (with its copy button) and + the Project and includes the Creator. +- Sections follow: usage figures in a KPI strip, then tables in cards. + +### Dialogs +Dialogs are 448px Paper cards with 8px corners, a 48px header and a 52px footer +separated by Hairlines, and the floating shadow. They cannot be closed while a +request runs. +- **ConfirmDialog**: the one grammar for destructive actions. The body states what + will be deleted and its consequences; the footer holds Cancel (outline) and the + confirm button (danger), whose label changes while busy. Core's reason for a + rejection, or an uncertain-outcome warning, appears in red inside the dialog. + The Skill page's delete dialogs follow the same grammar; deleting a whole Skill + also requires typing its name. +- **CopyDialog**: the source name with its type and project; a target-project + select (active projects other than the source) with a help tip; a target Vault + select when copying one Credential; the **Include dependencies** checkbox with a + help tip where it applies. After success it lists new IDs (source → copy) and + skipped entries with their reasons, and offers "Open in ". A failed copy + says whether nothing was copied or the outcome is uncertain. +- **Key dialogs**: name fields carry their rules in a help tip and their problem in + red underneath. The issued key appears in a read-only field with a copy button, + under a notice that it is shown once; only "I've saved this key" dismisses it. + Closing the dialog moves the key into a pending notice card on the page. + +### Navigation +Sidebar groups Monitor, Resources and Platform with 12px Graphite group labels; +items are 30px rows with a 15px outline icon and Sidebar Ink text. Hover takes the +ink wash; the active item sits on a white chip (the page panel's surface, ringed) +with Ledger Ink at 500, and the chip glides to the next item on navigation. The +Platform group sits below a hairline. A secondary page (one Session) highlights its +parent. The footer holds sign-out and the language/theme menu. + +### KPI strip and metric tiles +A KPI strip is one card of equal cells separated by inset rules. Each cell: a +12.5px Graphite label with an optional help tip, then the figure at 20px/500 with +any unit or limit small beside it, optionally led by an 8px tone dot. Overview uses +four separate metric tiles instead: a 13px label with a help tip, the same 20px +figure (the service status as a dot and a word), and one 12.5px line of context. +Figures ellipsize rather than wrap. Live figures on monitor pages roll their digits +to a new value on refresh (NumberFlow) instead of swapping. + +### Help tip +An 18px circular button holding a 13px circled "?" in Pencil; hover or open takes +Ledger Ink on the ink wash. It opens on hover, focus or click (click pins it), +closes on Escape, scroll or resize, and renders a 12.5px popover (Paper, Hairline, +8px, floating shadow, max 288px) in a portal. The text also exists in a visually +hidden element for assistive technology. + +### Status dot +A 7px circle plus a plain label at 12.5px: ok green, warning amber, danger red, +pending Series 1 with a soft expanding ring while work is in progress, neutral +Idle Gray. A failed Session's error and a waiting +Session's required actions sit in a help tip beside the label. Never a coloured +pill, never colour alone. + +### Meter +A 6px pill rail in Meter Track with a neutral ink fill. The fill turns amber at 90% +and red at 100% of its limit by default, and a nonzero ratio shows at least 3% +width. An unknown ratio draws an empty rail. A share meter may carry a fixed +identity colour and then ignores thresholds. + +### Charts +Time-series charts live in chart panels (caption 13px/600, legend with series +totals, plot) inside one chart-grid card. Lines are 2px round-joined with a +surface-ringed end dot; gridlines are crisp Hairlines with 11px tabular ticks; +hovering draws a Pencil crosshair, a hover-wash band and a floating tooltip. Missing +buckets are gaps, not zeros. Every chart has a 26px table toggle at its top right +that reveals the numbers in a 220px scrolling table. When a range is first shown, +bars rise from the baseline in a short left-to-right wave and lines trace from their +first point; refreshes of the same range redraw in place. + +### Tables +A card with a sticky 34px Margin Gray header in Graphite 12px/500, 44px rows divided +by Faint Rules, hover wash, right-aligned tabular numerics, clickable rows where a +detail page exists, and the list grammar above. + +### Notices +Coverage notes (Margin Gray, Hairline, 12px corners, 12.5px Graphite) state bounded +aggregation or a stale view; the error variant tints toward Fault Red. Partial-data +chips are amber-tinted pills with a help tip. Safety notices (a key shown once, an +uncertain write, a destructive consequence) stay visible in body text. + +### Onboarding +Signing in and first-run setup share one frame: a dark stage on the left (always +dark, whatever the theme) and the task panel on the right, which follows the +theme. The stage is the product's one authored moment: a flickering indigo dot +grid under slow light rays (Magic UI's flickering grid and light rays), Core as +the Parsar mark on a tile with a travelling border beam, and two orbits of +Agents, Sessions, Skills, Vaults, files, templates and machines around it; the +Parsar mark is itself nodes on a ring. Brand copy sits bottom-left in solid +ink; it is a paragraph, not a heading, because the panel's title names the task. +First run is three steps with numbered progress in the panel header: the +administrator account, the first project and its key (shown once; the example +request types itself into a terminal), and a three-chapter tour — Monitor, +Resources, Platform — whose stage shows a real dark screenshot of those pages, +tilted towards the panel. Steps change inside a View Transition: the stage holds +still while the panel slides and blurs across; opening the console dissolves +the onboarding forward and reveals the console in a circle growing from the +pressed button. With reduced motion the orbits hold their places, the grid is a +still frame, the request is a plain block and no transition runs. + +### Loading and motion +The console has no spinners and no "Loading…" lines. Reads are cached (TanStack +Query) and prefetched on navigation hover, so revisits show data at once and +refreshes keep the last data on screen. Only a first read shows a skeleton in the +final layout's cards: table rows, a headline strip with chart panels, or a facts +card with a table, swept once under a second. Work in progress is the Agent's +shimmering "Working…" line in the conversation and the breathing pending dot. + +Motion reports state and never makes anyone wait: the navigation chip and segmented +thumbs glide (Motion, one 320ms spring without bounce), figures roll, charts draw in +once per range, new conversation messages settle 6px upward in 260ms, pages fade in +160ms, popovers and dialogs scale from 98%. Reduced motion makes all of it instant. + +## Do's and Don'ts + +### Do: +- **Do** put every explanation of a figure, column, section or page behind a + circled "?" help tip; keep errors, warnings and safety notices (deletion + consequences, uncertain writes, a key shown once) visible. +- **Do** start every project-scoped toolbar with the project filter, then search, + with the count on the right. +- **Do** end every resource table with the Creator column and then the row actions. +- **Do** confirm every deletion in ConfirmDialog and every copy in CopyDialog. +- **Do** keep meters in neutral ink and let amber and red mean a threshold was + crossed. +- **Do** reserve Parsar Indigo for selection, focus, primary actions and the single + `--data` series. +- **Do** place figures, charts and tables in one card divided by 1px internal rules. +- **Do** render missing data as "—", a chart gap, "Unavailable" or "Unknown". +- **Do** show status as a 7px dot plus a plain label. +- **Do** use tabular numerals for every aligned figure and right-align numeric + columns. +- **Do** build every page from PageHeader, PageBody and Section. + +### Don't: +- **Don't** add lines of small explanatory print under headings, KPIs, fields or + charts. +- **Don't** colour healthy meters, bars or states; colour is for problems and data. +- **Don't** colour multi-series data with the indigo accent. +- **Don't** nest cards inside cards or draw a dashed empty state. +- **Don't** render an unreported value as 0 or draw a missing interval as a zero line. +- **Don't** use coloured status pills or colour-only status. +- **Don't** reassign a categorical colour by rank when data re-sorts. +- **Don't** show full IDs in list columns; show the compact ID with its copy button. +- **Don't** add uppercase letter-spaced micro-labels or eyebrow lines above + headings; a section is named by its title alone. +- **Don't** mix synonyms in zh-CN copy (for example alternating 沙盒 with 沙箱, or + API 密钥 with API key). diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md new file mode 100644 index 000000000..533741d8b --- /dev/null +++ b/apps/web/PRODUCT.md @@ -0,0 +1,141 @@ +# Product + + + +## Platform + +web + +## Users + +The primary user is the administrator who deployed Parsar Core: a self-hosted, +OpenAI Agents API compatible execution service. After signing in to the paired +console they need to answer quickly: is the service healthy, is there enough +sandbox capacity, how much is each project using, and where is work failing. +They also create projects and issue their keys, enroll execution nodes, and clean +up or redistribute assets between projects. + +API callers (application developers, and Parsar itself) use the Agents API from +their own code with the keys of their project, not this console. The console is +the administrator's management tool, comparable to what the provider of a hosted +API runs internally: it manages the service and its projects, it does not build or +run things on a caller's behalf. + +## Product Purpose + +A management console for one Parsar Core deployment. Success: the administrator +lands on health, capacity, usage and failures across every project; inspects any +project's Agents, Environment templates, Skills, Files, Vaults and Session history +together with the API key that created each of them; deletes assets (for example a +leaked Credential) and copies them between projects; manages projects and their +named keys; and administers sandbox nodes. + +## Positioning + +The console runs beside the administrator's own Core, with execution, files and +credentials on infrastructure they control. It shows only evidence Core actually +reports and never invents readiness, traffic or zero values for missing data. It +is not a playground: there is no Agent builder, Session composer or request +workbench. + +## Operating Context + +- Paired console (`services/core-console`): a single local administrator account + (legacy installations keep Basic authentication). The browser holds only the + console sign-in; the console server holds the deployment administrator + credential and forwards the Web API (`/core/v1/admin/**`) and sandbox + administration (`/core/v1/sandbox/**`). The console never calls `/v1`. +- The console account is not an Agents API identity. An administrator who wants to + call the Agents API issues a key in a project like any other caller. +- `/console/config` reports whether sandbox administration is available; without + it the Nodes page explains that it is not configured and the fleet figures show + as unavailable. +- Chinese and English UI; light and dark themes; reduced motion honored. + +## Information Architecture + +- **Monitor**: Overview (service status, running Sessions, sandbox slots, Sessions + needing attention, 24-hour Session activity, the topology of Core and its nodes + with a popover glance at each, the attention table, usage by project), Agent metrics (requests, errors, + duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), + Sandbox metrics (node capacity and hosted Runtimes across projects), Session log + (every Session, read-only, opening one Session's history). +- **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list + shows one project or all projects, with a Project column when all are shown and a + Creator column naming the creating key. Detail pages show the resource's facts + and offer Copy and Delete. +- **Platform**: Projects and keys (projects, their assets and usage, named keys, + write history), Nodes (sandbox deployment, the node list with each node's + capacity, host figures and allocations, enrollment and removal), System (startup + configuration and the sandbox deployment). +- **First run**: after the administrator account is created and while no project + exists, full-screen steps outside the shell create the first project (default name + `Default`) and its first key, show the plaintext once with an example request, + then give a three-chapter tour of the console (Monitor, Resources, Platform) + before opening it. Signing in uses the same stage. +- Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn, + Skill, Vault, Credential, API key). + +## Capabilities and Constraints + +- **Projects and keys.** A project owns an isolated set of assets shared by all of + its named API keys; projects do not see each other's assets. Issuing or revoking + a key never touches assets. Archiving a project revokes every key and keeps its + assets viewable, deletable and copyable. Key plaintext is shown once, at issuance, + and never stored by the console. +- **Web API only.** Every read and write goes through `/core/v1/admin/**` or + `/core/v1/sandbox/**`. The console holds no API key and sends nothing to `/v1`. +- **No asset writes except delete and copy.** Assets are created and changed only by + a project's keys through the Agents API. The console does not create or edit + Agents or Templates, upload Skills or Files, create or replace Credentials, start + Sessions, send input or cancel work. Deletion follows the public deletion rules; + a busy Session is not deletable and the console never cancels work to make it so. +- **Copies are independent.** A copy lands in another active project with new IDs, + optionally with its dependencies, and is never synchronised afterwards. Core + re-encrypts secrets internally; OAuth Credentials with a refresh configuration are + skipped. Sessions cannot be copied. +- **Secrets stay write-only.** Credential tokens, Template environment variables and + setup commands are never returned, to the administrator included. +- **Creators.** Core records the key behind every write. The console shows the + creating key of each asset and a project's write history; an administrator's copy + shows as Admin copy and an asset without a record as Unknown. +- **Session history is read-only.** A Session page reads the Session, its Items and + Turns and polls while work is in flight; there is no live event stream. +- **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, + tool and activity figures are still assembled in the browser from bounded reads + and state their coverage. Metrics that need new Core endpoints are recorded as + backend requirements, not simulated. Usage is cumulative per Session and is not + billing. +- Runtime CPU and memory exist only for Core-managed hosted sandboxes. +- Preserve workflow safety: confirmed deletion, no automatic retry of uncertain + writes, idempotent copies, no secrets in browser storage. + +## Brand Commitments + +- Product name: Parsar Core. Parsar mark assets in `apps/web/public/`. +- Keep the Parsar visual identity shared with the public landing (`site/`): + neutral grays and a quiet indigo accent. The console uses Inter and Geist Mono + on Beautiful UI's foundation tokens and structure; `DESIGN.md` records the system. + +## Evidence on Hand + +- Browser acceptance in `apps/web/e2e/`: one test per acceptance behavior against + `fixture-console.mjs`, a synthetic console service with deterministic data. +- No customer data, benchmarks or usage claims exist; do not fabricate them. + +## Product Principles + +1. Operations first: health, capacity, usage and failures lead. +2. Manage, don't operate: the administrator views, deletes, copies and manages + projects and keys; assets belong to the projects' keys. +3. Report evidence, not assumptions: missing data stays visibly missing. +4. One page grammar everywhere: the same header, toolbar, tables, metrics and + states on every screen. +5. Projects are the unit: every asset shows the project that owns it and the key + that created it. +6. Deployment-level truth (nodes, configuration) is distinct from project assets. + +## Accessibility & Inclusion + +Keyboard navigation with visible focus, reduced-motion support, and bilingual +Chinese/English copy through the existing i18n modules. diff --git a/apps/web/README.md b/apps/web/README.md index dc9a40fe9..50cc9b821 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,10 +1,9 @@ # Core administrator Web frontend -This package contains the React application served by `services/core-console`. -The frontend team owns migration to the implemented administrator API and -`AdminClient`. Current execution pages and the Vite development proxy still reflect -the previous frontend implementation; their builds, screenshots and fixture tests -do not establish acceptance of the new management UI. +This package contains the React application served by `services/core-console`: +the administrator console for monitoring Core, inspecting and copying Project +resources, and managing Projects, keys and sandbox nodes. Its design system is +described in [DESIGN.md](DESIGN.md) and its product scope in [PRODUCT.md](PRODUCT.md). ## Integration contract @@ -34,6 +33,6 @@ pnpm --filter @agents-core-web/web test pnpm --filter @agents-core-web/web build ``` -These checks cover the current application source. Management browser acceptance -must run separately after migration through `services/core-console`. Required -repository checks are documented in [CONTRIBUTING.md](../../CONTRIBUTING.md). +These checks cover the application source. Browser acceptance through +`services/core-console` is tracked in the [frontend roadmap](../../docs/web/roadmap.md). +Required repository checks are documented in [CONTRIBUTING.md](../../CONTRIBUTING.md). diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 000000000..de19aebfa --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/app.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts new file mode 100644 index 000000000..1be37409b --- /dev/null +++ b/apps/web/e2e/access.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +import { expectManagementBoundary, resetFixture } from "./console"; + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +test("creates the administrator, keeps no credential in the browser, and signs out and back in", async ({ page, request }) => { + await resetFixture(request, "setup"); + await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "Create your administrator account" })).toBeVisible(); + await page.locator("input[name=username]").fill("admin"); + await page.locator("input[name=password]").fill("correct horse battery"); + await page.locator("input[name=confirm]").fill("correct horse battery"); + await page.getByRole("button", { name: "Create administrator account" }).click(); + await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); + + const stored = await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage })); + expect(stored).not.toContain("correct horse battery"); + + await page.getByRole("button", { name: "Sign out" }).click(); + await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible(); + await page.locator("input[name=username]").fill("admin"); + await page.locator("input[name=password]").fill("wrong password!!"); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + await page.locator("input[name=password]").fill("correct horse battery"); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); +}); + +test("sets up a fresh install: first project, a key shown once, the tour, then the console", async ({ page, request }) => { + await resetFixture(request, "setup", { fresh: true }); + await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); + await page.goto("/"); + await page.locator("input[name=username]").fill("admin"); + await page.locator("input[name=password]").fill("correct horse battery"); + await page.locator("input[name=confirm]").fill("correct horse battery"); + await page.getByRole("button", { name: "Create administrator account" }).click(); + + await expect(page.getByRole("heading", { name: "Create your first project" })).toBeVisible(); + await page.locator("input[name=key-name]").fill("my-app"); + await page.getByRole("button", { name: "Create project and key" }).click(); + await expect(page.getByLabel("New key my-app")).toHaveValue(/fixture-secret/); + await page.getByRole("button", { name: "I've saved it, continue" }).click(); + + await expect(page.getByLabel("New key my-app")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Is it healthy, and where does it fail?" })).toBeVisible(); + await page.getByRole("button", { name: "Skip" }).click(); + await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); + expect(await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage }))).not.toContain("fixture-secret"); +}); diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts deleted file mode 100644 index 9de471d89..000000000 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ /dev/null @@ -1,3796 +0,0 @@ -import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from "@playwright/test"; - -const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; - -interface FixtureRequest { - method: string; - path: string; - query?: string; - beta: string | null; - authorizationPresent: boolean; - idempotencyKeyPresent: boolean; - idempotencyKey: string | null; - body?: Record; -} - -async function resetFixture(request: APIRequestContext) { - const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); - expect(response.ok()).toBe(true); -} - -async function controlFixture(request: APIRequestContext, control: Record) { - const response = await request.post(`${fixtureBaseUrl}/__fixture/control`, { data: control }); - expect(response.ok()).toBe(true); -} - -interface FixtureState { - sessions: Array<{ id: string; metadata: Record }>; - aborts: { sessionListReads: number; sessionReads: number; itemReads: number; turnReads: number; streams: number; environmentFileReads: number }; - openStreams: string[]; -} - -async function fixtureState(request: APIRequestContext): Promise { - const response = await request.get(`${fixtureBaseUrl}/__fixture/state`); - expect(response.ok()).toBe(true); - return response.json() as Promise; -} - -async function setFixtureSessionMetadata( - request: APIRequestContext, - id: string, - metadata: Record, -) { - const response = await request.post(`${fixtureBaseUrl}/__fixture/session-metadata`, { data: { id, metadata } }); - expect(response.ok()).toBe(true); -} - -async function removeFixtureSession(request: APIRequestContext, id: string) { - const response = await request.post(`${fixtureBaseUrl}/__fixture/remove-session`, { data: { id } }); - expect(response.ok()).toBe(true); -} - -async function emitTurnFixture(request: APIRequestContext, status: "completed" | "failed" | "cancelled") { - const response = await request.post(`${fixtureBaseUrl}/__fixture/emit-turn`, { data: { status } }); - expect(response.ok()).toBe(true); -} - -async function emitSessionFixture(request: APIRequestContext, status: "in_progress" | "idle" | "failed") { - const response = await request.post(`${fixtureBaseUrl}/__fixture/emit-session`, { data: { status } }); - expect(response.ok()).toBe(true); -} - -async function fixtureRequests(request: APIRequestContext): Promise { - const response = await request.get(`${fixtureBaseUrl}/__fixture/requests`); - expect(response.ok()).toBe(true); - return response.json() as Promise; -} - -async function createFixtureSession(request: APIRequestContext, label: string) { - const response = await request.post(`${fixtureBaseUrl}/v1/agents/sessions`, { - headers: { - "OpenAI-Beta": "agents=v1", - "Idempotency-Key": `fixture-filter-${label}`, - }, - data: { - agent_id: "agent_b", - environment: { type: "none" }, - metadata: { filter_fixture: label }, - input: `Review the filter fixture ${label}.`, - stream: false, - vault_ids: [], - }, - }); - expect(response.status()).toBe(201); -} - -async function expectSelectedDeleteAbortsSessionRead( - page: Page, - request: APIRequestContext, - startRead: () => Promise, -) { - const path = "/v1/agents/sessions/session_snapshot"; - const failedReads = new Map(); - page.on("requestfailed", (failedRequest) => { - const failedPath = new URL(failedRequest.url()).pathname; - if (failedRequest.method() === "GET") { - failedReads.set(failedPath, failedRequest.failure()?.errorText ?? "unknown failure"); - } - }); - const before = await fixtureState(request); - const previousReads = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === path - )).length; - - await startRead(); - await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === path - )).length).toBeGreaterThan(previousReads); - - await page.locator(".conversation-session-action").click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog).toHaveCount(0); - - await expect.poll(() => failedReads.get(path)).toContain("ERR_ABORTED"); - await expect.poll(async () => (await fixtureState(request)).aborts.sessionReads) - .toBeGreaterThan(before.aborts.sessionReads); - expect((await fixtureState(request)).sessions.some((session) => session.id === "session_snapshot")).toBe(false); -} - -/** - * Single-Environment reads only. The reusable Template collection shares this - * path prefix but is durable configuration, not an Environment resource read. - */ -function isEnvironmentResourcePath(path: string): boolean { - return path.startsWith("/v1/agents/environments/") && !path.startsWith("/v1/agents/environments/templates"); -} - -async function openAgents(page: Page, request: APIRequestContext) { - await resetFixture(request); - await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents" }).click(); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); -} - -async function startSessionWithSecondAgent(page: Page) { - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - await expect(dialog).toBeVisible(); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the selected Agent configuration."); - const liveHandoff = page.waitForResponse((response) => ( - response.request().method() === "GET" && /\/agents\/sessions\/session_created_[^/]+\/events$/u.test(new URL(response.url()).pathname) - )); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await liveHandoff; -} - -async function openAdvancedSessionSettings(dialog: Locator) { - const toggle = dialog.getByRole("button", { name: /Advanced settings/ }); - if (await toggle.getAttribute("aria-expanded") !== "true") await toggle.click(); - await expect(toggle).toHaveAttribute("aria-expanded", "true"); - await expect(dialog.getByRole("region", { name: "Advanced Session settings" })).toBeVisible(); -} - -function environmentTrigger(page: Page) { - return page.getByRole("button", { name: /Environment|Connect environment/i }); -} - -function connectedLiveEvents(page: Page) { - return page.getByRole("status", { name: "Session live events: Live events" }); -} - -async function setDarkTheme(page: Page) { - await page.getByRole("button", { name: "Language and appearance" }).click(); - await page.getByRole("menuitemradio", { name: "Dark theme" }).click(); -} - -async function openEnvironmentDialog(page: Page) { - const conversation = page.getByRole("tabpanel", { name: "Conversation" }); - await expect(conversation.getByRole("region", { name: "Environment and Workspace status" })).toHaveCount(0); - - const trigger = environmentTrigger(page); - await expect(trigger).toBeVisible(); - await expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); - await trigger.click(); - - const dialog = page.getByRole("dialog", { name: "Environment" }); - await expect(dialog).toBeVisible(); - const panel = dialog.getByRole("region", { name: "Environment and Workspace status" }); - await expect(panel).toBeVisible(); - return { dialog, panel, trigger }; -} - -async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { - await testInfo.attach(name, { - body: await page.screenshot({ fullPage: true, animations: "disabled" }), - contentType: "image/png", - }); -} - -async function attachElementScreenshot(locator: Locator, testInfo: TestInfo, name: string) { - await testInfo.attach(name, { - body: await locator.screenshot({ animations: "disabled" }), - contentType: "image/png", - }); -} - -async function openSessionsFromHome(page: Page) { - await page.goto("/"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); -} - -test("opens Dashboard as the default landing page", async ({ page, request }) => { - await resetFixture(request); - await page.goto("/"); - - await expect(page.getByRole("button", { name: "Dashboard", exact: true })).toHaveAttribute("aria-current", "page"); - await expect(page.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); - await expect(page.locator(".dashboard-page")).toBeVisible(); -}); - -test("explains a 502 Core backend failure and opens copyable Docker recovery steps", async ({ page }) => { - await page.route("**/v1/agents**", async (route) => { - await route.fulfill({ - status: 502, - contentType: "application/json", - body: JSON.stringify({ - error: { message: "Agent core request failed (502).", type: "gateway_error" }, - }), - }); - }); - await page.goto("/"); - - const recovery = page.getByRole("button", { - name: "Agent Core backend is not ready. Open Docker startup guide", - }); - await expect(recovery).toBeVisible(); - await expect(recovery).toContainText("HTTP 502"); - await expect(page.getByText("Agents: Agent core request failed (502).", { exact: true })).toHaveCount(0); - await expect(page.getByText( - "Agent Core backend is not ready. Open the Dashboard startup guide.", - { exact: true }, - )).toHaveCount(1); - await expect(page.getByText("Agent core request failed (502).", { exact: true })).toHaveCount(0); - await recovery.click(); - - const dialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); - await expect(dialog).toContainText("Start a local Docker backend"); - await expect(dialog).toContainText("docker start parsar-agents-api-web-smoke-db"); - await expect(dialog).toContainText("docker start agents-core-web-api agents-core-web-daemon"); - await expect(dialog).toContainText("http://127.0.0.1:8091/healthz"); - await expect(dialog).toContainText("First time on this computer"); - await expect(dialog).toContainText("make docker-build-agents-api"); - await expect(dialog.getByRole("link", { name: "Core container setup" })).toHaveAttribute( - "href", - /parsar-core\/blob\/main\/services\/agents-api\/CONTAINER\.md$/, - ); - await expect(dialog.getByRole("button", { name: "Copy database start command" })).toBeVisible(); - await expect(dialog.getByRole("button", { name: "Copy Core image build command" })).toBeVisible(); - await expect(dialog.getByRole("button", { name: "Test connection" })).toBeVisible(); -}); - -test("retrieves the latest Agent and opens its validated edit setup directly", async ({ page, request }, testInfo) => { - const browserErrors: string[] = []; - page.on("console", (message) => { - if (message.type() === "error") browserErrors.push(message.text()); - }); - page.on("pageerror", (error) => browserErrors.push(error.message)); - - await openAgents(page, request); - await expect(page.getByText("Lifecycle Agent · stale list", { exact: true })).toBeVisible(); - - await controlFixture(request, { retrieveDelayMs: 250 }); - await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); - await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); - const setup = page.locator(".agent-setup-page"); - await expect(setup.getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); - await expect(setup).not.toContainText("stale list"); - await expect(setup).toContainText("agent_a"); - await expect(setup.getByRole("heading", { name: "Saved definition" })).toBeVisible(); - await expect(setup).toContainText("Start Session is unavailable. Current Core Session admission requires"); - await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(setup.getByRole("button", { name: "Save changes" })).toBeInViewport(); - expect(await setup.locator(".agent-setup-actions").evaluate((element) => getComputedStyle(element).position)).toBe("sticky"); - await attachScreenshot(page, testInfo, "desktop-light-agent-edit-setup"); - - const name = page.getByLabel("Name"); - await expect(name).toBeFocused(); - await expect(setup).toContainText("Web-side suggestions, not a discovered Core catalog"); - await expect(setup).toContainText("Never store secrets in Agent metadata"); - - await page.getByLabel("Metadata").fill('{"retries":3}'); - await page.getByRole("button", { name: "Save changes" }).click(); - await expect(page.getByText("Every metadata value must be a string.")).toBeVisible(); - let requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); - - await page.getByLabel("Metadata").fill(JSON.stringify(Object.fromEntries( - Array.from({ length: 17 }, (_, index) => [`key-${index}`, "value"]), - ))); - await page.getByRole("button", { name: "Save changes" }).click(); - await expect(page.getByText("Agent metadata supports at most 16 pairs.")).toBeVisible(); - requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); - - await name.fill(""); - await page.getByLabel("Instructions").fill(""); - await page.getByLabel("Metadata").fill('{"team":"acceptance"}'); - await page.getByLabel("Reasoning effort").selectOption(""); - await page.getByLabel("Reasoning summary").selectOption(""); - await page.getByRole("button", { name: "Save changes" }).click(); - await expect(page.getByRole("status")).toContainText("Agent definition updated"); - await expect(name).toBeFocused(); - - requests = await fixtureRequests(request); - const updates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a"); - expect(updates).toHaveLength(1); - expect(updates[0]?.body).toMatchObject({ - name: null, - instructions: null, - metadata: { team: "acceptance" }, - reasoning: { effort: null, summary: null }, - }); - expect(updates[0]?.body).not.toHaveProperty("tools"); - expect(browserErrors).toEqual([]); -}); - -test("fails closed when the latest Agent response has a different identity", async ({ page, request }) => { - await openAgents(page, request); - await page.route("**/v1/agents/agent_a", async (route) => { - if (route.request().method() !== "GET") { - await route.continue(); - return; - } - const upstream = await route.fetch(); - const payload = await upstream.json() as Record; - await route.fulfill({ response: upstream, json: { ...payload, id: "agent_other" } }); - }); - - const editTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); - await editTrigger.click(); - await expect(page.getByRole("alert")).toContainText("returned a different Agent than the one requested"); - await expect(page.locator(".agent-setup-page")).toHaveCount(0); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); - await expect(editTrigger).toBeFocused(); - - const reads = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === "/v1/agents/agent_a" - )); - expect(reads).toHaveLength(1); -}); - -test("creates, previews, edits, and removes bounded Function and anonymous HTTP MCP tools", async ({ page, request }) => { - await openAgents(page, request); - await page.getByRole("button", { name: /^Create agent/ }).click(); - await page.getByLabel("Name").fill("Tool Agent"); - await page.getByLabel("Instructions").fill("Use only the configured tools."); - await page.getByLabel("Harness").selectOption("claude_sdk"); - await page.getByLabel("Model").selectOption({ label: "Custom model ID…" }); - await page.getByLabel("Custom model ID").fill("fixture/tool-model"); - - await page.getByRole("button", { name: "Add Function" }).click(); - const createFunction = page.locator(".agent-tool-card").filter({ hasText: "Function" }).first(); - await createFunction.getByLabel("Name", { exact: true }).fill("lookup_customer"); - await createFunction.getByLabel("Description", { exact: true }).fill("Look up a customer."); - await createFunction.getByRole("textbox", { name: /^Parameters JSON Schema/ }).fill('{"type":"object","properties":{"id":{"type":"string"}}}'); - - await page.getByRole("button", { name: "Add HTTP MCP" }).click(); - const createMcp = page.locator(".agent-tool-card").filter({ hasText: "Anonymous HTTP MCP" }).first(); - await createMcp.getByLabel("Server label").fill("docs"); - await createMcp.getByLabel("Server URL").fill("https://mcp.example/tools"); - await createMcp.getByLabel("Only the listed tools").check(); - await createMcp.getByLabel("Allowed tools for docs").fill("search\nread_document"); - await createMcp.getByLabel("Require this server for Core execution").check(); - - const previewBody = page.getByRole("region", { name: "Request preview" }) - .locator(".agent-preview-block") - .filter({ has: page.getByText("agent.json", { exact: true }) }) - .locator("pre"); - await expect(previewBody).toContainText('"type": "function"'); - await expect(previewBody).toContainText('"name": "lookup_customer"'); - await expect(previewBody).toContainText('"type": "mcp"'); - await expect(previewBody).toContainText('"required": true'); - await expect(previewBody).toContainText('"harness": "claude_sdk"'); - - await page.getByRole("button", { name: "Save Agent definition" }).click(); - await expect(page.getByRole("status")).toContainText("Agent definition saved as"); - let requests = await fixtureRequests(request); - const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); - expect(creates).toHaveLength(1); - expect(creates[0]?.body).toEqual({ - x_agents_core: { harness: "claude_sdk" }, - model: "fixture/tool-model", - name: "Tool Agent", - instructions: "Use only the configured tools.", - metadata: {}, - service_tier: "auto", - text: { format: { type: "text" }, verbosity: "medium" }, - tools: [ - { - type: "function", - name: "lookup_customer", - description: "Look up a customer.", - parameters: { type: "object", properties: { id: { type: "string" } } }, - defer_loading: false, - }, - { - type: "mcp", - server_label: "docs", - transport: { type: "http", server_url: "https://mcp.example/tools" }, - allowed_tools: ["search", "read_document"], - connection_origin: "service", - required: true, - }, - ], - }); - - await page.getByRole("button", { name: "Back to Agents" }).click(); - await page.getByRole("button", { name: /^Edit Tool Agent/ }).click(); - const setup = page.locator(".agent-setup-page"); - await expect(setup.getByRole("heading", { name: "Saved definition" })).toBeVisible(); - await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(setup.getByLabel("Harness")).toHaveValue("claude_sdk"); - const editFunction = setup.locator(".agent-tool-card").filter({ hasText: "Function" }).first(); - await editFunction.getByLabel("Name", { exact: true }).fill("lookup_customer_v2"); - const editMcp = setup.locator(".agent-tool-card").filter({ hasText: "Anonymous HTTP MCP" }).first(); - await editMcp.getByRole("button", { name: "Remove" }).click(); - await setup.getByRole("button", { name: "Save changes" }).click(); - await expect(setup.getByRole("status")).toContainText("Agent definition updated"); - - requests = await fixtureRequests(request); - let updates = requests.filter((entry) => entry.method === "POST" && entry.path.startsWith("/v1/agents/agent_created_")); - expect(updates).toHaveLength(1); - expect(updates[0]?.body?.tools).toEqual([ - { - type: "function", - name: "lookup_customer_v2", - description: "Look up a customer.", - parameters: { type: "object", properties: { id: { type: "string" } } }, - defer_loading: false, - }, - ]); - expect(updates[0]?.body).not.toHaveProperty("x_agents_core"); - - await setup.getByLabel("Harness").selectOption("codex"); - await setup.locator(".agent-tool-card").filter({ hasText: "Function" }).first().getByRole("button", { name: "Remove" }).click(); - await setup.getByRole("button", { name: "Save changes" }).click(); - await expect(setup.getByRole("status")).toContainText("Agent definition updated"); - - requests = await fixtureRequests(request); - updates = requests.filter((entry) => entry.method === "POST" && entry.path.startsWith("/v1/agents/agent_created_")); - expect(updates).toHaveLength(2); - expect(updates[1]?.body?.tools).toEqual([]); - expect(updates[1]?.body?.x_agents_core).toEqual({ harness: "codex" }); -}); - -test("keeps Source Files controls out of the System status page", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await page.getByRole("button", { name: "System", exact: true }).click(); - await expect(page.getByRole("region", { name: "Source Files" })).toHaveCount(0); - await expect(page.locator(".system-page")).not.toContainText("Source Files"); -}); - -test("supports global Create keyboard navigation and consumes setup requests once", async ({ page, request }) => { - await openAgents(page, request); - const sidebar = page.locator(".app-sidebar"); - const productNavigation = sidebar.getByRole("navigation", { name: "Agents product" }); - await expect(productNavigation).toBeVisible(); - await expect(productNavigation.getByRole("button", { name: "Agents", exact: true })).toHaveAttribute("aria-current", "page"); - await expect(page.locator(".product-header").getByRole("navigation", { name: "Agents product" })).toHaveCount(0); - const createMenu = page.getByRole("button", { name: "Create", exact: true }); - await createMenu.focus(); - await page.keyboard.press("Enter"); - await expect(page.getByRole("menuitem", { name: /^Agent\b/ })).toBeFocused(); - await page.keyboard.press("Escape"); - await expect(createMenu).toBeFocused(); - await page.keyboard.press("ArrowDown"); - const createAgentItem = page.getByRole("menuitem", { name: /^Agent\b/ }); - const startSessionItem = page.getByRole("menuitem", { name: /^Start Session\b/ }); - await expect(createAgentItem).toBeFocused(); - await page.keyboard.press("ArrowDown"); - await expect(startSessionItem).toBeFocused(); - await page.keyboard.press("ArrowUp"); - await expect(createAgentItem).toBeFocused(); - await page.keyboard.press("Enter"); - await expect(page.getByLabel("Name")).toBeFocused(); - await expect(page.getByRole("heading", { name: "Request preview" })).toBeVisible(); - - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); - await expect(page.getByRole("heading", { name: "New Agent" })).toHaveCount(0); - - const editTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); - await editTrigger.focus(); - await editTrigger.click(); - await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); - await expect(page.getByRole("dialog")).toHaveCount(0); - await page.getByRole("button", { name: "Back to Agents" }).click(); - await expect(editTrigger).toBeFocused(); - await expect(page.locator(".modal-backdrop")).toHaveCount(0); - - await createMenu.click(); - await startSessionItem.click(); - const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); - await expect(sessionDialog).toBeVisible(); - await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_a"); - await expect(sessionDialog.locator('option[value="agent_a"]')).toBeEnabled(); - await expect(sessionDialog.locator('option[value="agent_a"]')).toContainText("Lifecycle Agent"); - await expect(sessionDialog.locator('option[value="agent_a"]')).not.toContainText("Session requires changes"); - await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toBeEnabled(); - await expect(sessionDialog.locator('option[value="agent_tool_only"]')).toContainText("Saved-only Tool Agent"); - await expect(sessionDialog.locator('option[value="agent_tool_only"]')).not.toContainText("Session requires changes"); - const sessionPostsBeforeCancel = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )).length; - await sessionDialog.getByRole("button", { name: "Cancel" }).click(); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - ))).toHaveLength(sessionPostsBeforeCancel); - await expect(createMenu).toBeFocused(); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await expect(page.getByRole("dialog", { name: "Create a Session" })).toHaveCount(0); - - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await createMenu.click(); - await createAgentItem.click(); - const createMetadata = page.locator(".agent-metadata-input"); - const createName = page.locator('input[data-agent-initial-focus="true"]'); - await expect(createName).toBeFocused(); - await createMetadata.fill('{"owner":"local-test"}'); - await expect(createMetadata).toHaveValue('{"owner":"local-test"}'); - await expect(createName).toHaveValue(""); - await createName.focus(); - await page.keyboard.press("Enter"); - await expect(page.getByRole("status")).toContainText("Agent definition saved as"); - - await createMenu.click(); - await createAgentItem.click(); - await expect(page.getByLabel("Name")).toHaveValue(""); - await expect(page.getByLabel("Name")).toBeEnabled(); - await expect(page.getByRole("status")).toHaveCount(0); - await page.getByRole("button", { name: "Back to Agents" }).click(); - await expect(createMenu).toBeFocused(); - - const catalogCreate = page.getByRole("button", { name: /^Create agent/ }); - await catalogCreate.click(); - await page.getByRole("button", { name: "Back to Agents" }).click(); - await expect(catalogCreate).toBeFocused(); - - const requests = await fixtureRequests(request); - const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents"); - expect(creates).toHaveLength(1); - expect(creates[0]?.body).toMatchObject({ - name: null, - instructions: null, - metadata: { owner: "local-test" }, - service_tier: "auto", - text: { format: { type: "text" }, verbosity: "medium" }, - }); - expect(creates[0]?.body).not.toHaveProperty("reasoning"); -}); - -test("continues from a default Agent definition into a Session with initial input", async ({ page, request }) => { - await openAgents(page, request); - await page.getByRole("button", { name: /^Create agent/ }).click(); - await page.getByLabel("Name").fill("Session-safe Agent"); - await page.getByRole("button", { name: "Save Agent definition" }).click(); - await expect(page.getByRole("status")).toContainText("Agent definition saved as"); - - const requestsAfterSave = await fixtureRequests(request); - const create = requestsAfterSave.find((entry) => entry.method === "POST" && entry.path === "/v1/agents"); - expect(create?.body).toMatchObject({ - name: "Session-safe Agent", - service_tier: "auto", - text: { format: { type: "text" }, verbosity: "medium" }, - }); - expect(create?.body).not.toHaveProperty("reasoning"); - - await expect(page.getByRole("button", { name: "Start Session" })).toBeEnabled(); - await page.getByRole("button", { name: "Start Session" }).click(); - const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); - await sessionDialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review this Agent definition."); - await expect(sessionDialog).toBeVisible(); - await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/); - await expect(sessionDialog.getByRole("button", { name: /Advanced settings/ })).toHaveAttribute("aria-expanded", "false"); - await expect(sessionDialog.getByRole("textbox", { name: /^Additional metadata\b/u })).toHaveCount(0); - await expect(sessionDialog.getByRole("radio", { name: /No environment/ })).toBeChecked(); - await sessionDialog.getByRole("button", { name: "Create Session" }).click(); - await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); - await expect(environmentTrigger(page)).toHaveCount(0); - await expect( - page.getByRole("tabpanel", { name: "Conversation" }) - .getByRole("region", { name: "Environment and Workspace status" }), - ).toHaveCount(0); - - const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(sessionCreates).toHaveLength(1); - expect(sessionCreates[0]?.body).toMatchObject({ - agent_id: expect.stringMatching(/^agent_created_/), - environment: { type: "none" }, - stream: true, - }); -}); - -test("opens repair setup for a saved response while blocking an invalid Session write", async ({ page, request }) => { - await openAgents(page, request); - await controlFixture(request, { createAgentResponseVariant: "reasoning" }); - await page.getByRole("button", { name: /^Create agent/ }).click(); - await page.getByLabel("Name").fill("Core-adjusted Agent"); - await page.getByRole("button", { name: "Save Agent definition" }).click(); - - await expect(page.getByRole("status")).toContainText("Agent definition saved as"); - await expect(page.locator("#created-agent-session-blocker")).toContainText("Start Session is unavailable"); - const startSession = page.getByRole("button", { name: "Start Session" }); - await expect(startSession).toBeDisabled(); - - // Bypass the setup view's disabled control to inspect the explicit repair - // surface. The dialog remains fail-closed until the saved-only field is reset. - await startSession.evaluate((button) => { - const propsKey = Object.getOwnPropertyNames(button).find((key) => key.startsWith("__reactProps$")); - if (!propsKey) throw new Error("React event props were not found on the Start Session button."); - const props = (button as unknown as Record void }>)[propsKey]; - if (!props?.onClick) throw new Error("Start Session does not have an onClick handler."); - props.onClick(); - }); - const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); - await expect(sessionDialog).toBeVisible(); - await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue(/^agent_created_/u); - await expect(sessionDialog.getByRole("alert")).toContainText("explicit reasoning options are saved-only"); - await expect(sessionDialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - ))).toHaveLength(0); -}); - -test("starts only Agents that pass known Session admission", async ({ page, request }) => { - await openAgents(page, request); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); - - const blockedStart = page.getByRole("button", { name: /^Start a Session with Lifecycle Agent/ }); - await expect(blockedStart).toContainText("Unavailable"); - await expect(blockedStart).toHaveAttribute("aria-disabled", "true"); - await blockedStart.focus(); - await expect(blockedStart.locator("xpath=..").getByRole("tooltip")).toBeVisible(); - const blockedSessionCount = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )).length; - await page.keyboard.press("Enter"); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - ))).toHaveLength(blockedSessionCount); - - const toolOnlyStart = page.getByRole("button", { name: /^Start a Session with Saved-only Tool Agent/ }); - await expect(toolOnlyStart).toHaveAttribute("aria-disabled", "true"); - await toolOnlyStart.focus(); - await expect(toolOnlyStart.locator("xpath=..").getByRole("tooltip")).toContainText("tool_search is saved-only"); - await page.keyboard.press("Enter"); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - ))).toHaveLength(blockedSessionCount); - await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toBeEnabled(); - await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toContainText("Start Session"); - - const before = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )).length; - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); - await sessionDialog.getByRole("textbox", { name: /^First message\b/u }).fill("Check the selected Agent configuration."); - await expect(sessionDialog).toBeVisible(); - await expect(sessionDialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); - await sessionDialog.getByRole("button", { name: "Create Session" }).click(); - await expect(page.getByRole("button", { name: "Sessions", exact: true })).toHaveAttribute("aria-current", "page"); - - const sessionCreates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(sessionCreates).toHaveLength(before + 1); - expect(sessionCreates.at(-1)?.body).toMatchObject({ - agent_id: "agent_b", - environment: { type: "none" }, - stream: true, - }); -}); - -test("serializes complete saved-Agent overrides with initial input", async ({ page, request }) => { - await openAgents(page, request); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await page.getByRole("button", { name: "New Session" }).click(); - let dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the Session-specific settings."); - await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_a"); - await expect(dialog.getByRole("alert")).toContainText("multi-agent execution is not supported"); - - await openAdvancedSessionSettings(dialog); - await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check(); - await dialog.getByRole("checkbox", { name: "Replace model" }).check(); - await dialog.getByLabel("Session model").fill("fixture/session-model"); - await dialog.getByRole("checkbox", { name: "Replace instructions" }).check(); - await dialog.getByLabel("Session instructions").fill(" "); - await dialog.getByRole("checkbox", { name: /Replace text configuration/ }).check(); - await dialog.getByLabel("Text verbosity").selectOption("low"); - await dialog.getByRole("checkbox", { name: "Reset multi-agent to disabled" }).check(); - await dialog.getByRole("checkbox", { name: "Reset reasoning to Core defaults" }).check(); - await dialog.getByRole("checkbox", { name: "Reset service tier to auto" }).check(); - await dialog.getByRole("radio", { name: "Clear all" }).check(); - await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); - await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog).toHaveCount(0); - - await page.getByRole("button", { name: "New Session" }).click(); - dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review the Session-specific settings."); - await dialog.getByLabel("Saved Agent", { exact: true }).selectOption("agent_b"); - await openAdvancedSessionSettings(dialog); - await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog).toHaveCount(0); - - const creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates).toHaveLength(2); - expect(creates[0]?.body).toEqual({ - agent_id: "agent_a", - agent: { - model: "fixture/session-model", - instructions: null, - multi_agent: null, - reasoning: null, - service_tier: null, - text: { format: { type: "text" }, verbosity: "low" }, - tools: [], - }, - environment: { type: "none" }, - metadata: {}, - input: "Review the Session-specific settings.", - stream: true, - vault_ids: [], - }); - expect(creates[1]?.body).toMatchObject({ - agent_id: "agent_b", - environment: { type: "none" }, - metadata: {}, - input: "Review the Session-specific settings.", - stream: true, - vault_ids: [], - }); - expect(creates[1]?.body).not.toHaveProperty("agent"); - expect(creates[1]?.body?.input).toBe("Review the Session-specific settings."); -}); - -test("derives manual Vault attachments for anonymous and explicit MCP Credentials", async ({ page, request }) => { - await resetFixture(request); - const serverURL = "https://mcp.vault-fixture.test/tools"; - const createVault = async (name: string) => { - const response = await request.post(`${fixtureBaseUrl}/v1/vaults`, { data: { name, metadata: {} } }); - expect(response.ok()).toBe(true); - return response.json() as Promise<{ id: string }>; - }; - const createCredential = async (vaultId: string, name: string) => { - const response = await request.post(`${fixtureBaseUrl}/v1/vaults/${vaultId}/credentials`, { data: { - name, - auth: { type: "static_bearer", mcp_server_url: serverURL, token: "fixture-secret-never-rendered" }, - } }); - expect(response.ok()).toBe(true); - return response.json() as Promise<{ id: string }>; - }; - const vaultAlpha = await createVault("Vault Alpha"); - const vaultBeta = await createVault("Vault Beta"); - const credentialAlpha = await createCredential(vaultAlpha.id, "Credential Alpha"); - await createCredential(vaultBeta.id, "Credential Beta"); - const mcpTool = (credentialId: string | null) => ({ - type: "mcp", - server_label: "docs", - transport: { type: "http", server_url: serverURL, headers: {} }, - allowed_tools: null, - connection_origin: "service", - credential_id: credentialId, - request_metadata: {}, - required: false, - }); - for (const [name, credentialId] of [ - ["Anonymous MCP Agent", null], - ["Explicit MCP Agent", credentialAlpha.id], - ] as const) { - const response = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { - model: "fixture/model-mcp", - name, - tools: [mcpTool(credentialId)], - } }); - expect(response.status()).toBe(201); - } - - await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); - - await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); - let dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); - await openAdvancedSessionSettings(dialog); - await expect(dialog).toContainText("Anonymous for this Session"); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog).toHaveCount(0); - - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); - dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); - await openAdvancedSessionSettings(dialog); - await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); - await expect(dialog).toContainText("Implicit unique match · Credential Alpha · Vault Alpha"); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog).toHaveCount(0); - - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await page.getByRole("button", { name: /^Start a Session with Anonymous MCP Agent/ }).click(); - dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); - await openAdvancedSessionSettings(dialog); - await dialog.getByRole("checkbox", { name: "Vault Alpha" }).check(); - await dialog.getByRole("checkbox", { name: "Vault Beta" }).check(); - await expect(dialog.getByRole("alert")).toContainText("matches multiple Credentials"); - await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); - await dialog.getByRole("button", { name: "Cancel" }).click(); - - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); - dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); - await openAdvancedSessionSettings(dialog); - const requiredVault = dialog.getByRole("checkbox", { name: /Vault Alpha · attached automatically/ }); - await expect(requiredVault).toBeChecked(); - await expect(requiredVault).toBeDisabled(); - await expect(dialog).toContainText("Explicit · Credential Alpha · Vault Alpha"); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog).toHaveCount(0); - - let creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates).toHaveLength(3); - expect(creates.map((entry) => entry.body?.vault_ids)).toEqual([ - [], - [vaultAlpha.id], - [vaultAlpha.id], - ]); - const createsBeforeCredentialDelete = creates.length; - - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await page.getByRole("button", { name: /^Start a Session with Explicit MCP Agent/ }).click(); - dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("List the available documentation resources."); - const deleted = await request.delete(`${fixtureBaseUrl}/v1/vaults/${vaultAlpha.id}/credentials/${credentialAlpha.id}`); - expect(deleted.ok()).toBe(true); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog.getByRole("alert")).toContainText("explicit MCP Credential is unavailable"); - creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates.length - createsBeforeCredentialDelete).toBeLessThanOrEqual(1); -}); - -test("clears manual Vault attachments when overrides remove HTTP MCP tools", async ({ page, request }) => { - await resetFixture(request); - const serverURL = "https://mcp.vault-clear.test/tools"; - const vaultResponse = await request.post(`${fixtureBaseUrl}/v1/vaults`, { - data: { name: "Vault to clear", metadata: {} }, - }); - expect(vaultResponse.ok()).toBe(true); - const vault = await vaultResponse.json() as { id: string }; - const agentResponse = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { - model: "fixture/model-mcp-clear", - name: "MCP Clear Agent", - tools: [{ - type: "mcp", - server_label: "docs", - transport: { type: "http", server_url: serverURL, headers: {} }, - allowed_tools: null, - connection_origin: "service", - credential_id: null, - request_metadata: {}, - required: false, - }], - } }); - expect(agentResponse.status()).toBe(201); - - await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await page.getByRole("button", { name: /^Start a Session with MCP Clear Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Explain the Session without MCP tools."); - await openAdvancedSessionSettings(dialog); - await dialog.getByRole("checkbox", { name: "Vault to clear" }).check(); - await dialog.getByRole("checkbox", { name: /Configure Session-only overrides/ }).check(); - await dialog.getByRole("radio", { name: "Clear all" }).check(); - - await expect(dialog.getByRole("heading", { name: "Tools & Vaults" })).toHaveCount(0); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog).toHaveCount(0); - const creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates).toHaveLength(1); - expect(creates[0]?.body).toMatchObject({ - agent: { tools: [] }, - vault_ids: [], - }); - expect(creates[0]?.body?.vault_ids).not.toContain(vault.id); -}); - -test("creates the bounded self-hosted profile and renders a secret-free connection guide", async ({ page, request }) => { - await openAgents(page, request); - const sessionPostsBefore = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )).length; - - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - await expect(dialog).toBeVisible(); - await expect(dialog.getByLabel("Saved Agent", { exact: true })).toHaveValue("agent_b"); - await dialog.getByRole("radio", { name: /Self-hosted/ }).check(); - const workspace = dialog.getByLabel("Workspace directory"); - await workspace.fill("relative/workspace"); - await expect(dialog.getByText("Workspace directory must be an absolute POSIX path")).toBeVisible(); - await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - ))).toHaveLength(sessionPostsBefore); - - await workspace.fill("/executor/workspace"); - await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); - await dialog.getByRole("button", { name: "Create Session" }).click(); - - const { dialog: environmentDialog, panel: environmentPanel, trigger } = await openEnvironmentDialog(page); - await expect(environmentPanel).toBeVisible(); - await expect(environmentPanel).toContainText("Self-hosted Environment"); - await expect(environmentPanel).toContainText("0f745b0d-b545-49cd-8d7e-4c31c80dc564"); - await expect(environmentPanel).toContainText("https://executor.example.test"); - await expect(environmentPanel).toContainText("/executor/workspace"); - await expect(environmentPanel).toContainText("Pending"); - const files = environmentPanel.getByRole("region", { name: "Workspace files" }); - await expect(files.getByLabel("Directory")).toHaveValue("/executor/workspace"); - await expect(files).toContainText("Workspace root"); - - await expect(environmentPanel.locator("details.environment-launcher-guide")).toHaveAttribute("open", ""); - await expect(environmentPanel.getByRole("button", { name: "Copy native command" })).toBeVisible(); - await expect(environmentPanel).toContainText("agents-api-codex-executor"); - await expect(environmentPanel).toContainText("$HOME/.parsar/executor-key.json"); - await expect(environmentPanel).toContainText("Web copies its path but never creates, reads, stores, or transmits the key"); - await expect(environmentPanel).not.toContainText("executor_token"); - await expect(environmentPanel).not.toContainText("Authorization: Bearer"); - - await page.keyboard.press("Escape"); - await expect(environmentDialog).toHaveCount(0); - await expect(trigger).toBeFocused(); - - const requests = await fixtureRequests(request); - const sessionCreates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); - expect(sessionCreates).toHaveLength(sessionPostsBefore + 1); - expect(sessionCreates.at(-1)?.body).toEqual({ - agent_id: "agent_b", - environment: { - type: "self_hosted", - workspace_directory: "/executor/workspace", - capability_directories: [], - }, - metadata: {}, - stream: false, - vault_ids: [], - }); - expect(requests.filter((entry) => entry.method === "POST" && entry.path.endsWith("/events"))).toHaveLength(0); -}); - -test("saves a reusable Environment Template and references it from a managed Session", async ({ page, request }) => { - await openAgents(page, request); - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); - - const templateSelect = dialog.getByLabel("Reusable Environment Template"); - await expect(templateSelect).toBeDisabled(); - await expect(dialog).toContainText("This Core exposes no saved Templates"); - - await dialog.getByRole("button", { name: "New Environment Template" }).click(); - await dialog.getByLabel("Template name").fill("Restricted outbound access"); - await dialog.getByLabel("Template network access").selectOption("disabled"); - await dialog.getByRole("button", { name: "Save Template" }).click(); - - await expect(templateSelect).toBeEnabled(); - await expect(templateSelect).toHaveValue(/^4/); - const selectedTemplateId = await templateSelect.inputValue(); - await expect(dialog.getByLabel("Managed Environment network access")) - .toContainText("Inherit from Template (Disabled)"); - - const templateCreates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/environments/templates" - )); - expect(templateCreates).toHaveLength(1); - expect(templateCreates[0]?.beta).toBe("agents=v1"); - expect(templateCreates[0]?.body).toEqual({ - name: "Restricted outbound access", - network: { access: "disabled" }, - }); - - // A Session can keep or narrow the Template policy, never widen it. - await dialog.getByLabel("Managed Environment network access").selectOption("enabled"); - await expect(dialog).toContainText("never widen it"); - await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); - await dialog.getByLabel("Managed Environment network access").selectOption("default"); - - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog).toHaveCount(0); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - - const creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - const latest = creates.at(-1)?.body; - expect(latest?.environment).toEqual({ - type: "openai_hosted", - environment_template_id: selectedTemplateId, - }); - expect(latest?.stream).toBe(false); -}); - -test("hides Template selection when the connected Core lacks the resource", async ({ page, request }) => { - await resetFixture(request); - await controlFixture(request, { environmentTemplateListStatus: 400 }); - await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents" }).click(); - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); - - await expect(dialog).toContainText("does not expose the Environment Template resource"); - await expect(dialog.getByLabel("Reusable Environment Template")).toBeDisabled(); - await expect(dialog.getByRole("button", { name: "New Environment Template" })).toHaveCount(0); - await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); -}); - -test("creates managed hosted profiles with JSON for empty input and SSE for initial input", async ({ page, request }) => { - await openAgents(page, request); - const profiles = [ - { label: "default", option: "default", environment: { type: "openai_hosted" }, input: undefined }, - { label: "enabled", option: "enabled", environment: { type: "openai_hosted", network: { access: "enabled" } }, input: "Managed initial input" }, - { label: "disabled", option: "disabled", environment: { type: "openai_hosted", network: { access: "disabled" } }, input: undefined }, - ] as const; - - for (const [index, profile] of profiles.entries()) { - if (index > 0) await page.getByRole("button", { name: "Agents", exact: true }).click(); - if (index === 0) await controlFixture(request, { environmentEventStatus: 3, environmentEventCount: 1 }); - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); - const network = dialog.getByLabel("Managed Environment network access"); - await network.selectOption(profile.option); - if (profile.input) await dialog.getByRole("textbox", { name: /^First message\b/u }).fill(profile.input); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog).toHaveCount(0); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await expect(connectedLiveEvents(page)).toBeVisible(); - - const creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - const latest = creates.at(-1)?.body; - expect(latest).toMatchObject({ - agent_id: "agent_b", - environment: profile.environment, - stream: profile.input !== undefined, - vault_ids: [], - }); - expect(latest?.environment).toEqual(profile.environment); - if (profile.input) expect(latest?.input).toBe(profile.input); - else expect(latest).not.toHaveProperty("input"); - - if (index === 0) { - const { dialog: environmentDialog, panel } = await openEnvironmentDialog(page); - await expect(panel).toContainText("Managed hosted Environment"); - await expect(panel).toContainText("7a263c51-6bf0-4d53-8518-c792eb1f0d21"); - await expect(panel).toContainText("Network access"); - await expect(panel).toContainText("Enabled"); - await expect(panel).toContainText("/workspace"); - await expect(panel).toContainText("None installed by the basic profile"); - await expect(panel).toContainText("Workspace files"); - await expect(panel.getByText("Add inline Workspace file", { exact: true })).toBeVisible(); - await expect(panel).not.toContainText("Connect Environment"); - await expect(panel).not.toContainText("Remote URL"); - - await panel.getByLabel("Local file").setInputFiles({ - name: "managed.txt", - mimeType: "text/plain", - buffer: Buffer.from("managed bytes", "utf8"), - }); - await panel.getByLabel("Destination path").fill("/workspace/managed.txt"); - await panel.getByRole("button", { name: "Write selected file" }).click(); - await expect(panel).toContainText("Write confirmed:"); - await expect(panel).toContainText("/workspace/managed.txt"); - let writes = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") - )); - expect(writes.at(-1)?.body).toEqual({ - type: "inline", - data: Buffer.from("managed bytes", "utf8").toString("base64"), - path: "/workspace/managed.txt", - }); - - await panel.getByLabel("Local file").setInputFiles({ - name: "unknown.txt", - mimeType: "text/plain", - buffer: Buffer.from("uncertain bytes", "utf8"), - }); - await panel.getByLabel("Destination path").fill("/workspace/unknown.txt"); - await controlFixture(request, { environmentFileCreateResponseLoss: 1 }); - const writesBeforeUnknown = writes.length; - await panel.getByRole("button", { name: "Write selected file" }).click(); - await expect(panel.getByRole("alert")).toContainText("Web did not retry the request"); - writes = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") - )); - expect(writes).toHaveLength(writesBeforeUnknown + 1); - await page.waitForTimeout(250); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path.endsWith("/environments/7a263c51-6bf0-4d53-8518-c792eb1f0d21/files") - ))).toHaveLength(writesBeforeUnknown + 1); - await environmentDialog.getByRole("button", { name: "Done" }).click(); - } - } - - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); -}); - -test("blocks hosted MCP before persistence while allowing a Function-only managed Session", async ({ page, request }) => { - await resetFixture(request); - const mcp = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { - model: "fixture/model-mcp", - name: "Hosted MCP Agent", - tools: [{ - type: "mcp", - server_label: "docs", - transport: { type: "http", server_url: "https://mcp.example/tools", headers: {} }, - allowed_tools: null, - connection_origin: "service", - credential_id: null, - request_metadata: {}, - required: false, - }], - } }); - expect(mcp.status()).toBe(201); - const fn = await request.post(`${fixtureBaseUrl}/v1/agents`, { data: { - model: "fixture/model-function", - name: "Hosted Function Agent", - tools: [{ type: "function", name: "lookup", description: "", parameters: {}, defer_loading: false }], - } }); - expect(fn.status()).toBe(201); - - await openSessionsFromHome(page); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - const before = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )).length; - await page.getByRole("button", { name: /^Start a Session with Hosted MCP Agent/ }).click(); - let dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); - await expect(dialog.getByRole("alert")).toContainText("Managed hosted Sessions do not yet support MCP tools"); - await expect(dialog.getByRole("button", { name: "Create Session" })).toBeDisabled(); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - ))).toHaveLength(before); - await dialog.getByRole("button", { name: "Cancel" }).click(); - - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await page.getByRole("button", { name: /^Start a Session with Hosted Function Agent/ }).click(); - dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); - await expect(dialog.getByRole("button", { name: "Create Session" })).toBeEnabled(); - await dialog.getByRole("button", { name: "Create Session" }).click(); - await expect(dialog).toHaveCount(0); - const creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates).toHaveLength(before + 1); - expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "openai_hosted" }, stream: false }); -}); - -test("keeps managed Environment resource and terminal event states fail-closed", async ({ page, request }) => { - await resetFixture(request); - await controlFixture(request, { environmentScenario: 8, environmentResourceStatus: "expired" }); - await openSessionsFromHome(page); - let trigger = environmentTrigger(page); - await expect(trigger).toContainText("Environment: Expired"); - let opened = await openEnvironmentDialog(page); - await expect(opened.panel).toContainText("Managed Environment Expired"); - await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); - await expect(opened.panel).not.toContainText("Connect Environment"); - await opened.dialog.getByRole("button", { name: "Done" }).click(); - - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - await expect(page.getByRole("table", { name: "Recent Sessions" })).toContainText("Managed hosted"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await controlFixture(request, { environmentResourceStatus: "pending", environmentResourceVariant: "wrong_type" }); - await page.getByRole("button", { name: "Refresh Sessions" }).click(); - trigger = environmentTrigger(page); - await expect(trigger).toContainText("Environment: Unavailable"); - opened = await openEnvironmentDialog(page); - await expect(opened.panel).toContainText("Durable managed Environment status is unavailable"); - await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); - await opened.dialog.getByRole("button", { name: "Done" }).click(); - - await controlFixture(request, { - environmentResourceVariant: "valid", - environmentEventStatus: 5, - environmentEventCount: 1, - }); - await page.reload(); - await expect(environmentTrigger(page)).toContainText("Environment: Failed"); - opened = await openEnvironmentDialog(page); - await expect(opened.panel).toContainText("Managed Environment Failed"); - await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); - await expect(opened.panel).not.toContainText("Connect Environment"); - - await opened.dialog.getByRole("button", { name: "Done" }).click(); - await controlFixture(request, { - environmentScenario: 9, - environmentResourceStatus: "failed", - environmentResourceVariant: "valid", - environmentEventCount: 0, - }); - await page.reload(); - await expect(page.getByText("Session cannot continue", { exact: true })).toBeVisible(); - await expect(page.getByText('Failed to provision environment: script "setup_commands[0]" failed with exit code 3', { exact: true })).toBeVisible(); - await expect(page.getByLabel("Message the Agent")).toBeDisabled(); - opened = await openEnvironmentDialog(page); - await expect(opened.panel).toContainText("Managed Environment Failed"); - await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); - await expect(opened.panel).not.toContainText("Connect Environment"); - - await opened.dialog.getByRole("button", { name: "Done" }).click(); - await controlFixture(request, { - environmentScenario: 8, - environmentResourceStatus: "pending", - environmentResourceVariant: "populated", - }); - await page.reload(); - opened = await openEnvironmentDialog(page); - await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); - await expect(opened.panel).not.toContainText("Connect Environment"); -}); - -test("creates an inline Session without saved Agents and preserves ordered user-message input", async ({ page, request }) => { - await openAgents(page, request); - for (const agentId of ["agent_a", "agent_b", "agent_tool_only"]) { - const deleted = await request.delete(`${fixtureBaseUrl}/v1/agents/${agentId}`); - expect(deleted.ok()).toBe(true); - } - await page.getByRole("button", { name: "Refresh Agents" }).click(); - await expect(page.getByText("No saved Agents", { exact: true })).toBeVisible(); - - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - const newSession = page.getByRole("button", { name: "New Session" }); - await expect(newSession).toBeEnabled(); - await newSession.click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - await openAdvancedSessionSettings(dialog); - await expect(dialog.getByRole("radio", { name: /Inline Agent/ })).toBeChecked(); - await expect(dialog.getByRole("radio", { name: /Saved Agent/ })).toBeDisabled(); - await dialog.getByLabel("Inline model").fill("fixture/inline-model"); - await dialog.getByLabel("Inline instructions").fill("Keep exact input structure."); - const tools = dialog.getByRole("region", { name: "Inline tools" }); - await tools.getByRole("button", { name: "Add Function" }).click(); - await tools.getByLabel("Name", { exact: true }).fill("lookup_fixture"); - await tools.getByLabel("Description", { exact: true }).fill("Look up fixture data"); - await tools.getByRole("textbox", { name: /^Parameters JSON Schema/ }).fill('{"type":"object","properties":{"id":{"type":"string"}}}'); - - await dialog.getByRole("radio", { name: "Message array" }).check(); - await expect(dialog.getByRole("alert").filter({ hasText: "User message 1 needs nonblank text across its parts." })).toHaveCount(1); - const advancedToggle = dialog.getByRole("button", { name: /Advanced settings/ }); - await advancedToggle.click(); - await expect(dialog.getByRole("button", { name: "Edit messages", exact: true })).toBeVisible(); - await dialog.getByRole("button", { name: "Edit messages", exact: true }).click(); - await expect(advancedToggle).toHaveAttribute("aria-expanded", "true"); - let messages = dialog.locator(".session-initial-message"); - await messages.nth(0).locator(".session-initial-part textarea").nth(0).fill("first-message-part-one"); - await dialog.getByRole("button", { name: "Add text part" }).click(); - await messages.nth(0).locator(".session-initial-part textarea").nth(1).fill("first-message-part-two"); - await dialog.getByRole("button", { name: "Add message" }).click(); - messages = dialog.locator(".session-initial-message"); - await messages.nth(1).locator(".session-initial-part textarea").fill("second-message"); - await dialog.getByRole("button", { name: "Move user message 2 up" }).click(); - await dialog.getByRole("button", { name: "Move text part 2 of user message 2 up" }).click(); - await expect(dialog).not.toContainText("Creation events stream automatically"); - await expect(dialog.getByRole("checkbox", { name: /Stream idle creation events/ })).toHaveCount(0); - await dialog.getByRole("button", { name: "Create Session" }).click(); - - await expect(dialog).toHaveCount(0); - const conversation = page.getByRole("tabpanel", { name: "Conversation" }); - await expect(conversation.getByText("second-message", { exact: true })).toBeVisible(); - await expect(conversation).toContainText("first-message-part-two"); - await expect(conversation).toContainText("first-message-part-one"); - const creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates).toHaveLength(1); - expect(creates[0]?.body).toEqual({ - agent: { - model: "fixture/inline-model", - instructions: "Keep exact input structure.", - tools: [{ - type: "function", - name: "lookup_fixture", - description: "Look up fixture data", - parameters: { type: "object", properties: { id: { type: "string" } } }, - defer_loading: false, - }], - }, - environment: { type: "none" }, - input: [ - { type: "message", role: "user", content: [{ type: "input_text", text: "second-message" }] }, - { - type: "message", - role: "user", - content: [ - { type: "input_text", text: "first-message-part-two" }, - { type: "input_text", text: "first-message-part-one" }, - ], - }, - ], - metadata: {}, - stream: true, - vault_ids: [], - }); - expect(creates[0]?.body).not.toHaveProperty("agent_id"); -}); - -test("keeps failures visible, rejects stale async continuations, and never retries writes", async ({ page, request }) => { - await openAgents(page, request); - - await controlFixture(request, { retrieveStatus: 500 }); - const failedEditTrigger = page.getByRole("button", { name: /^Edit Lifecycle Agent/ }); - await failedEditTrigger.click(); - const openError = page.getByRole("alert"); - await expect(openError).toContainText("Fixture retrieve failed."); - await expect(failedEditTrigger).toBeEnabled(); - await expect(failedEditTrigger).toBeFocused(); - await openError.getByRole("button", { name: "Retry" }).click(); - await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Lifecycle Agent", exact: true })).toBeVisible(); - await expect(page.locator(".agent-setup-page")).not.toContainText("stale list"); - await page.getByRole("button", { name: "Back to Agents" }).click(); - - await controlFixture(request, { retrieveDelayMs: 400 }); - await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); - await expect(page.getByRole("status")).toHaveText("Opening latest definition…"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await page.waitForTimeout(500); - await expect(page.locator(".session-page")).toBeVisible(); - await expect(page.locator(".agent-setup-page")).toHaveCount(0); - - await page.getByRole("button", { name: "Agents", exact: true }).click(); - await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); - await expect(page.locator(".agent-setup-page")).toBeVisible(); - await page.getByLabel("Name").fill("Preserved after failure"); - await controlFixture(request, { updateStatus: 500 }); - await page.getByRole("button", { name: "Save changes" }).click(); - await expect(page.getByRole("alert")).toContainText("Fixture update failed."); - await expect(page.getByLabel("Name")).toHaveValue("Preserved after failure"); - - let requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(1); - - await controlFixture(request, { updateDelayMs: 600 }); - await page.getByRole("button", { name: "Save changes" }).click(); - await expect(page.getByRole("button", { name: "Back to Agents" })).toBeDisabled(); - await page.waitForTimeout(100); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await page.waitForTimeout(700); - await expect(page.locator(".session-page")).toBeVisible(); - await expect(page.locator(".agent-setup-page")).toHaveCount(0); - - requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/agent_a")).toHaveLength(2); -}); - -test("requires delete confirmation, preserves failures, and keeps Session snapshots", async ({ page, request }) => { - await openAgents(page, request); - await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); - await page.getByRole("button", { name: "Delete Agent" }).click(); - let dialog = page.getByRole("dialog", { name: "Delete Agent?" }); - await expect(dialog).toContainText("Existing Sessions keep their durable Agent snapshots"); - await expect(dialog).toContainText("agent_a"); - await dialog.getByRole("button", { name: "Cancel" }).click(); - await expect(page.getByRole("button", { name: "Delete Agent" })).toBeFocused(); - - let requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(0); - - await page.getByRole("button", { name: "Delete Agent" }).click(); - dialog = page.getByRole("dialog", { name: "Delete Agent?" }); - await controlFixture(request, { deleteStatus: 500 }); - await dialog.getByRole("button", { name: "Delete Agent" }).click(); - await expect(dialog.getByRole("alert")).toContainText("Fixture delete failed."); - await expect(dialog).toContainText("Lifecycle Agent"); - - requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(1); - - await controlFixture(request, { deleteDelayMs: 300 }); - await dialog.getByRole("button", { name: "Delete Agent" }).click(); - await expect(dialog.getByRole("button", { name: "Cancel" })).toBeDisabled(); - await expect(dialog).toHaveCount(0); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toHaveCount(0); - - await page.getByRole("button", { name: "Sessions" }).click(); - await expect(page.getByRole("button", { name: /Idle Lifecycle Agent/ })).toBeVisible(); - requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "DELETE" && entry.path === "/v1/agents/agent_a")).toHaveLength(2); -}); - -test("keeps the Agent card grid, setup, and delete confirmation usable at 390 px", async ({ page, request }, testInfo) => { - await page.setViewportSize({ width: 390, height: 844 }); - await openAgents(page, request); - - const agentList = page.getByRole("list", { name: "Agents", exact: true }); - await expect(agentList.getByRole("listitem")).toHaveCount(2); - await expect(agentList.getByRole("listitem").first()).toContainText("Create agent"); - await expect(page.getByRole("button", { name: /^Edit Lifecycle Agent/ })).toBeVisible(); - await expect(page.getByRole("button", { name: /^Edit Second Agent/ })).toHaveCount(0); - const showMore = page.getByRole("button", { name: "Show 2 more" }); - await expect(showMore).toHaveAttribute("aria-expanded", "false"); - await showMore.click(); - await expect(page.getByRole("button", { name: "Show less" })).toHaveAttribute("aria-expanded", "true"); - await expect(agentList.getByRole("listitem")).toHaveCount(4); - const secondAgent = page.getByRole("button", { name: /^Edit Second Agent/ }); - await expect(secondAgent).toBeVisible(); - await secondAgent.click(); - await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "Second Agent", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Back to Agents" }).click(); - await expect(secondAgent).toBeFocused(); - await expect(page.getByRole("button", { name: "Show less" })).toBeVisible(); - - const metrics = await page.evaluate(() => { - const main = document.querySelector(".app-main")?.getBoundingClientRect(); - const trigger = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Create agent"))?.getBoundingClientRect(); - const grid = document.querySelector(".agent-card-grid")?.getBoundingClientRect(); - const sessionTrigger = document.querySelector('button[aria-label^="Start a Session with Second Agent ("]')?.getBoundingClientRect(); - return { - innerWidth, - documentScrollWidth: document.documentElement.scrollWidth, - bodyScrollWidth: document.body.scrollWidth, - main: main && { left: main.left, right: main.right, width: main.width }, - trigger: trigger && { left: trigger.left, right: trigger.right, width: trigger.width }, - grid: grid && { left: grid.left, right: grid.right, width: grid.width }, - sessionTrigger: sessionTrigger && { left: sessionTrigger.left, right: sessionTrigger.right, width: sessionTrigger.width }, - }; - }); - expect(metrics.documentScrollWidth).toBeLessThanOrEqual(metrics.innerWidth); - expect(metrics.bodyScrollWidth).toBeLessThanOrEqual(metrics.innerWidth); - expect(metrics.main?.left).toBeGreaterThanOrEqual(0); - expect(metrics.main?.right).toBeLessThanOrEqual(390); - expect(metrics.trigger?.left).toBeGreaterThanOrEqual(0); - expect(metrics.trigger?.right).toBeLessThanOrEqual(390); - expect(metrics.grid?.left).toBeGreaterThanOrEqual(0); - expect(metrics.grid?.right).toBeLessThanOrEqual(390); - expect(metrics.sessionTrigger?.left).toBeGreaterThanOrEqual(metrics.grid?.left ?? 0); - expect(metrics.sessionTrigger?.right).toBeLessThanOrEqual(metrics.grid?.right ?? 390); - await expect(page.getByRole("button", { name: /^Start a Session with Second Agent/ })).toContainText("Start Session"); - await attachScreenshot(page, testInfo, "narrow-light-agent-card-grid"); - - await expect(page.getByRole("button", { name: "Environments", exact: true })).toHaveCount(0); - const sessionsNavigation = page.getByRole("button", { name: "Sessions", exact: true }); - await sessionsNavigation.click(); - await expect(sessionsNavigation).toHaveAttribute("aria-current", "page"); - await expect(page.locator(".session-page")).toBeVisible(); - await page.getByRole("button", { name: "Agents", exact: true }).click(); - - const globalCreate = page.getByRole("button", { name: "Create", exact: true }); - await globalCreate.click(); - const createPanel = page.getByRole("menu", { name: "Create" }); - await expect(createPanel).toBeVisible(); - await expect(createPanel.getByRole("menuitem")).toHaveCount(2); - await expect(createPanel.getByRole("menuitem", { name: /Environment template/i })).toHaveCount(0); - await expect(createPanel.getByRole("menuitem", { name: /Environment key/i })).toHaveCount(0); - const createPanelBox = await createPanel.boundingBox(); - expect(createPanelBox).not.toBeNull(); - expect(createPanelBox?.x ?? -1).toBeGreaterThanOrEqual(0); - expect((createPanelBox?.x ?? 0) + (createPanelBox?.width ?? 0)).toBeLessThanOrEqual(390); - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); - await createPanel.getByRole("menuitem", { name: /^Agent\b/ }).click(); - const setup = page.locator(".agent-setup-page"); - const box = await setup.boundingBox(); - expect(box).not.toBeNull(); - expect(box?.x ?? -1).toBeGreaterThanOrEqual(0); - expect(box?.y ?? -1).toBeGreaterThanOrEqual(0); - expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390.5); - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); - await expect(page.getByRole("button", { name: "Save Agent definition" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Request preview" })).toBeVisible(); - await expect(page.getByLabel("Text format")).toHaveValue("Text"); - await expect(page.getByLabel("Reasoning effort")).toBeDisabled(); - await expect(setup).toContainText("Creation is locked to the current Session-compatible profile"); - await attachScreenshot(page, testInfo, "narrow-light-agent-setup"); - await page.getByRole("button", { name: "Back to Agents" }).click(); - await expect(globalCreate).toBeFocused(); - - await setDarkTheme(page); - await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - await page.getByRole("button", { name: /^Edit Lifecycle Agent/ }).click(); - const editSetup = page.locator(".agent-setup-page"); - await expect(editSetup).toBeVisible(); - await expect(page.getByRole("dialog")).toHaveCount(0); - const editLayout = await editSetup.evaluate((element) => { - const box = element.getBoundingClientRect(); - return { - documentScrollWidth: document.documentElement.scrollWidth, - bodyScrollWidth: document.body.scrollWidth, - left: box.left, - right: box.right, - }; - }); - expect(editLayout.documentScrollWidth).toBeLessThanOrEqual(390); - expect(editLayout.bodyScrollWidth).toBeLessThanOrEqual(390); - expect(editLayout.left).toBeGreaterThanOrEqual(0); - expect(editLayout.right).toBeLessThanOrEqual(390.5); - await attachScreenshot(page, testInfo, "narrow-dark-agent-edit-setup"); - - const deleteAction = editSetup.getByRole("button", { name: "Delete Agent" }); - await deleteAction.scrollIntoViewIfNeeded(); - await expect(deleteAction).toBeInViewport(); - await deleteAction.click(); - const deleteDialog = page.getByRole("dialog", { name: "Delete Agent?" }); - await expect(deleteDialog).toBeVisible(); - await expect(deleteDialog).toContainText("agent_a"); - await expect(deleteDialog.getByRole("button", { name: "Cancel" })).toBeInViewport(); - await expect(deleteDialog.getByRole("button", { name: "Delete Agent" })).toBeInViewport(); - const dialogBox = await deleteDialog.boundingBox(); - expect(dialogBox).not.toBeNull(); - expect(dialogBox?.x ?? -1).toBeGreaterThanOrEqual(0); - expect((dialogBox?.x ?? 0) + (dialogBox?.width ?? 0)).toBeLessThanOrEqual(390.5); - await attachScreenshot(page, testInfo, "narrow-dark-agent-delete-confirmation"); - await deleteDialog.getByRole("button", { name: "Cancel" }).click(); - await expect(editSetup).toBeVisible(); - await expect(editSetup.getByRole("button", { name: "Delete Agent" })).toBeFocused(); -}); - -test("requires a first message without an Environment and preserves the draft across environment changes", async ({ page, request }) => { - await openAgents(page, request); - const before = await fixtureState(request); - for (const input of [undefined, null]) { - for (const fields of [{ environment: { type: "none" } }, { environment: { type: "openai_hosted" }, stream: true }]) { - const response = await request.post(`${fixtureBaseUrl}/v1/agents/sessions`, { data: { - agent_id: "agent_b", ...fields, ...(input === undefined ? {} : { input }), - } }); - expect(response.status()).toBe(400); - } - } - expect((await fixtureState(request)).sessions).toEqual(before.sessions); - const postsBefore = (await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions").length; - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - const firstMessage = dialog.getByRole("textbox", { name: /^First message\b/u }); - const create = dialog.getByRole("button", { name: "Create Session" }); - await expect(firstMessage).toHaveAttribute("aria-required", "true"); - await expect(create).toBeDisabled(); - await firstMessage.fill(" \t\u0085 "); - await expect(create).toBeDisabled(); - expect((await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions")).toHaveLength(postsBefore); - await dialog.getByRole("radio", { name: /Managed hosted/ }).check(); - await expect(create).toBeEnabled(); - await expect(firstMessage).toHaveAttribute("aria-required", "false"); - const input = " Explain this Agent's capabilities.\n"; - await firstMessage.fill(input); - await dialog.getByRole("radio", { name: /No environment/ }).check(); - await expect(firstMessage).toHaveValue(input); - await create.click(); - await expect(dialog).toHaveCount(0); - const creates = (await fixtureRequests(request)).filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); - expect(creates).toHaveLength(postsBefore + 1); - expect(creates.at(-1)?.body).toMatchObject({ environment: { type: "none" }, input, stream: true }); -}); - -test("starts one Session with an idempotency key and without browser authorization", async ({ page, request }) => { - await openAgents(page, request); - await startSessionWithSecondAgent(page); - await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); - - const requests = await fixtureRequests(request); - const creates = requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions"); - expect(creates).toHaveLength(1); - expect(creates[0]?.idempotencyKeyPresent).toBe(true); - expect(creates[0]?.body).toMatchObject({ agent_id: "agent_b", environment: { type: "none" }, input: "Review the selected Agent configuration.", stream: true }); - for (const entry of requests.filter((candidate) => candidate.path.startsWith("/v1/"))) { - expect(entry.beta).toBe("agents=v1"); - expect(entry.authorizationPresent).toBe(false); - } -}); - -test("filters every Session page by Agent and aborts a stale filter read", async ({ page, request }) => { - await resetFixture(request); - await controlFixture(request, { sessionListPageSize: 1 }); - - await openSessionsFromHome(page); - const filter = page.getByLabel("Filter Sessions by Agent"); - await expect(filter).toBeVisible(); - await expect(page.locator(".session-row")).toHaveCount(1); - - // These Sessions are deliberately created after the global snapshot loads. - // The Agent-scoped list must be able to select them without inflating Dashboard totals. - await createFixtureSession(request, "first"); - await createFixtureSession(request, "second"); - - await filter.selectOption("agent_b"); - await expect(page.locator(".session-row")).toHaveCount(2); - await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); - await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - - const filteredReads = (await fixtureRequests(request)).filter((entry) => { - if (entry.method !== "GET" || entry.path !== "/v1/agents/sessions") return false; - return new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_b"; - }); - expect(filteredReads).toHaveLength(2); - expect(filteredReads.every((entry) => ( - new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_b" - ))).toBe(true); - expect(filteredReads.some((entry) => ( - new URLSearchParams(entry.query ?? "").has("after") - ))).toBe(true); - - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - const dashboard = page.locator(".dashboard-page"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await expect(filter).toHaveValue("agent_b"); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - - const before = await fixtureState(request); - const agentAReadsBefore = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && - entry.path === "/v1/agents/sessions" && - new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_a" - )).length; - await controlFixture(request, { sessionListDelayMs: 500 }); - await filter.selectOption("agent_a"); - await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && - entry.path === "/v1/agents/sessions" && - new URLSearchParams(entry.query ?? "").get("agent_id") === "agent_a" - )).length).toBeGreaterThan(agentAReadsBefore); - - await filter.selectOption("agent_b"); - await expect.poll(async () => (await fixtureState(request)).aborts.sessionListReads) - .toBeGreaterThan(before.aborts.sessionListReads); - await expect(page.locator(".session-row")).toHaveCount(2); - await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); - await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); - await page.waitForTimeout(550); - await expect(filter).toHaveValue("agent_b"); - await expect(page.locator(".session-row").filter({ hasText: "Second Agent" })).toHaveCount(2); - await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); - - await filter.selectOption(""); - await expect(filter).toHaveValue(""); - await expect(page.locator(".session-row")).toHaveCount(1); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - - await filter.selectOption("agent_b"); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - const lifecycleRow = page.getByRole("table", { name: "Recent Sessions" }) - .getByRole("row") - .filter({ hasText: "Lifecycle Agent" }); - await lifecycleRow.getByRole("button").click(); - await expect(filter).toHaveValue(""); - await expect(page.locator(".session-row")).toHaveCount(1); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); -}); - -test("fences the filtered workspace across loading, errors, unavailable Agents, and deletes", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - await createFixtureSession(request, "delete-first"); - await createFixtureSession(request, "delete-second"); - - const filter = page.getByLabel("Filter Sessions by Agent"); - await controlFixture(request, { sessionListDelayMs: 500 }); - await filter.selectOption("agent_b"); - await expect(page.getByLabel("Loading Session workspace")).toBeVisible(); - await expect(page.locator(".conversation-panel")).toHaveCount(0); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - - await controlFixture(request, { sessionListStatus: 503 }); - await filter.selectOption("agent_a"); - await expect(page.locator(".workspace-error")).toContainText("Couldn’t load Sessions"); - await expect(page.locator(".conversation-panel")).toHaveCount(0); - await page.locator(".workspace-error").getByRole("button", { name: "Retry" }).click(); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - - await filter.selectOption("agent_b"); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - const deletedAgent = await request.delete(`${fixtureBaseUrl}/v1/agents/agent_b`); - expect(deletedAgent.ok()).toBe(true); - await page.getByRole("button", { name: "Refresh Sessions" }).click(); - await expect(filter).toHaveValue("agent_b"); - await expect(filter.locator("option:checked")).toHaveText("Unavailable Agent (not loaded)"); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - - for (const remainingRows of [1, 0]) { - await page.locator(".conversation-session-action").click(); - const dialog = page.getByRole("dialog"); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog).toHaveCount(0); - await expect(page.locator(".session-row")).toHaveCount(remainingRows); - await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); - if (remainingRows) { - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - } - } - await expect(page.locator(".conversation-panel")).toHaveCount(0); - await expect(page.locator(".workspace-empty")).toContainText("Select or create a Session"); -}); - -test("streams initial Session creation, captures early events, then hands off to one GET stream", async ({ page, request }) => { - await openAgents(page, request); - await controlFixture(request, { sessionCreateStreamCloseDelayMs: 1_500 }); - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^Title\b/u }).fill("Streamed creation"); - const exactInput = " Initial streamed input \n"; - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill(exactInput); - await dialog.getByRole("button", { name: "Create Session" }).click(); - - await expect(dialog).toHaveCount(0); - await expect(page.locator(".conversation-header h2")).toHaveText("Streamed creation"); - await expect(connectedLiveEvents(page)).toBeVisible(); - await expect( - page.getByRole("tabpanel", { name: "Conversation" }) - .getByText("Initial streamed input", { exact: true }), - ).toBeVisible(); - - await expect.poll(async () => (await fixtureState(request)).sessions[0]?.id).toMatch(/^session_created_/u); - const createdSessionId = (await fixtureState(request)).sessions[0]?.id; - if (!createdSessionId) throw new Error("Fixture did not retain the streamed Session."); - const createRequests = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(createRequests).toHaveLength(1); - expect(createRequests[0]?.body).toMatchObject({ - agent_id: "agent_b", - environment: { type: "none" }, - input: exactInput, - metadata: { title: "Streamed creation" }, - stream: true, - vault_ids: [], - }); - - await page.waitForTimeout(250); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` - ))).toHaveLength(0); - - await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` - )).length).toBe(1); - const handoffRequests = await fixtureRequests(request); - const getStreamIndex = handoffRequests.findIndex((entry) => ( - entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}/events` - )); - expect(getStreamIndex).toBeGreaterThan(-1); - expect(handoffRequests.slice(0, getStreamIndex).filter((entry) => ( - entry.method === "GET" && entry.path === `/v1/agents/sessions/${createdSessionId}` - )).length).toBeGreaterThanOrEqual(2); - await expect(connectedLiveEvents(page)).toBeVisible(); - await expect( - page.getByRole("tabpanel", { name: "Conversation" }) - .getByText("Initial streamed input", { exact: true }), - ).toBeVisible(); -}); - -for (const failure of [ - { label: "response loss", control: { sessionCreateResponseLoss: 1 }, message: "Agent core request failed (502)." }, - { label: "creation-stream EOF before identity", control: { sessionCreateStreamMissingIdentity: 1 }, message: "Agent Core already recorded this Session creation, so its stream sends no events." }, -]) { - test(`keeps one Session create attempt across ${failure.label} and an unchanged manual retry`, async ({ page, request }) => { - await openAgents(page, request); - await page.getByRole("button", { name: /^Start a Session with Second Agent/ }).click(); - const dialog = page.getByRole("dialog", { name: "Create a Session" }); - await dialog.getByRole("textbox", { name: /^First message\b/u }).fill("Review this request once after response recovery."); - const create = dialog.getByRole("button", { name: "Create Session" }); - await controlFixture(request, { sessionCreateDelayMs: 1_500, ...failure.control }); - - await create.evaluate((button) => { - button.click(); - button.click(); - }); - await expect(dialog.getByRole("alert")).toContainText(failure.message); - await expect(dialog.getByText("Retrying this unchanged request reuses the original idempotency key.")).toBeVisible(); - - let creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates).toHaveLength(1); - const originalKey = creates[0]?.idempotencyKey; - expect(originalKey).toBeTruthy(); - expect((await fixtureState(request)).sessions).toHaveLength(2); - - await create.click(); - await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); - creates = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions" - )); - expect(creates).toHaveLength(2); - expect(creates[1]?.idempotencyKey).toBe(originalKey); - expect(creates.map((entry) => entry.body?.stream)).toEqual([true, false]); - expect(creates[1]?.body?.input).toBe(creates[0]?.body?.input); - const recovered = (await fixtureState(request)).sessions; - expect(recovered).toHaveLength(2); - const sessionId = recovered.find((session) => session.id !== "session_snapshot")?.id; - const turns = await request.get(`${fixtureBaseUrl}/v1/agents/sessions/${sessionId}/turns`); - const items = await request.get(`${fixtureBaseUrl}/v1/agents/sessions/${sessionId}/items`); - expect((await turns.json() as { data: unknown[] }).data).toHaveLength(1); - expect((await items.json() as { data: unknown[] }).data).toHaveLength(1); - }); -} - -test("shows composer activity only for a Core-reported in-progress Session", async ({ page, request }, testInfo) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - - const activity = page.locator(".conversation-activity"); - await expect(activity).toHaveCount(0); - await emitSessionFixture(request, "in_progress"); - await expect(activity).toBeVisible(); - await expect(activity).toContainText("Lifecycle Agent is working…"); - await expect(activity).toHaveAttribute("aria-live", "polite"); - await expect(activity).toHaveAttribute("aria-atomic", "true"); - await expect(activity.locator(".status-running")).toBeVisible(); - await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeVisible(); - await attachElementScreenshot(activity, testInfo, "conversation-activity"); - - await emitSessionFixture(request, "idle"); - await expect(activity).toHaveCount(0); - await expect(page.getByRole("button", { name: "Send message" })).toBeVisible(); -}); - -test("shows immediate local feedback while a message submission is waiting for Core", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - - let releaseSend: (() => void) | undefined; - const sendGate = new Promise((resolve) => { - releaseSend = resolve; - }); - let interceptedSends = 0; - await page.route("**/v1/agents/sessions/*/events", async (route) => { - if (route.request().method() !== "POST") { - await route.continue(); - return; - } - interceptedSends += 1; - await sendGate; - await route.continue(); - }); - - const composer = page.getByLabel("Message the Agent"); - await composer.fill("Immediate pending message"); - await composer.press("Enter"); - - const pending = page.locator('[data-send-state="sending"]'); - await expect(pending).toBeVisible(); - await expect(pending).toContainText("Immediate pending message"); - await expect(pending).toContainText("Sending…"); - await expect(page.locator(".conversation-activity")).toContainText("Sending message…"); - await expect(page.getByText("Lifecycle Agent is working…")).toHaveCount(0); - await expect(composer).toHaveValue(""); - await expect.poll(() => interceptedSends).toBe(1); - - releaseSend?.(); - await expect(pending).toHaveCount(0); - await expect(page.locator(".conversation-activity")).toHaveCount(0); - await expect.poll(async () => (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ).length).toBe(1); -}); - -test("updates Session title and metadata after a latest read while preserving failed and unknown drafts", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - const manage = page.locator(".conversation-session-action"); - const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path.endsWith("/events") - )).length; - - await manage.click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); - await dialog.getByRole("button", { name: "Edit", exact: true }).click(); - await dialog.getByLabel("Session title", { exact: true }).fill("Renamed Session"); - await dialog.getByLabel("Additional Session metadata", { exact: true }).fill('{"team":"web","note":"safe"}'); - await dialog.getByRole("button", { name: "Save changes" }).click(); - await expect(dialog.getByRole("heading", { name: "Renamed Session" })).toBeVisible(); - await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); - - let requests = await fixtureRequests(request); - const updateIndex = requests.findLastIndex((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" - )); - const latestReadIndex = requests.findLastIndex((entry, index) => ( - index < updateIndex && entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" - )); - expect(latestReadIndex).toBeGreaterThanOrEqual(0); - expect(latestReadIndex).toBeLessThan(updateIndex); - expect(requests[updateIndex]?.body).toEqual({ - metadata: { team: "web", note: "safe", title: "Renamed Session" }, - }); - expect(requests.filter((entry) => entry.method === "GET" && entry.path.endsWith("/events"))).toHaveLength(streamReadsBefore); - - await dialog.getByRole("button", { name: "Edit", exact: true }).click(); - await dialog.getByLabel("Session title", { exact: true }).fill("Draft survives 503"); - await controlFixture(request, { sessionUpdateStatus: 503 }); - const postsBefore503 = requests.filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" - )).length; - await dialog.getByRole("button", { name: "Save changes" }).click(); - await expect(dialog.getByRole("alert")).toContainText("503"); - await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft survives 503"); - await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); - await page.waitForTimeout(350); - requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot")) - .toHaveLength(postsBefore503 + 1); - - await controlFixture(request, { sessionUpdateResponseLoss: 1 }); - await dialog.getByRole("button", { name: "Save changes" }).click(); - await expect(dialog.getByRole("alert")).toContainText("result is unknown"); - await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft survives 503"); - await expect(page.locator(".conversation-header h2")).toHaveText("Renamed Session"); - const postsAfterLoss = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" - )).length; - await page.waitForTimeout(350); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" - ))).toHaveLength(postsAfterLoss); -}); - -test("preserves and safely rebases a Session metadata draft after a same-key conflict", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - await page.locator(".conversation-session-action").click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); - await dialog.getByRole("button", { name: "Edit", exact: true }).click(); - await dialog.getByLabel("Session title", { exact: true }).fill("My preserved draft"); - await setFixtureSessionMetadata(request, "session_snapshot", { - title: "Concurrent title", - concurrent: "must survive", - }); - - await dialog.getByRole("button", { name: "Save changes" }).click(); - await expect(dialog.getByRole("alert")).toContainText("Metadata changed in Agent Core"); - await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("My preserved draft"); - await expect(dialog.getByLabel("Additional Session metadata", { exact: true })).toContainText('"concurrent": "must survive"'); - await expect(page.locator(".conversation-header h2")).toHaveText("Concurrent title"); - let writes = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" - )); - expect(writes).toHaveLength(0); - - await dialog.getByRole("button", { name: "Save changes" }).click(); - await expect(dialog.getByRole("heading", { name: "My preserved draft" })).toBeVisible(); - writes = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" - )); - expect(writes).toHaveLength(1); - expect(writes[0]?.body).toEqual({ - metadata: { title: "My preserved draft", concurrent: "must survive" }, - }); -}); - -test("rejects wrong-id and deep-malformed Session reads before writes or delete retries", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - - await controlFixture(request, { sessionRetrieveVariant: "wrong_id" }); - await page.locator(".conversation-session-action").click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("alert")).toContainText("invalid Session resource"); - await expect(dialog).toContainText("session_snapshot"); - await expect(dialog).not.toContainText("another_session"); - - await dialog.getByRole("button", { name: "Edit", exact: true }).click(); - await dialog.getByLabel("Session title", { exact: true }).fill("Draft stays local"); - await controlFixture(request, { sessionRetrieveVariant: "deep_malformed" }); - const writesBefore = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" - )).length; - await dialog.getByRole("button", { name: "Save changes" }).click(); - await expect(dialog.getByRole("alert")).toContainText("invalid Session retrieval response"); - await expect(dialog.getByLabel("Session title", { exact: true })).toHaveValue("Draft stays local"); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot" - ))).toHaveLength(writesBefore); - - await dialog.getByRole("button", { name: "Cancel" }).click(); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await expect(dialog).toContainText("Exact Session: session_snapshot"); - await controlFixture(request, { - sessionDeleteResponseLoss: 2, - sessionRetrieveVariant: "deep_malformed", - }); - const deletesBefore = (await fixtureRequests(request)).filter((entry) => entry.method === "DELETE").length; - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog.getByRole("alert")).toContainText("follow-up durable Session refresh also failed"); - await expect(dialog.getByRole("button", { name: "Delete Session" })).toBeDisabled(); - expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")) - .toHaveLength(deletesBefore + 1); -}); - -test("requires confirmation and reconciles unknown Session deletes once without retrying the write", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - const manage = page.locator(".conversation-session-action"); - await manage.click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await expect(dialog).toContainText("Exact Session: session_snapshot"); - await expect(dialog).toContainText("not a promise of physical history erasure"); - await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); - await dialog.getByRole("button", { name: "Cancel" }).click(); - await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeFocused(); - expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(0); - - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await controlFixture(request, { sessionDeleteStatus: 409 }); - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog.getByRole("alert")).toContainText("compatible Core rejected"); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - - await controlFixture(request, { sessionDeleteStatus: 503 }); - const readsBefore503 = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" - )).length; - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog.getByRole("alert")).toContainText("503"); - await expect(dialog.getByRole("alert")).toContainText("refresh confirmed that the Session is still present"); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - let requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(2); - expect(requests.filter((entry) => ( - entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" - ))).toHaveLength(readsBefore503 + 1); - - await controlFixture(request, { - sessionDeleteResponseLoss: 2, - sessionRetrieveStatus: 503, - }); - const readsBeforeUnresolved = requests.filter((entry) => ( - entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" - )).length; - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog.getByRole("alert")).toContainText("result is unknown"); - await expect(dialog.getByRole("alert")).toContainText("follow-up durable Session refresh also failed"); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - await expect(dialog.getByRole("button", { name: "Delete Session" })).toBeDisabled(); - requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(3); - expect(requests.filter((entry) => ( - entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" - ))).toHaveLength(readsBeforeUnresolved + 1); - await page.waitForTimeout(350); - expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(3); - - await dialog.getByRole("button", { name: "Cancel" }).click(); - await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeDisabled(); - await dialog.getByRole("button", { name: "Close", exact: true }).click(); - await manage.click(); - await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await controlFixture(request, { sessionDeleteResponseLoss: 1 }); - const readsBeforeAppliedLoss = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" - )).length; - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog).toHaveCount(0); - await expect(page.locator(".session-row").filter({ hasText: "Lifecycle Agent" })).toHaveCount(0); - requests = await fixtureRequests(request); - expect(requests.filter((entry) => entry.method === "DELETE")).toHaveLength(4); - expect(requests.filter((entry) => ( - entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot" - ))).toHaveLength(readsBeforeAppliedLoss + 1); - await page.waitForTimeout(350); - expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(4); -}); - -test("keeps a stale Session row and surfaces each explicit repeated 404 deletion", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - const manage = page.locator(".conversation-session-action"); - await manage.click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); - await removeFixtureSession(request, "session_snapshot"); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - - for (const expectedDeletes of [1, 2]) { - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog.getByRole("alert")).toContainText("not found in Agent Core"); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - expect((await fixtureRequests(request)).filter((entry) => entry.method === "DELETE")).toHaveLength(expectedDeletes); - } -}); - -test("deletes an inactive Session without disturbing the active composer or live event stream", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - await page.getByRole("button", { name: "Agents" }).click(); - await startSessionWithSecondAgent(page); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await expect(connectedLiveEvents(page)).toBeVisible(); - const composer = page.getByLabel("Message the Agent"); - await composer.fill("active draft must survive"); - const before = await fixtureState(request); - const activeId = before.sessions.find((session) => session.id !== "session_snapshot")?.id; - expect(activeId).toBeTruthy(); - const activeStreamReads = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === `/v1/agents/sessions/${activeId}/events` - )).length; - - const inactiveRow = page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }); - await inactiveRow.locator(".session-row-action").click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await controlFixture(request, { sessionDeleteDelayMs: 1_500 }); - const deleteButton = dialog.locator(".modal-footer .button.danger"); - const deleteClick = deleteButton.click(); - await expect(deleteButton).toBeDisabled(); - await expect(deleteButton).toHaveText("Deleting…"); - await expect(inactiveRow).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(dialog).toBeVisible(); - await deleteClick; - await expect(dialog).toHaveCount(0); - await expect(inactiveRow).toHaveCount(0); - await expect(composer).toHaveValue("active draft must survive"); - await expect(connectedLiveEvents(page)).toBeVisible(); - await expect(page.locator(".conversation-session-action")).toBeFocused(); - - const after = await fixtureState(request); - expect(after.aborts.streams).toBe(before.aborts.streams); - expect(after.openStreams).toContain(activeId); - expect((await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === `/v1/agents/sessions/${activeId}/events` - ))).toHaveLength(activeStreamReads); -}); - -test("continues a Session with a new Turn after its latest attempt fails", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - - await emitSessionFixture(request, "failed"); - await expect(page.getByText("Latest attempt failed", { exact: true })).toBeVisible(); - await expect(page.locator(".session-runtime-error")).toContainText( - "You can send another message to start a new Turn in this Session.", - ); - - const composer = page.getByLabel("Message the Agent"); - await expect(composer).toBeEnabled(); - await composer.fill("Continue after the failed Turn"); - const send = page.getByRole("button", { name: "Send message" }); - await expect(send).toBeEnabled(); - await send.click(); - - await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot/events" - )).length).toBe(1); - const submission = (await fixtureRequests(request)).find((entry) => ( - entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot/events" - )); - expect(submission?.body).toEqual({ - events: [{ - type: "agent.session.input.message", - input: [{ - role: "user", - content: [{ type: "input_text", text: "Continue after the failed Turn" }], - }], - }], - }); -}); - -for (const pendingRead of [ - { - label: "Session", - path: "/v1/agents/sessions/session_snapshot", - control: { sessionRetrieveDelayMs: 5_000 }, - }, - { - label: "Item", - path: "/v1/agents/sessions/session_snapshot/items", - control: { itemsRetrieveDelayMs: 5_000 }, - }, - { - label: "Turn", - path: "/v1/agents/sessions/session_snapshot/turns", - control: { turnsRetrieveDelayMs: 5_000 }, - }, -] as const) { - test(`aborts the selected Session's pending ${pendingRead.label} read and SSE after confirmed delete`, async ({ page, request }) => { - const failedReads = new Map(); - page.on("requestfailed", (failedRequest) => { - const path = new URL(failedRequest.url()).pathname; - if (failedRequest.method() === "GET") { - failedReads.set(path, failedRequest.failure()?.errorText ?? "unknown failure"); - } - }); - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - await page.getByRole("button", { name: "Agents" }).click(); - await startSessionWithSecondAgent(page); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await controlFixture(request, { turnsScenario: 1 }); - await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - await expect(connectedLiveEvents(page)).toBeVisible(); - await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); - await page.locator(".conversation-session-action").click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - const before = await fixtureState(request); - const previousReads = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === pendingRead.path - )).length; - await controlFixture(request, pendingRead.control); - await emitTurnFixture(request, "completed"); - await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === pendingRead.path - )).length).toBeGreaterThan(previousReads); - - await controlFixture(request, { sessionDeleteStreamCloseDelayMs: 3_000 }); - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog).toHaveCount(0); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); - await expect(page.locator('[data-turn-id="turn_completed"]')).toHaveCount(0); - await expect(page.locator(".conversation-session-action")).toBeFocused(); - await expect(connectedLiveEvents(page)).toBeVisible(); - - await expect.poll(() => failedReads.get(pendingRead.path)).toContain("ERR_ABORTED"); - await expect.poll(async () => (await fixtureState(request)).aborts.streams).toBeGreaterThan(before.aborts.streams); - const after = await fixtureState(request); - expect(after.sessions.some((session) => session.id === "session_snapshot")).toBe(false); - expect(after.openStreams).not.toContain("session_snapshot"); - }); -} - -test("aborts a pending manual recovery read after deleting the selected Session", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); - - await expectSelectedDeleteAbortsSessionRead(page, request, () => ( - page.getByRole("button", { name: "Refresh Sessions" }).click() - )); -}); - -test("aborts a pending detail retry read after deleting the selected Session", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - await controlFixture(request, { sessionRetrieveStatus: 503 }); - await page.getByRole("button", { name: "Refresh Sessions" }).click(); - const detailError = page.locator(".session-detail-error"); - await expect(detailError).toBeVisible(); - await controlFixture(request, { sessionRetrieveDelayMs: 5_000 }); - - await expectSelectedDeleteAbortsSessionRead(page, request, () => ( - detailError.getByRole("button", { name: "Retry" }).click() - )); -}); - -test("deletes the selected Session while its SSE is still connecting", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - await page.getByRole("button", { name: "Agents" }).click(); - await startSessionWithSecondAgent(page); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await expect(connectedLiveEvents(page)).toBeVisible(); - - await controlFixture(request, { streamOpenDelayMs: 3_000 }); - const streamReadsBefore = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot/events" - )).length; - await page.locator(".session-row").filter({ hasText: "Lifecycle Agent" }).locator(".session-row-select").click(); - await expect(page.locator(".conversation-header h2")).toHaveText("Lifecycle Agent"); - await expect(page.getByText("Connecting events…", { exact: true })).toBeVisible(); - await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path === "/v1/agents/sessions/session_snapshot/events" - )).length).toBeGreaterThan(streamReadsBefore); - const before = await fixtureState(request); - - await page.locator(".conversation-session-action").click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.getByRole("button", { name: "Delete", exact: true })).toBeEnabled(); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await dialog.getByRole("button", { name: "Delete Session" }).click(); - await expect(dialog).toHaveCount(0); - await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); - await expect.poll(async () => (await fixtureState(request)).aborts.streams).toBeGreaterThan(before.aborts.streams); - expect((await fixtureState(request)).sessions.some((session) => session.id === "session_snapshot")).toBe(false); -}); - -test("keeps Session actions accessible and contained at 390 px in dark mode", async ({ page, request }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - await setDarkTheme(page); - const manage = page.locator(".conversation-session-action"); - await manage.focus(); - await page.keyboard.press("Enter"); - const dialog = page.getByRole("dialog"); - await expect(dialog).toBeVisible(); - const metrics = await dialog.evaluate((element) => { - const box = element.getBoundingClientRect(); - const footer = element.querySelector(".modal-footer")?.getBoundingClientRect(); - return { - viewportWidth: innerWidth, - documentWidth: document.documentElement.scrollWidth, - left: box.left, - right: box.right, - bottom: box.bottom, - footerBottom: footer?.bottom ?? Number.POSITIVE_INFINITY, - }; - }); - expect(metrics.documentWidth).toBeLessThanOrEqual(metrics.viewportWidth); - expect(metrics.left).toBeGreaterThanOrEqual(0); - expect(metrics.right).toBeLessThanOrEqual(390); - expect(metrics.bottom).toBeLessThanOrEqual(844); - expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.bottom); - - await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeEnabled(); - await dialog.getByRole("button", { name: "Delete", exact: true }).click(); - await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); - await dialog.getByRole("button", { name: "Cancel" }).click(); - await expect(dialog.getByRole("button", { name: "Edit", exact: true })).toBeFocused(); - await page.keyboard.press("Escape"); - await expect(dialog).toHaveCount(0); - await expect(manage).toBeFocused(); -}); - -test("presents Dashboard page-chain results and System boundaries without extra detail reads", async ({ page, request }, testInfo) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - await expect(page.getByText("Session is ready", { exact: true })).toBeVisible(); - const initialDetailPaths = [ - "/v1/agents/sessions/session_snapshot", - "/v1/agents/sessions/session_snapshot/items", - "/v1/agents/sessions/session_snapshot/turns", - ]; - await expect.poll(async () => { - const entries = await fixtureRequests(request); - return initialDetailPaths.every((path) => entries.filter((entry) => ( - entry.method === "GET" && entry.path === path - )).length >= 2); - }).toBe(true); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - - const dashboard = page.locator(".dashboard-page"); - await expect(dashboard.getByRole("heading", { name: /Dashboard/ })).toBeVisible(); - await expect(dashboard).toContainText("Latest complete paginated reads"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "In progress" })).toContainText("0"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Needs attention" })).toContainText("0"); - await expect(dashboard).not.toContainText("Reported aggregate tokens"); - await expect(dashboard).toContainText("No Sessions currently need attention."); - await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); - await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); - - const before = await fixtureRequests(request); - const count = (entries: FixtureRequest[], path: string) => entries.filter((entry) => ( - entry.method === "GET" && entry.path === path - )).length; - const detailPaths = [ - "/v1/agents/sessions/session_snapshot", - "/v1/agents/sessions/session_snapshot/items", - "/v1/agents/sessions/session_snapshot/turns", - ]; - let delayNextSessionList = true; - let releaseSessionList: (() => void) | null = null; - let markSessionListStarted: (() => void) | null = null; - const sessionListStarted = new Promise((resolve) => { - markSessionListStarted = resolve; - }); - await page.route("**/v1/agents/sessions*", async (route) => { - if (new URL(route.request().url()).pathname !== "/v1/agents/sessions" || !delayNextSessionList) { - return route.continue(); - } - delayNextSessionList = false; - markSessionListStarted?.(); - await new Promise((resolve) => { - releaseSessionList = resolve; - }); - await route.continue(); - }); - const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); - await refresh.click(); - await sessionListStarted; - await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange"))); - releaseSessionList?.(); - await expect.poll(async () => { - const entries = await fixtureRequests(request); - return [count(entries, "/v1/agents"), count(entries, "/v1/agents/sessions")]; - }).toEqual([count(before, "/v1/agents") + 1, count(before, "/v1/agents/sessions") + 2]); - const after = await fixtureRequests(request); - expect(count(after, "/v1/agents")).toBe(count(before, "/v1/agents") + 1); - expect(count(after, "/v1/agents/sessions")).toBe(count(before, "/v1/agents/sessions") + 2); - expect(count(after, "/v1/agents/runtime-observations")).toBe(count(before, "/v1/agents/runtime-observations") + 1); - for (const path of detailPaths) expect(count(after, path)).toBe(count(before, path)); - await attachScreenshot(page, testInfo, "desktop-dashboard-loaded-snapshot"); - - await page.setViewportSize({ width: 390, height: 844 }); - const dashboardBounds = await dashboard.evaluate((element) => { - const box = element.getBoundingClientRect(); - return { - viewportWidth: innerWidth, - documentWidth: document.documentElement.scrollWidth, - bodyWidth: document.body.scrollWidth, - left: box.left, - right: box.right, - }; - }); - expect(dashboardBounds.documentWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); - expect(dashboardBounds.bodyWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); - expect(dashboardBounds.left).toBeGreaterThanOrEqual(0); - expect(dashboardBounds.right).toBeLessThanOrEqual(390); - await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); - await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); - await attachScreenshot(page, testInfo, "narrow-dashboard-loaded-snapshot"); - await page.setViewportSize({ width: 1280, height: 720 }); - - await dashboard.getByRole("button", { name: /Create agent/ }).click(); - await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "New Agent" })).toBeVisible(); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - await dashboard.getByRole("button", { name: /Start session/ }).click(); - const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); - await expect(sessionDialog).toBeVisible(); - await sessionDialog.getByRole("button", { name: "Cancel" }).click(); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - - await dashboard.getByRole("table", { name: "Recent Sessions" }).getByRole("button", { name: "Lifecycle Agent" }).click(); - const sessionPage = page.locator(".session-page"); - await expect(sessionPage).toBeVisible(); - await expect(sessionPage.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible(); - - await page.getByRole("button", { name: "System", exact: true }).click(); - const system = page.locator(".system-page"); - await expect(system.getByRole("listitem").filter({ hasText: "Daemon gateway" })).toContainText("Enabled"); - await expect(system.getByRole("listitem").filter({ hasText: "Managed sandbox" })).toContainText("Docker"); - await expect(system.getByRole("listitem").filter({ hasText: "Endpoint overrides" })).toContainText("Configured"); - await expect(system.getByRole("listitem")).toHaveCount(3); - await expect(system).not.toContainText("Configured for this process"); - await expect(system).not.toContainText("Managed execution"); - await expect(system).not.toContainText("Self-hosted execution"); - await expect(system).not.toContainText("Default harness"); - await expect(system).toContainText("Agent create and edit forms can select any adapter enabled for this Core process"); - await expect(system).not.toContainText("Enabled · default"); - await expect(system).toContainText("Operator endpoint override: configured"); - await expect(system).toContainText("Compiled into this build, but not enabled when this Core process started"); - await expect(system).toContainText("Runtime connection, native binary availability, sandbox health, and model execution belong to the relevant Session or Environment"); - await expect(system).not.toContainText("Source Files"); - await expect(system).not.toContainText("Public capability surface"); - await expect(system).not.toContainText("Cannot be pre-checked"); - - const beforeSystemRefresh = await fixtureRequests(request); - const systemRefresh = system.getByRole("button", { name: "Refresh System status" }); - await systemRefresh.click(); - await expect.poll(async () => count(await fixtureRequests(request), "/v1/agents/core/startup-configuration")).toBe( - count(beforeSystemRefresh, "/v1/agents/core/startup-configuration") + 1, - ); - const afterSystemRefresh = await fixtureRequests(request); - expect(count(afterSystemRefresh, "/v1/agents")).toBe(count(beforeSystemRefresh, "/v1/agents")); - expect(count(afterSystemRefresh, "/v1/agents/sessions")).toBe(count(beforeSystemRefresh, "/v1/agents/sessions")); - expect(count(afterSystemRefresh, "/v1/vaults")).toBe(count(beforeSystemRefresh, "/v1/vaults")); - expect(count(afterSystemRefresh, "/v1/agents/core/startup-configuration")).toBe( - count(beforeSystemRefresh, "/v1/agents/core/startup-configuration") + 1, - ); - for (const path of detailPaths) expect(count(afterSystemRefresh, path)).toBe(count(beforeSystemRefresh, path)); - await attachScreenshot(page, testInfo, "desktop-system-contract-boundary"); - - await page.setViewportSize({ width: 778, height: 844 }); - const compactSystemBounds = await system.evaluate((element) => ({ - viewportWidth: innerWidth, - documentWidth: document.documentElement.scrollWidth, - bodyWidth: document.body.scrollWidth, - right: element.getBoundingClientRect().right, - sectionInset: element.querySelector(".system-config-section")!.getBoundingClientRect().left - element.getBoundingClientRect().left, - sectionEdges: [...element.querySelectorAll(".system-config-section")].map((section) => ({ - left: section.getBoundingClientRect().left - element.getBoundingClientRect().left, - right: element.getBoundingClientRect().right - section.getBoundingClientRect().right, - header: section.querySelector("header")!.getBoundingClientRect().left, - content: section.querySelector(".system-harness-grid")!.getBoundingClientRect().left, - explanation: section.querySelector(".system-config-explanation")?.getBoundingClientRect().left ?? null, - })), - boundaryInset: element.querySelector(".system-boundary-note")!.getBoundingClientRect().left - element.getBoundingClientRect().left, - })); - expect(compactSystemBounds.documentWidth).toBeLessThanOrEqual(compactSystemBounds.viewportWidth); - expect(compactSystemBounds.bodyWidth).toBeLessThanOrEqual(compactSystemBounds.viewportWidth); - expect(compactSystemBounds.right).toBeLessThanOrEqual(compactSystemBounds.viewportWidth); - expect(compactSystemBounds.sectionInset).toBe(24); - expect(compactSystemBounds.boundaryInset).toBe(24); - for (const edge of compactSystemBounds.sectionEdges) { - expect({ left: edge.left, right: edge.right }).toEqual({ left: 24, right: 24 }); - expect(edge.header).toBe(edge.content); - if (edge.explanation !== null) expect(edge.explanation).toBe(edge.header); - } - - await page.setViewportSize({ width: 390, height: 844 }); - const systemBounds = await system.evaluate((element) => { - const rows = [...element.querySelectorAll(".system-summary-cell")]; - return { - viewportWidth: innerWidth, - documentWidth: document.documentElement.scrollWidth, - sectionInset: element.querySelector(".system-config-section")!.getBoundingClientRect().left - element.getBoundingClientRect().left, - sectionRightInset: element.getBoundingClientRect().right - element.querySelector(".system-config-section")!.getBoundingClientRect().right, - boundaryInset: element.querySelector(".system-boundary-note")!.getBoundingClientRect().left - element.getBoundingClientRect().left, - rowBounds: rows.map((row) => ({ - top: row.getBoundingClientRect().top, - bottom: row.getBoundingClientRect().bottom, - height: row.getBoundingClientRect().height, - borderBottomWidth: getComputedStyle(row).borderBottomWidth, - })), - }; - }); - expect(systemBounds.documentWidth).toBeLessThanOrEqual(systemBounds.viewportWidth); - expect(systemBounds.sectionInset).toBe(12); - expect(systemBounds.sectionRightInset).toBe(12); - expect(systemBounds.boundaryInset).toBe(12); - for (let index = 1; index < systemBounds.rowBounds.length; index += 1) { - expect(systemBounds.rowBounds[index]!.top).toBeGreaterThanOrEqual(systemBounds.rowBounds[index - 1]!.bottom); - } - expect(systemBounds.rowBounds.every((row) => row.height >= 36)).toBe(true); - expect(systemBounds.rowBounds.slice(0, -1).every((row) => row.borderBottomWidth !== "0px")).toBe(true); - expect(systemBounds.rowBounds.at(-1)?.borderBottomWidth).toBe("0px"); - await attachScreenshot(page, testInfo, "narrow-system-contract-boundary"); -}); - -test("renders Runtime telemetry as visual snapshot panels with details on demand", async ({ page }, testInfo) => { - const baseline = 1_789_438_800; - const sessionId = "11111111-1111-4111-8111-111111111111"; - const environmentId = "22222222-2222-4222-8222-222222222222"; - const allocationId = "33333333-3333-4333-8333-333333333333"; - const runtimeSession = { - id: sessionId, - object: "agent.session", - agent: { - id: "agent_b", - model: "fixture/model", - name: "Runtime analyst", - instructions: null, - multi_agent: { enabled: false, max_concurrent_subagents: null }, - reasoning: {}, - service_tier: "auto", - text: { format: { type: "text" }, verbosity: "medium" }, - tools: [], - }, - environment: { - type: "openai_hosted", - id: environmentId, - capability_directories: [], - network: { access: "enabled", allowed_domains: [] }, - packages: { npm: [], python: [], system: [] }, - files: [], - plugins: [], - skills: [], - }, - status: "in_progress", - error: null, - metadata: { title: "Repository migration" }, - required_actions: [], - vault_ids: [], - usage: { - input_tokens: 420_000, - output_tokens: 66_000, - total_tokens: 486_000, - input_tokens_details: { cached_tokens: 180_000 }, - output_tokens_details: { reasoning_tokens: 22_000 }, - }, - created_at: baseline - 9_000, - last_active_at: baseline - 20, - }; - const runtimeSessions = Array.from({ length: 12 }, (_, index) => ({ - ...runtimeSession, - id: index === 0 ? sessionId : `11111111-1111-4111-8111-${String(index + 1).padStart(12, "0")}`, - metadata: { title: `Repository migration ${index + 1}` }, - usage: { ...runtimeSession.usage, input_tokens: 420_000 + index, total_tokens: 486_000 + index }, - })); - const list = (data: Array<{ id: string }>) => ({ - object: "list", - data, - has_more: false, - first_id: data[0]?.id ?? null, - last_id: data.at(-1)?.id ?? null, - }); - - await page.route("**/v1/agents/runtime-history/capabilities", (route) => route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - object: "agent.runtime_history_capabilities", - available: false, - reason: "not_configured", - collection_mode: null, - sample_interval_seconds: null, - retention_seconds: null, - minimum_step_seconds: null, - maximum_range_seconds: null, - maximum_points: null, - metrics: [], - }), - })); - await page.route("**/v1/agents/sessions*", async (route) => { - if (new URL(route.request().url()).pathname !== "/v1/agents/sessions") return route.fallback(); - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(list(runtimeSessions)) }); - }); - let runtimeObservationReads = 0; - await page.route("**/v1/agents/runtime-observations*", async (route) => { - const sampleIndex = runtimeObservationReads; - runtimeObservationReads += 1; - const observedAt = baseline - 1 + sampleIndex * 30; - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(list(runtimeSessions.map((runtime, index) => ({ - id: runtime.id, - object: "agent.runtime_observation", - session_id: runtime.id, - environment_id: environmentId, - mode: "openai_hosted", - provider_type: "docker", - instance: { - kind: "managed_allocation", - allocation_id: index === 0 ? allocationId : `33333333-3333-4333-8333-${String(index + 1).padStart(12, "0")}`, - device_id: null, - connection_generation: null, - }, - lifecycle_state: "active", - status: "observed", - reason: null, - allocation_created_at: baseline - 8_500, - resolved_at: observedAt + 1, - observed_at: observedAt, - started_at: baseline - 8_100, - cpu: { - usage_seconds_total: 7_350 + index + sampleIndex * 30, - capacity_cores: 2, - usage_cores: null, - utilization_ratio: null, - }, - memory: { usage_bytes: 1_288_490_188, limit_bytes: 2_147_483_648 }, - })))), - }); - }); - - await page.goto("/"); - const dashboard = page.locator(".dashboard-page"); - await expect(dashboard.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible(); - const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); - await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("1 sample ·"); - await expect(dashboard.getByRole("heading", { name: "CPU usage" })).toBeVisible(); - await expect(dashboard.getByRole("heading", { name: "Memory usage" })).toBeVisible(); - await expect(dashboard.getByRole("heading", { name: "Active sandboxes" })).toBeVisible(); - await expect(dashboard.getByRole("heading", { name: "Token throughput" })).toBeVisible(); - await expect(dashboard.getByLabel("Live Runtime sampling every 30 seconds")).toBeVisible(); - const liveRange = dashboard.getByRole("group", { name: "Runtime live range" }); - await expect(liveRange.getByRole("button", { name: "1h" })).toHaveAttribute("aria-pressed", "true"); - await liveRange.getByRole("button", { name: "15m" }).click(); - await expect(liveRange.getByRole("button", { name: "15m" })).toHaveAttribute("aria-pressed", "true"); - await refresh.click(); - await expect(dashboard.getByLabel("CPU usage: 2 live samples")).toBeVisible(); - await refresh.click(); - await expect(dashboard.getByLabel("CPU usage: 3 live samples")).toBeVisible(); - await expect(dashboard.getByLabel("Memory usage: 3 live samples")).toBeVisible(); - await expect(dashboard.getByLabel("Active sandboxes: 3 live samples")).toBeVisible(); - await expect(dashboard.getByLabel("Token throughput: 3 live samples")).toBeVisible(); - await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("3 samples"); - await expect(dashboard.getByText("CPU usage live trend available")).toBeAttached(); - await expect(dashboard).not.toContainText("Collecting live samples"); - const memoryCard = dashboard.getByRole("region", { name: "Memory usage live chart" }); - const memoryStrokePixelCount = () => memoryCard.locator("canvas").evaluate((canvas: HTMLCanvasElement) => { - const context = canvas.getContext("2d"); - if (!context) return 0; - const { data, width, height } = context.getImageData(0, 0, canvas.width, canvas.height); - let pixels = 0; - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const offset = (y * width + x) * 4; - const red = data[offset] ?? 0; - const green = data[offset + 1] ?? 0; - const blue = data[offset + 2] ?? 0; - const alpha = data[offset + 3] ?? 0; - if (alpha > 128 && Math.abs(red - 185) < 20 && Math.abs(green - 152) < 20 && Math.abs(blue - 244) < 20) { - pixels += 1; - } - } - } - return pixels; - }); - expect(await memoryStrokePixelCount()).toBeGreaterThan(0); - const cpuChart = dashboard.getByLabel("CPU usage: 3 live samples"); - const cpuCard = cpuChart.locator("xpath=ancestor::section[contains(@class, 'dashboard-runtime-trend-card')]"); - await cpuChart.focus(); - await cpuChart.press("Enter"); - await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); - await cpuChart.press("Escape"); - await cpuChart.hover({ position: { x: 260, y: 90 } }); - await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toBeVisible(); - await cpuChart.click({ position: { x: 260, y: 90 } }); - await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); - await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("10%"); - await cpuChart.focus(); - await cpuChart.press("ArrowRight"); - await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned"); - await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("50%"); - const cpuSeriesToggle = cpuCard.locator(".dashboard-runtime-trend-legend button").first(); - await cpuSeriesToggle.click(); - await expect(cpuSeriesToggle).toHaveAttribute("aria-pressed", "false"); - await cpuSeriesToggle.click(); - await expect(cpuSeriesToggle).toHaveAttribute("aria-pressed", "true"); - - await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(4); - await expect(dashboard.locator(".dashboard-runtime-timeline")).toHaveCount(0); - const initialViewStart = Number(await cpuChart.getAttribute("data-view-start")); - const initialViewEnd = Number(await cpuChart.getAttribute("data-view-end")); - const plot = cpuCard.locator(".u-over"); - const plotBox = await plot.boundingBox(); - expect(plotBox).not.toBeNull(); - await page.mouse.move(plotBox!.x + plotBox!.width * .55, plotBox!.y + plotBox!.height / 2); - await page.mouse.down(); - await page.mouse.move(plotBox!.x + plotBox!.width, plotBox!.y + plotBox!.height / 2, { steps: 8 }); - await page.mouse.up(); - await expect.poll(async () => Number(await cpuChart.getAttribute("data-view-start"))).toBeGreaterThan(initialViewStart); - await expect.poll(async () => Number(await cpuChart.getAttribute("data-view-end"))).toBeLessThanOrEqual(initialViewEnd); - const zoomedViewStart = Number(await cpuChart.getAttribute("data-view-start")); - const zoomedViewEnd = Number(await cpuChart.getAttribute("data-view-end")); - await cpuChart.focus(); - await cpuChart.press("Escape"); - await cpuChart.press("ArrowRight"); - const keyboardSelectedAt = Number(await cpuChart.getAttribute("data-selected-at")); - expect(keyboardSelectedAt).toBeGreaterThanOrEqual(zoomedViewStart); - expect(keyboardSelectedAt).toBeLessThanOrEqual(zoomedViewEnd); - for (const chartName of ["Memory usage", "Active sandboxes", "Token throughput"]) { - await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-start", String(initialViewStart)); - await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-end", String(initialViewEnd)); - } - const resetZoom = cpuCard.getByRole("button", { name: "Reset zoom" }); - await expect(resetZoom).toBeEnabled(); - await resetZoom.click(); - await expect(cpuChart).toHaveAttribute("data-view-start", String(initialViewStart)); - await expect(cpuChart).toHaveAttribute("data-view-end", String(initialViewEnd)); - await expect(dashboard.getByRole("table", { name: "Runtime targets" })).not.toBeVisible(); - for (const close of await page.getByRole("button", { name: "Close notification" }).all()) await close.click(); - await expect(page.getByRole("button", { name: "Close notification" })).toHaveCount(0); - await attachElementScreenshot(dashboard.locator(".dashboard-runtime-panel"), testInfo, "runtime-visual-dashboard"); - - await dashboard.getByText("Runtime targets", { exact: true }).click(); - await expect(dashboard.getByRole("table", { name: "Runtime targets" })).toBeVisible(); - await expect(dashboard.getByText("Page 1 of 2")).toBeVisible(); - const runtimeSearch = dashboard.getByPlaceholder("Search Session, provider, or identity"); - await runtimeSearch.fill("Repository migration 12"); - await expect(dashboard.getByText("1 visible")).toBeVisible(); - await expect(dashboard.getByRole("table", { name: "Runtime targets" }).getByRole("row", { name: /Repository migration 12/ })).toBeVisible(); - await runtimeSearch.fill(""); - await dashboard.getByRole("button", { name: "Tokens" }).click(); - await expect(dashboard.getByRole("columnheader", { name: "Tokens" })).toHaveAttribute("aria-sort", "descending"); - await dashboard.getByRole("button", { name: "Next" }).click(); - await expect(dashboard.getByText("Page 2 of 2")).toBeVisible(); - - await page.setViewportSize({ width: 390, height: 844 }); - await expect(dashboard.getByRole("heading", { name: "Memory usage" })).toBeVisible(); - await expect.poll(() => cpuChart.evaluate((element) => getComputedStyle(element).touchAction)).toBe("pan-y"); - for (const close of await page.getByRole("button", { name: "Close notification" }).all()) await close.click(); - const widths = await dashboard.locator(".dashboard-runtime-panel").evaluate((element) => ({ - viewport: innerWidth, - document: document.documentElement.scrollWidth, - panel: element.getBoundingClientRect().width, - })); - expect(widths.document).toBeLessThanOrEqual(widths.viewport); - expect(widths.panel).toBeLessThanOrEqual(widths.viewport); - await attachElementScreenshot(dashboard.locator(".dashboard-runtime-panel"), testInfo, "runtime-visual-dashboard-narrow"); -}); - -test("restores retained Runtime history after a Dashboard reload", async ({ page }) => { - const sessionId = "11111111-1111-4111-8111-111111111111"; - const environmentId = "22222222-2222-4222-8222-222222222222"; - const allocationId = "33333333-3333-4333-8333-333333333333"; - const runtimeSession = { - id: sessionId, - object: "agent.session", - agent: { - id: "agent_history", model: "fixture/model", name: "History worker", instructions: null, - multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: {}, service_tier: "auto", - text: { format: { type: "text" }, verbosity: "medium" }, tools: [], - }, - environment: { - type: "openai_hosted", id: environmentId, capability_directories: [], - network: { access: "enabled", allowed_domains: [] }, packages: { npm: [], python: [], system: [] }, - files: [], plugins: [], skills: [], - }, - status: "idle", error: null, metadata: { title: "Persisted runtime" }, required_actions: [], vault_ids: [], - usage: null, created_at: 1_789_430_000, last_active_at: 1_789_438_000, - }; - const list = (data: Array<{ id: string }>) => ({ - object: "list", data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null, - }); - - await page.route("**/v1/agents/sessions*", async (route) => { - if (new URL(route.request().url()).pathname !== "/v1/agents/sessions") return route.fallback(); - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(list([runtimeSession])) }); - }); - await page.route("**/v1/agents/runtime-observations*", async (route) => { - const now = Math.floor(Date.now() / 1_000); - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(list([{ - id: sessionId, object: "agent.runtime_observation", session_id: sessionId, environment_id: environmentId, - mode: "openai_hosted", provider_type: "docker", - instance: { kind: "managed_allocation", allocation_id: allocationId, device_id: null, connection_generation: null }, - lifecycle_state: "active", - status: "observed", reason: null, allocation_created_at: now - 600, resolved_at: now, - observed_at: now - 1, started_at: now - 600, - cpu: { usage_seconds_total: 120, capacity_cores: 2, usage_cores: null, utilization_ratio: null }, - memory: { usage_bytes: 536_870_912, limit_bytes: 2_147_483_648 }, - }])), - }); - }); - await page.route("**/v1/agents/runtime-history/capabilities", (route) => route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - object: "agent.runtime_history_capabilities", available: true, reason: null, collection_mode: "periodic", - sample_interval_seconds: 30, retention_seconds: 604_800, minimum_step_seconds: 30, - maximum_range_seconds: 86_400, maximum_points: 1_000, metrics: ["cpu", "memory", "tokens"], - }), - })); - await page.route(`**/v1/agents/sessions/${sessionId}/runtime-history*`, async (route) => { - const url = new URL(route.request().url()); - const start = Number(url.searchParams.get("start")); - const end = Number(url.searchParams.get("end")); - const firstStart = start; - const gapStart = end - 60; - const lastStart = end - 30; - const startedAt = start; - const point = (pointStart: number, ratio: number, memory: number) => ({ - start: pointStart, end: pointStart + 30, first_observed_at: pointStart + 10, last_observed_at: pointStart + 20, - observation_count: 1, observed_count: 1, unavailable_count: 0, - cpu: { contributor_count: 1, utilization_ratio: ratio, capacity_cores: 2 }, - memory: { contributor_count: 1, usage_bytes: memory, limit_bytes: 2_147_483_648 }, - }); - const gap = { - start: gapStart, end: gapStart + 30, first_observed_at: gapStart + 10, last_observed_at: gapStart + 20, - observation_count: 1, observed_count: 0, unavailable_count: 1, cpu: null, memory: null, - }; - const points = Array.from({ length: Math.floor((end - start) / 30) }, (_, index) => { - const pointStart = start + index * 30; - return pointStart === gapStart - ? gap - : point(pointStart, .25 + index / 1_000, 536_870_912 + index * 1_048_576); - }).filter((point) => point.start !== end - 90); - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - object: "agent.runtime_history", source: "durable", session_id: sessionId, - requested_range: { start, end }, resolution_seconds: 30, generated_at: end, - coverage: { - retained_start: start, first_sample_at: firstStart + 10, last_sample_at: lastStart + 20, - sample_count: points.length, expected_sample_count: Math.floor((end - start) / 30), - buckets: points.map(({ cpu: _cpu, memory: _memory, ...coverage }) => coverage), - }, - series: [{ - environment_id: environmentId, allocation_id: allocationId, - started_at: { seconds: startedAt, nanoseconds: 123_456_789 }, provider_type: "docker", points, - }], - token_usage: points.map((point) => ({ - start: point.start, - end: point.end, - sampled_at: point.start + 20, - input_tokens: 10_000 + (point.start - start) * 10, - output_tokens: 2_000 + (point.start - start) * 2, - })), - }), - }); - }); - - await page.goto("/"); - const dashboard = page.locator(".dashboard-runtime-panel"); - await expect(dashboard.getByRole("group", { name: "Runtime trend source" })).toHaveCount(0); - await expect(dashboard.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible(); - await expect(dashboard.getByLabel("Runtime durable-history charts")).toBeVisible(); - await expect(dashboard.getByRole("heading", { name: "Compute uptime", exact: true })).toHaveCount(0); - await expect(dashboard.getByRole("heading", { name: "Active sandboxes", exact: true })).toBeVisible(); - await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(4); - await expect(dashboard.getByText("CPU usage durable trend available")).toBeAttached(); - await expect(dashboard).toContainText("120 buckets"); - await expect(dashboard).toContainText("119/120 observations"); - await expect(dashboard.getByText("Active sandboxes durable trend available")).toBeAttached(); - await expect(dashboard.getByText("Token throughput durable trend available")).toBeAttached(); - const durableCpuChart = dashboard.getByLabel("CPU usage: 120 retained buckets"); - await expect(dashboard.getByRole("region", { name: "CPU usage durable history chart" })).toBeVisible(); - const durableCpuCard = durableCpuChart.locator("xpath=ancestor::section[contains(@class, 'dashboard-runtime-trend-card')]"); - await durableCpuChart.focus(); - await durableCpuChart.press("ArrowLeft"); - await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("0%"); - await durableCpuChart.press("ArrowLeft"); - await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("0%"); - const durableMemoryCard = dashboard.getByRole("region", { name: "Memory usage durable history chart" }); - const durableMemorySpan = await durableMemoryCard.locator("canvas").evaluate((canvas: HTMLCanvasElement) => { - const context = canvas.getContext("2d"); - if (!context) return 0; - const { data, width, height } = context.getImageData(0, 0, canvas.width, canvas.height); - let minimumX = width; - let maximumX = -1; - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const offset = (y * width + x) * 4; - const red = data[offset] ?? 0; - const green = data[offset + 1] ?? 0; - const blue = data[offset + 2] ?? 0; - const alpha = data[offset + 3] ?? 0; - if (alpha > 128 && Math.abs(red - 185) < 20 && Math.abs(green - 152) < 20 && Math.abs(blue - 244) < 20) { - minimumX = Math.min(minimumX, x); - maximumX = Math.max(maximumX, x); - } - } - } - return maximumX < minimumX ? 0 : maximumX - minimumX; - }); - expect(durableMemorySpan).toBeGreaterThan(100); - const durableInitialStart = Number(await durableCpuChart.getAttribute("data-view-start")); - const durablePlotBox = await durableCpuCard.locator(".u-over").boundingBox(); - expect(durablePlotBox).not.toBeNull(); - await page.mouse.move(durablePlotBox!.x + durablePlotBox!.width * .2, durablePlotBox!.y + durablePlotBox!.height / 2); - await page.mouse.down(); - await page.mouse.move(durablePlotBox!.x + durablePlotBox!.width * .8, durablePlotBox!.y + durablePlotBox!.height / 2, { steps: 8 }); - await page.mouse.up(); - await expect.poll(async () => Number(await durableCpuChart.getAttribute("data-view-start"))).toBeGreaterThan(durableInitialStart); - const durableZoomStart = Number(await durableCpuChart.getAttribute("data-view-start")); - const durableZoomEnd = Number(await durableCpuChart.getAttribute("data-view-end")); - await expect(durableCpuCard.getByRole("button", { name: "Reset zoom" })).toBeVisible(); - await page.getByRole("button", { name: "Refresh Dashboard snapshot" }).click(); - const refreshedDurableCpuChart = dashboard.getByLabel("CPU usage: 120 retained buckets"); - await expect(refreshedDurableCpuChart).toHaveAttribute("data-view-start", String(durableZoomStart)); - await expect(refreshedDurableCpuChart).toHaveAttribute("data-view-end", String(durableZoomEnd)); - - await page.reload(); - await expect(dashboard.getByRole("group", { name: "Runtime trend source" })).toHaveCount(0); - await expect(dashboard.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible(); - await expect(dashboard.getByText("CPU usage durable trend available")).toBeAttached(); -}); - -test("publishes Dashboard counts only after every top-level Agent and Session page loads", async ({ page, request }) => { - await resetFixture(request); - const agentAfters: Array = []; - const sessionAfters: Array = []; - let sessionTemplate: Record | null = null; - - await page.route("**/v1/agents**", async (route) => { - const url = new URL(route.request().url()); - if (route.request().method() !== "GET" || !["/v1/agents", "/v1/agents/sessions"].includes(url.pathname)) { - await route.continue(); - return; - } - - const upstream = await route.fetch(); - const payload = await upstream.json() as { - object: string; - data: Array>; - }; - const after = url.searchParams.get("after"); - expect(url.searchParams.get("limit")).toBe("100"); - expect(url.searchParams.get("order")).toBe("desc"); - - if (url.pathname === "/v1/agents") { - agentAfters.push(after); - const data = after === null ? payload.data.slice(0, 2) : payload.data.slice(2); - await route.fulfill({ - response: upstream, - json: { - object: "list", - data, - has_more: after === null, - first_id: data[0]?.id ?? null, - last_id: data.at(-1)?.id ?? null, - }, - }); - return; - } - - sessionAfters.push(after); - if (after === null) sessionTemplate = payload.data[0] ?? null; - if (sessionTemplate === null) throw new Error("The fixture did not provide a Session pagination template."); - const original = sessionTemplate; - const second = { - ...original, - id: "session_second_page", - metadata: { fixture: "second-page" }, - created_at: Number(original?.created_at ?? 0) - 1, - last_active_at: Number(original?.last_active_at ?? 0) - 1, - }; - const data = after === null ? [original] : [second]; - await route.fulfill({ - response: upstream, - json: { - object: "list", - data, - has_more: after === null, - first_id: data[0]?.id ?? null, - last_id: data.at(-1)?.id ?? null, - }, - }); - }); - - await page.goto("/"); - const dashboard = page.locator(".dashboard-page"); - // The synthetic second Session has no Runtime observation. Let that initial - // snapshot finish before navigation, so requests cannot be aborted mid-chain. - await expect(dashboard.locator(".dashboard-source-badge").filter({ hasText: "Runtime" })).toContainText("Unavailable"); - expect(sessionAfters).toEqual([null, "session_snapshot", null, "session_snapshot"]); - await page.getByRole("button", { name: "Sessions", exact: true }).click(); - await expect.poll(() => sessionAfters.length).toBe(4); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("2"); - expect(agentAfters).toEqual([null, "agent_b"]); - // The cached Dashboard and shared Session collection stay mounted across - // navigation, so no extra page-chain read occurs on either transition. - await expect.poll(() => sessionAfters).toEqual([ - null, "session_snapshot", - null, "session_snapshot", - ]); -}); - -test("keeps the previous Dashboard result when pagination exceeds the safety limit", async ({ page, request }) => { - await resetFixture(request); - await openSessionsFromHome(page); - await page.getByRole("button", { name: "Dashboard", exact: true }).click(); - - const dashboard = page.locator(".dashboard-page"); - const loadedAgents = dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" }); - await expect(loadedAgents).toContainText("3"); - - let template: Record | null = null; - let reads = 0; - await page.route("**/v1/agents**", async (route) => { - const url = new URL(route.request().url()); - if (route.request().method() !== "GET" || url.pathname !== "/v1/agents") { - await route.continue(); - return; - } - reads += 1; - if (template === null) { - const upstream = await route.fetch(); - const payload = await upstream.json() as { data: Array> }; - template = payload.data[0] ?? null; - } - expect(template).not.toBeNull(); - const id = `agent_safety_page_${reads}`; - await route.fulfill({ - json: { - object: "list", - data: [{ ...template, id, name: `Safety page ${reads}` }], - has_more: true, - first_id: id, - last_id: id, - }, - }); - }); - - const refresh = dashboard.getByRole("button", { name: "Refresh Dashboard snapshot" }); - await refresh.click(); - await expect.poll(() => reads).toBe(100); - await expect(refresh).toBeEnabled(); - await expect(dashboard).toContainText("Snapshot incomplete"); - await expect(dashboard).toContainText("collection pagination exceeded the Web safety limit"); - await expect(dashboard).toContainText("Sessions changed while Runtime observations were loading"); - await expect(loadedAgents).toContainText("3"); - await dashboard.getByRole("button", { name: "Connection settings" }).click(); - const connectionDialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); - await expect(connectionDialog).toBeVisible(); - await connectionDialog.getByRole("button", { name: "Cancel" }).click(); - expect(reads).toBe(100); -}); - -test("renders self-hosted Environment and Workspace state safely across reconnect and narrow themes", async ({ page, request }, testInfo) => { - await resetFixture(request); - await controlFixture(request, { - environmentScenario: 1, - environmentEventStatus: 1, - environmentEventCount: 1, - streamCloseCount: 1, - streamCloseDelayMs: 1_000, - }); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - - const { dialog, panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment: Pending"); - await expect(panel).toContainText("Self-hosted Environment"); - await expect(panel).toContainText("Pending"); - await expect(panel).toContainText("environment_fixture"); - await expect(panel).toContainText("Workspace is this Environment’s execution directory, not a top-level workspaces API"); - await expect(panel).toContainText("https://executor.example.test/connect"); - await expect(panel.locator('a[href="https://executor.example.test/connect"]')).toHaveCount(0); - await expect(panel.getByRole("link", { name: "Core setup" })).toBeVisible(); - await expect(panel.getByRole("link", { name: "Launcher setup" })).toBeVisible(); - await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); - await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Cancel active Turn" })).toBeEnabled(); - await expect(page.locator("body")).not.toContainText("launcher:private"); - await expect(page.locator("body")).not.toContainText("executor_token=secret"); - await expect(page.locator('a[href^="file:"]')).toHaveCount(0); - - await expect.poll(async () => ( - await fixtureRequests(request) - ).filter((entry) => entry.method === "GET" && entry.path.endsWith("/events")).length).toBeGreaterThanOrEqual(2); - await expect(panel).toContainText("Pending"); - await expect(panel).toContainText("Status comes from the durable Environment resource"); - await panel.evaluate((element) => element.scrollIntoView({ block: "start" })); - await attachElementScreenshot(panel, testInfo, "desktop-light-environment-panel"); - await attachScreenshot(page, testInfo, "desktop-light-self-hosted-environment"); - - await page.keyboard.press("Escape"); - await expect(dialog).toHaveCount(0); - await expect(trigger).toBeFocused(); - await page.setViewportSize({ width: 390, height: 844 }); - await setDarkTheme(page); - await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - const { panel: narrowPanel } = await openEnvironmentDialog(page); - const widths = await narrowPanel.evaluate((element) => { - const box = element.getBoundingClientRect(); - return { - viewport: innerWidth, - document: document.documentElement.scrollWidth, - body: document.body.scrollWidth, - left: box.left, - right: box.right, - }; - }); - expect(widths.document).toBeLessThanOrEqual(widths.viewport); - expect(widths.body).toBeLessThanOrEqual(widths.viewport); - expect(widths.left).toBeGreaterThanOrEqual(0); - expect(widths.right).toBeLessThanOrEqual(390); - await narrowPanel.getByRole("link", { name: "Launcher setup" }).focus(); - await expect(narrowPanel.getByRole("link", { name: "Launcher setup" })).toBeFocused(); - await narrowPanel.evaluate((element) => element.scrollIntoView({ block: "start" })); - await attachElementScreenshot(narrowPanel, testInfo, "narrow-dark-environment-panel"); - await attachScreenshot(page, testInfo, "narrow-dark-self-hosted-environment"); - - await controlFixture(request, { environmentScenario: 2, environmentEventStatus: 0 }); - await page.reload(); - const { panel: unknown, trigger: unknownTrigger } = await openEnvironmentDialog(page); - await expect(unknownTrigger).toHaveAccessibleName("Environment: Unavailable"); - await expect(unknown).toContainText("Environment unavailable"); - await expect(unknown).toContainText("Unknown type"); - await expect(unknown).not.toContainText("/must-not-render"); - await expect(unknown.locator("a")).toHaveCount(0); - - await controlFixture(request, { environmentScenario: 3, environmentEventStatus: 0 }); - await page.reload(); - await expect(page.locator(".workspace-error")).toContainText("Couldn’t load Sessions"); - await expect(page.locator(".workspace-error")).toContainText("invalid Session resource"); - await expect(environmentTrigger(page)).toHaveCount(0); -}); - -test("lists Workspace file metadata explicitly, paginates, fails closed, and fences Environment changes", async ({ page, request }, testInfo) => { - await resetFixture(request); - await controlFixture(request, { environmentScenario: 7 }); - await openSessionsFromHome(page); - - const { panel } = await openEnvironmentDialog(page); - const files = panel.getByRole("region", { name: "Workspace files" }); - await expect(files).toBeVisible(); - await expect(panel).toContainText("/executor/workspace"); - await expect(files.getByLabel("Directory")).toHaveValue("/executor/workspace"); - await expect(files).toContainText("Files are loaded only when requested"); - expect((await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files"))).toHaveLength(0); - - await files.getByRole("button", { name: "List files" }).click(); - const table = files.getByRole("table", { name: "Workspace file metadata" }); - await expect(table).toContainText("/executor/workspace/file-01.txt"); - await expect(table).toContainText("/executor/workspace/file-20.txt"); - await expect(table).not.toContainText("/executor/workspace/file-21.txt"); - await expect(files.getByRole("button", { name: "Load more" })).toBeVisible(); - - let reads = (await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files")); - expect(reads.at(-1)?.query).toBe("?path=%2Fexecutor%2Fworkspace&limit=20&order=asc"); - expect(reads.at(-1)?.beta).toBe("agents=v1"); - await files.getByRole("button", { name: "Load more" }).click(); - await expect(table).toContainText("/executor/workspace/file-21.txt"); - await expect(files.getByRole("button", { name: "Load more" })).toHaveCount(0); - reads = (await fixtureRequests(request)).filter((entry) => entry.path.endsWith("/files")); - expect(reads.at(-1)?.query).toBe("?path=%2Fexecutor%2Fworkspace&limit=20&order=asc&page=fixture-page-2"); - await attachElementScreenshot(files, testInfo, "workspace-file-metadata-list"); - - await controlFixture(request, { environmentFilesStatus: 503 }); - await files.getByRole("button", { name: "Refresh" }).click(); - await expect(files.getByRole("alert")).toContainText("Workspace files are temporarily unavailable"); - await expect(files.getByRole("table", { name: "Workspace file metadata" })).toHaveCount(0); - await expect(files).not.toContainText("No direct regular files were returned"); - - await controlFixture(request, { environmentFilesStatus: 404 }); - await files.getByRole("button", { name: "Refresh" }).click(); - await expect(files.getByRole("alert")).toContainText("not supported by the connected Core"); - await expect(files.getByRole("table", { name: "Workspace file metadata" })).toHaveCount(0); - - const beforeAbort = (await fixtureState(request)).aborts.environmentFileReads; - await controlFixture(request, { environmentFilesStatus: 200, environmentFilesDelayMs: 2_000 }); - await files.getByRole("button", { name: "Refresh" }).click(); - await expect.poll(async () => ( - await fixtureRequests(request) - ).filter((entry) => entry.path.endsWith("/files")).length).toBeGreaterThan(reads.length + 1); - await controlFixture(request, { environmentScenario: 2 }); - await page.getByRole("button", { name: "Refresh Sessions" }).evaluate((button) => ( - button as HTMLButtonElement - ).click()); - await expect(page.getByRole("region", { name: "Workspace files" })).toHaveCount(0); - await expect.poll(async () => (await fixtureState(request)).aborts.environmentFileReads).toBeGreaterThan(beforeAbort); -}); - -test("hydrates durable expired and unavailable Environment states without a write or paid Turn", async ({ page, request }) => { - await resetFixture(request); - await controlFixture(request, { - environmentScenario: 4, - environmentResourceStatus: "expired", - environmentEventStatus: 0, - }); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - - let { panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment: Expired"); - await expect(panel).toContainText("Expired"); - await expect(panel).toContainText("Environment expired"); - await expect(panel).toContainText("no API-managed files, plugins, or skills"); - await expect(page.getByLabel("Message the Agent")).toBeVisible(); - - const initialRequests = await fixtureRequests(request); - const sessionReadIndex = initialRequests.findIndex( - (entry) => entry.method === "GET" && /^\/v1\/agents\/sessions\/[^/]+$/.test(entry.path), - ); - const environmentReadIndex = initialRequests.findIndex( - (entry) => entry.method === "GET" && isEnvironmentResourcePath(entry.path), - ); - expect(sessionReadIndex).toBeGreaterThanOrEqual(0); - expect(environmentReadIndex).toBeGreaterThan(sessionReadIndex); - - let environmentRequests = initialRequests.filter( - (entry) => isEnvironmentResourcePath(entry.path), - ); - expect(environmentRequests.length).toBeGreaterThanOrEqual(1); - expect(environmentRequests.every((entry) => entry.method === "GET" && entry.body === undefined)).toBe(true); - - await controlFixture(request, { environmentRetrieveStatus: 503 }); - await page.reload(); - ({ panel, trigger } = await openEnvironmentDialog(page)); - await expect(trigger).toHaveAccessibleName("Environment: Unavailable"); - await expect(panel).toContainText("Unavailable"); - await expect(panel).toContainText("conversation remains usable"); - await expect(panel).not.toContainText("Expired"); - await expect(panel).not.toContainText("Connected"); - await expect(page.getByLabel("Message the Agent")).toBeVisible(); - - await controlFixture(request, { - environmentRetrieveStatus: 200, - environmentResourceVariant: "missing_skills", - }); - await page.reload(); - ({ panel, trigger } = await openEnvironmentDialog(page)); - await expect(trigger).toHaveAccessibleName("Environment: Unavailable"); - await expect(panel).toContainText("Unavailable"); - await expect(page.getByLabel("Message the Agent")).toBeVisible(); - - environmentRequests = (await fixtureRequests(request)).filter( - (entry) => isEnvironmentResourcePath(entry.path), - ); - expect(environmentRequests.every((entry) => entry.method === "GET")).toBe(true); -}); - -test("hydrates durable Environment state even when the live stream is rejected", async ({ page, request }) => { - await resetFixture(request); - await controlFixture(request, { - environmentScenario: 4, - environmentResourceStatus: "expired", - streamStatus: 401, - }); - await openSessionsFromHome(page); - - const { panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment: Expired"); - await expect(panel).toContainText("Expired"); - await expect(panel).toContainText("Status comes from the durable Environment resource"); - await expect(page.getByText("Events unavailable", { exact: true })).toBeVisible(); - await expect(page.getByLabel("Message the Agent")).toBeVisible(); - - const requests = await fixtureRequests(request); - expect(requests.some((entry) => ( - entry.method === "GET" && isEnvironmentResourcePath(entry.path) - ))).toBe(true); -}); - -test("keeps canonical Environment UUID identity across Session and resource projections", async ({ page, request }) => { - await resetFixture(request); - const canonicalEnvironmentUuid = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; - await controlFixture(request, { - environmentScenario: 5, - environmentResourceStatus: "connected", - environmentEventStatus: 0, - }); - await openSessionsFromHome(page); - - const { panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment: Connected"); - await expect(panel).toContainText("Connected"); - await expect(panel).toContainText("Status comes from the durable Environment resource"); - await expect(panel).not.toContainText("Durable Environment status is unavailable"); - await expect(panel).toContainText(canonicalEnvironmentUuid.toUpperCase()); - - const environmentRequests = (await fixtureRequests(request)).filter( - (entry) => entry.method === "GET" && isEnvironmentResourcePath(entry.path), - ); - expect(environmentRequests.length).toBeGreaterThanOrEqual(1); - expect(environmentRequests.every( - (entry) => entry.path === `/v1/agents/environments/${canonicalEnvironmentUuid.toUpperCase()}` && - entry.body === undefined, - )).toBe(true); -}); - -test("applies a buffered live Environment event after an earlier durable snapshot", async ({ page, request }) => { - await resetFixture(request); - await controlFixture(request, { - environmentScenario: 4, - environmentResourceStatus: "pending", - streamStatus: 401, - }); - await openSessionsFromHome(page); - - await expect(environmentTrigger(page)).toHaveAccessibleName("Environment: Pending"); - const streamError = page.locator(".session-stream-error"); - await expect(streamError.getByText("Couldn’t open live events")).toBeVisible(); - - await controlFixture(request, { - environmentRetrieveDelayMs: 500, - environmentEventStatus: 3, - environmentEventCount: 1, - streamStatus: 200, - }); - await streamError.getByRole("button", { name: "Retry" }).click(); - - const { panel, trigger } = await openEnvironmentDialog(page); - await expect(trigger).toHaveAccessibleName("Environment: Connected"); - await expect(panel).toContainText("Connected"); - await expect(panel).toContainText("last supported live event observed after the durable Environment snapshot"); - await expect(panel).not.toContainText("Pending"); -}); - -test("loads every Turn page, reconciles terminal events, and keeps diagnostics out of Conversation", async ({ page, request }, testInfo) => { - await resetFixture(request); - await controlFixture(request, { - turnsScenario: 1, - turnsPageSize: 2, - }); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - - const conversationTab = page.getByRole("tab", { name: "Conversation" }); - const conversation = page.getByRole("tabpanel", { name: "Conversation" }); - await expect(conversation.getByRole("region", { name: "Turn timeline" })).toHaveCount(0); - - await page.getByRole("tab", { name: "Trace" }).click(); - const trace = page.getByRole("tabpanel", { name: "Trace" }); - const diagnostics = trace.locator("details.trace-turn-diagnostics"); - await expect(diagnostics).not.toHaveAttribute("open", ""); - await diagnostics.locator("summary").click(); - await expect(diagnostics).toHaveAttribute("open", ""); - - const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); - await expect(timeline).toContainText("7 observed Turns"); - for (const status of ["Queued", "In progress", "Waiting", "Completed", "Failed", "Cancelled"]) { - await expect(timeline.getByRole("img", { name: `Turn status: ${status}` }).first()).toBeVisible(); - } - await expect(timeline).toContainText("Running ·"); - await expect(timeline.locator('[data-turn-id="turn_completed"]')).toContainText("7s"); - await expect(timeline.getByRole("region", { name: "Session aggregate usage" })).toContainText("26"); - await expect(timeline.locator('[data-turn-id="turn_completed"]').getByRole("group", { name: "Usage for Turn turn_completed" })).toContainText("13"); - const failed = timeline.locator('[data-turn-id="turn_failed"]'); - await expect(failed).toContainText("The execution could not complete."); - await expect(failed).toContainText("1 linked Item"); - await expect(trace.getByText("Persisted input before the Turn failed.")).toBeVisible(); - await expect(timeline).toContainText("1 Item is not associated with an observed Turn yet."); - - const readsBeforeTerminal = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path.endsWith("/turns") - )); - expect(readsBeforeTerminal.length).toBeGreaterThanOrEqual(4); - expect(readsBeforeTerminal.some((entry) => entry.query === "?limit=100&order=asc")).toBe(true); - expect(readsBeforeTerminal.some((entry) => entry.query?.includes("after=turn_in_progress"))).toBe(true); - expect(readsBeforeTerminal.every((entry) => entry.body === undefined)).toBe(true); - - const terminal = timeline.locator('[data-turn-id="turn_terminal_refresh"]'); - await expect(terminal).toHaveAttribute("data-turn-status", "in_progress"); - await emitTurnFixture(request, "completed"); - await expect(terminal).toHaveAttribute("data-turn-status", "completed"); - await expect(terminal).toContainText("Turn usage"); - await expect.poll(async () => ( - await fixtureRequests(request) - ).filter((entry) => entry.method === "GET" && entry.path.endsWith("/turns")).length).toBeGreaterThan(readsBeforeTerminal.length); - - await controlFixture(request, { turnsRetrieveStatus: 503 }); - await page.getByRole("button", { name: "Refresh Sessions" }).click(); - await expect(trace.locator(".trace-load-error").filter({ hasText: "Turn history is incomplete" })).toBeVisible(); - await expect(timeline.locator(".turn-timeline-failure")).toContainText("Couldn’t load Turn history"); - await expect(timeline).toContainText("last observed Turn timeline remains visible"); - await expect(trace.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); - - await page.setViewportSize({ width: 390, height: 844 }); - await diagnostics.evaluate((element) => element.scrollIntoView({ block: "start" })); - const diagnosticsLayout = await diagnostics.evaluate((element) => { - const box = element.getBoundingClientRect(); - return { - viewport: innerWidth, - document: document.documentElement.scrollWidth, - body: document.body.scrollWidth, - left: box.left, - right: box.right, - overflowY: getComputedStyle(element).overflowY, - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - }; - }); - expect(diagnosticsLayout.document).toBeLessThanOrEqual(diagnosticsLayout.viewport); - expect(diagnosticsLayout.body).toBeLessThanOrEqual(diagnosticsLayout.viewport); - expect(diagnosticsLayout.left).toBeGreaterThanOrEqual(0); - expect(diagnosticsLayout.right).toBeLessThanOrEqual(diagnosticsLayout.viewport); - expect(diagnosticsLayout.overflowY).toBe("auto"); - expect(diagnosticsLayout.scrollHeight).toBeGreaterThan(diagnosticsLayout.clientHeight); - await diagnostics.evaluate((element) => { element.scrollTop = element.scrollHeight; }); - await expect(timeline.locator(".turn-card").last()).toBeVisible(); - await attachElementScreenshot(diagnostics, testInfo, "narrow-turn-diagnostics"); - - await conversationTab.click(); - await expect(conversation.getByRole("region", { name: "Turn timeline" })).toHaveCount(0); - await expect(conversation.getByText("Completed Turn output remains in the conversation.")).toBeVisible(); - await expect(page.getByLabel("Message the Agent")).toBeVisible(); -}); - -test("presents an honest searchable Trace workbench without changing the conversation draft", async ({ page, request }, testInfo) => { - await resetFixture(request); - await controlFixture(request, { - turnsScenario: 1, - turnsPageSize: 2, - itemsScenario: 2, - }); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - - const viewTabs = page.getByRole("tablist", { name: "Session view" }); - const conversationTab = viewTabs.getByRole("tab", { name: "Conversation" }); - const traceTab = viewTabs.getByRole("tab", { name: "Trace" }); - const composer = page.getByLabel("Message the Agent"); - await expect(page.locator("#session-trace-panel")).toBeHidden(); - await composer.fill("Draft survives Trace inspection\nwith a second line"); - const sendsBefore = (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ).length; - - await conversationTab.focus(); - await page.keyboard.press("ArrowRight"); - await expect(traceTab).toHaveAttribute("aria-selected", "true"); - const trace = page.getByRole("tabpanel", { name: "Trace" }); - await expect(trace).toBeVisible(); - await expect(trace).toContainText("Known Turn time"); - const diagnostics = trace.locator("details.trace-turn-diagnostics"); - await expect(diagnostics).not.toHaveAttribute("open", ""); - await expect(diagnostics.locator("summary")).toContainText("Turn diagnostics"); - await expect(trace).toContainText("Turns"); - await expect(trace).toContainText("Tool calls"); - await expect(trace).toContainText("Equal-width sequence · not time-scaled"); - await expect(trace.locator(".trace-order-scroll")).toHaveCount(1); - await expect(trace).toContainText("Core reports Turn wall-clock time, but not per-item timing"); - await expect(trace).toContainText("Configured instructions"); - await expect(trace).toContainText("Completed Turn output remains in the conversation."); - await expect(trace).not.toContainText("TTFT"); - await expect(trace).not.toContainText("Throughput"); - - const search = trace.getByRole("searchbox", { name: "Search trace" }); - await search.fill("Persisted input failed"); - await expect(trace.locator(".trace-ledger-row")).toHaveCount(1); - await expect(trace).toContainText("Persisted input before the Turn failed."); - const messageDuration = trace.locator(".trace-ledger-row").filter({ hasText: "Persisted input before the Turn failed." }).locator(".trace-row-duration"); - await expect(messageDuration.locator('[aria-hidden="true"]')).toHaveText("—"); - await expect(messageDuration).toHaveAttribute("title", "Core does not provide per-item timing."); - await expect(messageDuration.locator(".trace-visually-hidden")).toHaveText("Per-item timing not provided by Core."); - await search.fill(""); - - const patchRow = trace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); - await expect(patchRow.locator('.trace-row-duration [aria-hidden="true"]')).toHaveText("—"); - await expect(patchRow.locator(".trace-row-duration")).toHaveAttribute("title", "Core does not provide per-item timing."); - await patchRow.click(); - const detail = page.getByRole("complementary", { name: "Trace item details" }); - await expect(detail).toBeVisible(); - await expect(detail.getByRole("button", { name: "Close trace details" })).toBeFocused(); - const previewTab = detail.getByRole("tab", { name: "Preview" }); - await expect(previewTab).toHaveAttribute("aria-controls", "session-trace-panel-detail-content"); - await previewTab.click(); - await expect(detail.getByRole("tabpanel", { name: "Preview" })).toHaveAttribute("tabindex", "0"); - const viewer = detail.getByRole("region", { name: "Parsar apply patch diff" }); - await expect(viewer).toContainText("3 files"); - await expect(viewer).toContainText("Completed"); - const desktopSplit = await Promise.all([ - trace.locator(".trace-ledger").boundingBox(), - detail.boundingBox(), - ]); - expect(desktopSplit[0]).not.toBeNull(); - expect(desktopSplit[1]).not.toBeNull(); - expect(desktopSplit[1]!.x).toBeGreaterThanOrEqual(desktopSplit[0]!.x + desktopSplit[0]!.width - 1); - await attachScreenshot(page, testInfo, "desktop-trace-detail"); - await page.keyboard.press("Escape"); - await expect(detail).toHaveCount(0); - await expect(patchRow).toBeFocused(); - - await patchRow.click(); - await search.fill("filter selected row out"); - await expect(patchRow).toBeHidden(); - await page.getByRole("button", { name: "Close trace details" }).click(); - await expect(search).toBeFocused(); - await search.fill(""); - - await conversationTab.click(); - await expect(composer).toBeVisible(); - await expect(composer).toHaveValue("Draft survives Trace inspection\nwith a second line"); - const sendsAfter = (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ).length; - expect(sendsAfter).toBe(sendsBefore); - - await page.setViewportSize({ width: 390, height: 844 }); - await traceTab.click(); - const narrowTrace = page.getByRole("tabpanel", { name: "Trace" }); - const narrowPatchRow = narrowTrace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); - await narrowPatchRow.click(); - const narrowDetail = page.getByRole("complementary", { name: "Trace item details" }); - await expect(narrowDetail).toBeVisible(); - await expect(narrowTrace.locator(".trace-ledger")).toBeHidden(); - const widths = await narrowDetail.evaluate((element) => { - const box = element.getBoundingClientRect(); - return { - viewport: innerWidth, - document: document.documentElement.scrollWidth, - body: document.body.scrollWidth, - left: box.left, - right: box.right, - }; - }); - expect(widths.document).toBeLessThanOrEqual(widths.viewport); - expect(widths.body).toBeLessThanOrEqual(widths.viewport); - expect(widths.left).toBeGreaterThanOrEqual(0); - expect(widths.right).toBeLessThanOrEqual(widths.viewport); - await attachScreenshot(page, testInfo, "narrow-trace-detail"); -}); - -test("drops a delayed Turn page after switching Sessions", async ({ page, request }) => { - await resetFixture(request); - await controlFixture(request, { - turnsScenario: 1, - turnsRetrieveDelayMs: 700, - turnsPageSize: 2, - }); - await openSessionsFromHome(page); - await expect(page.getByText("Completed Turn output remains in the conversation.")).toBeVisible({ timeout: 1_500 }); - await page.getByRole("tab", { name: "Trace" }).click(); - const diagnostics = page.locator("details.trace-turn-diagnostics"); - await diagnostics.locator("summary").click(); - const timeline = diagnostics.getByRole("region", { name: "Turn timeline" }); - await expect(timeline).toContainText("Loading every Turn page"); - await page.getByRole("button", { name: "Agents" }).click(); - await expect(page.getByRole("list", { name: "Agents", exact: true })).toBeVisible(); - await startSessionWithSecondAgent(page); - - await page.getByRole("tab", { name: "Trace" }).click(); - const nextDiagnostics = page.locator("details.trace-turn-diagnostics"); - await nextDiagnostics.locator("summary").click(); - const nextTimeline = nextDiagnostics.getByRole("region", { name: "Turn timeline" }); - await expect(nextTimeline).toContainText("Queued"); - await page.waitForTimeout(3_000); - await expect(nextTimeline).not.toContainText("turn_queued"); - await expect(page.getByText("Completed Turn output remains in the conversation.")).toHaveCount(0); - - const turnReads = (await fixtureRequests(request)).filter((entry) => ( - entry.method === "GET" && entry.path.endsWith("/turns") - )); - expect(turnReads.some((entry) => entry.path.includes("session_snapshot"))).toBe(true); - expect(turnReads.some((entry) => entry.path.includes("session_created_"))).toBe(true); -}); - -test("renders Parsar patches as accessible read-only diffs in desktop and narrow themes", async ({ page, request }, testInfo) => { - await resetFixture(request); - await controlFixture(request, { itemsScenario: 1 }); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - - const completedTrace = page.locator('[data-work-trace="completed"]'); - await completedTrace.getByRole("button", { name: /Completed/ }).click(); - const completedStep = completedTrace.locator('[data-trace-step="patch_completed"]'); - await completedStep.getByRole("button", { name: /apply_patch/ }).click(); - const viewer = completedStep.getByRole("region", { name: "Parsar apply patch diff" }); - await expect(viewer).toContainText("3 files"); - await expect(viewer).toContainText("Completed"); - await expect(viewer.getByRole("button", { name: /modify src\/modify.ts/ })).toHaveAttribute("aria-expanded", "true"); - await viewer.getByRole("button", { name: /modify src\/modify.ts/ }).focus(); - await page.keyboard.press("Enter"); - await expect(viewer.getByRole("button", { name: /modify src\/modify.ts/ })).toHaveAttribute("aria-expanded", "false"); - await viewer.getByText("Raw arguments").click(); - await viewer.getByText("Raw result").click(); - await expect(viewer).toContainText('"applied": true'); - await expect(viewer.locator("script")).toHaveCount(0); - const runningStep = page.locator('[data-trace-step="patch_running"]'); - await runningStep.getByRole("button", { name: /apply_patch/ }).click(); - await expect(runningStep.locator('[data-patch-status="in_progress"]')).toContainText("In progress"); - const failedTrace = page.locator('[data-work-trace="failed"]'); - await failedTrace.getByRole("button", { name: /Failed/ }).click(); - const failedStep = failedTrace.locator('[data-trace-step="patch_failed"]'); - await failedStep.getByRole("button", { name: /apply_patch/ }).click(); - await expect(failedStep.locator('[data-patch-status="failed"]')).toContainText("Failed"); - await failedStep.getByText("Raw result").click(); - await expect(failedStep).toContainText("fixture failure"); - const fallback = page.locator('[data-trace-step="patch_alternate"]'); - await fallback.getByRole("button", { name: /apply_patch/ }).click(); - await expect(fallback).toContainText("malformed alternate shape"); - await expect(fallback.getByRole("region", { name: "Parsar apply patch diff" })).toHaveCount(0); - await attachScreenshot(page, testInfo, "desktop-light-parsar-diff"); - - await page.setViewportSize({ width: 390, height: 844 }); - await setDarkTheme(page); - await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); - const widths = await viewer.evaluate((element) => { - const box = element.getBoundingClientRect(); - return { viewport: innerWidth, document: document.documentElement.scrollWidth, body: document.body.scrollWidth, viewerLeft: box.left, viewerRight: box.right }; - }); - expect(widths.document).toBeLessThanOrEqual(widths.viewport); - expect(widths.body).toBeLessThanOrEqual(widths.viewport); - expect(widths.viewerLeft).toBeGreaterThanOrEqual(0); - expect(widths.viewerRight).toBeLessThanOrEqual(widths.viewport); - await expect(viewer).toBeVisible(); - await attachScreenshot(page, testInfo, "narrow-dark-parsar-diff"); -}); - -test("manually retries uncertain sends with the original key only while the payload is unchanged", async ({ page, request }, testInfo) => { - await resetFixture(request); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - const composer = page.getByLabel("Message the Agent"); - - await controlFixture(request, { sendResponseLoss: 1 }); - await composer.fill("uncertain payload"); - await page.getByRole("button", { name: "Send message" }).click(); - await expect(page.locator(".session-send-error")).toContainText("may have accepted this message"); - await expect(composer).toHaveValue("uncertain payload"); - await attachScreenshot(page, testInfo, "desktop-uncertain-send"); - await page.getByRole("button", { name: "Send message" }).click(); - - let sends = (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ); - expect(sends).toHaveLength(2); - expect(sends[0]?.idempotencyKey).toBeTruthy(); - expect(sends[1]?.idempotencyKey).toBe(sends[0]?.idempotencyKey); - - await controlFixture(request, { sendResponseLoss: 1 }); - await composer.fill("original before edit"); - await page.getByRole("button", { name: "Send message" }).click(); - await expect(composer).toHaveValue("original before edit"); - await composer.fill("edited payload"); - await page.getByRole("button", { name: "Send message" }).click(); - - sends = (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ); - expect(sends).toHaveLength(4); - expect(sends[3]?.idempotencyKey).not.toBe(sends[2]?.idempotencyKey); - - await controlFixture(request, { sendStatus: 422 }); - await composer.fill("permanently rejected"); - await page.getByRole("button", { name: "Send message" }).click(); - await expect(page.locator(".session-send-error")).toContainText("Agent Core rejected the message"); - await page.getByRole("button", { name: "Send message" }).click(); - - sends = (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ); - expect(sends).toHaveLength(6); - expect(sends[5]?.idempotencyKey).not.toBe(sends[4]?.idempotencyKey); - await attachScreenshot(page, testInfo, "desktop-send-recovery"); -}); - -test("reuses Function result identity only for an unchanged uncertain explicit retry", async ({ page, request }) => { - await resetFixture(request); - await controlFixture(request, { environmentScenario: 10 }); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - const editor = page.getByLabel("Function result or error"); - const submit = page.getByRole("button", { name: "Submit result" }); - const eventWrites = async () => (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ); - - await editor.fill("uncertain result"); - await controlFixture(request, { sendResponseLoss: 1 }); - await submit.click(); - await expect(editor).toBeEnabled(); - await expect(editor).toHaveValue("uncertain result"); - await submit.click(); - await expect.poll(async () => (await eventWrites()).length).toBe(2); - let writes = await eventWrites(); - expect(writes[0]?.body).toEqual(writes[1]?.body); - expect(writes[0]?.idempotencyKey).toBeTruthy(); - expect(writes[1]?.idempotencyKey).toBe(writes[0]?.idempotencyKey); - - await editor.fill("before edit"); - await controlFixture(request, { sendResponseLoss: 1 }); - await submit.click(); - await expect(editor).toBeEnabled(); - await editor.fill("after edit"); - await submit.click(); - await expect.poll(async () => (await eventWrites()).length).toBe(4); - writes = await eventWrites(); - expect(writes[3]?.idempotencyKey).not.toBe(writes[2]?.idempotencyKey); - - await editor.fill("definite rejection"); - await controlFixture(request, { sendStatus: 422 }); - await submit.click(); - await expect(editor).toBeEnabled(); - await submit.click(); - await expect.poll(async () => (await eventWrites()).length).toBe(6); - writes = await eventWrites(); - expect(writes[5]?.idempotencyKey).not.toBe(writes[4]?.idempotencyKey); -}); - -test("keeps cancellation available for an Environment-only required action", async ({ page, request }) => { - await resetFixture(request); - await controlFixture(request, { environmentScenario: 6 }); - await openSessionsFromHome(page); - await expect(connectedLiveEvents(page)).toBeVisible(); - await expect(page.getByRole("region", { name: "Environment connection required" })).toBeVisible(); - await expect(page.getByRole("region", { name: "Function result required" })).toHaveCount(0); - const writesBefore = (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ).length; - const cancel = page.getByRole("button", { name: "Cancel active Turn" }); - await expect(cancel).toBeEnabled(); - await controlFixture(request, { sendResponseLoss: 1 }); - await cancel.click(); - await expect.poll(async () => (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ).length).toBe(writesBefore + 1); - await expect(cancel).toBeEnabled(); - await cancel.click(); - await expect.poll(async () => (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ).length).toBe(writesBefore + 2); - const writes = (await fixtureRequests(request)).filter( - (entry) => entry.method === "POST" && entry.path.endsWith("/events"), - ); - expect(writes.at(-1)?.body).toEqual({ events: [{ type: "agent.session.input.cancel" }] }); - expect(writes.at(-2)?.idempotencyKey).toBeTruthy(); - expect(writes.at(-1)?.idempotencyKey).toBe(writes.at(-2)?.idempotencyKey); -}); diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts new file mode 100644 index 000000000..80468b90f --- /dev/null +++ b/apps/web/e2e/console.ts @@ -0,0 +1,33 @@ +import { expect, type APIRequestContext, type Page } from "@playwright/test"; + +const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; +const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 4174}`; + +/** Fresh fixture state: "setup" (no administrator yet), "login" or "authenticated". */ +export async function resetFixture(request: APIRequestContext, auth: "setup" | "login" | "authenticated" = "authenticated", options: { fresh?: boolean } = {}) { + await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}`); +} + +/** Opens the console already signed in, in English. */ +export async function openConsole(page: Page, request: APIRequestContext, hash = "overview") { + await resetFixture(request, "authenticated"); + await page.context().addCookies([{ name: "core_console", value: "fixture-session", url: web }]); + await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); + await page.goto(`/#${hash}`); +} + +/** Makes the next matching write fail once with the given status. */ +export async function failNext(request: APIRequestContext, failure: { method: string; path: string; status: number; code?: string; message?: string }) { + await request.post(`${fixture}/__fixture/fail-next`, { data: failure }); +} + +/** Writes the browser sent through the console, as "METHOD /path". */ +export async function writes(request: APIRequestContext): Promise { + return (await (await request.get(`${fixture}/__fixture/requests`)).json()).writes; +} + +/** The console never calls /v1 and never sends its own Authorization header. */ +export async function expectManagementBoundary(request: APIRequestContext) { + const { violations } = await (await request.get(`${fixture}/__fixture/requests`)).json(); + expect(violations).toEqual([]); +} diff --git a/apps/web/e2e/core-connection.spec.ts b/apps/web/e2e/core-connection.spec.ts deleted file mode 100644 index c61f531ac..000000000 --- a/apps/web/e2e/core-connection.spec.ts +++ /dev/null @@ -1,559 +0,0 @@ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; - -const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; -const probeRequestPattern = /\/v1\/agents\?limit=1$/; - -interface ProbeInstrumentationWindow extends Window { - __probeAbortCount?: number; - __probeCallCount?: number; - __resolveFirstProbe?: (() => void) | null; - __stalledProbeCallCount?: number; - __holdNextLocalStartup?: boolean; - __oldStartupAbortCount?: number; - __resolveOldStartup?: (() => void) | null; - __resolveNewStartup?: (() => void) | null; -} - -async function resetFixture(request: APIRequestContext) { - const response = await request.post(`${fixtureBaseUrl}/__fixture/reset`); - expect(response.ok()).toBe(true); -} - -async function boot(page: Page, request: APIRequestContext) { - await resetFixture(request); - await page.goto("/"); - await expect(page.getByRole("button", { name: "Configure Agent Core connection" })).toBeVisible(); -} - -async function openConnection(page: Page) { - const trigger = page.getByRole("button", { name: "Configure Agent Core connection" }); - await trigger.click(); - const dialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); - await expect(dialog).toBeVisible(); - return { dialog, trigger }; -} - -test("migrates a stale local token without sending browser authorization when proxy auth is disabled", async ({ - page, - request, -}) => { - await page.addInitScript(() => { - localStorage.setItem("agents-core-web.core-base-url", "/v1"); - sessionStorage.setItem("agents-core-web.core-token", "historical-local-token"); - }); - let browserAuthorizationSeen = false; - page.on("request", (browserRequest) => { - const url = new URL(browserRequest.url()); - if (url.pathname.startsWith("/v1/") && browserRequest.headers().authorization) { - browserAuthorizationSeen = true; - } - }); - await page.route(probeRequestPattern, (route) => route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ object: "list", data: [], has_more: false, first_id: null, last_id: null }), - })); - - await boot(page, request); - await expect.poll(() => page.evaluate(() => sessionStorage.getItem("agents-core-web.core-token"))).toBeNull(); - const { dialog } = await openConnection(page); - await expect(dialog).toContainText("Server-managed key not detected"); - await expect(dialog.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); - await expect(dialog.getByLabel("Bearer token")).toHaveCount(0); - await dialog.getByRole("button", { name: "Test connection" }).click(); - await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); - expect(browserAuthorizationSeen).toBe(false); - - await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); - await expect(dialog.getByLabel("Bearer token")).toHaveValue(""); -}); - -test("scrubs a legacy remote HTTP connection before any bearer can leave the page", async ({ page, request }) => { - await page.addInitScript(() => { - localStorage.setItem("agents-core-web.core-base-url", "http://core.example/v1"); - sessionStorage.setItem("agents-core-web.core-token", "legacy-remote-token"); - }); - let unsafeRequests = 0; - await page.route("http://core.example/**", (route) => { - unsafeRequests += 1; - return route.abort("blockedbyclient"); - }); - - await boot(page, request); - - await expect.poll(() => page.evaluate(() => localStorage.getItem("agents-core-web.core-base-url"))).toBe("/v1"); - await expect.poll(() => page.evaluate(() => sessionStorage.getItem("agents-core-web.core-token"))).toBeNull(); - expect(unsafeRequests).toBe(0); - const { dialog } = await openConnection(page); - await expect(dialog.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); -}); - -test("rejects an unsafe runtime target before a local proxy write can be attempted", async ({ page, request }) => { - await boot(page, request); - - const outcome = await page.evaluate(async () => { - const modulePath = "/src/lib/connection.ts"; - const { createCore } = await import(/* @vite-ignore */ modulePath); - const originalFetch = window.fetch; - const calls: Array<{ method: string; url: string }> = []; - window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - calls.push({ - method: init?.method ?? "GET", - url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, - }); - return new Response(JSON.stringify({ id: "must-not-be-created" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }) as typeof fetch; - - try { - const core = createCore({ - baseUrl: "http://core.example/v1", - token: "must-not-leave-browser", - }); - await core.createAgent({ model: "must-not-create-locally" }); - return { calls, error: null }; - } catch (error) { - return { - calls, - error: error instanceof Error - ? { - code: "code" in error ? String(error.code) : null, - message: error.message, - name: error.name, - } - : null, - }; - } finally { - window.fetch = originalFetch; - } - }); - - expect(outcome.calls).toEqual([]); - expect(outcome.error).toEqual({ - code: "invalid_core_base_url", - message: "The Agent Core base URL is invalid or unsafe.", - name: "InvalidCoreConnectionError", - }); - expect(JSON.stringify(outcome)).not.toContain("must-not-leave-browser"); - expect(JSON.stringify(outcome)).not.toContain("http://core.example/v1"); -}); - -test("switches real connection modes and fences stale probes when the draft changes or reopens", async ({ - page, - request, -}) => { - await page.addInitScript(() => { - const target = window as ProbeInstrumentationWindow; - const originalFetch = window.fetch.bind(window); - let holdFirstProbe = true; - target.__probeAbortCount = 0; - target.__probeCallCount = 0; - target.__resolveFirstProbe = null; - - window.fetch = (input, init) => { - const url = typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url; - const candidate = new URL(url, window.location.href); - const isProbe = candidate.pathname.endsWith("/v1/agents") - && candidate.searchParams.get("limit") === "1" - && [...candidate.searchParams].length === 1; - if (isProbe) target.__probeCallCount = (target.__probeCallCount ?? 0) + 1; - if (holdFirstProbe && isProbe) { - holdFirstProbe = false; - init?.signal?.addEventListener("abort", () => { - target.__probeAbortCount = (target.__probeAbortCount ?? 0) + 1; - }, { once: true }); - return new Promise((resolve) => { - target.__resolveFirstProbe = () => resolve(new Response(JSON.stringify({ - object: "list", - data: [], - has_more: false, - first_id: null, - last_id: null, - }), { - status: 200, - headers: { "Content-Type": "application/json" }, - })); - }); - } - return originalFetch(input, init); - }; - }); - await boot(page, request); - const { dialog } = await openConnection(page); - const local = dialog.getByRole("radio", { name: /Local Parsar Core/ }); - const advanced = dialog.getByRole("radio", { name: /Other compatible Core/ }); - const testConnection = dialog.getByRole("button", { name: "Test connection" }); - - await expect(local).toBeChecked(); - await expect(dialog.getByLabel("Compatible Core base URL")).toHaveCount(0); - await expect(dialog.getByLabel("Bearer token")).toHaveCount(0); - await testConnection.click(); - await expect(dialog.getByRole("status")).toContainText("Testing Core connection…"); - await expect.poll(() => page.evaluate(() => ( - (window as ProbeInstrumentationWindow).__probeCallCount ?? 0 - ))).toBe(1); - await expect.poll(() => page.evaluate(() => ( - typeof (window as ProbeInstrumentationWindow).__resolveFirstProbe - ))).toBe("function"); - - await advanced.click(); - await expect(advanced).toBeChecked(); - await expect(dialog.getByRole("status")).toHaveCount(0); - await expect.poll(() => page.evaluate(() => ( - (window as ProbeInstrumentationWindow).__probeAbortCount ?? 0 - ))).toBe(1); - await page.evaluate(() => (window as ProbeInstrumentationWindow).__resolveFirstProbe?.()); - await expect(dialog.getByText("Core API authenticated")).toHaveCount(0); - - const directBase = `${new URL(page.url()).origin}/v1`; - const baseUrl = dialog.getByLabel("Compatible Core base URL"); - const token = dialog.getByLabel("Bearer token"); - for (const loopback of [ - "http://localhost:8091/v1", - "http://worker.localhost:8091/v1", - "http://127.0.0.42:8091/v1", - "http://[::1]:8091/v1", - ]) { - await baseUrl.fill(loopback); - await expect(testConnection).toBeEnabled(); - } - for (const unsafe of [ - "http://core.example/v1", - `${directBase}?`, - `${directBase}#`, - ]) { - await baseUrl.fill(unsafe); - await expect(dialog.getByText(/Enter an HTTPS URL, or an HTTP loopback URL/)).toBeVisible(); - await expect(testConnection).toBeDisabled(); - await expect(dialog.getByRole("button", { name: "Apply connection" })).toBeDisabled(); - } - expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__probeCallCount)).toBe(1); - - await baseUrl.fill(directBase); - await token.fill("current-tab-token"); - await testConnection.click(); - await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); - - await token.fill("changed-current-tab-token"); - await expect(dialog.getByRole("status")).toHaveCount(0); - await testConnection.click(); - await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); - - await baseUrl.fill(`${directBase}/`); - await expect(dialog.getByRole("status")).toHaveCount(0); - await testConnection.click(); - await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); - - await local.click(); - await expect(local).toBeChecked(); - await expect(dialog.getByRole("status")).toHaveCount(0); - await testConnection.click(); - await expect(dialog.getByRole("status")).toContainText("Core API authenticated"); - - await dialog.getByRole("button", { name: "Cancel" }).click(); - await expect(page.getByRole("dialog", { name: "Connect an Agent Core" })).toHaveCount(0); - const reopened = (await openConnection(page)).dialog; - await expect(reopened.getByRole("radio", { name: /Local Parsar Core/ })).toBeChecked(); - await expect(reopened.getByRole("status")).toHaveCount(0); - await expect(reopened.getByRole("alert")).toHaveCount(0); -}); - -test("clears startup configuration synchronously and fences a stale read when the Core changes", async ({ - page, - request, -}) => { - await page.addInitScript(() => { - const target = window as ProbeInstrumentationWindow; - const originalFetch = window.fetch.bind(window); - const startupResponse = (defaultHarness: "codex" | "claude_sdk", provider: "docker" | "microsandbox") => new Response(JSON.stringify({ - object: "agents.core.startup_configuration", - schema_version: 1, - supported: { - harnesses: ["claude_sdk", "codex", "mcode"], - managed_sandbox_providers: ["docker", "microsandbox"], - }, - configured: { - default_harness: defaultHarness, - enabled_harnesses: [defaultHarness], - daemon_gateway: true, - self_hosted: true, - managed_sandbox: { enabled: true, provider, maintenance: false }, - model_providers: [{ harness: defaultHarness, endpoint_configured: true }], - }, - }), { status: 200, headers: { "Content-Type": "application/json" } }); - - target.__holdNextLocalStartup = false; - target.__oldStartupAbortCount = 0; - target.__resolveOldStartup = null; - target.__resolveNewStartup = null; - window.fetch = (input, init) => { - const value = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const candidate = new URL(value, window.location.href); - if (!candidate.pathname.endsWith("/v1/agents/core/startup-configuration")) return originalFetch(input, init); - const authorization = new Headers(init?.headers).get("Authorization"); - if (target.__holdNextLocalStartup && authorization == null) { - target.__holdNextLocalStartup = false; - init?.signal?.addEventListener("abort", () => { - target.__oldStartupAbortCount = (target.__oldStartupAbortCount ?? 0) + 1; - }, { once: true }); - return new Promise((resolve) => { - target.__resolveOldStartup = () => resolve(startupResponse("codex", "docker")); - }); - } - if (authorization === "Bearer replacement-token") { - return new Promise((resolve) => { - target.__resolveNewStartup = () => resolve(startupResponse("claude_sdk", "microsandbox")); - }); - } - return originalFetch(input, init); - }; - }); - - await boot(page, request); - await page.getByRole("button", { name: "System", exact: true }).click(); - const system = page.locator(".system-page"); - await expect(system.locator(".system-harness-card").filter({ hasText: "Codex" })).toContainText("Enabled"); - await page.evaluate(() => { - (window as ProbeInstrumentationWindow).__holdNextLocalStartup = true; - }); - await system.getByRole("button", { name: "Refresh System status" }).click(); - await expect.poll(() => page.evaluate(() => typeof (window as ProbeInstrumentationWindow).__resolveOldStartup)).toBe("function"); - - const { dialog } = await openConnection(page); - await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); - await dialog.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); - await dialog.getByLabel("Bearer token").fill("replacement-token"); - await dialog.getByRole("button", { name: "Apply connection" }).click(); - - const daemonGateway = system.getByRole("listitem").filter({ hasText: "Daemon gateway" }); - await expect(daemonGateway).toContainText("Checking…"); - await expect(system).not.toContainText("Configured for this process"); - await expect.poll(() => page.evaluate(() => (window as ProbeInstrumentationWindow).__oldStartupAbortCount ?? 0)).toBe(1); - await expect.poll(() => page.evaluate(() => typeof (window as ProbeInstrumentationWindow).__resolveNewStartup)).toBe("function"); - await page.evaluate(() => (window as ProbeInstrumentationWindow).__resolveNewStartup?.()); - await expect(system.locator(".system-harness-card").filter({ hasText: "Claude SDK" })).toContainText("Enabled"); - await expect(system.getByRole("listitem").filter({ hasText: "Managed sandbox" })).toContainText("Microsandbox"); - - await page.evaluate(() => (window as ProbeInstrumentationWindow).__resolveOldStartup?.()); - await expect(system.locator(".system-harness-card").filter({ hasText: "Claude SDK" })).toContainText("Enabled"); - await expect(system).not.toContainText("Docker · Maintenance"); -}); - -for (const status of [404, 405]) { - test(`shows startup configuration as unsupported when an older Core returns ${status}`, async ({ page, request }) => { - await page.route("**/v1/agents/core/startup-configuration", (route) => route.fulfill({ - status, - contentType: "application/json", - body: JSON.stringify({ error: { code: "unsupported", message: "Unavailable." } }), - })); - await boot(page, request); - await page.getByRole("button", { name: "System", exact: true }).click(); - - const system = page.locator(".system-page"); - await expect(system.getByRole("listitem").filter({ hasText: "Daemon gateway" })).toContainText("Not exposed"); - await expect(system).toContainText("This Core version does not expose the startup configuration extension"); - await expect(system).not.toContainText("Configured for this process"); - await expect(system).not.toContainText("Checking…"); - }); -} - -test("announces loading, authenticated access, and each safe failure state from one GET", async ({ - page, - request, -}) => { - const replies = [ - { - status: 200, - body: { - object: "list", - data: [{ - id: "agent_empty_model", - object: "agent", - model: "", - name: null, - instructions: null, - metadata: {}, - multi_agent: { enabled: false, max_concurrent_subagents: null }, - reasoning: {}, - service_tier: "auto", - text: { format: { type: "text" }, verbosity: "medium" }, - tools: [], - created_at: 1_700_000_000, - updated_at: 1_700_000_001, - }], - has_more: false, - first_id: "agent_empty_model", - last_id: "agent_empty_model", - }, - }, - { status: 202, body: { object: "list", data: [], has_more: false, first_id: null, last_id: null } }, - { status: 200, body: { object: "list", data: [null], has_more: false, first_id: null, last_id: null } }, - { status: 401, body: { error: { code: "invalid_api_key", message: "safe fixture failure" } } }, - { status: 401, body: { error: { code: "gateway_auth_required", message: "safe fixture failure" } } }, - { status: 400, body: { error: { code: "invalid_beta", message: "safe fixture failure" } } }, - { status: 503, body: { error: { code: "unavailable", message: "safe fixture failure" } } }, - { abort: true }, - ]; - const methods: string[] = []; - - await page.route(probeRequestPattern, async (route) => { - methods.push(route.request().method()); - const reply = replies.shift(); - if (!reply) return route.abort("failed"); - await new Promise((resolve) => setTimeout(resolve, 150)); - if ("abort" in reply) return route.abort("failed"); - return route.fulfill({ - status: reply.status, - contentType: "application/json", - body: JSON.stringify(reply.body), - }); - }); - - await boot(page, request); - const { dialog } = await openConnection(page); - const action = dialog.getByRole("button", { name: "Test connection" }); - const cases: Array<{ role: "status" | "alert"; text: string; absentText?: string }> = [ - { role: "status", text: "Core API authenticated" }, - { role: "alert", text: "Agents API protocol mismatch" }, - { role: "alert", text: "Agents API protocol mismatch" }, - { role: "alert", text: "Authentication failed" }, - { role: "alert", text: "Core returned HTTP 401", absentText: "invalid_api_key" }, - { role: "alert", text: "Agents API protocol mismatch" }, - { role: "alert", text: "Core returned HTTP 503" }, - { role: "alert", text: "Core unreachable" }, - ]; - - for (const expected of cases) { - await action.click(); - const loading = dialog.getByRole("status"); - await expect(loading).toHaveAttribute("aria-live", "polite"); - await expect(loading).toContainText("Testing Core connection…"); - const terminal = dialog.getByRole(expected.role); - await expect(terminal).toContainText(expected.text); - await expect(terminal).toContainText("Chat uses the current Agents API contract"); - await expect(terminal).toContainText("does not start a Turn or verify its runtime dependencies"); - if (expected.absentText) await expect(terminal).not.toContainText(expected.absentText); - } - - expect(methods).toEqual(["GET", "GET", "GET", "GET", "GET", "GET", "GET", "GET"]); - expect(replies).toHaveLength(0); -}); - -test("turns a stalled probe into one bounded unreachable result", async ({ page, request }) => { - await page.addInitScript(() => { - const target = window as ProbeInstrumentationWindow; - const originalFetch = window.fetch.bind(window); - target.__stalledProbeCallCount = 0; - window.fetch = (input, init) => { - const value = typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url; - const candidate = new URL(value, window.location.href); - const stalledProbe = candidate.pathname.endsWith("/v1/agents") - && candidate.searchParams.get("limit") === "1"; - if (!stalledProbe) return originalFetch(input, init); - target.__stalledProbeCallCount = (target.__stalledProbeCallCount ?? 0) + 1; - return new Promise((_resolve, reject) => { - const rejectAbort = () => reject(init?.signal?.reason ?? new DOMException("Aborted", "AbortError")); - if (init?.signal?.aborted) rejectAbort(); - else init?.signal?.addEventListener("abort", rejectAbort, { once: true }); - }); - }; - }); - - await boot(page, request); - const { dialog } = await openConnection(page); - await dialog.getByRole("button", { name: "Test connection" }).click(); - await expect(dialog.getByRole("status")).toContainText("Testing Core connection…"); - await expect.poll(() => page.evaluate(() => ( - (window as ProbeInstrumentationWindow).__stalledProbeCallCount ?? 0 - ))).toBe(1); - await expect(dialog.getByRole("alert")).toContainText("Core unreachable", { timeout: 7_500 }); - await expect(dialog.getByRole("alert")).toContainText("Chat uses the current Agents API contract"); - await expect(dialog.getByRole("alert")).toContainText("does not start a Turn or verify its runtime dependencies"); - expect(await page.evaluate(() => (window as ProbeInstrumentationWindow).__stalledProbeCallCount)).toBe(1); -}); - -test("supports keyboard mode selection, traps focus, and returns focus on Escape", async ({ page, request }) => { - await boot(page, request); - const { dialog, trigger } = await openConnection(page); - const local = dialog.getByRole("radio", { name: /Local Parsar Core/ }); - const advanced = dialog.getByRole("radio", { name: /Other compatible Core/ }); - const apply = dialog.getByRole("button", { name: "Apply connection" }); - const close = dialog.getByRole("button", { name: "Close dialog" }); - - await expect(local).toBeFocused(); - await page.keyboard.press("ArrowRight"); - await expect(advanced).toBeChecked(); - await expect(advanced).toBeFocused(); - await page.keyboard.press("ArrowLeft"); - await expect(local).toBeChecked(); - await expect(local).toBeFocused(); - - await close.focus(); - await page.keyboard.press("Shift+Tab"); - await expect(apply).toBeFocused(); - await page.keyboard.press("Tab"); - await expect(close).toBeFocused(); - - await page.keyboard.press("Escape"); - await expect(page.getByRole("dialog", { name: "Connect an Agent Core" })).toHaveCount(0); - await expect(trigger).toBeFocused(); -}); - -test("keeps both connection modes operable without horizontal overflow at 390 px", async ({ page, request }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await boot(page, request); - const { dialog } = await openConnection(page); - const apply = dialog.getByRole("button", { name: "Apply connection" }); - const cancel = dialog.getByRole("button", { name: "Cancel" }); - - const assertContained = async () => { - const metrics = await page.evaluate(() => ({ - viewportWidth: innerWidth, - viewportHeight: innerHeight, - documentWidth: document.documentElement.scrollWidth, - bodyWidth: document.body.scrollWidth, - })); - const box = await dialog.boundingBox(); - expect(box).not.toBeNull(); - expect(metrics.documentWidth).toBeLessThanOrEqual(metrics.viewportWidth); - expect(metrics.bodyWidth).toBeLessThanOrEqual(metrics.viewportWidth); - expect(box?.x ?? -1).toBeGreaterThanOrEqual(0); - expect(box?.y ?? -1).toBeGreaterThanOrEqual(0); - expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(metrics.viewportWidth); - expect((box?.y ?? 0) + (box?.height ?? 0)).toBeLessThanOrEqual(metrics.viewportHeight); - const widths = await dialog.evaluate((card) => { - const body = card.querySelector(".modal-body"); - return { - cardClient: card.clientWidth, - cardScroll: card.scrollWidth, - bodyClient: body?.clientWidth ?? 0, - bodyScroll: body?.scrollWidth ?? 0, - }; - }); - expect(widths.cardScroll).toBeLessThanOrEqual(widths.cardClient); - expect(widths.bodyScroll).toBeLessThanOrEqual(widths.bodyClient); - await expect(apply).toBeInViewport(); - await expect(cancel).toBeInViewport(); - }; - - await assertContained(); - const testConnection = dialog.getByRole("button", { name: "Test connection" }); - await testConnection.scrollIntoViewIfNeeded(); - await expect(testConnection).toBeInViewport(); - - await dialog.getByRole("radio", { name: /Other compatible Core/ }).click(); - await dialog.getByLabel("Compatible Core base URL").fill(`${new URL(page.url()).origin}/v1`); - await assertContained(); - await dialog.getByLabel("Compatible Core base URL").scrollIntoViewIfNeeded(); - await expect(dialog.getByLabel("Compatible Core base URL")).toBeInViewport(); -}); diff --git a/apps/web/e2e/data/admin.mjs b/apps/web/e2e/data/admin.mjs new file mode 100644 index 000000000..3caae01a5 --- /dev/null +++ b/apps/web/e2e/data/admin.mjs @@ -0,0 +1,161 @@ +// Synthetic management-plane data (/core/v1/admin/**) for the browser acceptance fixture. +// Projects own isolated assets shared by their named keys; the base demo's +// resources are split across projects so every page can be filtered. +import { agentProject } from "./agents.mjs"; +const iso = (seconds) => new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"); + +export function buildAdmin(now, base, resources) { + const key = (id, name, prefix, createdDaysAgo, revokedDaysAgo = null) => ({ id, name, prefix, created_at: now - 86400 * createdDaysAgo, revoked_at: revokedDaysAgo === null ? null : now - 86400 * revokedDaysAgo }); + const projects = [ + { id: "proj_7f3a91c2", name: "Production", created_at: now - 86400 * 21, archived_at: null, keys: [ + key("fb533e99-524f-4e44-94bc-8f6e571646a7", "web-backend", "pc_live_7Hq", 21), + key("3c1d9e20-7a41-4b8e-9f02-5d6e7f8a9b10", "ci-pipeline", "pc_live_Qm4", 16), + key("5a2b3c4d-6e7f-4a1b-8c2d-3e4f5a6b7c8d", "alice", "pc_live_Al1", 12), + key("6e2f1a30-8b52-4c9f-a013-6e7f8a9b0c21", "old-ci", "pc_live_Bv8", 30, 16), + ] }, + { id: "proj_a47e2b19", name: "Data team", created_at: now - 86400 * 9, archived_at: null, keys: [ + key("a47e2b19-0c3d-4e5f-8a6b-7c8d9e0f1a2b", "notebook", "pc_live_k9T", 9), + key("b58f3c2a-1d4e-4f60-9b7c-8d9e0f1a2b3c", "bob", "pc_live_Bo2", 5), + ] }, + { id: "proj_2c8d4e10", name: "Operations", created_at: now - 86400 * 40, archived_at: null, keys: [ + key("c69a4d3b-2e5f-4071-8c8d-9e0f1a2b3c4d", "on-call", "pc_live_Oc3", 20), + ] }, + { id: "proj_0b533e99", name: "Legacy", created_at: now - 86400 * 30, archived_at: now - 86400 * 6, keys: [ + key("0b533e99-524f-4e44-94bc-8f6e571646a7", "staging", "pc_live_2Xa", 30, 6), + ] }, + ]; + const active = projects.slice(0, 3); + const owner = new Map(); + const assign = (items, type, pick) => items.forEach((item, index) => owner.set(`${type}:${item.id}`, pick(item, index))); + assign(base.agents, "agent", (_, index) => projects[agentProject[index] ?? index % 3].id); + assign(base.sessions, "session", (session) => owner.get(`agent:${session.agent.id}`)); + assign(resources.skills, "skill", (_, index) => active[index % 3].id); + assign(resources.files, "file", (_, index) => (index === 8 ? projects[3].id : active[index % 3].id)); + assign(resources.templates, "environment_template", (_, index) => active[index % 3].id); + assign(resources.vaults, "vault", (_, index) => (index === 3 ? projects[3].id : active[index % 3].id)); + const of = (type, projectId) => (item) => owner.get(`${type}:${item.id}`) === projectId; + + const collections = (projectId) => ({ + agents: base.agents.filter(of("agent", projectId)), + sessions: base.sessions.filter(of("session", projectId)), + skills: resources.skills.filter(of("skill", projectId)), + files: resources.files.filter(of("file", projectId)), + templates: resources.templates.filter(of("environment_template", projectId)), + vaults: resources.vaults.filter(of("vault", projectId)), + }); + + const keyRef = (project, k) => ({ id: k.id, name: k.name, prefix: k.prefix, kind: "issued", revoked_at: k.revoked_at ? iso(k.revoked_at) : null }); + // Deterministic creator per resource: rotate through the project's keys; one in seven is unknown. + const creators = new Map(); + let turn = 0; + const creatorFor = (project, type, id) => { + const cacheKey = `${type}:${id}`; + if (!creators.has(cacheKey)) { + turn += 1; + creators.set(cacheKey, turn % 11 === 0 ? { copy: true } : turn % 7 === 0 ? null : keyRef(project, project.keys[turn % project.keys.length])); + } + return creators.get(cacheKey); + }; + + const operations = new Map(projects.map((project) => { + const own = collections(project.id); + const list = []; + const push = (at, action, type, id, parent = "", creatorType = type) => { + const creator = action === "create" ? creatorFor(project, creatorType, id) : keyRef(project, project.keys[list.length % project.keys.length]); + if (creator?.copy) return; // Administrator copies are in the audit log, not in key write history. + list.push({ id: `op_${project.id.slice(5)}_${list.length}`, created_at: iso(at), api_key: creator, action, resource_type: type, resource_id: id, parent_id: parent, request_id: `req_${list.length}`, trace_id: `${list.length}`.padStart(32, "0") }); + }; + for (const agent of own.agents) { push(agent.created_at, "create", "agent", agent.id); if (agent.updated_at > agent.created_at) push(agent.updated_at, "update", "agent", agent.id); } + for (const session of own.sessions) { push(session.created_at, "create", "session", session.id); if (session.last_active_at > session.created_at + 60) push(session.last_active_at, "send_events", "session", session.id); } + for (const skill of own.skills) push(skill.created_at, "create", "skill", skill.id); + for (const file of own.files) push(file.created_at, "create", "file", file.id); + for (const template of own.templates) push(template.created_at, "create", "environment_template", template.id); + for (const vault of own.vaults) { + push(vault.created_at, "create", "vault", vault.id); + for (const credential of resources.credentials.get(vault.id) ?? []) push(credential.created_at, "create", "credential", credential.id, vault.id); + } + list.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + return [project.id, list]; + })); + + function resourceOwners(project, url) { + const type = url.searchParams.get("resource_type"); + const ids = (url.searchParams.get("resource_ids") ?? "").split(",").filter(Boolean).slice(0, 100); + return { data: ids.map((id) => { + const creator = creatorFor(project, type, id); + if (creator?.copy) return { resource_id: id, api_key: null, source: "admin_copy", admin_audit_id: `audit_${id.slice(-8)}` }; + return creator ? { resource_id: id, api_key: creator, source: "api_key", admin_audit_id: null } : { resource_id: id, api_key: null, source: null, admin_audit_id: null }; + }) }; + } + + function summarize(sessions) { + const counts = { total: sessions.length, idle: 0, in_progress: 0, requires_action: 0, failed: 0 }; + let reported = 0; let last = null; + const usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } }; + for (const session of sessions) { + if (session.status in counts) counts[session.status] += 1; + last = Math.max(last ?? 0, session.last_active_at); + if (!session.usage) continue; + reported += 1; + usage.input_tokens += session.usage.input_tokens; + usage.output_tokens += session.usage.output_tokens; + usage.total_tokens += session.usage.total_tokens; + usage.input_tokens_details.cached_tokens += session.usage.input_tokens_details?.cached_tokens ?? 0; + usage.output_tokens_details.reasoning_tokens += session.usage.output_tokens_details?.reasoning_tokens ?? 0; + } + return { sessions: counts, usage, coverage: { measured_sessions: reported, total_sessions: sessions.length, ratio: sessions.length ? reported / sessions.length : null }, last_active_at: last }; + } + + function summary(url) { + const after = url.searchParams.get("created_after"); const before = url.searchParams.get("created_before"); + const lower = after ? Date.parse(after) / 1000 : -Infinity; const upper = before ? Date.parse(before) / 1000 : Infinity; + const only = url.searchParams.get("project_id"); + const groupBy = url.searchParams.get("group_by") ?? "project"; + const rows = []; + for (const project of projects) { + if (only && project.id !== only) continue; + const own = collections(project.id); + const sessions = own.sessions.filter((session) => session.created_at >= lower && session.created_at < upper); + if (groupBy === "agent") { + for (const agent of own.agents) rows.push({ project_id: project.id, key_id: null, agent_id: agent.id, assets: null, ...summarize(sessions.filter((session) => session.agent.id === agent.id)) }); + } else if (groupBy === "key") { + const groups = new Map(); + for (const session of sessions) { + const creator = creatorFor(project, "session", session.id); + const id = creator?.id ?? ""; + if (!groups.has(id)) groups.set(id, { key: creator?.id ?? null, sessions: [] }); + groups.get(id).sessions.push(session); + } + for (const group of groups.values()) rows.push({ project_id: project.id, key_id: group.key, agent_id: null, assets: null, ...summarize(group.sessions) }); + } else { + const credentials = own.vaults.reduce((sum, vault) => sum + (resources.credentials.get(vault.id)?.length ?? 0), 0); + rows.push({ project_id: project.id, key_id: null, agent_id: null, assets: { agents: own.agents.length, skills: own.skills.length, environment_templates: own.templates.length, files: own.files.length, vaults: own.vaults.length, credentials }, ...summarize(sessions) }); + } + } + return { data: rows, has_more: false, next_cursor: "" }; + } + + const publicProject = (project) => ({ + id: project.id, name: project.name, created_at: iso(project.created_at), archived_at: project.archived_at ? iso(project.archived_at) : null, + active_key_count: project.keys.filter((k) => !k.revoked_at).length, + }); + const publicKey = (project, k) => ({ id: k.id, project_id: project.id, name: k.name, prefix: k.prefix, created_at: iso(k.created_at), revoked_at: k.revoked_at ? iso(k.revoked_at) : null }); + const auditLog = () => { + const entries = []; + let n = 0; + const add = (at, action, project, type, id, results = []) => entries.push({ id: `audit_${String(++n).padStart(4, "0")}`, created_at: iso(at), admin_credential_id: "a1b2c3d4", actor_label: "admin", action, project_id: project.id, resource_type: type, resource_id: id, result_ids: results, request_id: `req_admin_${n}`, trace_id: `${n}`.padStart(32, "a") }); + for (const project of projects) { + add(project.created_at, "create_project", project, "project", project.id); + for (const k of project.keys) { add(k.created_at, "issue_key", project, "api_key", k.id); if (k.revoked_at) add(k.revoked_at, "revoke_key", project, "api_key", k.id); } + if (project.archived_at) add(project.archived_at, "archive_project", project, "project", project.id); + } + const agent = base.agents[0]; + add(now - 86400 * 2, "copy", projects[1], "agent", agent.id, [{ type: "agent", source_id: agent.id, target_id: "agent_c0p1ed01" }]); + return entries.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + }; + + return { + projects, owner, collections, operations, summary, publicProject, publicKey, resourceOwners, auditLog, + runtimeObservations: () => base.observations.map((observation) => ({ project_id: owner.get(`session:${observation.session_id}`), observation })), + }; +} diff --git a/apps/web/e2e/data/agents.mjs b/apps/web/e2e/data/agents.mjs new file mode 100644 index 000000000..14f77edc9 --- /dev/null +++ b/apps/web/e2e/data/agents.mjs @@ -0,0 +1,125 @@ +// Synthetic saved Agents for the browser acceptance fixture: realistic instructions, tools, +// metadata and generation settings so list and detail pages have content. +const fn = (name, description, properties, required = Object.keys(properties)) => ({ + type: "function", name, description, + parameters: { type: "object", properties, required, additionalProperties: false }, + strict: true, +}); +const mcp = (label, url, allowed = null, credential = null) => ({ + type: "mcp", server_label: label, server_url: url, allowed_tools: allowed, require_approval: "never", + ...(credential ? { x_agents_core: { credential_id: credential } } : {}), +}); +const str = (description) => ({ type: "string", description }); + +export const agentDefinitions = [ + { + name: "Incident responder", model: "gpt-5.1-codex", harness: "codex", reasoning: { effort: "high", summary: "auto" }, + instructions: "You are the on-call assistant for the web platform.\n\nWhen an alert arrives:\n1. Read the alert and the linked dashboard.\n2. Check recent deploys and error logs for the affected service.\n3. Propose the smallest safe mitigation and wait for approval before acting.\n4. Write a short incident note: impact, timeline, cause, next steps.", + tools: [ + fn("lookup_deploys", "List deploys of a service in a time window.", { service: str("Service name"), since: str("ISO-8601 start") }), + fn("page_owner", "Page the owning team with a summary.", { team: str("Team handle"), summary: str("One-paragraph summary") }), + mcp("observability", "https://mcp.internal.example/observability", ["query_logs", "query_metrics"]), + ], + metadata: { team: "sre", tier: "production", runbook: "RB-0142" }, + }, + { + name: "Support triage", model: "claude-sonnet-5", harness: "claude_sdk", reasoning: {}, + instructions: "Classify each incoming support ticket by product area and severity.\nAsk one clarifying question when the report lacks steps to reproduce.\nNever promise dates. Link the matching help-center article when one exists.", + tools: [ + fn("search_help_center", "Search published help-center articles.", { query: str("Search text") }), + mcp("crm", "https://mcp.example.com/crm-read", ["get_customer", "list_tickets"]), + ], + metadata: { team: "support", queue: "tier-1" }, + }, + { + name: "Code reviewer", model: "gpt-5.1-codex", harness: "codex", reasoning: { effort: "medium" }, + instructions: "Review pull requests for correctness first, then clarity.\nComment only on issues that matter; group nits into one comment.\nFlag missing tests for changed behaviour.", + tools: [mcp("github", "https://mcp.example.com/github", ["get_pull_request", "list_files", "create_review"])], + metadata: { team: "platform", repo_scope: "monorepo" }, + }, + { + name: "Data analyst", model: "MiniMax-M2", harness: "mcode", reasoning: {}, + instructions: "Answer questions about product metrics using the warehouse.\nShow the SQL you ran and state the time range and filters explicitly.\nIf data is missing, say so instead of estimating.", + tools: [ + fn("run_sql", "Run a read-only SQL query against the analytics warehouse.", { sql: str("SELECT statement") }), + fn("plot", "Render a chart from query results.", { kind: str("line | bar"), title: str("Chart title") }), + ], + metadata: { team: "data", warehouse: "analytics" }, + }, + { + name: "Contract checker", model: "gpt-5.1-mini", harness: "codex", reasoning: {}, + instructions: "Compare a supplier contract against the standard template.\nList every deviation with the clause number, the template wording and the contract wording.\nMark liability, termination and data-protection deviations as high priority.", + tools: [fn("fetch_template", "Fetch the current standard contract template.", { kind: str("Contract type") })], + metadata: { team: "legal" }, + }, + { + name: "Release notes writer", model: "claude-sonnet-5", harness: "claude_sdk", reasoning: {}, + instructions: "Draft release notes from merged pull requests since the last tag.\nGroup by user-visible change; leave out refactors and CI changes.", + tools: [mcp("github", "https://mcp.example.com/github", ["list_merged_pull_requests", "get_release"])], + metadata: { team: "platform", channel: "stable" }, + }, + { + name: "Onboarding guide", model: "gpt-5.1-mini", harness: "codex", reasoning: {}, + instructions: "Help new employees find internal documentation and set up their accounts.\nKeep answers short and link the source page.", + tools: [mcp("docs", "https://mcp.internal.example/docs", null)], + metadata: { team: "people-ops" }, + }, + { + name: "Churn forecaster", model: "MiniMax-M2", harness: "mcode", reasoning: { effort: "high" }, + instructions: "Estimate 90-day churn risk per account from usage and support signals.\nExplain the top three drivers for each high-risk account.", + tools: [fn("run_sql", "Run a read-only SQL query against the analytics warehouse.", { sql: str("SELECT statement") })], + metadata: { team: "data", model_version: "2026-09" }, + }, + { + name: "Log summarizer", model: "gpt-5.1-codex", harness: "codex", reasoning: {}, + instructions: "Summarise error logs for the last hour: top error signatures, first seen, count, affected hosts.", + tools: [mcp("observability", "https://mcp.internal.example/observability", ["query_logs"])], + metadata: { team: "sre" }, + }, + { + name: "Staging smoke tester", model: "gpt-5.1-mini", harness: "codex", reasoning: {}, + instructions: "Run the staging smoke checklist after each deploy and report failures with request IDs.", + tools: [fn("http_check", "Call an endpoint and return status and latency.", { url: str("Absolute URL") })], + metadata: { team: "qa", environment: "staging" }, + }, +]; + +/** Which project owns each definition (index into the demo projects). */ +export const agentProject = [0, 1, 2, 0, 1, 0, 1, 1, 2, 3]; + +/** Prompt and reply pairs per Agent so Session transcripts read like real work. */ +export const agentConversations = { + "Incident responder": [ + ["Alert: p95 latency on checkout-api above 2 s for 10 minutes. What changed?", "checkout-api was deployed at 09:42 (build 7f3c2a9). Errors started 3 minutes later and are limited to the new pods.\n\n**Proposed mitigation:** roll back to build 51d418a. Waiting for approval before acting."], + ["Approved. Roll back and write the incident note.", "Rollback finished at 10:05; p95 is back to 380 ms.\n\n- **Impact:** 18 minutes of slow checkouts, no failed orders\n- **Cause:** N+1 query in the new discount lookup\n- **Next:** add a query-count test to the discount service"], + ], + "Support triage": [ + ["Customer says exports to CSV are empty since yesterday. Ticket #48213.", "Classified as **Reports / high**. The report omits the date range, so I asked which range they exported. Linked article: \"Exporting reports to CSV\"."], + ["They used 'Last 7 days' on the EU workspace.", "Reproduced on an EU test workspace: the export job times out and returns an empty file. Escalated to the reports team with the request ID."], + ], + "Code reviewer": [ + ["Review PR #1287: add retry to the payment webhook handler.", "Two issues matter:\n\n1. Retries are not idempotent: a retried webhook can charge twice. Key the handler on the event ID.\n2. No test covers the retry path.\n\nThe remaining comments are nits, grouped in one comment."], + ], + "Data analyst": [ + ["How many workspaces were active last week compared to the week before?", "Active workspaces (at least one Session):\n\n| Week | Active |\n|---|---|\n| Sep 8–14 | 1,284 |\n| Sep 15–21 | 1,367 |\n\nThat is **+6.5%**. Range: Monday to Sunday UTC, excluding internal workspaces."], + ["Break it down by plan.", "Team +9.1%, Business +4.2%, Enterprise flat. The Team growth comes from 61 new workspaces created during the onboarding campaign."], + ], + "Contract checker": [ + ["Check the Acme Logistics supplier contract against our template.", "Found 4 deviations. High priority:\n\n- **§9.2 Liability:** cap lowered from 12 months of fees to 3 months\n- **§14 Data protection:** sub-processors can be added without notice\n\nThe other two concern payment terms (§5.1) and notice periods (§17.3)."], + ], + "Release notes writer": [ + ["Draft release notes since v2.14.0.", "## v2.15.0\n\n- **Sessions:** failed Turns now show the error next to the conversation\n- **Files:** uploads up to 512 MiB\n- **Fixes:** exports no longer time out on large workspaces\n\nLeft out 23 refactor and CI changes."], + ], + "Onboarding guide": [ + ["Where do I request access to the staging cluster?", "Request it in the access portal under **Infrastructure → Staging cluster**; your manager approves it. Source: \"Getting access to environments\" in the engineering handbook."], + ], + "Churn forecaster": [ + ["Which enterprise accounts are at risk this quarter?", "Three accounts score above 0.7:\n\n1. **Northwind** (0.82): seats used fell 40%, two P1 tickets open\n2. **Globex** (0.76): champion left, no logins from admins in 30 days\n3. **Initech** (0.71): renewal in 45 days, usage flat"], + ], + "Log summarizer": [ + ["Summarize errors from the last hour.", "Top signatures:\n\n- `TimeoutError: upstream inventory` — 412 hits, first 09:12, web-01…web-04\n- `ConnectionReset: redis-cache` — 57 hits, first 09:40, worker-02\n\nNo new signatures compared to yesterday."], + ], + "Staging smoke tester": [ + ["Run the smoke checklist on the 10:30 staging deploy.", "18 of 19 checks passed. **Failed:** `POST /v1/exports` returned 500 (request req_8d2f1c). Everything else is within latency budgets."], + ], +}; diff --git a/apps/web/e2e/data/core-metrics.mjs b/apps/web/e2e/data/core-metrics.mjs new file mode 100644 index 000000000..18b46084d --- /dev/null +++ b/apps/web/e2e/data/core-metrics.mjs @@ -0,0 +1,41 @@ +// Synthetic Core metrics for the browser acceptance fixture (GET /core/v1/admin/core-metrics). +const RANGES = { "1h": [60, 60], "6h": [72, 300], "24h": [96, 900], "7d": [84, 7200] }; + +export function coreMetrics(range = "1h", now = Math.floor(Date.now() / 1000)) { + const [count, step] = RANGES[range] ?? RANGES["1h"]; + const end = Math.floor(now / step) * step; + const start = end - count * step; + const wave = (i, period, phase = 0) => Math.sin((i / period) * Math.PI * 2 + phase); + const execution = []; + const database = []; + for (let i = 0; i < count; i += 1) { + const at = new Date((start + i * step) * 1000).toISOString(); + const load = 1 + 0.4 * wave(i, 24) + 0.2 * wave(i, 7, 1); + const running = Math.max(0, Math.min(4, Math.round(2.4 * load))); + const burst = i % 23 === 7 ? 5 : 0; + const queued = Math.max(0, Math.round((running >= 4 ? 2 : 0) + burst + (load > 1.3 ? 1 : 0))); + execution.push({ start: at, queued, in_progress: running, queue_wait_p95_ms: queued ? Math.round(900 + 700 * queued) : 180 }); + database.push({ start: at, ping_p95_ms: Number((2.2 + 0.8 * load + (i % 31 === 11 ? 9 : 0)).toFixed(1)), pool_in_use: Math.max(1, Math.round(4 + 3 * load)) }); + } + const ago = (s) => new Date((now - s) * 1000).toISOString(); + return { + object: "core.metrics", + range: { start: new Date(start * 1000).toISOString(), end: new Date(end * 1000).toISOString(), resolution_seconds: step }, + service: { status: "running", revision: "b134a1b5", started_at: ago(86400 * 3 + 4 * 3600), execution_owner: true }, + execution: { + slots_in_use: 3, slots_total: 4, queued_turns: 2, waiting_for_daemon: 1, in_progress_turns: 3, + oldest_queued_seconds: 130, connected_daemons: 9, interrupted: 0, unavailable: 3, + queue_wait_ms: { p50: 420, p95: 2600 }, + series: execution, + }, + database: { ping_ms: { p50: 1.8, p95: 3.4 }, pool: { in_use: 6, idle: 4, max: 20 }, size_bytes: Math.round(1.24 * 2 ** 30), series: database }, + jobs: [ + { id: "scheduler", status: "ok", last_run_at: ago(0), processed: 1, failed: 0 }, + { id: "runtime_sampler", status: "ok", last_run_at: ago(18), processed: 12, failed: 1 }, + { id: "history_cleanup", status: "ok", last_run_at: ago(41), processed: 230, failed: 0 }, + { id: "audit_cleanup", status: "ok", last_run_at: ago(41), processed: 0, failed: 0 }, + ], + // Unmeasured: the console must show it as missing, never as zero. + process: { memory_bytes: null, goroutines: 214 }, + }; +} diff --git a/apps/web/e2e/data/resources.mjs b/apps/web/e2e/data/resources.mjs new file mode 100644 index 000000000..79bd37bc0 --- /dev/null +++ b/apps/web/e2e/data/resources.mjs @@ -0,0 +1,112 @@ +// Synthetic Resources for the browser acceptance fixture (Skills, Files, Templates, Vaults). +let seed = 7; +const rand = () => ((seed = (seed * 1664525 + 1013904223) % 4294967296) / 4294967296); +const hex = (n) => Array.from({ length: n }, () => Math.floor(rand() * 16).toString(16)).join(""); +const uuid = () => `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`; +const iso = (seconds) => new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"); + +export function buildResources(now, agents, sessions) { + seed = 7; + const keys = [ + { id: "fb533e99-524f-4e44-94bc-8f6e571646a7", name: "Production app", prefix: "pc_live_7Hq", created_at: iso(now - 86400 * 21), revoked_at: null }, + { id: "3c1d9e20-7a41-4b8e-9f02-5d6e7f8a9b10", name: "CI pipeline", prefix: "pc_live_Qm4", created_at: iso(now - 86400 * 16), revoked_at: null }, + { id: "a47e2b19-0c3d-4e5f-8a6b-7c8d9e0f1a2b", name: "Data team notebook", prefix: "pc_live_k9T", created_at: iso(now - 86400 * 9), revoked_at: null }, + { id: "0b533e99-524f-4e44-94bc-8f6e571646a7", name: "Staging", prefix: "pc_live_2Xa", created_at: iso(now - 86400 * 30), revoked_at: iso(now - 86400 * 6) }, + ]; + const refOf = (key) => ({ type: "project_api_key", id: key.id, name: key.name, prefix: key.prefix, revoked_at: key.revoked_at }); + const consoleRef = { type: "console", id: null, name: null, prefix: null, revoked_at: null }; + // Weighted owner choice: most traffic from production, some unknown (created before recording). + const owners = [refOf(keys[0]), refOf(keys[0]), refOf(keys[0]), refOf(keys[1]), refOf(keys[1]), refOf(keys[2]), refOf(keys[3]), consoleRef, null]; + const ownerOf = () => owners[Math.floor(rand() * owners.length)]; + + const skills = [ + ["report", "Create quarterly and incident reports from structured notes.", 3, 2], + ["triage", "Sort incoming issues by severity and owner.", 2, 2], + ["sql-review", "Review SQL migrations for locking and data loss.", 1, 1], + ["release-notes", "Draft release notes from merged changes.", 4, 4], + ["pdf-extract", "Extract tables and text from PDF documents.", 2, 1], + ].map(([name, description, latest, defaultVersion], index) => ({ + id: `skill_${uuid()}`, object: "skill", created_at: now - 86400 * (2 + index * 3) - 3600 * index, + name, description, default_version: String(defaultVersion), latest_version: String(latest), + })); + const skillVersions = new Map(skills.map((skill) => [skill.id, Array.from({ length: Number(skill.latest_version) }, (_, index) => ({ + id: `skillver_${uuid()}`, object: "skill.version", created_at: skill.created_at + index * 86400 * 0.7, + skill_id: skill.id, version: String(index + 1), name: skill.name, description: skill.description, + })).reverse()])); + + const files = [ + ["incident-2026-09-21.md", 18_432], ["customers.csv", 2_418_112], ["contract-template.docx", 96_512], ["schema.sql", 7_340], + ["logs-web-01.tar.gz", 48_211_968], ["notes.txt", 312], ["q3-metrics.xlsx", 391_224], ["input.csv", 42], ["empty.bin", 0], + ].map(([filename, bytes], index) => ({ + id: `file-${uuid()}`, object: "file", bytes, created_at: now - 3600 * (3 + index * 17), filename, purpose: "user_data", + status: "processed", expires_at: null, status_details: null, + })); + + const templates = [ + { name: "Report builder", network: { access: "restricted", allowed_domains: ["pypi.org", "files.pythonhosted.org"] }, + packages: { npm: ["typescript@5.8.3"], python: ["packaging==26.0", "pandas==2.3.1"], system: ["jq"] }, + files: [{ type: "inline", path: "/workspace/config/settings.json", size_bytes: 128 }, { type: "file_id", path: "/workspace/data/input.csv", file_id: files[7].id }], + skills: [{ type: "skill_reference", skill_id: skills[0].id, version: null }, { type: "skill_reference", skill_id: skills[3].id, version: "latest" }] }, + { name: "Python analysis", network: { access: "enabled", allowed_domains: [] }, + packages: { npm: [], python: ["numpy==2.3.0", "polars==1.31.0"], system: [] }, files: [], skills: [{ type: "skill_reference", skill_id: skills[4].id, version: "2" }] }, + { name: "Offline sandbox", network: { access: "disabled", allowed_domains: [] }, packages: { npm: [], python: [], system: [] }, files: [], skills: [] }, + { name: "Web research", network: { access: "restricted", allowed_domains: ["en.wikipedia.org", "arxiv.org", "github.com"] }, + packages: { npm: ["playwright@1.55.0"], python: [], system: ["chromium"] }, files: [], skills: [] }, + ].map((template, index) => ({ + id: uuid(), object: "agent.environment.template", name: template.name, + created_at: now - 86400 * (4 + index * 5), updated_at: now - 86400 * (1 + index * 2), + capability_directories: index === 0 ? ["/workspace/capabilities"] : [], network: template.network, packages: template.packages, + files: template.files, plugins: [], skills: template.skills, + })); + + const vaults = [["Internal services", 2], ["GitHub MCP", 1], ["Customer CRM", 3], ["Sandbox testing", 0]].map(([name, count], index) => ({ + vault: { id: uuid(), object: "vault", created_at: now - 86400 * (6 + index * 4), name, metadata: {} }, count, + })); + const credentialNames = ["Internal MCP", "Search MCP", "GitHub", "CRM read", "CRM write", "CRM export"]; + let credentialIndex = 0; + const credentials = new Map(vaults.map(({ vault, count }) => [vault.id, Array.from({ length: count }, (_, index) => { + const name = credentialNames[credentialIndex++ % credentialNames.length]; + const created = vault.created_at + 3600 * (index + 1); + return { id: uuid(), vault_id: vault.id, name, object: "vault.credential", + auth: { type: "static_bearer", mcp_server_url: `https://mcp.example.com/${name.toLowerCase().replace(/\s+/g, "-")}` }, + created_at: created, updated_at: created + (index % 2 ? 86400 : 0) }; + }).reverse()])); + + const ownership = new Map(); + const own = (type, id, created) => { const owner = ownerOf(); ownership.set(`${type}:${id}`, { resource_type: type, resource_id: id, owner, created_at: owner ? iso(created) : null }); return owner; }; + const activity = []; + const record = (owner, action, type, id, at, parent = null) => { + if (!owner) return; + activity.push({ id: `act_${uuid()}`, object: "api_key.activity", created_at: iso(at), actor: owner, action, resource_type: type, resource_id: id, parent_resource_id: parent, trace_id: hex(32) }); + }; + for (const agent of agents) { const owner = own("agent", agent.id, agent.created_at); record(owner, "create", "agent", agent.id, agent.created_at); if (agent.updated_at > agent.created_at) record(owner, "update", "agent", agent.id, agent.updated_at); } + for (const session of sessions) { const owner = own("session", session.id, session.created_at); record(owner, "create", "session", session.id, session.created_at); if (session.last_active_at > session.created_at + 60) record(owner, "send", "session", session.id, session.last_active_at); } + for (const skill of skills) { + const owner = own("skill", skill.id, skill.created_at); + record(owner, "create", "skill", skill.id, skill.created_at); + for (const version of skillVersions.get(skill.id).slice(0, -1)) record(owner, "create", "skill_version", version.id, version.created_at, skill.id); + if (skill.default_version !== skill.latest_version) record(owner, "update", "skill", skill.id, skill.created_at + 86400); + } + for (const file of files) record(own("file", file.id, file.created_at), "create", "file", file.id, file.created_at); + for (const template of templates) { const owner = own("environment_template", template.id, template.created_at); record(owner, "create", "environment_template", template.id, template.created_at); record(owner, "update", "environment_template", template.id, template.updated_at); } + for (const { vault } of vaults) { + const owner = own("vault", vault.id, vault.created_at); + record(owner, "create", "vault", vault.id, vault.created_at); + for (const credential of credentials.get(vault.id)) { + own("vault_credential", credential.id, credential.created_at); + record(owner, "create", "vault_credential", credential.id, credential.created_at, vault.id); + if (credential.updated_at > credential.created_at) record(owner, "update", "vault_credential", credential.id, credential.updated_at, vault.id); + } + } + // Deleted resources still appear in the log. + record(refOf(keys[3]), "delete", "agent", `agent_${hex(8)}`, now - 86400 * 7); + record(refOf(keys[1]), "delete", "file", `file-${uuid()}`, now - 86400 * 2 - 600); + record(refOf(keys[1]), "delete", "skill_version", `skillver_${uuid()}`, now - 86400 * 3, skills[1].id); + activity.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + + return { + keys, skills, skillVersions, files, templates, + vaults: vaults.map(({ vault }) => vault), credentials, + ownership, activity, + }; +} diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs new file mode 100644 index 000000000..1918200e7 --- /dev/null +++ b/apps/web/e2e/data/routes.mjs @@ -0,0 +1,97 @@ +// Synthetic deployment data for the browser acceptance fixture: Sessions, Turns, Items, nodes. +import { agentConversations, agentDefinitions } from "./agents.mjs"; +let seed = 42; +const rand = () => ((seed = (seed * 1664525 + 1013904223) % 4294967296) / 4294967296); +const pick = (list) => list[Math.floor(rand() * list.length)]; +const uuid = () => "xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx".replace(/x/g, () => Math.floor(rand() * 16).toString(16)); + +export function buildDemo(now = Math.floor(Date.now() / 1000)) { + seed = 42; + const models = ["gpt-5.1-codex", "claude-sonnet-5", "MiniMax-M2", "gpt-5.1-mini"]; + const agents = agentDefinitions.map((definition, index) => ({ + id: `agent_${index + 1}${uuid().slice(0, 6)}`, object: "agent", model: definition.model, name: definition.name, instructions: definition.instructions, + metadata: definition.metadata, multi_agent: { enabled: false, max_concurrent_subagents: null }, reasoning: definition.reasoning, service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, tools: definition.tools, + x_agents_core: { harness: definition.harness }, + created_at: now - 86400 * (10 + index * 2), updated_at: now - 3600 * (index * 5 + 2), + })); + const sessions = []; + const turns = new Map(); + const items = new Map(); + const statuses = [...Array(40).fill("idle"), "in_progress", "in_progress", "in_progress", "requires_action", "requires_action", "failed", "failed", "failed"]; + statuses.forEach((status, index) => { + const agent = agents[Math.floor(rand() * agents.length)]; + const envType = index % 4 === 0 ? "openai_hosted" : index % 7 === 0 ? "self_hosted" : "none"; + const created = now - Math.floor(rand() * 86400 * 1.2); + const id = uuid(); + const sessionTurns = []; + const sessionItems = []; + const turnCount = 1 + Math.floor(rand() * 9); + let cursor = created; + let input = 0; let output = 0; let cached = 0; let reasoning = 0; + for (let t = 0; t < turnCount; t += 1) { + cursor += 60 + Math.floor(rand() * 2400); + if (cursor > now - 5) break; + const last = t === turnCount - 1; + const failed = (status === "failed" && last) || rand() < 0.04; + const running = status === "in_progress" && last; + const duration = Math.floor(4 + rand() * rand() * 180); + const turnInput = Math.floor(2000 + rand() * 30000); + const turnOutput = Math.floor(200 + rand() * 4000); + const turnId = uuid(); + const turn = { + id: turnId, agent_id: agent.id, session_id: id, object: "agent.session.turn", + status: running ? "in_progress" : failed ? "failed" : rand() < 0.03 ? "cancelled" : "completed", + created_at: cursor, started_at: cursor + Math.floor(rand() * 4), completed_at: running ? null : cursor + duration, + error: failed ? { code: "internal_error", message: "The execution could not complete." } : null, + usage: running ? null : { input_tokens: turnInput, output_tokens: turnOutput, total_tokens: turnInput + turnOutput, input_tokens_details: { cached_tokens: Math.floor(turnInput * 0.4) }, output_tokens_details: { reasoning_tokens: Math.floor(turnOutput * 0.3) } }, + }; + if (!running) { input += turnInput; output += turnOutput; cached += Math.floor(turnInput * 0.4); reasoning += Math.floor(turnOutput * 0.3); } + sessionTurns.push(turn); + const exchange = (agentConversations[agent.name] ?? [["Investigate", "Done"]])[t % (agentConversations[agent.name]?.length ?? 1)]; + sessionItems.push({ id: uuid(), turn_id: turnId, type: "message", status: "completed", role: "user", content: [{ type: "input_text", text: exchange[0] }] }); + const calls = Math.floor(rand() * 6); + for (let c = 0; c < calls; c += 1) { + const kind = pick(["function", "function", "mcp", "command", "command", "web"]); + const itemFailed = rand() < 0.06; + if (kind === "function") sessionItems.push({ id: uuid(), turn_id: turnId, type: "function_call", status: itemFailed ? "failed" : "completed", call_id: uuid(), name: pick(["apply_patch", "read_file", "search_docs"]), arguments: {} }); + if (kind === "mcp") sessionItems.push({ id: uuid(), turn_id: turnId, type: "mcp_call", status: itemFailed ? "failed" : "completed", server_label: pick(["github", "linear"]), name: pick(["search_issues", "get_file"]), arguments: {}, output: null, error: null }); + if (kind === "command") sessionItems.push({ id: uuid(), turn_id: turnId, type: "command_execution", status: "completed", command: "pytest -q", exit_code: itemFailed ? 1 : 0, duration_ms: 1200 }); + if (kind === "web") sessionItems.push({ id: uuid(), turn_id: turnId, type: "web_search_call", status: "completed" }); + } + if (!running && !failed) sessionItems.push({ id: uuid(), turn_id: turnId, type: "message", status: "completed", role: "assistant", phase: "final_answer", content: [{ type: "output_text", text: exchange[1] }] }); + } + const lastActive = sessionTurns.length ? Math.max(...sessionTurns.map((turn) => turn.completed_at ?? turn.created_at)) : created; + const { object: _o, metadata: _m, created_at: _c, updated_at: _u, ...snapshot } = agent; + sessions.push({ + id, object: "agent.session", agent: snapshot, + environment: envType === "none" ? { type: "none" } : envType === "self_hosted" ? { type: "self_hosted", id: uuid(), remote_url: "wss://core.example.internal/executor", workspace_directory: "/srv/work", capability_directories: [] } : { type: "openai_hosted", id: uuid(), capability_directories: [], network: { access: "disabled", allowed_domains: [] }, packages: { npm: [], python: [], system: [] }, files: [], plugins: [], skills: [] }, + status, error: status === "failed" ? pick(["Sandbox allocation failed: node unavailable.", "Model provider returned 429 Too Many Requests.", "Tool call timed out after 300 s."]) : null, + metadata: {}, required_actions: status === "requires_action" ? [{ type: "function_call", call_id: uuid(), turn_id: sessionTurns.at(-1)?.id ?? uuid(), name: "approve_refund", arguments: {} }] : [], + vault_ids: [], usage: status === "in_progress" ? null : sessionTurns.length ? { input_tokens: input, output_tokens: output, total_tokens: input + output, input_tokens_details: { cached_tokens: cached }, output_tokens_details: { reasoning_tokens: reasoning } } : null, + created_at: created, last_active_at: lastActive, + }); + turns.set(id, sessionTurns); + items.set(id, sessionItems); + }); + sessions.sort((a, b) => b.created_at - a.created_at); + const nodes = [ + { id: "node-local", name: "core-01", provider: "docker", online: true, provider_ready: true, diagnostic: "", cpu_count: 16, available_memory_bytes: 38 * 2 ** 30, available_disk_bytes: 410 * 2 ** 30, running: 5, snapshots: 2, last_seen_at: new Date((now - 8) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 5, reserved: 1, retained: 2, cleanup_pending: 0, created_at: new Date((now - 86400 * 30) * 1000).toISOString() }, + { id: "node-gpu", name: "gpu-worker-02", provider: "docker", online: true, provider_ready: true, diagnostic: "", cpu_count: 32, available_memory_bytes: 12 * 2 ** 30, available_disk_bytes: 96 * 2 ** 30, running: 7, snapshots: 5, last_seen_at: new Date((now - 12) * 1000).toISOString(), max_active: 8, max_retained: 16, active: 7, reserved: 0, retained: 5, cleanup_pending: 1, created_at: new Date((now - 86400 * 12) * 1000).toISOString() }, + { id: "node-edge", name: "edge-03", provider: "docker", online: false, provider_ready: false, diagnostic: "", cpu_count: 8, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: new Date((now - 5400) * 1000).toISOString(), max_active: 4, max_retained: 8, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date((now - 86400 * 3) * 1000).toISOString() }, + ]; + const hosted = sessions.filter((session) => session.environment.type === "openai_hosted"); + const allocations = hosted.map((session, index) => ({ + id: uuid(), node_id: index % 2 ? "node-gpu" : "node-local", tenant_id: "project", session_id: session.id, environment_id: session.environment.id, + state: "active", compute_phase: index % 3 === 0 ? "suspended" : "running", diagnostic: "", initialization: "ready", created_at: new Date(session.created_at * 1000).toISOString(), + })); + const observations = sessions.map((session) => { + const base = { id: session.id, object: "agent.runtime_observation", session_id: session.id, resolved_at: now }; + if (session.environment.type === "none") return { ...base, environment_id: null, mode: "none", provider_type: null, instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, cpu: null, memory: null }; + if (session.environment.type === "self_hosted") return { ...base, environment_id: session.environment.id, mode: "self_hosted", provider_type: null, instance: { kind: "self_hosted_connection", allocation_id: null, device_id: null, connection_generation: null }, lifecycle_state: null, status: "unsupported", reason: "runtime_mode_not_observable", allocation_created_at: null, observed_at: null, started_at: null, cpu: null, memory: null }; + const allocation = allocations.find((entry) => entry.session_id === session.id); + const sleeping = allocation.compute_phase === "suspended"; + return { ...base, environment_id: session.environment.id, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: allocation.id, device_id: null, connection_generation: null }, lifecycle_state: sleeping ? "sleeping" : "active", status: "observed", reason: null, allocation_created_at: session.created_at, observed_at: now - 2, started_at: session.created_at, cpu: sleeping ? null : { usage_seconds_total: 600 + Math.floor(rand() * 4000), capacity_cores: 2, usage_cores: Number((rand() * 1.6).toFixed(2)), utilization_ratio: null }, memory: sleeping ? null : { usage_bytes: Math.floor((0.4 + rand() * 1.4) * 2 ** 30), limit_bytes: 2 * 2 ** 30 } }; + }); + return { agents, sessions, turns, items, nodes, allocations, observations }; +} diff --git a/apps/web/e2e/environment-templates.spec.ts b/apps/web/e2e/environment-templates.spec.ts deleted file mode 100644 index d56661715..000000000 --- a/apps/web/e2e/environment-templates.spec.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; - -const fixtureBaseUrl = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; -const collectionPath = "/v1/agents/environments/templates"; -const beta = { "OpenAI-Beta": "agents=v1" }; - -interface Template { - id: string; - name: string | null; - network: { access: "enabled" | "disabled"; allowed_domains: string[] }; - created_at: number; - updated_at: number; -} - -interface RecordedRequest { - method: string; - path: string; - query: string; - beta: string | null; - body?: Record; -} - -async function control(request: APIRequestContext, values: Record) { - expect((await request.post(`${fixtureBaseUrl}/__fixture/control`, { data: values })).ok()).toBe(true); -} - -async function requests(request: APIRequestContext): Promise { - return (await request.get(`${fixtureBaseUrl}/__fixture/requests`)).json(); -} - -async function seed(request: APIRequestContext, name: string, access: "enabled" | "disabled" = "enabled"): Promise