Compare commits

..
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 b5347c872b fix: only gate-created approvals unlock code execution; Code Studio re-sends approval_id
A low-risk execute_code request filed via POST /api/approvals is
auto-approved by the default policy and could be used as approval_id.
Require risk_level=critical (what the gate records). Code Studio now
re-submits the pending approval_id for the same code/language so Run
works after approval.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 01:56:08 +00:00
MervinPraisonandClaude Opus 5.5 690e055086 fix: require recorded approval before /api/code/execute runs code (fixes #311)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 01:22:01 +00:00
58 changed files with 452 additions and 8044 deletions

No files matched your search

+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
permissions:
issues: write
pull-requests: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].17
secrets: inherit
with:
hub_ref: v1.1.20
hub_ref: v1.1.17
+2 -2
View File
@@ -16,8 +16,8 @@ permissions:
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].17
secrets: inherit
with:
hub_ref: v1.1.20
hub_ref: v1.1.17
pr_number: ${{ github.event.inputs.pr_number || '' }}
+2 -2
View File
@@ -25,8 +25,8 @@ permissions:
jobs:
detect-and-trigger:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].17
secrets: inherit
with:
hub_ref: v1.1.20
hub_ref: v1.1.17
pr_number: ${{ github.event.inputs.pr_number || '' }}
+4 -4
View File
@@ -43,9 +43,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].17
with:
hub_ref: v1.1.20
hub_ref: v1.1.17
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
@@ -88,9 +88,9 @@ jobs:
actions: read
id-token: write
statuses: write # the assess job records security/outsider-review
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].17
secrets: inherit
with:
hub_ref: v1.1.20
hub_ref: v1.1.17
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].17
secrets: inherit
with:
hub_ref: v1.1.20
hub_ref: v1.1.17
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].17
with:
hub_ref: v1.1.20
hub_ref: v1.1.17
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
+2 -2
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
sync:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].17
secrets: inherit
with:
hub_ref: v1.1.20
hub_ref: v1.1.17
-94
View File
@@ -1,94 +0,0 @@
# ADR 0001: PraisonAI Replay view, a clean-room design
- **Status:** accepted
- **Date:** 2026-10-26
- **Scope:** `src/praisonaiui/features/replay.py`, `features/_trace_jsonl.py`,
`templates/frontend/plugins/views/replay-core.js`, `templates/frontend/plugins/views/replay.js`
## Context
PraisonAI records agent runs as context-trace JSONL (one `ContextEvent.to_dict()` per
line). People need to see what a team or flow did: who did what, when, why, and at what
cost, both after the fact and live. Other tools have shown that a graph plus a trace is a
useful way to look at agent runs. We want that value without copying anyone's product,
code or look, and without adding dependencies.
## Decision
Build our own view, in plain DOM and SVG, with our own layout code, palette and words.
### Ideas we took (ideas only)
| Idea | Where it came from | What we did with it |
|---|---|---|
| Show the graph of a multi-agent program next to the trace of a run | The general concept behind LangGraph Studio's graph-plus-trace view, known from public talks and articles | A **Map** view drawn by our own layered layout (`layoutMap`); the default view is a **Timeline**, which Studio does not have |
| Parallel voices on shared time, read left to right | Musical scores and swimlane diagrams | One lane per agent plus Manager and You; parallel branches as sub-lanes; tasks and steps as "chapters" across the top |
| Vocabulary for LLM and tool spans, tokens and cost | OpenTelemetry GenAI semantic conventions (public specification) | Block kinds (LLM, tool), token and cost totals; the wrapper's OTLP export uses the conventions, the UI only borrows the concepts |
### What we did not use
- **No third-party UI code, CSS, icons, layouts or screenshots** were used to build this
view. Shapes, palette, spacing, wording ("Brief", "Delivered", "Waiting for you") and
the layout algorithm are our own.
- **LangGraph's server (Elastic License 2.0) and the proprietary LangGraph Studio were
not consulted for code**, and none of their assets, markup or styles were copied. We
do not use the name "Studio", any "Lang…" naming, or `__start__`/`__end__` pills.
- No Mermaid, D3, React Flow, dagre, ELK or other graph libraries. The dashboard's
existing Mermaid CDN plugin is not used by Replay.
### How it is built
- `replay-core.js`: pure functions (`buildModel`, `narrate`, `layoutMap`,
`deriveGraph`). No DOM, no network. Tested with `node --test`.
- `replay.js`: DOM/SVG rendering, keyboard handling, SSE follow and approvals. It also
boots by itself when `window.__REPLAY_DATA__ = {events, graph}` is set, which is how
the wrapper's single-file HTML export works (`standalone_html()` inlines both files).
- `layoutMap` is a textbook layered layout written from scratch: depth-first search to
find back edges (loops), longest-path layering, one virtual waypoint per crossed layer
for long edges, and a few barycentre sweeps to order each layer. It is deterministic.
- Narration is template text, chosen per event type. No LLM, no randomness.
## Palette and accessibility rationale
| Role | Fill | Shape | Why |
|---|---|---|---|
| Agent | `#8B0000` | circle | PraisonAI's brand red; white text on it is 10.0:1 |
| Tool | `#0F7287` | rounded rectangle | Teal kept from the brand but darkened from `#189AB4` (white text on `#189AB4` fails AA); white on `#0F7287` is 5.6:1 |
| You (human) | `#8A5A00` | diamond | Amber distinct from red and teal for common colour-vision deficiencies; white on it is 5.9:1 |
| Manager | `#3C4A5C` | square | Neutral slate, so the manager reads as coordination, not work; white on it is 9.0:1 |
- **Never colour alone.** Every lane, block, node and step has a shape and a text label;
arrows differ by dash pattern and carry words ("hands off", "delegates", "routes").
Heat in the cost/latency overlay is a bar whose height and shade both grow, plus the
value in text.
- **Theme.** Follows the system setting (`prefers-color-scheme`) by default, with a
Light/Dark override; the light theme is the design baseline.
- **Light theme first.** Text tokens are at least 6.9:1 on their backgrounds in light mode
and 7.7:1 in dark mode. In dark mode the palette fills keep white text at AA, and get a
light 2 px outline so each shape's boundary stays above 3:1 against the dark page.
- **Keyboard throughout.** ← → (and ↑ ↓ in the step list) move the replay cursor, Home
and End jump to the ends, tabs follow the ARIA tabs pattern, map nodes use a roving
tab stop, and every action has a real button.
- **Screen readers.** The narration column is a real ordered list and is the main way
through a run. Timeline blocks and map nodes are named buttons whose names start with
their visible text (WCAG 2.5.3). Live updates are announced through a polite live
region, throttled.
- **Motion.** The only animation (the live dot) runs only under
`prefers-reduced-motion: no-preference`; scrolling is instant when motion is reduced.
- Checked with axe-core against WCAG 2.0, 2.1 and 2.2 A/AA in both themes (dev-only test
in `tests/browser/`).
## Dependency licences
No new runtime or build dependencies. The Python side uses only the standard library and
Starlette, which aiui already depends on. The browser side is plain ES modules with no
imports beyond our own file. Dev-only test tools that are not shipped: Playwright
(Apache-2.0, already used in this environment) and axe-core (MPL-2.0, loaded from a path
given by `AXE_CORE_JS`; not vendored).
## Consequences
- We own the look and the layout code, and can change them without licence questions.
- The layout is simpler than a dedicated graph engine. It is fine for the team and flow
sizes we see (tens of nodes). Very large graphs may need a better crossing-reduction
step later.
-176
View File
@@ -1,176 +0,0 @@
# Replay
**PraisonAI Replay** shows a recorded agent run as a story: who did what, when, why, and
at what cost. It works for finished runs and for runs that are still going.
## Record a run, then open it
```bash
praisonai replay view agents.yaml # records the run and opens Replay on it
```
or from Python:
```python
from praisonai.replay import record
with record(live=True):
team.start()
```
Every recorded run is a file `<id>.jsonl` in the traces folder. Replay reads it from
`$PRAISONAI_TRACES_DIR`, else `$PRAISON_HOME/traces`, else `~/.praison/traces`.
To open aiui on Replay yourself:
```bash
aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" \
--style dashboard --host 127.0.0.1 --port 8090
# then open http://127.0.0.1:8090/replay?run=<id>
```
`/#/replay?run=<id>` works too. In a full dashboard, Replay is in the sidebar under
**Control**.
## Watch a run live: the Run button
For learning, demos and debugging, open a page with a **Run** button. Each click records
a new run, and the page draws it as it happens:
```python
from praisonai.replay import watch
watch(flow, input="solar sails") # an Agent, AgentTeam, AgentFlow or agents.yaml
```
```bash
praisonai replay watch agents.yaml
```
On the **Map**, each step and agent shows where the run is: dashed means not reached,
blue (pulsing) means running now, green means done and red means failed. After a run,
**▶ Play** moves through it again one step at a time, and the map rewinds with it. One
run goes at a time. Each run of a team starts its tasks afresh; an Agent keeps its chat
history between runs.
## Show progress inside your own app
`create_replay_app()` is the same view as a small ASGI app, without the dashboard. Mount
it in any FastAPI or Starlette app:
```python
from praisonai.replay import record, replay_app
app.mount("/replay", replay_app()) # read-only: shows recorded runs
# wherever your app starts work:
with record(run_id, live=True):
team.start()
```
Then show one run, live, in your page, for example in an `<iframe>`:
```html
<iframe src="/replay/?run=RUN_ID" style="width:100%;height:640px"></iframe>
```
| URL | Shows |
|---|---|
| `/replay/` | The run list and the selected run |
| `/replay/?run=<id>` | Only that run, no list. A run that has not started yet is waited for. |
`replay_app(team)` adds the Run box to the mounted page. Without the PraisonAI wrapper,
pass any `run(input, run_id)` callable to `praisonaiui.features.replay.create_replay_app`;
it must record the run under `run_id`. For a page you build yourself, the view is one
module:
```html
<div id="progress"></div>
<script type="module">
import { render } from "/replay/replay.js";
render(document.getElementById("progress"), { base: "/replay", run: "RUN_ID", approve: false });
</script>
```
`render()` options: `base` (where the API is mounted), `run` (show only this run),
`runner` (`{input, placeholder, ask}` to show the Run box) and `approve` (`false` hides
the approve and deny buttons; the embedded app has no approvals API, so answer approvals
where your app asks for them).
Anyone who can reach the mounted app can read the runs, and with a runner can start one.
Keep it on localhost or behind your app's login. `POST /api/replay/run` only accepts
`Content-Type: application/json`, so another website cannot start a run from a visitor's
browser.
## What you see
| Part | What it shows |
|---|---|
| **Runs** | Recorded runs, newest first. A run that is still being written has a **Live** badge. |
| **Timeline** (default) | One lane per agent, plus **Manager** and **You**. Time runs left to right. LLM calls and tool calls are blocks in their agent's lane. Arrows show hand-offs, delegation, routing and approval requests. Parallel branches get their own sub-lanes. A call or step repeated back to back is one block marked ×N. Tasks and steps run across the top. |
| **Map** | The team or flow as a graph, drawn from the graph recorded at the start of the run and fitted to the page (−, Fit and + zoom; drag to pan). Agents that took part but are not in the graph, for example after a hand-off to an agent outside the team, appear in an **Outside the team** group with their hand-offs. Older recordings without a graph get a "who talked to whom" map built from the events. Each node shows its state at the cursor: dashed means not reached, blue means running, green means done (×N when it ran more than once) and red means failed; the node the cursor is on has a bold outline. |
| **What happened** | One plain sentence per step, for example *"Researcher asked web_search for 'solar sail facts' — 1.2 s, $0.002."* Hovering or focusing a sentence highlights its block, and the other way round. |
| **Details** | Three tabs for the selected step. **What happened**: when, who, how long, cost, tokens, output. **Why**: only what the recording holds, such as the input given to a tool, the context passed in a hand-off and its reason, the manager's task snapshot, the route a step chose, and an approval's decision and where an automatic approval came from. **Raw JSON**: the events themselves. |
| **Cost & latency** | Adds a bar under each block (taller and darker means more cost, or more time) and per-agent totals in the lane labels. |
Shapes always carry meaning as well as colour: agents are circles, tools are rounded
rectangles, you are a diamond and the manager is a square. In each lane, LLM calls sit on
the top row and that agent's tool calls on a thinner row under them, so a long LLM call
never hides its tools. Labels that do not fit end in "…"; hover or focus a block to see the
whole label.
The **Theme** menu follows your system's light or dark setting by default; choose Light or
Dark to override it. On a phone the run list, timeline and steps stack in one column.
## Moving through a run
Replay opens at the end of the run, so you see everything. Move the cursor to replay it:
anything after the cursor is drawn as an outline.
| Key | Action |
|---|---|
| ← → | Previous / next step |
| ↑ ↓ | Previous / next step in the step list; previous / next node in the map |
| Home / End | First / last step |
| Enter or Space | Open the focused block or node |
| ← → in the tabs | Switch between What happened, Why and Raw JSON |
The scrubber under the timeline does the same with a mouse or touch, and **▶ Play** steps
through on its own. On a live run the
cursor follows new events; if you step back, **Jump to live** takes you to the latest.
## Approving from Replay
When an agent asks for approval and the run uses aiui's approval store, a **Waiting for
you** card appears above the timeline:
- **Approve** or **Deny**, with an optional reason.
- **Edit arguments…** opens the arguments as JSON (taken from the server, not the redacted
recording when the server has them). The JSON must be an object; mistakes are shown
as you type. **Approve with these arguments** sends them as `modified_args`.
These buttons call `POST /api/approvals/{id}/approve` and `/deny`. The agent waits until
you answer, and is denied if nobody answers before the approval timeout.
## Sharing a run as one file
`praisonai replay export RUN --format html` writes a single HTML file with the run and
this viewer inside. It needs no server and no network. Approvals cannot be answered from
an exported file.
## HTTP API
| Endpoint | Returns |
|---|---|
| `GET /api/replay/runs` | `[{run_id, session_id, path, started_at, ended_at?, status, kind, name, agents, events, cost_usd, live}]`, newest first |
| `GET /api/replay/runs/{id}/events?after_seq=N` | `{events, graph, run}`; `after_seq` keeps only events with a larger `sequence_num`; `wait=1` answers `{events: [], graph: null, run: null}` instead of 404 for a run that has not started yet |
| `GET /api/replay/runs/{id}/stream` | Server-sent events: `event: ctx` per trace event (with `id:` = `sequence_num`), `ping` every 15 s, `end` once the run has finished and gone quiet for 2 s. Honours `after_seq` and `Last-Event-ID`. |
With a runner (`create_replay_app(run=...)`), `POST /api/replay/run` with
`{"input": "..."}` starts a run and returns `{run_id}` (409 while one is going).
Run ids must match `^[A-Za-z0-9._-]+$`. Replay never writes trace files itself. Half-written last lines, unknown fields and unknown event types are skipped
safely, and schema 1.0 recordings still open.
The **Traces** page reads the same files: each run is a trace, and its spans come from
matching start and end events.
-1
View File
@@ -100,7 +100,6 @@ nav:
- BeautifulUI Upstream Pin: frontend/BEAUTIFULUI_UPSTREAM.md
- Features:
- Dashboard: features/dashboard.md
- Replay: features/replay.md
- Gateway Chat: features/gateway-chat.md
- Attachments: features/attachments.md
- Theme System: features/theme-system.md
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.151"
version = "0.3.147"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+23 -87
View File
@@ -82,6 +82,20 @@
}
}
},
"node_modules/@a2ui/markdown-it": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dompurify": "^3.3.1",
"markdown-it": "^14.1.0"
},
"peerDependencies": {
"@a2ui/web_core": "^0.9.2"
}
},
"node_modules/@a2ui/react": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@a2ui/react/-/react-0.10.0.tgz",
@@ -115,16 +129,16 @@
}
},
"node_modules/@a2ui/web_core": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.2.tgz",
"integrity": "sha512-IfS4K7baTL9P9AzUOzKRWzL9A9/+Z7TCgMY2GPwsZ0H7LgY/9/uJnlbReNhpHsAfN12KZtW9iERWkw5HOf71GA==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@a2ui/web_core/-/web_core-0.10.0.tgz",
"integrity": "sha512-pSieKxaB6aZWBL2LjZfKo4/1v4xMWylczpzte1ehuGH0kfNGf/iTd73PKsIKYx3b0FNWTbD8gAloOM4JCVqUig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@preact/signals-core": "^1.14.2",
"date-fns": "^4.4.0",
"@preact/signals-core": "^1.13.0",
"date-fns": "^4.1.0",
"zod": "^3.25.76",
"zod-to-json-schema": "^3.25.2"
"zod-to-json-schema": "^3.25.1"
}
},
"node_modules/@a2ui/web_core/node_modules/zod": {
@@ -5488,9 +5502,9 @@
}
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -9733,84 +9747,6 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/@a2ui/react/node_modules/@a2ui/markdown-it": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@a2ui/markdown-it/-/markdown-it-0.0.4.tgz",
"integrity": "sha512-uaYNywdiHDH4i//pHpyckFnApvOshiUNT2Xl8awceNjL1XAX/gqtki9GRUJQ26RE7id2+aPNgOy1W6mXNBxriQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dompurify": "^3.3.1",
"markdown-it": "^14.1.0"
},
"peerDependencies": {
"@a2ui/web_core": "^0.9.2"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
}
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ interface ActionButtonsProps {
*/
export function ActionButtons({ actions, messageId, sessionId }: ActionButtonsProps) {
const [pendingActions, setPendingActions] = useState<Set<string>>(new Set())
const [hiddenActions] = useState<Set<string>>(new Set())
const [hiddenActions, setHiddenActions] = useState<Set<string>>(new Set())
const handleActionClick = useCallback(async (action: ActionButton) => {
if (pendingActions.has(action.id) || hiddenActions.has(action.id)) {
+1 -1
View File
@@ -73,7 +73,7 @@ export function AskActionPrompt({
{/* Action buttons */}
<div className="space-y-3">
{actions.map((action) => (
{actions.map((action, index) => (
<button
key={action.name}
type="button"
+2 -2
View File
@@ -15,7 +15,7 @@ import { Textarea } from '../components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
import { Slider } from '../components/ui/slider';
import { Switch } from '../components/ui/switch';
import { Card, CardContent } from '../components/ui/card';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/card';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '../components/ui/sheet';
interface SettingsWidget {
@@ -74,7 +74,7 @@ const WidgetRenderer: React.FC<{
id={name}
placeholder={placeholder}
value={value || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
onChange={(e) => onChange(e.target.value)}
maxLength={widget.maxLength}
rows={4}
/>
+34 -7
View File
@@ -20,16 +20,43 @@ interface CustomElementProps {
onError?: (error: Error) => void;
}
interface CustomComponentModule {
default: React.ComponentType<any>;
}
// Registry of loaded components (cache)
const componentCache = new Map<string, Promise<React.ComponentType<any>>>();
// Custom components available to <CustomElementHost name="..." />. The example
// widgets this once listed (ExampleWidget, UserCard, ...) were never added, and
// importing them broke the build. Register real components here as they land:
// const MyWidget = React.lazy(() => import('../components/custom/MyWidget'));
// componentMap.MyWidget = MyWidget;
const componentMap: Record<string, React.ComponentType<any>> = {};
const availableComponents = new Set(Object.keys(componentMap));
// List of available custom components (in practice, this would be populated from backend)
const availableComponents = new Set([
'ExampleWidget',
'UserCard',
'DataChart',
'FormBuilder',
'CodeEditor',
'ImageGallery',
'ChatEmbed'
]);
// Static imports for build-tool compatibility
const ExampleWidget = React.lazy(() => import('../components/custom/ExampleWidget'));
const UserCard = React.lazy(() => import('../components/custom/UserCard'));
const DataChart = React.lazy(() => import('../components/custom/DataChart'));
const FormBuilder = React.lazy(() => import('../components/custom/FormBuilder'));
const CodeEditor = React.lazy(() => import('../components/custom/CodeEditor'));
const ImageGallery = React.lazy(() => import('../components/custom/ImageGallery'));
const ChatEmbed = React.lazy(() => import('../components/custom/ChatEmbed'));
// Static mapping of component names to imports
const componentMap: Record<string, React.ComponentType<any>> = {
ExampleWidget,
UserCard,
DataChart,
FormBuilder,
CodeEditor,
ImageGallery,
ChatEmbed,
};
/**
* Load a custom component with static mapping for build-tool compatibility
+1
View File
@@ -4,6 +4,7 @@
* Shows live-updating task lists with states, progress bars, and click-to-jump functionality.
*/
import React from 'react'
import { ChevronRight, ChevronDown, CheckCircle, Clock, XCircle, Circle, ExternalLink } from 'lucide-react'
import { useTaskState, setTasksCollapsed, type Task, type TaskList, type TaskStatus } from '../state/tasks'
import { cn } from '../lib/utils'
-44
View File
@@ -1,44 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
)
)
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
)
)
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
@@ -1,19 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
)
Textarea.displayName = "Textarea"
export { Textarea }
+1 -1
View File
@@ -183,7 +183,7 @@ export function useMicStream(options: UseMicStreamOptions = {}): UseMicStreamRet
onConnectionChangeRef.current?.(false);
};
ws.onerror = () => {
ws.onerror = (error) => {
onErrorRef.current?.(new Error('WebSocket error'));
setConnected(false);
onConnectionChangeRef.current?.(false);
+3 -28
View File
@@ -5,33 +5,8 @@
* incoming messages from parent.
*/
import { useEffect, useCallback, useRef, useState } from 'react';
interface EventSourceOptions {
enabled?: boolean;
onMessage?: (event: MessageEvent) => void;
onOpen?: () => void;
onError?: () => void;
}
/**
* Minimal EventSource subscription. (useSSE in this package is the chat
* streaming store, not a generic SSE hook, so it cannot open this stream.)
*/
function useEventSource(url: string, { enabled = true, onMessage, onOpen, onError }: EventSourceOptions) {
const [connected, setConnected] = useState(false);
const handlers = useRef({ onMessage, onOpen, onError });
handlers.current = { onMessage, onOpen, onError };
useEffect(() => {
if (!enabled || typeof EventSource === 'undefined') return;
const source = new EventSource(url);
source.onopen = () => { setConnected(true); handlers.current.onOpen?.(); };
source.onmessage = (event) => handlers.current.onMessage?.(event);
source.onerror = () => { setConnected(false); handlers.current.onError?.(); };
return () => { source.close(); setConnected(false); };
}, [url, enabled]);
return { connected };
}
import { useEffect, useCallback, useRef } from 'react';
import { useSSE } from './useSSE';
export interface WindowMessageData {
type: string;
@@ -110,7 +85,7 @@ export function useWindowMessage(options: UseWindowMessageOptions = {}): UseWind
const isIframe = window !== window.parent;
// SSE connection for outbound messages
const { connected } = useEventSource('/sse/window-message', {
const { connected } = useSSE('/sse/window-message', {
enabled: enabled && isIframe,
onMessage: (event) => {
try {
@@ -7,7 +7,9 @@ import React, { useState } from 'react';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '../components/ui/dialog';
import { Separator } from '../components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs';
interface AddMCPServerModalProps {
+11 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Eye, AlertCircle } from 'lucide-react';
import { ChatMessages } from '../chat/ChatMessages';
import type { ChatMessage } from '../types';
import { Button } from '../components/ui/button';
interface SharedThreadData {
@@ -11,13 +11,18 @@ interface SharedThreadData {
title: string;
created_at: string;
};
messages: ChatMessage[];
messages: Array<{
id: string;
role: string;
content: string;
timestamp: string;
toolCalls?: any[];
}>;
read_only: boolean;
}
export function SharedThread() {
// Served at /shared/{token} (server.py); this app has no client-side router.
const token = decodeURIComponent(window.location.pathname.split('/shared/')[1]?.split('/')[0] ?? '');
const { token } = useParams<{ token: string }>();
const [threadData, setThreadData] = useState<SharedThreadData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');
@@ -123,13 +128,10 @@ export function SharedThread() {
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
{threadData.messages.length > 0 ? (
<div className="divide-y divide-gray-100">
<ChatMessages
<ChatMessages
messages={threadData.messages}
sessionId={threadData.thread_id}
currentResponse=""
thinkingSteps={[]}
toolCalls={[]}
isStreaming={false}
isSharedView={true}
/>
</div>
) : (
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.151"
__version__ = "0.3.147"
-1
View File
@@ -80,7 +80,6 @@ def auto_register_defaults() -> None:
(".tts", "TTSFeature"),
(".usage", "UsageFeature"),
(".realtime", "RealtimeFeature"),
(".replay", "ReplayFeature"),
(".workflows", "WorkflowsFeature"),
(".guardrails", "GuardrailsFeature"),
(".eval", "EvalFeature"),
-517
View File
@@ -1,517 +0,0 @@
"""Stdlib reader for PraisonAI context-trace JSONL files (Replay contract v1, section 1).
One file per run at ``<traces dir>/<run_or_session_id>.jsonl``; one JSON object
(``ContextEvent.to_dict()``) per line, in append order. Readers must tolerate:
* a partially written last line (it is skipped until it is complete);
* unknown fields and unknown ``event_type`` values (passed through untouched);
* schema 1.0 files (no ``run_id`` / ``parent_seq`` / ``node_id``).
Shared by the ``replay`` feature (runs, events, SSE tail) and the ``tracing``
feature (spans from start/end pairs). No third-party imports.
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
__all__ = [
"RUN_ID_RE",
"LIVE_WINDOW_S",
"traces_dir",
"is_valid_run_id",
"run_path",
"list_run_files",
"read_events",
"TailReader",
"EndTracker",
"find_graph",
"summarize_run",
"build_spans",
]
#: Allowed run ids: one path segment, no separators (path-traversal safe).
RUN_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
#: A run without an end marker counts as live while its file changed this recently.
LIVE_WINDOW_S = 15 * 60
#: start event type -> the end event type that closes it.
START_END: Dict[str, str] = {
"session_start": "session_end",
"workflow_start": "workflow_end",
"task_start": "task_end",
"step_start": "step_end",
"agent_start": "agent_end",
"tool_call_start": "tool_call_end",
"llm_request": "llm_response",
"approval_requested": "approval_decided",
}
END_START: Dict[str, str] = {v: k for k, v in START_END.items()}
_SPAN_KIND = {
"session_start": "session",
"workflow_start": "workflow",
"task_start": "task",
"step_start": "step",
"agent_start": "agent",
"tool_call_start": "tool",
"llm_request": "llm",
"approval_requested": "approval",
}
# ── Location ──────────────────────────────────────────────────────────
def traces_dir() -> Path:
"""Resolve the traces directory.
Precedence: ``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` > ``~/.praison/traces``.
Never creates the directory (this module is read-only).
"""
override = os.environ.get("PRAISONAI_TRACES_DIR")
if override:
return Path(override).expanduser()
home = os.environ.get("PRAISON_HOME")
base = Path(home).expanduser() if home else Path.home() / ".praison"
return base / "traces"
def is_valid_run_id(run_id: Any) -> bool:
"""True when *run_id* is a single safe path segment."""
return (
isinstance(run_id, str)
and 0 < len(run_id) <= 200
and run_id not in (".", "..")
and bool(RUN_ID_RE.match(run_id))
)
def run_path(run_id: str, directory: Optional[Path] = None) -> Optional[Path]:
"""Return the JSONL path for *run_id*, or ``None`` when the id is unsafe.
The resolved path is additionally checked to sit directly inside the
traces directory, so a symlinked or odd id can never escape it.
"""
if not is_valid_run_id(run_id):
return None
base = directory or traces_dir()
candidate = base / f"{run_id}.jsonl"
try:
if candidate.resolve().parent != base.resolve():
return None
except OSError:
return None
return candidate
def list_run_files(directory: Optional[Path] = None) -> List[Path]:
"""All ``*.jsonl`` files with a valid id, newest (mtime) first."""
base = directory or traces_dir()
if not base.is_dir():
return []
items: List[Tuple[float, Path]] = []
for p in base.iterdir():
if p.suffix != ".jsonl" or not is_valid_run_id(p.stem):
continue
try:
st = p.stat()
except OSError:
continue
if p.is_file():
items.append((st.st_mtime, p))
items.sort(key=lambda it: (it[0], it[1].name), reverse=True)
return [p for _, p in items]
# ── Reading ───────────────────────────────────────────────────────────
def _parse_line(raw: bytes) -> Optional[Dict[str, Any]]:
raw = raw.strip()
if not raw:
return None
try:
obj = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return None
return obj if isinstance(obj, dict) else None
class TailReader:
"""Incrementally read complete JSON lines from a growing file.
Keeps a byte offset; each :meth:`poll` returns only events whose line is
complete. A trailing fragment without a newline is consumed only when it
already parses as a JSON object (a proper prefix of a JSON object never
does), so a half-written line is never surfaced. If the file shrinks
(the writer truncates on restart), reading restarts from the top.
"""
def __init__(self, path: Path) -> None:
self.path = Path(path)
self.offset = 0
def poll(self) -> List[Dict[str, Any]]:
try:
size = self.path.stat().st_size
except OSError:
return []
if size < self.offset:
self.offset = 0
if size == self.offset:
return []
try:
with open(self.path, "rb") as fh:
fh.seek(self.offset)
chunk = fh.read(size - self.offset)
except OSError:
return []
events: List[Dict[str, Any]] = []
consumed = 0
start = 0
while True:
nl = chunk.find(b"\n", start)
if nl < 0:
break
ev = _parse_line(chunk[start:nl])
if ev is not None:
events.append(ev)
start = nl + 1
consumed = start
tail = chunk[start:]
if tail.strip():
ev = _parse_line(tail)
if ev is not None: # complete object, newline not written yet
events.append(ev)
consumed = len(chunk)
self.offset += consumed
return events
_CACHE_LOCK = threading.Lock()
_EVENT_CACHE: Dict[str, Tuple[Tuple[int, int], List[Dict[str, Any]]]] = {}
_SUMMARY_CACHE: Dict[str, Tuple[Tuple[int, int, int], Dict[str, Any]]] = {}
_EVENT_CACHE_MAX = 8
_SUMMARY_CACHE_MAX = 512
def _sig(path: Path) -> Optional[Tuple[int, int]]:
try:
st = path.stat()
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
def _seq(ev: Dict[str, Any], default: int = -1) -> int:
s = ev.get("sequence_num")
return s if isinstance(s, int) and not isinstance(s, bool) else default
def read_events(path: Path, after_seq: Optional[int] = None) -> List[Dict[str, Any]]:
"""All complete events in *path* (append order), optionally ``sequence_num > after_seq``."""
path = Path(path)
sig = _sig(path)
if sig is None:
return []
key = str(path)
with _CACHE_LOCK:
hit = _EVENT_CACHE.get(key)
if hit and hit[0] == sig:
events = hit[1]
else:
events = TailReader(path).poll()
with _CACHE_LOCK:
_EVENT_CACHE.pop(key, None)
_EVENT_CACHE[key] = (sig, events)
while len(_EVENT_CACHE) > _EVENT_CACHE_MAX:
_EVENT_CACHE.pop(next(iter(_EVENT_CACHE)))
if after_seq is None:
return list(events)
return [ev for ev in events if _seq(ev) > after_seq]
# ── Interpretation ────────────────────────────────────────────────────
class EndTracker:
"""Decide when a run has finished, feeding events one at a time.
The outermost scope is the first ``session_start`` or, failing that, the
first top-level ``workflow_start``. The run has ended once that scope's
end event arrives (``session_end`` always ends it).
"""
def __init__(self) -> None:
self.outer: Optional[Tuple[str, int]] = None
self.first_workflow: Optional[int] = None
self.ended = False
self.status: Optional[str] = None
self.ended_at: Optional[float] = None
def feed(self, ev: Dict[str, Any]) -> None:
et = ev.get("event_type")
seq = _seq(ev)
if et == "session_start" and self.outer is None:
self.outer = ("session", seq)
elif et == "workflow_start":
if self.first_workflow is None:
self.first_workflow = seq
if self.outer is None and ev.get("parent_seq") is None:
self.outer = ("workflow", seq)
elif et == "workflow_end":
data = ev.get("data") or {}
ps = ev.get("parent_seq")
if ps is None or ps == self.first_workflow:
if isinstance(data, dict) and data.get("status"):
self.status = str(data["status"])
if self.outer is None or (self.outer[0] == "workflow" and ps in (None, self.outer[1])):
self._end(ev)
elif et == "session_end":
self._end(ev)
def _end(self, ev: Dict[str, Any]) -> None:
self.ended = True
ts = ev.get("timestamp")
if isinstance(ts, (int, float)):
self.ended_at = float(ts)
def find_graph(events: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""The graph from the first ``workflow_start`` carrying one, else ``None``."""
for ev in events:
if ev.get("event_type") == "workflow_start":
data = ev.get("data")
if isinstance(data, dict) and isinstance(data.get("graph"), dict):
return data["graph"]
return None
def _num(v: Any) -> float:
return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else 0.0
def summarize_run(path: Path, now: Optional[float] = None) -> Optional[Dict[str, Any]]:
"""Run-picker row for one trace file (cached on mtime/size)."""
path = Path(path)
try:
st = path.stat()
except OSError:
return None
now = time.time() if now is None else now
sig = (st.st_mtime_ns, st.st_size, int(now - st.st_mtime < LIVE_WINDOW_S))
key = str(path)
with _CACHE_LOCK:
hit = _SUMMARY_CACHE.get(key)
if hit and hit[0] == sig:
return dict(hit[1])
events = read_events(path)
tracker = EndTracker()
agents: List[str] = []
cost = 0.0
session_id = None
event_run_id = None
started_at = None
kind, name, process = "session", None, None
schema = None
for ev in events:
tracker.feed(ev)
if session_id is None and ev.get("session_id"):
session_id = ev.get("session_id")
if event_run_id is None and ev.get("run_id"):
event_run_id = ev.get("run_id")
if schema is None and ev.get("schema_version"):
schema = str(ev.get("schema_version"))
ts = ev.get("timestamp")
if started_at is None and isinstance(ts, (int, float)):
started_at = float(ts)
agent = ev.get("agent_name")
if isinstance(agent, str) and agent and agent not in agents:
agents.append(agent)
cost += _num(ev.get("cost_usd"))
if ev.get("event_type") == "workflow_start" and name is None:
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
kind = str(data.get("kind") or "workflow")
name = data.get("name")
process = data.get("process")
live = (not tracker.ended) and (now - st.st_mtime) < LIVE_WINDOW_S
if tracker.ended:
status = tracker.status or "completed"
else:
status = "running" if live else "incomplete"
summary: Dict[str, Any] = {
"run_id": path.stem,
"session_id": session_id,
"path": str(path),
"started_at": started_at,
"status": status,
"kind": kind,
"name": name or (agents[0] if agents else path.stem),
"agents": agents,
"events": len(events),
"cost_usd": round(cost, 6),
"live": live,
"schema_version": schema,
}
if tracker.ended_at is not None:
summary["ended_at"] = tracker.ended_at
if process:
summary["process"] = process
if event_run_id and event_run_id != path.stem:
summary["event_run_id"] = event_run_id
with _CACHE_LOCK:
_SUMMARY_CACHE[key] = (sig, summary)
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX:
_SUMMARY_CACHE.pop(next(iter(_SUMMARY_CACHE)))
return dict(summary)
def _span_name(ev: Dict[str, Any]) -> str:
et = ev.get("event_type")
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
agent = ev.get("agent_name") or ""
if et == "tool_call_start":
return str(data.get("tool_name") or "tool")
if et == "llm_request":
model = data.get("model")
return f"llm {model}" if model else "llm"
if et == "approval_requested":
return f"approval {data.get('tool') or ''}".strip()
if et in ("task_start", "step_start", "workflow_start"):
return str(data.get("name") or ev.get("node_id") or et.split("_")[0])
if et == "agent_start":
return agent or "agent"
return "session"
def _match_key(ev: Dict[str, Any]) -> Tuple[Any, ...]:
"""Identity used to pair a start with its end when ``parent_seq`` is absent."""
et = ev.get("event_type")
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
base = END_START.get(et, et)
extra: Any = None
if base == "tool_call_start":
extra = data.get("tool_name")
elif base == "approval_requested":
extra = data.get("approval_id")
elif base in ("task_start", "step_start"):
extra = ev.get("node_id") or data.get("name")
agent = ev.get("agent_name") or ""
if base in ("approval_requested", "session_start", "workflow_start"):
agent = "" # decisions/ends are often emitted by a different emitter name
return (base, agent, ev.get("branch_id"), extra)
def build_spans(events: List[Dict[str, Any]], trace_id: str) -> List[Dict[str, Any]]:
"""Spans from start/end pairs.
Schema 1.1: an end event's ``parent_seq`` names its start; a start's
``parent_seq`` names the enclosing span. Schema 1.0 (no ``parent_seq``):
ends pair with the oldest open start of the same kind/agent/tool, and the
parent is the innermost span open when the start was seen (sequence order).
``llm_response`` is a point event whose ``parent_seq`` is its enclosing
scope, so LLM spans always pair by order.
"""
ordered = sorted(enumerate(events), key=lambda it: (_seq(it[1], it[0]), it[0]))
spans: Dict[int, Dict[str, Any]] = {}
start_type: Dict[int, str] = {}
start_key: Dict[int, Tuple[Any, ...]] = {}
open_by_key: Dict[Tuple[Any, ...], List[int]] = {}
stack: List[int] = []
order: List[int] = []
for idx, ev in ordered:
et = ev.get("event_type")
seq = _seq(ev, idx)
ts = _num(ev.get("timestamp"))
if et in START_END:
parent = ev.get("parent_seq")
if not (isinstance(parent, int) and parent in spans):
parent = next((s for s in reversed(stack) if spans[s]["status"] == "open"), None)
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
span = {
"id": f"{trace_id}:{seq}",
"trace_id": trace_id,
"parent_span_id": f"{trace_id}:{parent}" if parent is not None else None,
"agent_id": ev.get("agent_name") or "",
"name": _span_name(ev),
"kind": _SPAN_KIND[et],
"status": "open",
"start_time": ts,
"end_time": None,
"duration_ms": None,
"timestamp": ts,
"attributes": {
k: v
for k, v in {
"node_id": ev.get("node_id"),
"branch_id": ev.get("branch_id"),
"start_seq": seq,
"input": data.get("tool_args") if et == "tool_call_start" else None,
}.items()
if v is not None
},
"events": [],
}
spans[seq] = span
start_type[seq] = et
start_key[seq] = _match_key(ev)
order.append(seq)
stack.append(seq)
open_by_key.setdefault(start_key[seq], []).append(seq)
continue
if et not in END_START:
continue
start_seq = None
ps = ev.get("parent_seq")
key = _match_key(ev)
if (
et != "llm_response"
and isinstance(ps, int)
and ps in spans
and spans[ps]["status"] == "open"
and START_END.get(start_type[ps]) == et
):
start_seq = ps
else:
queue = open_by_key.get(key) or []
while queue and spans[queue[0]]["status"] != "open":
queue.pop(0)
if queue:
start_seq = queue[0]
if start_seq is None:
continue
q = open_by_key.get(start_key[start_seq])
if q and start_seq in q:
q.remove(start_seq)
span = spans[start_seq]
data = ev.get("data") if isinstance(ev.get("data"), dict) else {}
dur = data.get("duration_ms")
span["end_time"] = ts
span["duration_ms"] = (
float(dur) if isinstance(dur, (int, float)) and not isinstance(dur, bool)
else round(max(0.0, ts - span["start_time"]) * 1000, 3)
)
failed = bool(data.get("error")) or data.get("status") == "failed" or data.get("approved") is False
span["status"] = "error" if failed else "ok"
attrs = span["attributes"]
for k in ("cost_usd", "prompt_tokens", "completion_tokens"):
v = ev.get(k)
if isinstance(v, (int, float)) and not isinstance(v, bool) and v:
attrs[k] = v
if data.get("error"):
attrs["error"] = data.get("error")
if start_seq in stack:
stack.remove(start_seq)
return [spans[s] for s in order]
+49 -240
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import asyncio
import logging
import threading
import time
import uuid
from abc import ABC, abstractmethod
@@ -67,13 +66,7 @@ class ApprovalProtocol(ABC):
@abstractmethod
def approve(
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
) -> Optional[Dict[str, Any]]:
"""Approve a pending request.
Implementations may also accept ``modified_args: dict`` (replacement
tool arguments); the HTTP handler only passes it when the client sent it.
"""
...
) -> Optional[Dict[str, Any]]: ...
@abstractmethod
def deny(
@@ -93,66 +86,6 @@ class ApprovalProtocol(ABC):
return {"status": "ok", "provider": self.__class__.__name__}
# ── Decision waiter ──────────────────────────────────────────────────
class _DecisionWaiter:
"""One pending decision that both threads and coroutines can wait on.
``set()`` may be called from any thread (normally the server's event loop
handling ``POST /api/approvals/{id}/approve``); sync waiters block on a
``threading.Event`` and async waiters get their future resolved on their
own loop via ``call_soon_threadsafe``, so neither blocks the other.
"""
def __init__(self) -> None:
self._event = threading.Event()
self._lock = threading.Lock()
self._result: Optional[Dict[str, Any]] = None
self._futures: List[tuple] = []
@property
def done(self) -> bool:
return self._event.is_set()
def set(self, entry: Dict[str, Any]) -> None:
with self._lock:
if self._event.is_set():
return
self._result = entry
self._event.set()
futures, self._futures = self._futures, []
for loop, fut in futures:
try:
loop.call_soon_threadsafe(_resolve_future, fut, entry)
except RuntimeError: # loop already closed
pass
def wait(self, timeout: Optional[float]) -> Optional[Dict[str, Any]]:
return self._result if self._event.wait(timeout) else None
async def wait_async(self, timeout: Optional[float]) -> Optional[Dict[str, Any]]:
loop = asyncio.get_running_loop()
fut = loop.create_future()
with self._lock:
if self._event.is_set():
return self._result
self._futures.append((loop, fut))
try:
return await asyncio.wait_for(fut, timeout)
except asyncio.TimeoutError:
return None
finally:
with self._lock:
if (loop, fut) in self._futures:
self._futures.remove((loop, fut))
def _resolve_future(fut: "asyncio.Future", value: Any) -> None:
if not fut.done():
fut.set_result(value)
# ── Simple Approval Manager ──────────────────────────────────────────
@@ -162,8 +95,6 @@ class SimpleApprovalManager(ApprovalProtocol):
def __init__(self) -> None:
self._pending: Dict[str, Dict[str, Any]] = {}
self._history: deque = deque(maxlen=500)
self._lock = threading.RLock()
self._waiters: Dict[str, _DecisionWaiter] = {}
self._policies: Dict[str, Any] = {
"auto_approve_tools": [],
"always_deny_tools": [],
@@ -205,67 +136,44 @@ class SimpleApprovalManager(ApprovalProtocol):
entry.get("agent_name", ""),
entry.get("risk_level", "medium"),
)
with self._lock:
if auto:
entry["status"] = auto
entry["resolved_at"] = time.time()
entry["approver"] = "auto-policy"
entry["reason"] = f"Auto-{auto} by policy"
self._history.appendleft(entry)
else:
self._pending[approval_id] = entry
self._waiters.setdefault(approval_id, _DecisionWaiter())
if auto:
entry["status"] = auto
entry["resolved_at"] = time.time()
entry["approver"] = "auto-policy"
entry["reason"] = f"Auto-{auto} by policy"
self._history.appendleft(entry)
else:
self._pending[approval_id] = entry
return entry
def waiter(self, approval_id: str) -> Optional[_DecisionWaiter]:
"""The waiter for a pending request (``None`` once it is resolved)."""
with self._lock:
return self._waiters.get(approval_id)
def _signal(self, approval_id: str, entry: Dict[str, Any]) -> None:
waiter = self._waiters.pop(approval_id, None)
if waiter is not None:
waiter.set(entry)
def approve(
self,
approval_id: str,
reason: str = "",
approver: str = "user",
always: bool = False,
modified_args: Optional[Dict[str, Any]] = None,
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
) -> Optional[Dict[str, Any]]:
with self._lock:
if approval_id not in self._pending:
return None
entry = self._pending.pop(approval_id)
entry["status"] = "approved"
entry["reason"] = reason
entry["resolved_at"] = time.time()
entry["approver"] = approver
if modified_args is not None:
entry["modified_args"] = dict(modified_args)
if always and entry["tool_name"] not in self._policies["auto_approve_tools"]:
self._policies["auto_approve_tools"].append(entry["tool_name"])
self._history.appendleft(entry)
self._signal(approval_id, entry)
if approval_id not in self._pending:
return None
entry = self._pending.pop(approval_id)
entry["status"] = "approved"
entry["reason"] = reason
entry["resolved_at"] = time.time()
entry["approver"] = approver
if always and entry["tool_name"] not in self._policies["auto_approve_tools"]:
self._policies["auto_approve_tools"].append(entry["tool_name"])
self._history.appendleft(entry)
return entry
def deny(
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
) -> Optional[Dict[str, Any]]:
with self._lock:
if approval_id not in self._pending:
return None
entry = self._pending.pop(approval_id)
entry["status"] = "denied"
entry["reason"] = reason
entry["resolved_at"] = time.time()
entry["approver"] = approver
if always and entry["tool_name"] not in self._policies["always_deny_tools"]:
self._policies["always_deny_tools"].append(entry["tool_name"])
self._history.appendleft(entry)
self._signal(approval_id, entry)
if approval_id not in self._pending:
return None
entry = self._pending.pop(approval_id)
entry["status"] = "denied"
entry["reason"] = reason
entry["resolved_at"] = time.time()
entry["approver"] = approver
if always and entry["tool_name"] not in self._policies["always_deny_tools"]:
self._policies["always_deny_tools"].append(entry["tool_name"])
self._history.appendleft(entry)
return entry
def get(self, approval_id: str) -> Optional[Dict[str, Any]]:
@@ -329,14 +237,9 @@ class SDKApprovalManager(ApprovalProtocol):
return result
def approve(
self,
approval_id: str,
reason: str = "",
approver: str = "user",
always: bool = False,
modified_args: Optional[Dict[str, Any]] = None,
self, approval_id: str, reason: str = "", approver: str = "user", always: bool = False
) -> Optional[Dict[str, Any]]:
entry = self._simple.approve(approval_id, reason, approver, always, modified_args)
entry = self._simple.approve(approval_id, reason, approver, always)
if entry and always and entry.get("tool_name"):
self._registry.add_requirement(entry["tool_name"], entry.get("risk_level", "high"))
return entry
@@ -370,121 +273,31 @@ class SDKApprovalManager(ApprovalProtocol):
class _UIStoreBackend:
"""Routes agent approval requests into the UI store and waits for a human.
"""Routes agent approval requests into the UI SimpleApprovalManager."""
Each request becomes a pending entry keyed by ``request.approval_id`` and
the caller blocks (sync: on a ``threading.Event``; async: on a future of
its own loop) until ``POST /api/approvals/{id}/approve|deny`` resolves it.
No answer within the timeout is a denial (fail-closed) and the entry is
resolved as ``denied`` with ``approver="timeout"`` so the queue stays honest.
"""
def __init__(self, store: SimpleApprovalManager, timeout: Optional[float] = None) -> None:
def __init__(self, store: SimpleApprovalManager) -> None:
self._store = store
self._timeout = timeout
def _effective_timeout(self, timeout: Optional[float]) -> float:
if timeout is not None:
return float(timeout)
if self._timeout is not None:
return float(self._timeout)
try:
from praisonaiagents.approval import get_approval_registry
return float(getattr(get_approval_registry(), "timeout", 300.0))
except Exception:
return 300.0
def _submit(self, request) -> Dict[str, Any]:
entry: Dict[str, Any] = {
"tool_name": request.tool_name,
"agent_name": getattr(request, "agent_name", "") or "",
"risk_level": request.risk_level or "medium",
"arguments": request.arguments or {},
}
approval_id = getattr(request, "approval_id", None)
if approval_id:
entry["id"] = str(approval_id)
session_id = getattr(request, "session_id", None)
if session_id:
entry["session_id"] = session_id
return self._store.request_approval(entry)
@staticmethod
def _decision(entry: Optional[Dict[str, Any]]) -> Any:
def request_approval_sync(self, request) -> Any:
from praisonaiagents.approval.protocols import ApprovalDecision
if entry is None:
return ApprovalDecision(
approved=False, reason="Timed out waiting for UI approval", approver="timeout"
)
if entry.get("status") == "approved":
return ApprovalDecision(
approved=True,
reason=entry.get("reason", ""),
modified_args=dict(entry.get("modified_args") or {}),
approver=entry.get("approver"),
)
return ApprovalDecision(
approved=False,
reason=entry.get("reason") or "denied",
approver=entry.get("approver"),
entry = self._store.request_approval(
{
"tool_name": request.tool_name,
"agent_name": getattr(request, "agent_name", "") or "",
"risk_level": request.risk_level or "medium",
"arguments": request.arguments or {},
}
)
def _expire(
self,
approval_id: str,
reason: str = "Timed out waiting for UI approval",
approver: str = "timeout",
) -> Optional[Dict[str, Any]]:
"""Deny a request nobody answered; return the entry if a human won the race."""
denied = self._store.deny(approval_id, reason=reason, approver=approver)
if denied is not None:
return None
return self._store.get(approval_id)
def request_approval_sync(self, request, timeout: Optional[float] = None) -> Any:
from praisonaiagents.approval.protocols import ApprovalDecision
entry = self._submit(request)
if entry.get("status") != "pending":
return self._decision(entry)
approval_id = entry["id"]
try:
asyncio.get_running_loop()
except RuntimeError:
pass
else:
# Blocking here would freeze this thread's event loop (and with it,
# possibly the very server that has to deliver the answer).
reason = "UI approval cannot block an event-loop thread; use the async approval path"
self._expire(approval_id, reason=reason, approver="ui")
return ApprovalDecision(approved=False, reason=reason, approver="ui")
waiter = self._store.waiter(approval_id)
result = waiter.wait(self._effective_timeout(timeout)) if waiter else self._store.get(approval_id)
if result is None:
result = self._expire(approval_id)
return self._decision(result)
status = entry.get("status", "pending")
if status == "approved":
return ApprovalDecision(approved=True, reason=entry.get("reason", ""))
if status == "denied":
return ApprovalDecision(approved=False, reason=entry.get("reason", "denied"))
return ApprovalDecision(approved=False, reason="Awaiting UI approval")
async def request_approval(self, request) -> Any:
entry = self._submit(request)
if entry.get("status") != "pending":
return self._decision(entry)
approval_id = entry["id"]
waiter = self._store.waiter(approval_id)
try:
result = (
await waiter.wait_async(self._effective_timeout(None))
if waiter
else self._store.get(approval_id)
)
except asyncio.CancelledError:
# The registry's own wait_for timed out (or the turn was cancelled).
self._expire(approval_id)
raise
if result is None:
result = self._expire(approval_id)
return self._decision(result)
return self.request_approval_sync(request)
# ── Manager singleton ────────────────────────────────────────────────
@@ -636,15 +449,11 @@ class ApprovalsFeature(BaseFeatureProtocol):
mgr = get_approval_manager()
approval_id = request.path_params["approval_id"]
body = await request.json() if request.headers.get("content-length") else {}
modified_args = body.get("modified_args")
if modified_args is not None and not isinstance(modified_args, dict):
return JSONResponse({"error": "modified_args must be an object"}, status_code=400)
entry = mgr.approve(
approval_id,
reason=body.get("reason", ""),
approver=body.get("approver", "user"),
always=body.get("always", False),
**({"modified_args": modified_args} if modified_args is not None else {}),
)
if entry is None:
return JSONResponse({"error": "Approval not found"}, status_code=404)
+88 -2
View File
@@ -141,6 +141,81 @@ def set_execution_manager(manager: CodeExecutionProtocol) -> None:
_execution_manager = manager
# ── Approval gate ────────────────────────────────────────────────────
async def _approval_gate(
code: str, language: str, approval_id: Optional[str]
) -> Optional[JSONResponse]:
"""Return None if execution is approved, else the response to send.
Each run is recorded via the approvals feature as a ``critical``
``execute_code`` request. A pending request returns 202 with its id;
re-submit with ``approval_id`` once approved (single use, bound to
the exact code/language).
"""
from .approvals import _notify_subscribers, get_approval_manager
approvals = get_approval_manager()
arguments = {"code": code, "language": language}
if approval_id:
entry = approvals.get(approval_id)
if (
entry
and entry.get("tool_name") == "execute_code"
and entry.get("risk_level") == "critical"
and entry.get("status") == "approved"
and entry.get("arguments") == arguments
and not entry.get("consumed")
):
entry["consumed"] = True
return None
return JSONResponse(
{
"status": "error",
"language": language,
"error": "Approval not found, not approved, already used, "
"or does not match this code",
},
status_code=403,
)
entry = approvals.request_approval(
{
"tool_name": "execute_code",
"arguments": arguments,
"risk_level": "critical",
"description": f"Execute {language} code ({len(code)} chars)",
}
)
status = entry.get("status")
await _notify_subscribers("new" if status == "pending" else "resolved", entry)
if status == "approved":
entry["consumed"] = True
return None
if status == "denied":
return JSONResponse(
{
"status": "error",
"language": language,
"approval_id": entry["id"],
"error": entry.get("reason") or "Code execution denied",
},
status_code=403,
)
return JSONResponse(
{
"status": "pending_approval",
"language": language,
"approval_id": entry["id"],
"output": f"Awaiting approval {entry['id']}. Approve it, then re-run "
"with this approval_id.",
},
status_code=202,
)
# ── Feature class ────────────────────────────────────────────────────
@@ -173,9 +248,20 @@ class CodeExecutionFeature(BaseFeatureProtocol):
async def _execute(self, request: Request) -> JSONResponse:
mgr = get_execution_manager()
body = await request.json()
code = body.get("code", "")
language = body.get("language", "python")
allowed = {lang["id"] for lang in mgr.list_languages()}
if language not in allowed:
return JSONResponse(
{"status": "error", "error": f"Language '{language}' not allowed"},
status_code=400,
)
blocked = await _approval_gate(code, language, body.get("approval_id"))
if blocked is not None:
return blocked
result = mgr.execute(
code=body.get("code", ""),
language=body.get("language", "python"),
code=code,
language=language,
timeout=body.get("timeout", 30),
)
status = 200 if result.get("status") != "error" else 400
-441
View File
@@ -1,441 +0,0 @@
"""PraisonAI Replay feature: read-only access to recorded agent runs.
Routes (contract v1, section 5):
* ``GET /api/replay/runs`` run picker rows, newest first
* ``GET /api/replay/runs/{run_id}/events`` ``{events, graph, run}``; ``?after_seq=N``;
``?wait=1`` answers ``{events: [], graph: null, run: null}`` (not 404) for a run
that has not written its first event yet
* ``GET /api/replay/runs/{run_id}/stream`` SSE tail of the JSONL file
The trace directory is ``$PRAISONAI_TRACES_DIR``, else ``$PRAISON_HOME/traces``,
else ``~/.praison/traces``. Run ids must match ``^[A-Za-z0-9._-]+$``.
The browser side lives in ``templates/frontend/plugins/views/replay-core.js``
(pure functions) and ``replay.js`` (DOM/SVG). :func:`standalone_html` inlines
both into one self-contained page for ``praisonai replay export --format html``.
:func:`create_replay_app` is the same view as a small ASGI app (no dashboard) to
mount inside your own app, optionally with a Run box that starts runs::
app.mount("/replay", create_replay_app(run=my_runner))
"""
from __future__ import annotations
import asyncio
import html
import inspect
import json
import logging
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import quote
from starlette.concurrency import run_in_threadpool
from starlette.requests import Request
from starlette.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
from starlette.routing import Route
from . import _trace_jsonl as tj
from ._base import BaseFeatureProtocol
logger = logging.getLogger(__name__)
#: Page id of the dashboard view; open ``/replay?run=<id>`` (or ``/#/replay?run=<id>``).
REPLAY_PAGE_ID = "replay"
#: Replay's own mark (three lanes and a cursor), used as the page's ``icon_svg``.
REPLAY_MARK_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" '
'aria-hidden="true" focusable="false" '
'style="display:inline-block;vertical-align:-0.125em">'
'<rect x="1" y="3" width="8" height="2.2" rx="1.1" fill="currentColor"/>'
'<rect x="3" y="7" width="10" height="2.2" rx="1.1" fill="currentColor"/>'
'<rect x="1" y="11" width="6" height="2.2" rx="1.1" fill="currentColor"/>'
'<rect x="11.2" y="1" width="1.6" height="14" rx="0.8" fill="currentColor" opacity="0.55"/>'
"</svg>"
)
#: Pages a replay-only app should whitelist (Replay first, then its companions).
REPLAY_PAGES = ("replay", "approvals", "traces")
_VIEWS_DIR = Path(__file__).resolve().parent.parent / "templates" / "frontend" / "plugins" / "views"
#: Stable on-disk locations of the renderer (also served at /plugins/views/...).
REPLAY_CORE_JS = _VIEWS_DIR / "replay-core.js"
REPLAY_VIEW_JS = _VIEWS_DIR / "replay.js"
def configure_replay_app() -> None:
"""Configure aiui as a Replay-first dashboard (call before the server starts).
Equivalent to ``aiui.set_style("dashboard")`` plus
``aiui.set_pages(["replay", "approvals", "traces"])``; Replay sorts first,
so ``/`` lands on it. Used by ``praisonaiui/replay_app.py``.
"""
from praisonaiui.server import set_pages, set_style
set_style("dashboard")
set_pages(list(REPLAY_PAGES))
def _json_for_script(obj: Any) -> str:
"""JSON that is safe inside an inline ``<script>`` element."""
text = json.dumps(obj, default=str, separators=(",", ":"))
return (
text.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
)
def standalone_html(
events: List[Dict[str, Any]],
graph: Optional[Dict[str, Any]] = None,
title: str = "PraisonAI Replay",
) -> str:
"""One self-contained HTML page (no CDN, no network) rendering *events*.
Inlines ``replay-core.js`` and ``replay.js`` and sets
``window.__REPLAY_DATA__ = {events, graph}``; the view boots itself.
Approval buttons are hidden because there is no server to answer them.
"""
core = REPLAY_CORE_JS.read_text(encoding="utf-8").replace("</script", "<\\/script")
view = REPLAY_VIEW_JS.read_text(encoding="utf-8").replace("</script", "<\\/script")
if graph is None:
graph = tj.find_graph(events)
data = _json_for_script({"events": list(events), "graph": graph})
safe_title = (
str(title).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)
return (
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
f"<title>{safe_title}</title>\n"
"<style>body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',sans-serif}</style>\n"
"</head>\n<body>\n<main id=\"replay-root\"></main>\n"
f"<script>window.__REPLAY_DATA__ = {data};</script>\n"
f"<script type=\"module\">\n{core}\n</script>\n"
f"<script type=\"module\">\n{view}\n</script>\n"
"</body>\n</html>\n"
)
def _bad_request(msg: str) -> JSONResponse:
return JSONResponse({"error": msg}, status_code=400)
def _parse_after_seq(request: Request) -> tuple[Optional[int], Optional[JSONResponse]]:
"""``?after_seq=N``; an SSE reconnect's ``Last-Event-ID`` wins when it is later."""
values = []
for raw, strict in (
(request.query_params.get("after_seq"), True),
(request.headers.get("last-event-id"), False),
):
if raw in (None, ""):
continue
try:
values.append(int(raw))
except (TypeError, ValueError):
if strict:
return None, _bad_request("after_seq must be an integer")
return (max(values) if values else None), None
def _resolve(request: Request) -> tuple[Optional[Path], Optional[JSONResponse]]:
run_id = request.path_params.get("run_id", "")
path = tj.run_path(run_id)
if path is None:
return None, _bad_request("invalid run id (allowed: A-Z a-z 0-9 . _ -)")
if not path.is_file():
return None, JSONResponse({"error": "run not found"}, status_code=404)
return path, None
def _seq_of(ev: Dict[str, Any]) -> Optional[int]:
s = ev.get("sequence_num")
return s if isinstance(s, int) and not isinstance(s, bool) else None
class ReplayFeature(BaseFeatureProtocol):
"""Recorded-run timeline, map and live tail (PraisonAI Replay)."""
feature_name = "replay"
feature_description = "Replay recorded agent runs (timeline, map, live tail)"
# Stream timings; instance attributes so tests can shorten them.
poll_interval = 0.25
ping_interval = 15.0
quiet_period = 2.0
@property
def name(self) -> str:
return self.feature_name
@property
def description(self) -> str:
return self.feature_description
def routes(self) -> List[Route]:
return [
Route("/api/replay/runs", self._runs, methods=["GET"]),
Route("/api/replay/runs/{run_id}/events", self._events, methods=["GET"]),
Route("/api/replay/runs/{run_id}/stream", self._stream, methods=["GET"]),
]
async def health(self) -> Dict[str, Any]:
d = tj.traces_dir()
return {
"status": "ok",
"feature": self.name,
"traces_dir": str(d),
"runs": len(tj.list_run_files(d)),
}
def cli_commands(self) -> List[Dict[str, Any]]:
return [
{
"name": "replay",
"help": "List recorded runs (PraisonAI Replay)",
"commands": {"list": {"help": "List recorded runs", "handler": self._cli_list}},
}
]
# ── data ──────────────────────────────────────────────────────────
@staticmethod
def list_runs(limit: int = 200) -> List[Dict[str, Any]]:
rows = []
now = time.time()
for path in tj.list_run_files()[: max(1, limit)]:
row = tj.summarize_run(path, now=now)
if row is not None:
rows.append(row)
rows.sort(key=lambda r: (r.get("started_at") or 0.0, r["run_id"]), reverse=True)
return rows
def _cli_list(self) -> str:
rows = self.list_runs()
if not rows:
return f"No recorded runs in {tj.traces_dir()}"
lines = [f"Runs ({len(rows)}):"]
for r in rows:
live = " LIVE" if r.get("live") else ""
lines.append(
f" {r['run_id']} {r.get('name')} {r['status']}{live} "
f"events={r['events']} ${r['cost_usd']:.4f}"
)
return "\n".join(lines)
# ── handlers ──────────────────────────────────────────────────────
async def _runs(self, request: Request) -> JSONResponse:
try:
limit = int(request.query_params.get("limit", "200"))
except ValueError:
return _bad_request("limit must be an integer")
rows = await run_in_threadpool(self.list_runs, limit)
return JSONResponse(rows)
async def _events(self, request: Request) -> JSONResponse:
path, err = _resolve(request)
if err is not None:
if err.status_code == 404 and request.query_params.get("wait") == "1":
return JSONResponse({"events": [], "graph": None, "run": None})
return err
after_seq, err = _parse_after_seq(request)
if err is not None:
return err
def load() -> Dict[str, Any]:
all_events = tj.read_events(path)
events = all_events if after_seq is None else tj.read_events(path, after_seq)
return {
"events": events,
"graph": tj.find_graph(all_events),
"run": tj.summarize_run(path),
}
return JSONResponse(await run_in_threadpool(load))
async def _stream(self, request: Request) -> StreamingResponse:
path, err = _resolve(request)
if err is not None:
return err # type: ignore[return-value]
after_seq, err = _parse_after_seq(request)
if err is not None:
return err # type: ignore[return-value]
poll, ping, quiet = self.poll_interval, self.ping_interval, self.quiet_period
async def gen():
tail = tj.TailReader(path)
tracker = tj.EndTracker()
last_seq = after_seq
loop = asyncio.get_running_loop()
last_activity = last_ping = loop.time()
yield "retry: 3000\n\n"
while True:
events = await run_in_threadpool(tail.poll)
now = loop.time()
if events:
last_activity = now
for ev in events:
tracker.feed(ev)
seq = _seq_of(ev)
if last_seq is not None and seq is not None and seq <= last_seq:
continue
if seq is not None:
last_seq = seq
sid = f"id: {seq}\n" if seq is not None else ""
yield f"{sid}event: ctx\ndata: {json.dumps(ev, default=str)}\n\n"
idle = now - last_activity
if tracker.ended and idle >= quiet:
yield "event: end\ndata: {}\n\n"
return
if not path.exists() or idle >= tj.LIVE_WINDOW_S:
yield 'event: end\ndata: {"reason": "stale"}\n\n'
return
if now - last_ping >= ping:
last_ping = now
yield "event: ping\ndata: {}\n\n"
await asyncio.sleep(poll)
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
# ── embeddable app ─────────────────────────────────────────────────────
#: Longest input the Run box accepts (characters).
MAX_RUN_INPUT = 20_000
def _embed_page(base: str, title: str, cfg: Dict[str, Any]) -> str:
"""The page at the app's root: the view, mounted with *cfg*, and nothing else."""
return (
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
f"<title>{html.escape(title)}</title>\n"
f"<link rel=\"icon\" href=\"data:image/svg+xml,{quote(REPLAY_MARK_SVG.replace('currentColor', '#1A56DB'))}\">\n"
"<style>body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',sans-serif}"
"#replay-root{padding:12px;box-sizing:border-box;max-width:100%}</style>\n"
"</head>\n<body>\n<main id=\"replay-root\"></main>\n"
"<script type=\"module\">\n"
f"import {{ render }} from {_json_for_script(base + '/replay.js')};\n"
f"render(document.getElementById('replay-root'), {_json_for_script(cfg)});\n"
"</script>\n</body>\n</html>\n"
)
def create_replay_app(
run: Optional[Callable[[str, str], Any]] = None,
*,
title: str = "PraisonAI Replay",
input: Optional[str] = "",
placeholder: str = "",
):
"""PraisonAI Replay as a small ASGI app to mount in your own app.
Serves the view at ``/`` (no dashboard around it), its two scripts, and the
``/api/replay/...`` routes, all relative to where it is mounted::
from fastapi import FastAPI
from praisonaiui.features.replay import create_replay_app
app = FastAPI()
app.mount("/replay", create_replay_app())
# <iframe src="/replay/?run=RUN_ID"> shows one run live; /replay/ lists runs
Args:
run: Optional ``run(input, run_id)``. When given, the page shows an
input box and a **Run** button; each click calls it in a worker
thread with a new ``run_id``, and the page follows that run live.
It must record the run under ``run_id`` (``praisonai.replay.app()``
does this for an agent, team or flow). One run at a time.
title: Page title.
input: Text the input box starts with; ``None`` for no input box
(just a Run button, e.g. for a YAML file that has its own topic).
placeholder: Placeholder text for the input box.
Traces are read from the same folder as the rest of Replay
(``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` > ``~/.praison/traces``).
Anyone who can reach the app can read the runs (and, with ``run``, start
them): keep it on localhost or behind your app's auth.
"""
from starlette.applications import Starlette
feature = ReplayFeature()
busy = threading.Lock()
async def page(request: Request) -> HTMLResponse:
base = str(request.scope.get("root_path", "")).rstrip("/")
cfg: Dict[str, Any] = {"base": base, "approve": False}
wanted = request.query_params.get("run")
if wanted and tj.is_valid_run_id(wanted):
cfg["run"] = wanted
if run is not None:
cfg["runner"] = {"input": input or "", "placeholder": placeholder, "ask": input is not None}
return HTMLResponse(_embed_page(base, title, cfg), headers={"Cache-Control": "no-store"})
def script(path: Path):
async def serve(request: Request) -> FileResponse:
return FileResponse(path, media_type="text/javascript", headers={"Cache-Control": "no-cache"})
return serve
def _call(text: str, run_id: str) -> None:
try:
result = run(text, run_id) # type: ignore[misc]
if inspect.isawaitable(result):
asyncio.run(result)
except Exception:
logger.exception("replay run %s failed", run_id)
finally:
busy.release()
async def start(request: Request) -> JSONResponse:
# JSON only: a cross-site form post cannot set this content type without
# a CORS preflight, which this app never answers.
if not request.headers.get("content-type", "").lower().startswith("application/json"):
return JSONResponse({"error": "send JSON: {\"input\": \"...\"}"}, status_code=415)
try:
body = await request.json()
except ValueError:
return _bad_request("body must be JSON")
text = body.get("input", "") if isinstance(body, dict) else None
if not isinstance(text, str) or len(text) > MAX_RUN_INPUT:
return _bad_request(f"input must be a string of at most {MAX_RUN_INPUT} characters")
if not busy.acquire(blocking=False):
return JSONResponse({"error": "a run is already going; wait for it to finish"}, status_code=409)
run_id = f"run-{uuid.uuid4().hex[:12]}"
try:
threading.Thread(target=_call, args=(text, run_id), name=f"replay-{run_id}", daemon=True).start()
except Exception:
busy.release()
raise
# Answer once the trace file exists (usually milliseconds), so the page
# can open the run straight away; it waits on its own if this times out.
path = tj.run_path(run_id)
for _ in range(40):
if path is not None and path.is_file():
break
await asyncio.sleep(0.05)
return JSONResponse({"run_id": run_id})
routes = [
Route("/", page, methods=["GET"]),
Route("/replay.js", script(REPLAY_VIEW_JS), methods=["GET"]),
Route("/replay-core.js", script(REPLAY_CORE_JS), methods=["GET"]),
*feature.routes(),
]
if run is not None:
routes.append(Route("/api/replay/run", start, methods=["POST"]))
return Starlette(routes=routes)
+90 -109
View File
@@ -2,8 +2,8 @@
Architecture:
TracingProtocol (ABC) <- any backend implements this
├── SimpleTracingManager <- in-memory (no deps)
└── JsonlTracingManager <- recorded runs (context-trace JSONL) + in-memory
├── SimpleTracingManager <- default in-memory (no deps)
└── SDKTracingManager <- wraps praisonaiagents.obs + trace
PraisonAITracing (BaseFeatureProtocol)
└── delegates to active TracingProtocol implementation
@@ -110,112 +110,56 @@ class SimpleTracingManager(TracingProtocol):
}
# ── JSONL Tracing Manager ────────────────────────────────────────────
# ── SDK Tracing Manager ──────────────────────────────────────────────
class JsonlTracingManager(TracingProtocol):
"""Reads recorded runs from the context-trace JSONL directory.
One ``<id>.jsonl`` file is one trace; spans come from start/end event
pairs (``parent_seq`` on schema 1.1, sequence order on 1.0). The
directory is ``$PRAISONAI_TRACES_DIR`` > ``$PRAISON_HOME/traces`` >
``~/.praison/traces`` (shared with the ``replay`` feature). Traces and
spans posted to ``/api/traces/record`` stay in memory alongside.
"""
#: Upper bound on files scanned for an unfiltered span listing.
MAX_FILES_FOR_SPANS = 20
class SDKTracingManager(TracingProtocol):
"""Wraps praisonaiagents.obs + trace for production use."""
def __init__(self) -> None:
self._trace_available = False
self._obs_available = False
try:
from praisonaiagents.trace import ActionEvent # noqa: F401
self._trace_available = True
except (ImportError, AttributeError):
pass
try:
from praisonaiagents.obs import Span, Trace # noqa: F401
self._obs_available = True
except ImportError:
pass
if not (self._trace_available or self._obs_available):
raise ImportError("Neither praisonaiagents.trace nor praisonaiagents.obs available")
self._simple = SimpleTracingManager()
# -- JSONL side ---------------------------------------------------
@staticmethod
def _trace_from_file(path) -> Optional[Dict[str, Any]]:
from . import _trace_jsonl as tj
row = tj.summarize_run(path)
if row is None:
return None
events = tj.read_events(path)
started = row.get("started_at") or 0.0
ended = row.get("ended_at")
last_ts = max(
(e["timestamp"] for e in events if isinstance(e.get("timestamp"), (int, float))),
default=started,
logger.info(
"SDKTracingManager initialized (trace=%s, obs=%s)",
self._trace_available,
self._obs_available,
)
agents = row.get("agents") or []
return {
"id": row["run_id"],
"agent_id": agents[0] if agents else "",
"agents": agents,
"name": row.get("name") or row["run_id"],
"status": row["status"],
"start_time": started,
"end_time": ended,
"timestamp": started,
"duration_ms": round(((ended or last_ts) - started) * 1000, 3),
"span_count": len(tj.build_spans(events, row["run_id"])),
"source": "jsonl",
"metadata": {
"path": row["path"],
"session_id": row.get("session_id"),
"kind": row.get("kind"),
"events": row["events"],
"cost_usd": row["cost_usd"],
"live": row["live"],
},
}
@staticmethod
def _files():
from . import _trace_jsonl as tj
return tj.list_run_files()
def list_traces(
self, *, limit: int = 50, agent_id: Optional[str] = None
) -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
for path in self._files():
if len(items) >= limit:
break
trace = self._trace_from_file(path)
if trace is None:
continue
if agent_id and agent_id not in trace["agents"]:
continue
items.append(trace)
remaining = max(0, limit - len(items))
if remaining:
items.extend(self._simple.list_traces(limit=remaining, agent_id=agent_id))
return items
sdk_traces = self._list_traces_from_sdk(limit=limit, agent_id=agent_id)
if sdk_traces is not None:
return sdk_traces
return self._simple.list_traces(limit=limit, agent_id=agent_id)
def list_spans(
self, *, limit: int = 100, trace_id: Optional[str] = None
) -> List[Dict[str, Any]]:
from . import _trace_jsonl as tj
if trace_id:
path = tj.run_path(trace_id)
if path is not None and path.is_file():
return tj.build_spans(tj.read_events(path), trace_id)[:limit]
return self._simple.list_spans(limit=limit, trace_id=trace_id)
spans: List[Dict[str, Any]] = []
for path in self._files()[: self.MAX_FILES_FOR_SPANS]:
spans.extend(tj.build_spans(tj.read_events(path), path.stem))
if len(spans) >= limit:
return spans[:limit]
spans.extend(self._simple.list_spans(limit=limit - len(spans)))
return spans[:limit]
sdk_spans = self._list_spans_from_sdk(limit=limit, trace_id=trace_id)
if sdk_spans is not None:
return sdk_spans
return self._simple.list_spans(limit=limit, trace_id=trace_id)
def get_trace(self, trace_id: str) -> Optional[Dict[str, Any]]:
from . import _trace_jsonl as tj
path = tj.run_path(trace_id)
if path is not None and path.is_file():
return self._trace_from_file(path)
sdk_trace = self._get_trace_from_sdk(trace_id)
if sdk_trace is not None:
return sdk_trace
return self._simple.get_trace(trace_id)
def record_trace(self, trace: Dict[str, Any]) -> str:
@@ -224,34 +168,69 @@ class JsonlTracingManager(TracingProtocol):
def record_span(self, span: Dict[str, Any]) -> str:
return self._simple.record_span(span)
def _list_traces_from_sdk(
self, *, limit: int, agent_id: Optional[str]
) -> Optional[List[Dict[str, Any]]]:
try:
from praisonaiagents import trace as trace_module
getter = getattr(trace_module, "list_traces", None)
if not callable(getter):
return None
items = getter(limit=limit, agent_id=agent_id)
return items if isinstance(items, list) else None
except Exception:
return None
def _list_spans_from_sdk(
self, *, limit: int, trace_id: Optional[str]
) -> Optional[List[Dict[str, Any]]]:
try:
from praisonaiagents import trace as trace_module
getter = getattr(trace_module, "list_spans", None)
if not callable(getter):
return None
items = getter(limit=limit, trace_id=trace_id)
return items if isinstance(items, list) else None
except Exception:
return None
def _get_trace_from_sdk(self, trace_id: str) -> Optional[Dict[str, Any]]:
try:
from praisonaiagents import trace as trace_module
getter = getattr(trace_module, "get_trace", None)
if not callable(getter):
return None
trace = getter(trace_id)
return trace if isinstance(trace, dict) else None
except Exception:
return None
def health(self) -> Dict[str, Any]:
from . import _trace_jsonl as tj
h = self._simple.health()
files = self._files()
h["provider"] = self.__class__.__name__
h["source"] = "jsonl" if files else "memory"
h["traces_dir"] = str(tj.traces_dir())
h["jsonl_traces"] = len(files)
h["provider"] = "SDKTracingManager"
h["trace_available"] = self._trace_available
h["obs_available"] = self._obs_available
return h
#: Deprecated alias. The old class looked up ``praisonaiagents.trace.list_traces``
#: / ``list_spans`` / ``get_trace``, which never existed, so it always fell back
#: to the in-memory store. Recorded runs are JSONL files; read those instead.
SDKTracingManager = JsonlTracingManager
# ── Manager singleton ────────────────────────────────────────────────
_tracing_manager: Optional[TracingProtocol] = None
def get_tracing_manager() -> TracingProtocol:
"""Get the active tracing manager (recorded JSONL runs + in-memory records)."""
"""Get the active tracing manager (SDK-first, fallback to Simple)."""
global _tracing_manager
if _tracing_manager is None:
_tracing_manager = JsonlTracingManager()
try:
_tracing_manager = SDKTracingManager()
logger.info("Using SDKTracingManager")
except Exception as e:
logger.debug("SDKTracingManager init failed (%s), using SimpleTracingManager", e)
_tracing_manager = SimpleTracingManager()
return _tracing_manager
@@ -322,10 +301,12 @@ class TracingFeature(BaseFeatureProtocol):
h = mgr.health()
lines = ["Tracing Status:"]
lines.append(f" Provider: {h.get('provider', 'unknown')}")
if "source" in h:
lines.append(f" Source: {h['source']} ({h.get('traces_dir', '')})")
lines.append(f" Total traces: {h.get('total_traces', 0)}")
lines.append(f" Total spans: {h.get('total_spans', 0)}")
if "trace_available" in h:
lines.append(f" SDK trace: {'available' if h['trace_available'] else 'unavailable'}")
if "obs_available" in h:
lines.append(f" SDK obs: {'available' if h['obs_available'] else 'unavailable'}")
return "\n".join(lines)
def _cli_list(self, limit: int = 20) -> str:
-15
View File
@@ -1,15 +0,0 @@
"""Ready-made aiui app that opens straight to PraisonAI Replay.
Usage (what ``praisonai replay view`` runs)::
aiui run "$(python -c 'import praisonaiui.replay_app as m; print(m.__file__)')" \
--style dashboard --host 127.0.0.1 --port 8090
then open ``http://127.0.0.1:8090/replay?run=<run_id>`` (``/#/replay?run=<run_id>``
is accepted too). The trace directory follows ``PRAISONAI_TRACES_DIR`` >
``PRAISON_HOME/traces`` > ``~/.praison/traces``.
"""
from praisonaiui.features.replay import configure_replay_app
configure_replay_app()
+2 -18
View File
@@ -1303,11 +1303,9 @@ async def patch_session(request: Request) -> JSONResponse:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
title = body.get("title") if isinstance(body, dict) else None
title = body.get("title")
if title is not None:
if not isinstance(title, str) or not title.strip():
return JSONResponse({"error": "title must be a non-empty string"}, status_code=400)
await _datastore.update_session(session_id, title=title.strip()[:200])
await _datastore.update_session(session_id, title=title)
return JSONResponse({"status": "updated", "session_id": session_id})
@@ -3282,8 +3280,6 @@ def create_app(
routes.extend(feature.routes())
# Register built-in dashboard pages via the same protocol
from praisonaiui.features.replay import REPLAY_MARK_SVG
_builtin_pages = [
{
"id": "overview",
@@ -3300,7 +3296,6 @@ def create_app(
"group": "Agent",
"description": "AI agent chat",
"order": 1,
"layout": "full-bleed",
},
{
"id": "chat-canvas",
@@ -3309,7 +3304,6 @@ def create_app(
"group": "Agent",
"description": "Chat with live A2UI preview panel",
"order": 2,
"layout": "full-bleed",
},
{
"id": "canvas",
@@ -3351,15 +3345,6 @@ def create_app(
"description": "Run flight recorder — timeline debugger",
"order": 22,
},
{
"id": "replay",
"title": "Replay",
"icon": "⏮️",
"icon_svg": REPLAY_MARK_SVG,
"group": "Control",
"description": "Recorded agent runs, step by step: who did what, when, and at what cost",
"order": 21,
},
{
"id": "usage",
"title": "Usage",
@@ -3597,7 +3582,6 @@ def create_app(
_page_api_overrides = {
"sessions": "/sessions",
"runs": "/api/sessions",
"replay": "/api/replay/runs",
"cron": "/api/schedules",
"channels": "/api/channels",
"mcp": "/api/mcp/servers",
@@ -147,7 +147,6 @@ const BUILTIN_VIEWS = {
agents: '/plugins/views/agents.js',
sessions: '/plugins/views/sessions.js',
runs: '/plugins/views/run-recorder.js',
replay: '/plugins/views/replay.js',
memory: '/plugins/views/memory.js',
knowledge: '/plugins/views/knowledge.js',
logs: '/plugins/views/logs.js',
@@ -378,12 +377,8 @@ const DASHBOARD_STYLE = `
.db-main { flex: 1; padding: 32px 40px; overflow-y: auto; min-width: 0; }
.db-main.db-clean { padding: 0; display: flex; flex-direction: column; overflow: hidden; height: 100vh; }
.db-main.db-clean > #db-page-content { flex: 1; display: flex; flex-direction: column; min-height: 0; }
/* Per-page full bleed (page.layout === 'full-bleed'): view fills the main column. */
.db-main.db-fill { display: flex; flex-direction: column; overflow: hidden; height: 100vh; box-sizing: border-box; }
.db-main.db-fill > #db-page-content { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.db-page-header { margin-bottom: 28px; }
.db-page-title { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 4px; }
.db-page-icon svg, .db-nav-icon svg { display: inline-block; vertical-align: -0.125em; }
.db-page-desc { color: var(--db-text-dim); font-size: 14px; margin: 0; }
/* Generic data viewer */
@@ -462,20 +457,10 @@ const DASHBOARD_STYLE = `
.db-mobile-overlay { display: none; position: fixed; inset: 0; z-index: 35; background: rgba(0,0,0,0.5); }
@media (max-width: 1023px) {
.db-mobile-header { display: flex; }
/* !important: buildSidebar() sets an inline position:relative for its toggle,
which otherwise keeps the hidden sidebar's 260px in the flex row. */
.db-sidebar { position: fixed !important; top: 0; left: 0; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
.db-sidebar { position: fixed; z-index: 45; transform: translateX(-100%); transition: transform 0.2s ease; }
.db-sidebar.db-sidebar-open { transform: translateX(0); }
.db-mobile-overlay.db-visible { display: block; }
.db-main { padding-top: 48px; }
/* The shell header slot sits in the same flex row; give it its own line. */
#root.db-active { flex-wrap: wrap; }
#db-shell-header-slot { flex: 0 0 100%; margin-top: 48px; }
#db-shell-header-slot + .db-main { padding-top: 16px; }
}
@media (max-width: 600px) {
.db-main { padding: 64px 16px 16px; }
#db-shell-header-slot + .db-main { padding-top: 16px; }
}
.db-card {
background: var(--db-card-bg); border: 1px solid var(--db-border);
@@ -1767,13 +1752,6 @@ async function buildDashboard() {
renderSlot('shell:header', shellHeader);
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
// Hash deep links (e.g. /#/replay?run=<id> from `praisonai replay view`)
// become the path form (/replay?run=<id>) before the page is resolved.
const hashRoute = location.hash.match(/^#\/([A-Za-z0-9_-]+)(\?[^#]*)?$/);
if (hashRoute && pagesData.find(p => p.id === hashRoute[1])) {
history.replaceState(null, '', `/${hashRoute[1]}${hashRoute[2] || ''}`);
}
// Resolve initial page from URL path or default to first page
const pathId = location.pathname.replace(/^\//, '') || '';
const initialPage = pathId && pagesData.find(p => p.id === pathId)
@@ -1790,29 +1768,6 @@ async function buildDashboard() {
});
}
// Pages may carry an inline SVG mark (`icon_svg`) next to the text `icon`.
// It is parsed as SVG and dropped if it holds scripts, foreignObject,
// event handlers or javascript: URLs; otherwise the text icon is used.
function safeSvgIcon(markup) {
if (typeof markup !== 'string' || !markup.trim().startsWith('<svg')) return null;
try {
const doc = new DOMParser().parseFromString(markup, 'image/svg+xml');
const svg = doc.documentElement;
if (!svg || svg.nodeName !== 'svg' || doc.querySelector('parsererror, script, foreignObject')) return null;
for (const el of [svg, ...svg.querySelectorAll('*')]) {
for (const a of [...el.attributes]) {
if (/^on/i.test(a.name) || /javascript:/i.test(a.value)) return null;
}
}
return document.importNode(svg, true);
} catch (e) { return null; }
}
function setPageIcon(span, page, fallback) {
const svg = safeSvgIcon(page.icon_svg);
if (svg) { span.textContent = ''; span.appendChild(svg); } else span.textContent = page.icon || fallback;
}
function buildSidebar(pages) {
const sidebar = document.createElement('nav');
sidebar.className = 'db-sidebar';
@@ -1911,7 +1866,7 @@ function buildSidebar(pages) {
item.dataset.navId = page.id;
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
setPageIcon(iconSpan, page, '📄');
iconSpan.textContent = page.icon || '📄';
item.appendChild(iconSpan);
item.appendChild(document.createTextNode(' ' + page.title));
item.addEventListener('click', () => selectPage(page.id));
@@ -1933,7 +1888,7 @@ function buildSidebar(pages) {
btn.className = 'db-nav-item';
const iconSpan = document.createElement('span');
iconSpan.className = 'db-nav-icon';
setPageIcon(iconSpan, page, '⚙');
iconSpan.textContent = page.icon || '⚙';
btn.appendChild(iconSpan);
btn.appendChild(document.createTextNode(' ' + page.title));
btn.addEventListener('click', () => {
@@ -1995,18 +1950,14 @@ async function selectPage(pageId) {
return;
}
// Full-bleed pages (e.g. chat) fill the main column and carry their own chrome
const fullBleed = page.layout === 'full-bleed';
main.classList.toggle('db-fill', fullBleed);
// Set page header (controlled by dashboard config protocol)
const showPageHeader = main.dataset.showPageHeader !== 'false' && !fullBleed;
const showPageHeader = main.dataset.showPageHeader !== 'false';
const toolbarSlotId = `page:${pageId}:toolbar`;
const headerHtml = showPageHeader ? `
<div class="db-page-header">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div>
<h1 class="db-page-title">${page.icon_svg ? '<span class="db-page-icon"></span>' : (page.icon || '')} ${page.title}</h1>
<h1 class="db-page-title">${page.icon || ''} ${page.title}</h1>
${page.description ? `<p class="db-page-desc">${page.description}</p>` : ''}
</div>
<div id="db-page-toolbar-slot" class="db-slot-host" data-slot="${toolbarSlotId}"></div>
@@ -2014,8 +1965,6 @@ async function selectPage(pageId) {
</div>
` : '';
main.innerHTML = `${headerHtml}<div data-page="${pageId}" id="db-page-content"></div>`;
const pageIcon = main.querySelector('.db-page-icon');
if (pageIcon) setPageIcon(pageIcon, page, '');
if (showPageHeader) {
const toolbarHost = document.getElementById('db-page-toolbar-slot');
if (toolbarHost && SLOT_REGISTRY[toolbarSlotId]) {
@@ -164,20 +164,6 @@ function renderProgressBar(percentage) {
`;
}
function escapeAttr(value) {
return String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Navigate to Chat and load the job's linked session (same event the
// session-search palette and run recorder use).
function openJobSession(sessionId) {
if (!sessionId) return;
const nav = (typeof window.aiui?.selectPage === 'function') ? window.aiui.selectPage('chat') : null;
Promise.resolve(nav).finally(() => {
window.dispatchEvent(new CustomEvent('aiui:session-select', { detail: { sessionId } }));
});
}
function renderJobRow(job) {
const status = job.status || 'queued';
const isTerminal = ['succeeded', 'failed', 'cancelled'].includes(status);
@@ -213,7 +199,6 @@ function renderJobRow(job) {
` : `
${_jobsCfg.backend !== 'praisonai' ? `<button class="aiui-btn aiui-btn-sm aiui-btn-delete" onclick="window.aiuiDeleteJob('${job.id}')" title="Delete">🗑️</button>` : ''}
`}
${job.session_id ? `<button class="aiui-btn aiui-btn-sm aiui-btn-open-session" data-session-id="${escapeAttr(job.session_id)}" onclick="window.aiuiOpenJobSession(this.dataset.sessionId)" title="Open session in Chat">💬</button>` : ''}
<button class="aiui-btn aiui-btn-sm aiui-btn-view" onclick="window.aiuiViewJob('${job.id}')" title="View Details">
👁️
</button>
@@ -614,7 +599,6 @@ function checkForJobsPage(root) {
window.aiuiCancelJob = cancelJob;
window.aiuiDeleteJob = deleteJob;
window.aiuiViewJob = viewJob;
window.aiuiOpenJobSession = openJobSession;
export default {
name: 'jobs',
@@ -21,7 +21,6 @@ function injectStyles() {
overflow: hidden;
}
.db-clean .chat-canvas-root { height: 100vh; }
.db-fill .chat-canvas-root { flex: 1; min-height: 0; height: auto; }
.chat-canvas-chat {
flex: 1 1 60%;
min-width: 0;
@@ -19,7 +19,6 @@ let isStreaming = false;
let streamingSessionId = null; // tracks which session is actively streaming
let currentRunId = null;
let agents = [];
let allSessions = []; // last fetched session list, filtered client-side by the rail search
let messageQueue = [];
let focusMode = false;
let sessionSelectHandler = null;
@@ -222,11 +221,6 @@ function injectStyles() {
style.textContent = `
.chat-root { display:flex; height:calc(100vh - 140px); gap:0; border-radius:12px; overflow:hidden; border:1px solid var(--db-border); background:var(--db-card-bg); }
.db-clean .chat-root { height:100%; border-radius:0; border:none; }
.db-fill .chat-root { flex:1; min-height:0; height:auto; }
@media (max-width:900px) {
.db-fill .chat-root { flex-direction:column; }
.db-fill .chat-sidebar-panel { width:100%; min-width:0; max-height:40vh; border-right:none; border-bottom:1px solid var(--db-border); }
}
.chat-root.focus-mode .chat-sidebar-panel { display:none; }
.chat-root.focus-mode .chat-main-panel { border-left:none; }
@@ -236,17 +230,12 @@ function injectStyles() {
.chat-sidebar-agent { padding:10px 16px; border-bottom:1px solid var(--db-border); }
.chat-sidebar-agent label { font-size:11px; color:var(--db-text-dim); display:block; margin-bottom:4px; text-transform:uppercase; font-weight:600; letter-spacing:.5px; }
.chat-sidebar-agent select { width:100%; padding:6px 10px; background:var(--db-card-bg); border:1px solid var(--db-border); border-radius:6px; color:var(--db-text); font-size:13px; }
.chat-sess-search { padding:8px 16px 0; }
.chat-sess-search input { width:100%; box-sizing:border-box; padding:6px 10px; background:var(--db-card-bg); border:1px solid var(--db-border); border-radius:6px; color:var(--db-text); font-size:13px; }
.chat-sess-list { flex:1; overflow-y:auto; padding:8px; }
.chat-sess-item { padding:10px 12px; border-radius:8px; cursor:pointer; margin-bottom:4px; font-size:13px; transition:background .15s; display:flex; flex-direction:column; gap:2px; }
.chat-sess-item:hover { background:rgba(var(--db-accent-rgb,100,100,255),.08); }
.chat-sess-item.active { background:rgba(var(--db-accent-rgb,100,100,255),.15); }
.chat-sess-item .sess-title { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.chat-sess-item .sess-meta { font-size:11px; color:var(--db-text-dim); }
.chat-sess-item { position:relative; }
.chat-sess-item .sess-rename { position:absolute; top:6px; right:6px; display:none; background:none; border:none; color:var(--db-text-dim); cursor:pointer; font-size:12px; padding:2px 4px; }
.chat-sess-item:hover .sess-rename, .chat-sess-item .sess-rename:focus { display:block; }
.chat-main-panel { flex:1; display:flex; flex-direction:column; min-width:0; }
.chat-top-bar { padding:10px 16px; border-bottom:1px solid var(--db-border); display:flex; align-items:center; justify-content:space-between; gap:10px; }
@@ -823,9 +812,6 @@ export async function render(container) {
<label>Agent</label>
<select id="chat-agent-selector"><option value="">Auto (default)</option></select>
</div>
<div class="chat-sess-search">
<input type="search" id="chat-session-search" placeholder="Search sessions…" aria-label="Search sessions" autocomplete="off">
</div>
<div class="chat-sess-list" id="chat-sessions-list"></div>
</div>
<div class="chat-main-panel">
@@ -906,8 +892,6 @@ export async function render(container) {
const agentSelector = document.getElementById('chat-agent-selector');
const attachBtn = document.getElementById('chat-attach-btn');
const fileInput = document.getElementById('chat-file-input');
const sessionSearch = document.getElementById('chat-session-search');
if (sessionSearch) sessionSearch.addEventListener('input', renderSessionList);
// Guard: if DOM elements aren't present yet (race condition), bail out
if (!input || !sendBtn) {
console.warn('[chat] DOM elements not ready — skipping event binding');
@@ -1055,92 +1039,47 @@ async function loadSessions() {
resp = await fetch('/sessions');
}
const data = await resp.json();
const fetched = data.sessions || data || [];
allSessions = Array.isArray(fetched) ? fetched : [];
renderSessionList();
const sessions = data.sessions || data || [];
const list = document.getElementById('chat-sessions-list');
if (!list) return;
list.innerHTML = '';
if (sessions.length === 0) {
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--db-text-dim);font-size:12px">No sessions yet</div>';
return;
}
sessions.slice(0, 30).forEach((s) => {
const el = document.createElement('div');
const id = s.id || s.session_id || '';
el.className = 'chat-sess-item' + (id === currentSessionId ? ' active' : '');
// Show platform icon for channel sessions (e.g. Slack with SVG)
const platformName = s.platform || '';
const platSvg = _getPlatformSVG(platformName);
let title;
let titlePrefix = '';
if (platformName) {
const displayName = platformName.charAt(0).toUpperCase() + platformName.slice(1);
if (platSvg) {
titlePrefix = '<span class="sess-platform-icon ' + _getPlatformClass(platformName) + '">' + platSvg + '</span>';
}
title = displayName;
} else {
title = s.name || s.title || s.label || id.substring(0, 12) + '…';
}
const agent = s.agent_id || s.agent || '';
const time = timeAgo(s.created_at || s.updated_at);
el.innerHTML = `
<div class="sess-title">${titlePrefix}${escapeHtml(title)}</div>
<div class="sess-meta">${agent ? '🤖 ' + escapeHtml(agent) + ' · ' : ''}${time}</div>
`;
el.title = id;
el.addEventListener('click', () => loadSession(id));
list.appendChild(el);
});
} catch (e) {
console.warn('[Chat] Failed to load sessions:', e);
}
}
function _sessionMatches(s, q) {
const fields = [s.id, s.session_id, s.name, s.title, s.label, s.agent_id, s.agent, s.platform];
return fields.some((v) => typeof v === 'string' && v.toLowerCase().includes(q));
}
function renderSessionList() {
const list = document.getElementById('chat-sessions-list');
if (!list) return;
const input = document.getElementById('chat-session-search');
const q = (input && input.value || '').trim().toLowerCase();
const sessions = q ? allSessions.filter((s) => _sessionMatches(s, q)) : allSessions;
list.innerHTML = '';
if (sessions.length === 0) {
const msg = q ? 'No matching sessions' : 'No sessions yet';
list.innerHTML = '<div style="padding:16px;text-align:center;color:var(--db-text-dim);font-size:12px">' + msg + '</div>';
return;
}
sessions.slice(0, 30).forEach((s) => {
const el = document.createElement('div');
const id = s.id || s.session_id || '';
el.className = 'chat-sess-item' + (id === currentSessionId ? ' active' : '');
// Show platform icon for channel sessions (e.g. Slack with SVG)
const platformName = s.platform || '';
const platSvg = _getPlatformSVG(platformName);
let title;
let titlePrefix = '';
if (platformName) {
const displayName = platformName.charAt(0).toUpperCase() + platformName.slice(1);
if (platSvg) {
titlePrefix = '<span class="sess-platform-icon ' + _getPlatformClass(platformName) + '">' + platSvg + '</span>';
}
title = displayName;
} else {
title = s.name || s.title || s.label || id.substring(0, 12) + '…';
}
const agent = s.agent_id || s.agent || '';
const time = timeAgo(s.created_at || s.updated_at);
el.innerHTML = `
<div class="sess-title">${titlePrefix}${escapeHtml(title)}</div>
<div class="sess-meta">${agent ? '🤖 ' + escapeHtml(agent) + ' · ' : ''}${time}</div>
`;
el.title = id;
el.addEventListener('click', () => loadSession(id));
if (!platformName && id) {
const btn = document.createElement('button');
btn.className = 'sess-rename';
btn.title = 'Rename session';
btn.setAttribute('aria-label', 'Rename session');
btn.textContent = '✎';
btn.addEventListener('click', (e) => { e.stopPropagation(); renameSession(id, title); });
el.appendChild(btn);
}
list.appendChild(el);
});
}
async function renameSession(sessionId, currentTitle) {
const next = (window.prompt('Rename session', currentTitle || '') || '').trim();
if (!next || next === currentTitle) return;
try {
const resp = await fetch('/sessions/' + encodeURIComponent(sessionId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: next }),
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const s = allSessions.find((x) => (x.id || x.session_id) === sessionId);
if (s) { s.title = next; delete s.name; }
renderSessionList();
if (sessionId === currentSessionId) {
const header = document.getElementById('chat-header-title');
if (header) header.textContent = next;
}
} catch (e) {
console.warn('[Chat] Failed to rename session:', e);
}
}
async function loadSession(sessionId) {
currentSessionId = sessionId;
refreshStreamingUI();
@@ -1192,8 +1131,7 @@ async function loadSession(sessionId) {
title.textContent = (sessionIcon || '📨') + ' ' + displayName;
}
} else if (title) {
const cached = allSessions.find((x) => (x.id || x.session_id) === sessionId);
title.textContent = (cached && (cached.name || cached.title)) || ('Session ' + sessionId.substring(0, 8));
title.textContent = 'Session ' + sessionId.substring(0, 8);
}
} catch (e) {
console.warn('[Chat] Failed to load session:', e);
@@ -23,6 +23,7 @@ let editor = null;
let monacoNs = null;
let running = false;
let timerId = null;
let pendingApproval = null; // { id, language, code } awaiting approval
const history = [];
function escapeHtml(s) {
@@ -178,14 +179,19 @@ async function runCode(container) {
runBtn.textContent = `Executing… ${((Date.now() - start) / 1000).toFixed(1)}s`;
}, 100);
const p = pendingApproval;
const approval_id = p && p.language === lang && p.code === code ? p.id : undefined;
let result;
try {
const res = await fetch('/api/code/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang, code, timeout: 30 }),
body: JSON.stringify({ language: lang, code, timeout: 30, approval_id }),
});
result = await res.json();
pendingApproval = result.status === 'pending_approval'
? { id: result.approval_id, language: lang, code }
: null;
} catch (e) {
result = { status: 'failed', error: String(e), language: lang };
} finally {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-524
View File
@@ -1,524 +0,0 @@
"""Browser smoke + axe accessibility check for the Replay view (dev-only, never shipped).
Opt-in: RUN_BROWSER_TESTS=1 AXE_CORE_JS=/path/to/axe.min.js pytest tests/browser -o addopts=""
* Needs the ``playwright`` Python package and a Chrome install (``channel="chrome"``)
or a Playwright Chromium (``python -m playwright install chromium``).
* axe-core (MPL-2.0) is loaded from ``AXE_CORE_JS`` (e.g. ``npm i axe-core`` in
any scratch dir); the axe checks are skipped when it is not provided.
"""
from __future__ import annotations
import json
import os
import shutil
import socket
import threading
import time
from pathlib import Path
import pytest
if os.environ.get("RUN_BROWSER_TESTS") != "1":
pytest.skip("browser tests are opt-in (RUN_BROWSER_TESTS=1)", allow_module_level=True)
sync_api = pytest.importorskip("playwright.sync_api")
ROOT = Path(__file__).resolve().parents[2]
FIXTURES = ROOT / "tests" / "fixtures" / "replay"
AXE = os.environ.get("AXE_CORE_JS")
WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]
def _events(name: str) -> list[dict]:
return [json.loads(line) for line in (FIXTURES / f"{name}.jsonl").read_text(encoding="utf-8").splitlines() if line.strip()]
@pytest.fixture(scope="module")
def browser():
with sync_api.sync_playwright() as p:
try:
b = p.chromium.launch(channel="chrome")
except Exception:
try:
b = p.chromium.launch()
except Exception as exc: # no browser available
pytest.skip(f"no Chromium/Chrome available: {exc}")
yield b
b.close()
def _page(browser, scheme: str = "light", reduced: bool = False):
page = browser.new_page(
viewport={"width": 1366, "height": 1000},
color_scheme=scheme,
reduced_motion="reduce" if reduced else "no-preference",
)
errors: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
return page, errors
def _axe(page, selector: str = ".rp-root") -> list[dict]:
if not AXE:
return []
page.add_script_tag(path=AXE)
result = page.evaluate(
"""async ([sel, tags]) => {
const r = await axe.run(document.querySelector(sel), {runOnly: {type: 'tag', values: tags}});
return r.violations.map(v => ({id: v.id, impact: v.impact, help: v.help, nodes: v.nodes.slice(0, 3).map(n => n.target.join(' '))}));
}""",
[selector, WCAG_TAGS],
)
return result
def _standalone(tmp_path: Path, name: str) -> str:
from praisonaiui.features.replay import standalone_html
out = tmp_path / f"{name}.html"
out.write_text(standalone_html(_events(name), title=name), encoding="utf-8")
return out.as_uri()
def _current_index(page) -> int:
return int(page.get_attribute('.rp-step[aria-current="step"]', "data-i"))
@pytest.mark.parametrize("name", ["team_handoff", "flow_parallel_loop", "legacy_v10", "team_outside"])
def test_standalone_steps_with_arrow_keys(browser, tmp_path, name):
page, errors = _page(browser)
page.goto(_standalone(tmp_path, name))
page.wait_for_selector(".rp-step")
total = page.locator(".rp-step").count()
assert total > 5
assert _current_index(page) == total - 1 # opens at the end (whole run visible)
page.locator(".rp-step").nth(total - 1).focus()
page.keyboard.press("Home")
assert _current_index(page) == 0
for _ in range(3):
page.keyboard.press("ArrowRight")
assert _current_index(page) == 3
page.keyboard.press("ArrowLeft")
assert _current_index(page) == 2
# focus follows the cursor in the step list, the scrubber and the drawer agree
assert page.evaluate("document.activeElement.getAttribute('data-i')") == "2"
assert page.input_value("#rp-scrub-range") == "2"
step_text = page.locator('.rp-step[aria-current="step"] .rp-step-text').inner_text()
assert page.locator(".rp-drawer h3").inner_text() == step_text
# replay: later blocks are drawn as "not reached yet"
assert page.locator(".rp-block.rp-future").count() > 0
page.keyboard.press("End")
assert _current_index(page) == total - 1
assert page.locator(".rp-block.rp-future").count() == 0
assert not errors, errors
def test_standalone_drawer_tabs_map_and_axe(browser, tmp_path):
page, errors = _page(browser)
page.goto(_standalone(tmp_path, "team_handoff"))
page.wait_for_selector(".rp-step")
# select the hand-off step and read the Why tab
page.locator(".rp-step", has_text="handed off to Writer").click()
page.locator('[role="tab"]', has_text="Why").click()
why = page.locator("#rp-tabpanel").inner_text()
assert "Hand-off reason" in why and "Facts gathered" in why
# keyboard on the tablist moves between tabs
page.keyboard.press("ArrowRight")
assert page.get_attribute('[role="tab"][aria-selected="true"]', "id") == "rp-tab-raw"
assert '"event_type": "agent_handoff"' in page.locator("#rp-tabpanel pre").inner_text()
# manager's task snapshot shows up for the delegated task
page.locator(".rp-step", has_text="Task 'research' went to Researcher").click()
page.locator('[role="tab"]', has_text="Why").click()
assert "Manager's task snapshot" in page.locator("#rp-tabpanel").inner_text()
# approval evidence
page.locator(".rp-step", has_text="You approved write_file").click()
assert "Decided by" in page.locator("#rp-tabpanel").inner_text()
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
page.click("[data-key='overlay']")
page.click("[data-key='view:map']")
page.wait_for_selector(".rp-node")
assert page.locator(".rp-node").count() == 10
labels = page.eval_on_selector_all(".rp-edge text", "els => els.map(e => e.textContent)")
assert "hands off" in labels and any(t.startswith("delegates") for t in labels)
page.locator(".rp-node").first.focus()
page.keyboard.press("ArrowDown")
page.keyboard.press("Enter")
assert page.locator(".rp-drawer h3").inner_text()
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
page.select_option("[data-key='theme']", "dark")
assert page.get_attribute(".rp-root", "data-theme") == "dark"
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
page.click("[data-key='view:timeline']")
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
assert not errors, errors
def test_reduced_motion_has_no_animation(browser, tmp_path):
page, _ = _page(browser, reduced=True)
page.goto(_standalone(tmp_path, "flow_parallel_loop"))
page.wait_for_selector(".rp-step")
names = page.evaluate(
"[...document.querySelectorAll('.rp-root *')].map(e => getComputedStyle(e).animationName).filter(n => n && n !== 'none')"
)
assert names == []
def _boxes(page, selector: str) -> list[dict]:
return page.eval_on_selector_all(
selector,
"els => els.map(e => { const r = e.getBoundingClientRect(); return {x: r.x, y: r.y, w: r.width, h: r.height, id: e.closest('[data-block]')?.getAttribute('data-block')}; })",
)
@pytest.mark.parametrize("name", ["team_outside", "team_handoff", "flow_parallel_loop"])
def test_timeline_blocks_never_overlap(browser, tmp_path, name):
"""An LLM span encloses its tool calls in real runs; tools go on their own row."""
page, errors = _page(browser)
page.goto(_standalone(tmp_path, name))
page.wait_for_selector(".rp-block")
boxes = _boxes(page, ".rp-block .rp-body")
for i, a in enumerate(boxes):
for b in boxes[i + 1:]:
apart = a["x"] + a["w"] <= b["x"] + 0.5 or b["x"] + b["w"] <= a["x"] + 0.5 or a["y"] + a["h"] <= b["y"] + 0.5 or b["y"] + b["h"] <= a["y"] + 0.5
assert apart, f"{a['id']} overlaps {b['id']}"
# a label that does not fit is ellipsized, and the full text is in the title and name
llm = page.locator(".rp-block.rp-k-llm").first
name_attr = llm.get_attribute("aria-label")
title = llm.locator("title").text_content()
assert name_attr.startswith(title)
llm.focus()
assert page.locator(".rp-tip").get_attribute("visibility") == "visible"
assert page.locator(".rp-tip text").text_content() == title
assert not errors, errors
def test_why_tab_by_role_and_keyboard(browser, tmp_path):
page, errors = _page(browser)
page.goto(_standalone(tmp_path, "team_outside"))
page.wait_for_selector(".rp-step")
page.locator(".rp-step", has_text="Triage handed off to Billing").click()
page.get_by_role("tab", name="Why").click()
assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true"
why = page.get_by_role("tabpanel").inner_text()
assert "Hand-off reason" in why and "Billing question" in why and "Context passed" in why
# the task input
page.locator(".rp-step", has_text="Task 'research' went to Researcher").click()
assert "Task description" in page.get_by_role("tabpanel").inner_text()
assert "Get one fact about electric vehicles" in page.get_by_role("tabpanel").inner_text()
# keyboard: focus the selected tab, arrow keys move between tabs
page.get_by_role("tab", name="Why").focus()
page.keyboard.press("ArrowLeft")
assert page.get_by_role("tab", name="What happened").get_attribute("aria-selected") == "true"
page.keyboard.press("ArrowRight")
assert page.get_by_role("tab", name="Why").get_attribute("aria-selected") == "true"
assert page.evaluate("document.activeElement.getAttribute('role')") == "tab"
# route chosen and approval decision
page.goto(_standalone(tmp_path, "flow_parallel_loop"))
page.wait_for_selector(".rp-step")
page.locator(".rp-step", has_text="chose route 'publish'").click()
page.get_by_role("tab", name="Why").click()
assert "Route chosen" in page.get_by_role("tabpanel").inner_text()
page.goto(_standalone(tmp_path, "team_handoff"))
page.wait_for_selector(".rp-step")
page.locator(".rp-step", has_text="You approved write_file").click()
page.get_by_role("tab", name="Why").click()
panel = page.get_by_role("tabpanel").inner_text()
assert "Decision" in panel and "Approved with edited arguments" in panel
assert not errors, errors
def test_theme_follows_system_with_override(browser, tmp_path):
url = _standalone(tmp_path, "team_handoff")
bg = "getComputedStyle(document.querySelector('.rp-root')).backgroundColor"
page, _ = _page(browser, scheme="dark")
page.goto(url)
page.wait_for_selector(".rp-step")
assert page.get_attribute(".rp-root", "data-theme") == "system"
assert page.evaluate(bg) == "rgb(22, 23, 27)"
page.select_option("[data-key='theme']", "light")
assert page.evaluate(bg) == "rgb(255, 255, 255)"
page.close()
page, _ = _page(browser, scheme="light")
page.goto(url)
page.wait_for_selector(".rp-step")
assert page.evaluate(bg) == "rgb(255, 255, 255)"
def test_map_fits_on_open_and_groups_outside_agents(browser, tmp_path):
page, errors = _page(browser)
page.goto(_standalone(tmp_path, "team_outside"))
page.wait_for_selector(".rp-step")
page.get_by_role("button", name="Map").click()
page.wait_for_selector(".rp-node")
box = page.evaluate("(() => { const c = document.querySelector('.rp-canvas'); const r = c.getBoundingClientRect(); return {l: r.left, r: r.right, sw: c.scrollWidth, cw: c.clientWidth}; })()")
assert box["sw"] <= box["cw"] + 1, "map overflows on open"
for n in _boxes(page, ".rp-node .rp-card"):
assert box["l"] - 1 <= n["x"] and n["x"] + n["w"] <= box["r"] + 1
assert "Outside the team" in page.locator(".rp-group text").text_content()
outside = page.get_by_role("group", name="Outside the team")
assert outside.locator(".rp-node").count() == 3
labels = page.eval_on_selector_all(".rp-edge text", "els => els.map(e => e.textContent)")
assert "hands off" in labels
before = float(page.get_attribute(".rp-canvas svg", "width"))
page.get_by_role("button", name="Zoom in").click()
assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before * 1.5, abs=2)
page.get_by_role("button", name="Fit").click()
assert float(page.get_attribute(".rp-canvas svg", "width")) == pytest.approx(before, abs=2)
assert not errors, errors
@pytest.mark.parametrize("name", ["team_outside", "flow_parallel_loop"])
def test_phone_width_has_no_page_scroll(browser, tmp_path, name):
page, errors = _page(browser)
page.set_viewport_size({"width": 400, "height": 860})
page.goto(_standalone(tmp_path, name))
page.wait_for_selector(".rp-step")
assert page.evaluate("document.documentElement.scrollWidth") <= 400
r = page.evaluate("(() => { const b = document.querySelector('.rp-root').getBoundingClientRect(); return [b.left, b.right]; })()")
assert r[0] == pytest.approx(16, abs=1) and r[1] == pytest.approx(384, abs=1)
page.get_by_role("button", name="Map").click()
assert page.evaluate("document.documentElement.scrollWidth") <= 400
assert not errors, errors
@pytest.mark.parametrize("order", ["core-first", "view-first"])
def test_wrapper_style_export_boots(browser, tmp_path, order):
"""The wrapper inlines both files as module scripts into #replay-root (which holds
a fallback table) after window.__REPLAY_DATA__; the view must replace it."""
views = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views"
core = (views / "replay-core.js").read_text(encoding="utf-8").replace("</", "<\\/")
view = (views / "replay.js").read_text(encoding="utf-8").replace("</", "<\\/")
scripts = [core, view] if order == "core-first" else [view, core]
data = json.dumps({"run_id": "team-outside-01", "events": _events("team_outside"), "graph": None}).replace("<", "\\u003c")
html = (
"<!doctype html><html lang=en><head><meta charset=utf-8><title>PraisonAI Replay: x</title></head><body>"
"<div id=replay-root><table><tr><td>fallback</td></tr></table></div>"
f"<script id=replay-data>window.__REPLAY_DATA__ = {data};</script>"
+ "".join(f"<script type=module>\n{js}\n</script>" for js in scripts)
+ "</body></html>"
)
out = tmp_path / f"export-{order}.html"
out.write_text(html, encoding="utf-8")
page, errors = _page(browser)
page.goto(out.as_uri())
page.wait_for_selector(".rp-step")
assert page.locator("#replay-root table").count() == 0
assert page.locator(".rp-title").inner_text() == "team-outside-01"
# graph:null -> taken from workflow_start.data.graph
page.get_by_role("button", name="Map").click()
assert page.locator(".rp-node").count() == 10
assert page.locator(".rp-ask").count() == 0
assert not errors, errors
# ── dashboard + live run + approval through the You lane ─────────────
_PROVIDER_KEYS = ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "GROQ_API_KEY")
def _no_provider_keys(monkeypatch) -> None:
"""A real aiui server runs startup hooks; keep them from calling paid APIs."""
for key in _PROVIDER_KEYS:
monkeypatch.delenv(key, raising=False)
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture()
def live_server(tmp_path, monkeypatch):
import uvicorn
import praisonaiui.server as server
from praisonaiui.features import approvals
from praisonaiui.features.replay import configure_replay_app
traces = tmp_path / "traces"
traces.mkdir()
for name in ("team_handoff", "flow_parallel_loop"):
shutil.copy(FIXTURES / f"{name}.jsonl", traces / f"{name}.jsonl")
lines = (FIXTURES / "team_handoff.jsonl").read_text(encoding="utf-8").splitlines(keepends=True)
cut = next(i for i, line in enumerate(lines) if '"approval_decided"' in line)
live = traces / "live-team.jsonl"
live.write_text("".join(lines[:cut]), encoding="utf-8")
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(traces))
monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data"))
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home"))
_no_provider_keys(monkeypatch)
store = approvals.SimpleApprovalManager()
monkeypatch.setattr(approvals, "_approval_manager", store)
server.reset_state()
configure_replay_app()
app = server.create_app()
port = _free_port()
srv = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
t = threading.Thread(target=srv.run, daemon=True)
t.start()
for _ in range(100):
if srv.started:
break
time.sleep(0.05)
yield {"url": f"http://127.0.0.1:{port}", "store": store, "live": live, "lines": lines, "cut": cut}
srv.should_exit = True
t.join(timeout=5)
server.reset_state()
def test_dashboard_live_run_and_approval(browser, live_server):
pytest.importorskip("praisonaiagents.approval.protocols")
from praisonaiagents.approval.protocols import ApprovalRequest
from praisonaiui.features.approvals import _UIStoreBackend
store = live_server["store"]
backend = _UIStoreBackend(store, timeout=30)
box = {}
req = ApprovalRequest(tool_name="write_file", arguments={"path": "draft.md", "content": "hello"},
risk_level="high", agent_name="Writer", approval_id="appr-0001")
t = threading.Thread(target=lambda: box.setdefault("d", backend.request_approval_sync(req)), daemon=True)
t.start()
page, errors = _page(browser)
page.goto(f"{live_server['url']}/#/replay?run=live-team")
page.wait_for_selector(".rp-run")
assert page.evaluate("location.pathname + location.search") == "/replay?run=live-team"
assert page.locator(".rp-run[aria-current='true']").inner_text().startswith("Solar sail brief")
assert page.locator(".rp-badge.rp-b-live").count() >= 1
card = page.locator(".rp-ask")
card.wait_for()
assert "Writer asks to run write_file" in card.inner_text()
page.get_by_role("button", name="Edit arguments…").click()
ta = page.locator(".rp-ask textarea")
ta.wait_for()
assert json.loads(ta.input_value()) == {"path": "draft.md", "content": "hello"} # server-held args, not redacted
ta.fill("{not json")
assert ta.get_attribute("aria-invalid") == "true"
assert "Not valid JSON" in page.locator(".rp-ask .rp-err").inner_text()
ta.fill('{"path": "docs/draft.md", "content": "hello"}')
assert ta.get_attribute("aria-invalid") == "false"
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
page.get_by_role("button", name="Approve with these arguments").click()
t.join(timeout=10)
decision = box["d"]
assert decision.approved is True
assert decision.modified_args == {"path": "docs/draft.md", "content": "hello"}
page.locator(".rp-ask [role='status']").wait_for()
# the run continues: the rest of the file streams in over SSE
with open(live_server["live"], "a", encoding="utf-8") as fh:
fh.write("".join(live_server["lines"][live_server["cut"]:]))
page.wait_for_selector(".rp-step:has-text('You approved write_file')", timeout=10000)
page.wait_for_selector(".rp-step:has-text('Recording ended.')", timeout=10000)
assert page.locator(".rp-ask").count() == 0
page.wait_for_selector(".rp-bar .rp-b-completed", timeout=15000)
assert not [e for e in errors if "favicon" not in e], errors
def test_empty_state_explains_how_to_record(browser, tmp_path, monkeypatch):
import uvicorn
import praisonaiui.server as server
from praisonaiui.features.replay import configure_replay_app
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "empty"))
monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data"))
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home"))
_no_provider_keys(monkeypatch)
server.reset_state()
configure_replay_app()
app = server.create_app()
port = _free_port()
srv = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
th = threading.Thread(target=srv.run, daemon=True)
th.start()
try:
for _ in range(100):
if srv.started:
break
time.sleep(0.05)
page, _ = _page(browser)
page.goto(f"http://127.0.0.1:{port}/")
page.wait_for_selector(".rp-empty")
text = page.locator(".rp-empty").inner_text()
assert "praisonai replay view agents.yaml" in text
assert "with record(live=True):" in text
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
finally:
srv.should_exit = True
th.join(timeout=5)
server.reset_state()
def test_embedded_run_box_draws_each_step_live_then_plays_back(browser, tmp_path, monkeypatch):
"""create_replay_app(run=...) mounted in a host app: Run, watch the map, then Play."""
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount
from praisonaiui.features.replay import create_replay_app
traces = tmp_path / "traces"
traces.mkdir()
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(traces))
lines = (FIXTURES / "flow_parallel_loop.jsonl").read_text(encoding="utf-8").splitlines(keepends=True)
seen = []
def runner(text, run_id): # writes a recorded flow slowly, like a live run
seen.append(text)
with open(traces / f"{run_id}.jsonl", "w", encoding="utf-8") as fh:
for line in lines:
fh.write(line)
fh.flush()
time.sleep(0.08)
host = Starlette(routes=[Mount("/replay", app=create_replay_app(runner, input="weekly digest"))])
port = _free_port()
srv = uvicorn.Server(uvicorn.Config(host, host="127.0.0.1", port=port, log_level="warning"))
t = threading.Thread(target=srv.run, daemon=True)
t.start()
for _ in range(100):
if srv.started:
break
time.sleep(0.05)
try:
page, errors = _page(browser)
page.add_init_script("try{localStorage.setItem('rp-view','map')}catch(e){}")
page.goto(f"http://127.0.0.1:{port}/replay/")
page.wait_for_selector(".rp-empty h2:has-text('Nothing has run yet')")
page.get_by_role("button", name="▶ Run").click()
page.wait_for_selector(".rp-node.rp-st-running", timeout=10000) # a step is drawn as running
assert page.locator(".rp-node.rp-unvisited").count() > 0 # later steps not reached yet
page.wait_for_selector(".rp-bar .rp-b-completed", timeout=20000)
assert seen == ["weekly digest"]
assert page.locator(".rp-node.rp-st-running").count() == 0
assert page.get_by_role("button", name="▶ Run").is_enabled()
page.get_by_role("button", name="▶ Play").click() # from the end: restarts at step 1
page.wait_for_timeout(1300)
assert page.locator("[data-key=play]").inner_text() == "❚❚ Pause"
assert page.locator(".rp-node.rp-unvisited").count() > 0 # the map rewound with the cursor
page.locator("[data-key=play]").click()
violations = _axe(page)
assert violations == [], json.dumps(violations, indent=2)
assert not errors, errors
finally:
srv.should_exit = True
t.join(timeout=5)
-42
View File
@@ -1,42 +0,0 @@
{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-flow-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01"}
{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.02,"session_id":"sess-flow-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"flow","name":"Weekly digest","process":"flow","graph":{"schema":"praisonai.graph/1","kind":"flow","name":"Weekly digest","process":"flow","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"step:plan","kind":"step","label":"plan","parent":null,"meta":{"agent":"Planner"}},{"id":"step:gather","kind":"fork","label":"gather","parent":null,"meta":{"pattern":"parallel"}},{"id":"step:news","kind":"step","label":"news","parent":"step:gather","meta":{"agent":"Fetcher"}},{"id":"step:papers","kind":"step","label":"papers","parent":"step:gather","meta":{"agent":"Fetcher"}},{"id":"step:merge","kind":"join","label":"merge","parent":null,"meta":{}},{"id":"step:review_loop","kind":"step","label":"review_loop","parent":null,"meta":{"pattern":"loop"}},{"id":"step:polish","kind":"step","label":"polish","parent":"step:review_loop","meta":{"agent":"Editor"}},{"id":"step:decide","kind":"decision","label":"decide","parent":null,"meta":{"pattern":"route"}},{"id":"step:publish","kind":"step","label":"publish","parent":null,"meta":{"agent":"Publisher"}},{"id":"step:archive","kind":"step","label":"archive","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->step:plan:next","source":"start:begin","target":"step:plan","kind":"next","label":null},{"id":"step:plan->step:gather:next","source":"step:plan","target":"step:gather","kind":"next","label":null},{"id":"step:gather->step:news:parallel","source":"step:gather","target":"step:news","kind":"parallel","label":null},{"id":"step:gather->step:papers:parallel","source":"step:gather","target":"step:papers","kind":"parallel","label":null},{"id":"step:news->step:merge:next","source":"step:news","target":"step:merge","kind":"next","label":null},{"id":"step:papers->step:merge:next","source":"step:papers","target":"step:merge","kind":"next","label":null},{"id":"step:merge->step:review_loop:next","source":"step:merge","target":"step:review_loop","kind":"next","label":null},{"id":"step:review_loop->step:polish:loop","source":"step:review_loop","target":"step:polish","kind":"loop","label":"until approved"},{"id":"step:polish->step:review_loop:loop","source":"step:polish","target":"step:review_loop","kind":"loop","label":null},{"id":"step:review_loop->step:decide:next","source":"step:review_loop","target":"step:decide","kind":"next","label":null},{"id":"step:decide->step:publish:routes","source":"step:decide","target":"step:publish","kind":"routes","label":"publish"},{"id":"step:decide->step:archive:routes","source":"step:decide","target":"step:archive","kind":"routes","label":"archive"},{"id":"step:publish->end:done:next","source":"step:publish","target":"end:done","kind":"next","label":null},{"id":"step:archive->end:done:next","source":"step:archive","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":0}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225600.05,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"plan"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:plan"}
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.06,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":3,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":2,"node_id":"agent:Planner"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.1,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":4,"messages_count":3,"tokens_used":150,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":3,"node_id":"agent:Planner"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225600.9,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":60,"duration_ms":800.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":150,"completion_tokens":60,"cost_usd":5.9e-05,"run_id":"flow-loop-01","parent_seq":3,"node_id":"agent:Planner"}
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225600.95,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":3,"node_id":"agent:Planner"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225601.0,"session_id":"sess-flow-01","agent_name":"Planner","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":950.0,"output_preview":"Gather news and papers."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":2,"node_id":"step:plan"}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225601.05,"session_id":"sess-flow-01","agent_name":"","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"gather","pattern":"parallel"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:gather"}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225601.1,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"news"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":8,"node_id":"step:news"}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225601.1,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"papers"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":8,"node_id":"step:papers"}
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225601.15,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"fetch_rss","tool_args":{"url":"https://example.org/news.rss"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":9,"node_id":"tool:Fetcher/fetch_rss"}
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225601.16,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":12,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"search_papers","tool_args":{"query":"agent tracing"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":10,"node_id":"tool:Fetcher/search_papers"}
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225602.4,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"fetch_rss","result":"12 items","duration_ms":1250.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":11,"node_id":"tool:Fetcher/fetch_rss"}
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.0,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"search_papers","result":"5 papers","duration_ms":1840.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.001,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":12,"node_id":"tool:Fetcher/search_papers"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225602.45,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":1350.0,"output_preview":"12 items"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/0","run_id":"flow-loop-01","parent_seq":9,"node_id":"step:news"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225603.05,"session_id":"sess-flow-01","agent_name":"Fetcher","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":1950.0,"output_preview":"5 papers"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"branch_id":"gather/1","run_id":"flow-loop-01","parent_seq":10,"node_id":"step:papers"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225603.1,"session_id":"sess-flow-01","agent_name":"","sequence_num":17,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":2050.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":8,"node_id":"step:gather"}
{"schema_version":"1.1","event_type":"context_rebalance","timestamp":1767225603.12,"session_id":"sess-flow-01","agent_name":"","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"note":"future event type"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"future_field":{"x":1}}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225603.15,"session_id":"sess-flow-01","agent_name":"","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"review_loop","pattern":"loop"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:review_loop"}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225603.2,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"polish","iteration":1},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:polish"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225603.22,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":21,"messages_count":3,"tokens_used":400,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":20,"node_id":"agent:Editor"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225603.92,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":22,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":90,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":400,"completion_tokens":90,"cost_usd":0.000114,"run_id":"flow-loop-01","parent_seq":20,"node_id":"agent:Editor"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225603.95,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":750.0,"output_preview":"Draft v2"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":20,"node_id":"step:polish"}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225604.0,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":24,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"polish","iteration":2},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:polish"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225604.02,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":25,"messages_count":3,"tokens_used":450,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":24,"node_id":"agent:Editor"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225604.72,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":90,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":450,"completion_tokens":90,"cost_usd":0.00012150000000000001,"run_id":"flow-loop-01","parent_seq":24,"node_id":"agent:Editor"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225604.75,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":750.0,"output_preview":"Draft v3"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":24,"node_id":"step:polish"}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225604.8,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"polish","iteration":3},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:polish"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225604.82,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":29,"messages_count":3,"tokens_used":500,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":28,"node_id":"agent:Editor"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225605.52,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":30,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":90,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":500,"completion_tokens":90,"cost_usd":0.00012900000000000002,"run_id":"flow-loop-01","parent_seq":28,"node_id":"agent:Editor"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225605.55,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":31,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":750.0,"output_preview":"Draft v4"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":28,"node_id":"step:polish"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225605.6,"session_id":"sess-flow-01","agent_name":"","sequence_num":32,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":2450.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":19,"node_id":"step:review_loop"}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225605.65,"session_id":"sess-flow-01","agent_name":"","sequence_num":33,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"decide","pattern":"route"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:decide"}
{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225605.7,"session_id":"sess-flow-01","agent_name":"Editor","sequence_num":34,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Editor","to_agent":"Publisher","kind":"route","reason":"Draft approved by reviewer score 0.92","context_passed":{"score":0.92}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":33,"node_id":"step:decide"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225605.75,"session_id":"sess-flow-01","agent_name":"","sequence_num":35,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":100.0,"route":"publish"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":33,"node_id":"step:decide"}
{"schema_version":"1.1","event_type":"step_start","timestamp":1767225605.8,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":36,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"publish"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1,"node_id":"step:publish"}
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225605.85,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":37,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"post_to_blog","tool_args":{"title":"Weekly digest"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":36,"node_id":"tool:Publisher/post_to_blog"}
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225606.45,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":38,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"post_to_blog","result":"posted /digest/42","duration_ms":600.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":37,"node_id":"tool:Publisher/post_to_blog"}
{"schema_version":"1.1","event_type":"step_end","timestamp":1767225606.5,"session_id":"sess-flow-01","agent_name":"Publisher","sequence_num":39,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":700.0,"output_preview":"posted"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":36,"node_id":"step:publish"}
{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225606.55,"session_id":"sess-flow-01","agent_name":"","sequence_num":40,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":6530.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":1}
{"schema_version":"1.1","event_type":"session_end","timestamp":1767225606.6,"session_id":"sess-flow-01","agent_name":"","sequence_num":41,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"flow-loop-01","parent_seq":0}
-16
View File
@@ -1,16 +0,0 @@
{"schema_version":"1.0","event_type":"session_start","timestamp":1767225600.0,"session_id":"legacy-sess-10","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
{"schema_version":"1.0","event_type":"agent_start","timestamp":1767225600.1,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Assistant"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
{"schema_version":"1.0","event_type":"message_added","timestamp":1767225600.15,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":2,"messages_count":1,"tokens_used":0,"tokens_budget":0,"data":{"role":"user","content":"What is 17 * 23?"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
{"schema_version":"1.0","event_type":"llm_request","timestamp":1767225600.2,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":3,"messages_count":3,"tokens_used":90,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
{"schema_version":"1.0","event_type":"llm_response","timestamp":1767225600.7,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":20,"duration_ms":500.0,"finish_reason":"tool_calls","model":"gpt-4o-mini"},"prompt_tokens":90,"completion_tokens":20,"cost_usd":2.6e-05,"parent_seq":null,"node_id":null}
{"schema_version":"1.0","event_type":"tool_call_start","timestamp":1767225600.75,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"calculator","tool_args":{"expression":"17*23"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
{"schema_version":"1.0","event_type":"tool_call_end","timestamp":1767225600.76,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"calculator","result":"391","duration_ms":10.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
{"schema_version":"1.0","event_type":"llm_request","timestamp":1767225600.8,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":7,"messages_count":3,"tokens_used":130,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
{"schema_version":"1.0","event_type":"llm_response","timestamp":1767225601.2,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":12,"duration_ms":400.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":130,"completion_tokens":12,"cost_usd":2.7e-05,"parent_seq":null,"node_id":null}
{"schema_version":"1.0","event_type":"agent_handoff","timestamp":1767225601.25,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Assistant","to_agent":"Checker","reason":"Double-check the arithmetic","context_passed":{"answer":391}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
{"schema_version":"1.0","event_type":"agent_end","timestamp":1767225601.26,"session_id":"legacy-sess-10","agent_name":"Assistant","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
{"schema_version":"1.0","event_type":"agent_start","timestamp":1767225601.3,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Checker"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
{"schema_version":"1.0","event_type":"llm_request","timestamp":1767225601.35,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":12,"messages_count":3,"tokens_used":60,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"parent_seq":null,"node_id":null}
{"schema_version":"1.0","event_type":"llm_response","timestamp":1767225601.8,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":8,"duration_ms":450.0,"finish_reason":"stop","model":"gpt-4o-mini"},"prompt_tokens":60,"completion_tokens":8,"cost_usd":1.4e-05,"parent_seq":null,"node_id":null}
{"schema_version":"1.0","event_type":"agent_end","timestamp":1767225601.85,"session_id":"legacy-sess-10","agent_name":"Checker","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
{"schema_version":"1.0","event_type":"session_end","timestamp":1767225601.9,"session_id":"legacy-sess-10","agent_name":"","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0}
-388
View File
@@ -1,388 +0,0 @@
"""Regenerate the PraisonAI Replay JSONL fixtures (contract v1, schema 1.1 + 1.0).
Run from the repo root::
python tests/fixtures/replay/make_fixtures.py
The output is deterministic (fixed timestamps, no uuids) so the files are
committed and the tests compare against exact values.
"""
from __future__ import annotations
import json
from pathlib import Path
HERE = Path(__file__).resolve().parent
T0 = 1767225600.0 # 2026-01-01T00:00:00Z
class _Run:
"""Tiny emitter that mimics ContextTraceEmitter's to_dict() output."""
def __init__(self, session_id: str, run_id: str | None, schema: str = "1.1") -> None:
self.session_id = session_id
self.run_id = run_id
self.schema = schema
self.seq = 0
self.events: list[dict] = []
def emit(self, event_type: str, t: float, agent: str = "", data: dict | None = None, **kw) -> int:
ev = {
"schema_version": self.schema,
"event_type": event_type,
"timestamp": round(T0 + t, 3),
"session_id": self.session_id,
"agent_name": agent,
"sequence_num": self.seq,
"messages_count": kw.pop("messages_count", 0),
"tokens_used": kw.pop("tokens_used", 0),
"tokens_budget": kw.pop("tokens_budget", 0),
"data": data or {},
"prompt_tokens": kw.pop("prompt_tokens", 0),
"completion_tokens": kw.pop("completion_tokens", 0),
"cost_usd": kw.pop("cost_usd", 0.0),
}
for key in ("branch_id", "content_hash"):
if kw.get(key) is not None:
ev[key] = kw.pop(key)
else:
kw.pop(key, None)
if self.schema != "1.0":
if self.run_id:
ev["run_id"] = self.run_id
for key in ("parent_seq", "node_id"):
if kw.get(key) is not None:
ev[key] = kw.pop(key)
else:
kw.pop(key, None)
ev.update(kw) # unknown/extra fields pass straight through
self.events.append(ev)
self.seq += 1
return ev["sequence_num"]
def llm(self, t: float, agent: str, dur: float, parent: int | None, *, prompt: int, completion: int,
cost: float, model: str = "gpt-4o-mini", finish: str = "stop", content: str = "",
branch: str | None = None, node: str | None = None) -> None:
self.emit("llm_request", t, agent, {"model": model}, parent_seq=parent, messages_count=3,
tokens_used=prompt, tokens_budget=128000, branch_id=branch, node_id=node)
data = {"response_tokens": completion, "duration_ms": round(dur * 1000, 1),
"finish_reason": finish, "model": model}
if content:
data["response_content"] = content
self.emit("llm_response", t + dur, agent, data, parent_seq=parent, prompt_tokens=prompt,
completion_tokens=completion, cost_usd=cost, branch_id=branch, node_id=node)
def tool(self, t: float, agent: str, name: str, args: dict, dur: float, parent: int | None, *,
result: str, cost: float = 0.0, error: str | None = None, branch: str | None = None) -> None:
node = f"tool:{agent}/{name}" if self.schema != "1.0" else None
s = self.emit("tool_call_start", t, agent, {"tool_name": name, "tool_args": args},
parent_seq=parent, node_id=node, branch_id=branch)
self.emit("tool_call_end", t + dur, agent,
{"tool_name": name, "result": result, "duration_ms": round(dur * 1000, 1), "error": error},
parent_seq=s if self.schema != "1.0" else None, node_id=node, cost_usd=cost, branch_id=branch)
def write(self, name: str, *, partial_tail: str | None = None) -> None:
path = HERE / name
with open(path, "w", encoding="utf-8", newline="\n") as fh:
for ev in self.events:
fh.write(json.dumps(ev, separators=(",", ":")) + "\n")
if partial_tail:
fh.write(partial_tail)
print(f"wrote {path.name}: {len(self.events)} events")
def team_handoff() -> None:
"""Hierarchical 2-agent team: Manager delegates, Researcher hands off to Writer.
Researcher calls web_search three times in a row (collapses to x3); Writer
asks for approval to run write_file, the human approves with edited args.
"""
r = _Run("sess-team-01", "team-handoff-01")
graph = {
"schema": "praisonai.graph/1", "kind": "team", "name": "Solar sail brief", "process": "hierarchical",
"nodes": [
{"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}},
{"id": "manager:Manager", "kind": "manager", "label": "Manager", "parent": None, "meta": {}},
{"id": "task:research", "kind": "task", "label": "research", "parent": None, "meta": {}},
{"id": "task:write", "kind": "task", "label": "write", "parent": None, "meta": {}},
{"id": "agent:Researcher", "kind": "agent", "label": "Researcher", "parent": None, "meta": {}},
{"id": "agent:Writer", "kind": "agent", "label": "Writer", "parent": None, "meta": {}},
{"id": "tool:Researcher/web_search", "kind": "tool", "label": "web_search", "parent": None, "meta": {}},
{"id": "tool:Writer/write_file", "kind": "tool", "label": "write_file", "parent": None, "meta": {}},
{"id": "human:you", "kind": "human", "label": "You", "parent": None, "meta": {}},
{"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}},
],
"edges": [
{"id": "start:begin->manager:Manager:next", "source": "start:begin", "target": "manager:Manager", "kind": "next", "label": None},
{"id": "manager:Manager->task:research:delegates", "source": "manager:Manager", "target": "task:research", "kind": "delegates", "label": None},
{"id": "manager:Manager->task:write:delegates", "source": "manager:Manager", "target": "task:write", "kind": "delegates", "label": None},
{"id": "task:write->task:research:depends_on", "source": "task:write", "target": "task:research", "kind": "depends_on", "label": None},
{"id": "task:research->agent:Researcher:assigned", "source": "task:research", "target": "agent:Researcher", "kind": "assigned", "label": None},
{"id": "task:write->agent:Writer:assigned", "source": "task:write", "target": "agent:Writer", "kind": "assigned", "label": None},
{"id": "agent:Researcher->agent:Writer:handoff", "source": "agent:Researcher", "target": "agent:Writer", "kind": "handoff", "label": None},
{"id": "agent:Researcher->tool:Researcher/web_search:uses", "source": "agent:Researcher", "target": "tool:Researcher/web_search", "kind": "uses", "label": None},
{"id": "agent:Writer->tool:Writer/write_file:uses", "source": "agent:Writer", "target": "tool:Writer/write_file", "kind": "uses", "label": None},
{"id": "human:you->tool:Writer/write_file:reviews", "source": "human:you", "target": "tool:Writer/write_file", "kind": "reviews", "label": None},
{"id": "agent:Writer->end:done:next", "source": "agent:Writer", "target": "end:done", "kind": "next", "label": None},
],
}
s0 = r.emit("session_start", 0.0, "", {"source": "record"})
w = r.emit("workflow_start", 0.05, "", {"kind": "team", "name": "Solar sail brief", "process": "hierarchical",
"graph": graph}, parent_seq=s0)
r.emit("message_added", 0.1, "", {"role": "user", "content": "Write a short brief on solar sails."}, parent_seq=w)
r.llm(0.2, "Manager", 0.6, w, prompt=210, completion=48, cost=0.000060, node="manager:Manager",
content="Delegate research first, then writing.")
r.emit("agent_handoff", 0.85, "Manager", {
"from_agent": "Manager", "to_agent": "Researcher", "kind": "delegate",
"reason": "Research has to come before writing.",
"context_passed": {"task_snapshot": {"task": "research", "description": "Find three facts about solar sails",
"expected_output": "3 bullet facts", "status": "not started"}},
}, parent_seq=w, node_id="manager:Manager")
t1 = r.emit("task_start", 0.9, "Researcher", {"name": "research", "agent": "Researcher",
"description_preview": "Find three facts about solar sails"},
parent_seq=w, node_id="task:research")
a1 = r.emit("agent_start", 0.95, "Researcher", {"role": "Researcher", "goal": "Find facts"},
parent_seq=t1, node_id="agent:Researcher")
r.emit("message_added", 1.0, "Researcher", {"role": "user", "content": "Find three facts about solar sails"},
parent_seq=a1)
r.llm(1.05, "Researcher", 0.9, a1, prompt=320, completion=40, cost=0.000072, finish="tool_calls",
node="agent:Researcher")
r.tool(2.0, "Researcher", "web_search", {"query": "solar sail facts"}, 1.2, a1,
result="IKAROS (2010) was the first interplanetary solar sail.", cost=0.002)
r.tool(3.25, "Researcher", "web_search", {"query": "solar sail missions"}, 1.1, a1,
result="LightSail 2 raised its orbit using sunlight in 2019.", cost=0.002)
r.tool(4.4, "Researcher", "web_search", {"query": "solar sail physics"}, 0.9, a1,
result="Photon pressure at 1 AU is about 9 micronewtons per square metre.", cost=0.002)
r.llm(5.35, "Researcher", 1.4, a1, prompt=880, completion=160, cost=0.000228, node="agent:Researcher",
content="1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2")
r.emit("agent_end", 6.8, "Researcher", {"status": "completed"}, parent_seq=a1, node_id="agent:Researcher")
r.emit("task_end", 6.85, "Researcher", {"status": "completed", "retries": 0, "duration_ms": 5950.0,
"output_preview": "1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2"},
parent_seq=t1, node_id="task:research")
r.emit("agent_handoff", 6.9, "Researcher", {
"from_agent": "Researcher", "to_agent": "Writer", "kind": "handoff",
"reason": "Facts gathered; needs drafting.",
"context_passed": {"facts": ["IKAROS 2010", "LightSail 2 2019", "~9 uN/m^2 at 1 AU"]},
}, parent_seq=w, node_id="agent:Researcher")
t2 = r.emit("task_start", 6.95, "Writer", {"name": "write", "agent": "Writer",
"description_preview": "Draft a 100-word brief into draft.md"},
parent_seq=w, node_id="task:write")
a2 = r.emit("agent_start", 7.0, "Writer", {"role": "Writer"}, parent_seq=t2, node_id="agent:Writer")
r.llm(7.05, "Writer", 1.6, a2, prompt=640, completion=220, cost=0.000228, finish="tool_calls",
node="agent:Writer")
ap = r.emit("approval_requested", 8.7, "Writer", {
"approval_id": "appr-0001", "tool": "write_file",
"args": {"path": "draft.md", "content": "[REDACTED]"}, "risk": "high", "kind": "tool_call",
}, parent_seq=a2, node_id="tool:Writer/write_file")
r.emit("approval_decided", 11.2, "", {
"approval_id": "appr-0001", "approved": True, "reason": "Use the docs folder.",
"approver": "user", "modified": True,
}, parent_seq=ap, node_id="tool:Writer/write_file")
r.tool(11.25, "Writer", "write_file", {"path": "docs/draft.md", "content": "[REDACTED]"}, 0.05, a2,
result="Wrote 642 bytes to docs/draft.md")
r.llm(11.35, "Writer", 0.7, a2, prompt=900, completion=30, cost=0.000153, node="agent:Writer",
content="Saved the brief to docs/draft.md.")
r.emit("agent_end", 12.1, "Writer", {"status": "completed"}, parent_seq=a2, node_id="agent:Writer")
r.emit("task_end", 12.15, "Writer", {"status": "completed", "retries": 0, "duration_ms": 5200.0,
"output_preview": "Saved the brief to docs/draft.md."},
parent_seq=t2, node_id="task:write")
r.emit("workflow_end", 12.2, "", {"status": "completed", "duration_ms": 12150.0}, parent_seq=w)
r.emit("session_end", 12.25, "", {}, parent_seq=s0)
r.write("team_handoff.jsonl")
def flow_parallel_loop() -> None:
"""AgentFlow: plan -> parallel(gather news, papers) -> loop(polish x3) -> route(decide) -> publish.
Both parallel branches run the same agent (Fetcher) so they must appear as
two sub-lanes. Includes an unknown event type and an unknown field to prove
readers tolerate them.
"""
r = _Run("sess-flow-01", "flow-loop-01")
graph = {
"schema": "praisonai.graph/1", "kind": "flow", "name": "Weekly digest", "process": "flow",
"nodes": [
{"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}},
{"id": "step:plan", "kind": "step", "label": "plan", "parent": None, "meta": {"agent": "Planner"}},
{"id": "step:gather", "kind": "fork", "label": "gather", "parent": None, "meta": {"pattern": "parallel"}},
{"id": "step:news", "kind": "step", "label": "news", "parent": "step:gather", "meta": {"agent": "Fetcher"}},
{"id": "step:papers", "kind": "step", "label": "papers", "parent": "step:gather", "meta": {"agent": "Fetcher"}},
{"id": "step:merge", "kind": "join", "label": "merge", "parent": None, "meta": {}},
{"id": "step:review_loop", "kind": "step", "label": "review_loop", "parent": None, "meta": {"pattern": "loop"}},
{"id": "step:polish", "kind": "step", "label": "polish", "parent": "step:review_loop", "meta": {"agent": "Editor"}},
{"id": "step:decide", "kind": "decision", "label": "decide", "parent": None, "meta": {"pattern": "route"}},
{"id": "step:publish", "kind": "step", "label": "publish", "parent": None, "meta": {"agent": "Publisher"}},
{"id": "step:archive", "kind": "step", "label": "archive", "parent": None, "meta": {}},
{"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}},
],
"edges": [
{"id": "start:begin->step:plan:next", "source": "start:begin", "target": "step:plan", "kind": "next", "label": None},
{"id": "step:plan->step:gather:next", "source": "step:plan", "target": "step:gather", "kind": "next", "label": None},
{"id": "step:gather->step:news:parallel", "source": "step:gather", "target": "step:news", "kind": "parallel", "label": None},
{"id": "step:gather->step:papers:parallel", "source": "step:gather", "target": "step:papers", "kind": "parallel", "label": None},
{"id": "step:news->step:merge:next", "source": "step:news", "target": "step:merge", "kind": "next", "label": None},
{"id": "step:papers->step:merge:next", "source": "step:papers", "target": "step:merge", "kind": "next", "label": None},
{"id": "step:merge->step:review_loop:next", "source": "step:merge", "target": "step:review_loop", "kind": "next", "label": None},
{"id": "step:review_loop->step:polish:loop", "source": "step:review_loop", "target": "step:polish", "kind": "loop", "label": "until approved"},
{"id": "step:polish->step:review_loop:loop", "source": "step:polish", "target": "step:review_loop", "kind": "loop", "label": None},
{"id": "step:review_loop->step:decide:next", "source": "step:review_loop", "target": "step:decide", "kind": "next", "label": None},
{"id": "step:decide->step:publish:routes", "source": "step:decide", "target": "step:publish", "kind": "routes", "label": "publish"},
{"id": "step:decide->step:archive:routes", "source": "step:decide", "target": "step:archive", "kind": "routes", "label": "archive"},
{"id": "step:publish->end:done:next", "source": "step:publish", "target": "end:done", "kind": "next", "label": None},
{"id": "step:archive->end:done:next", "source": "step:archive", "target": "end:done", "kind": "next", "label": None},
],
}
s0 = r.emit("session_start", 0.0, "", {})
w = r.emit("workflow_start", 0.02, "", {"kind": "flow", "name": "Weekly digest", "process": "flow",
"graph": graph}, parent_seq=s0)
sp = r.emit("step_start", 0.05, "Planner", {"name": "plan"}, parent_seq=w, node_id="step:plan")
a = r.emit("agent_start", 0.06, "Planner", {}, parent_seq=sp, node_id="agent:Planner")
r.llm(0.1, "Planner", 0.8, a, prompt=150, completion=60, cost=0.000059, node="agent:Planner")
r.emit("agent_end", 0.95, "Planner", {}, parent_seq=a, node_id="agent:Planner")
r.emit("step_end", 1.0, "Planner", {"status": "completed", "duration_ms": 950.0,
"output_preview": "Gather news and papers."}, parent_seq=sp, node_id="step:plan")
g = r.emit("step_start", 1.05, "", {"name": "gather", "pattern": "parallel"}, parent_seq=w, node_id="step:gather")
# two branches interleaved in time, same agent
b0 = r.emit("step_start", 1.1, "Fetcher", {"name": "news"}, parent_seq=g, node_id="step:news", branch_id="gather/0")
b1 = r.emit("step_start", 1.1, "Fetcher", {"name": "papers"}, parent_seq=g, node_id="step:papers", branch_id="gather/1")
r.emit("tool_call_start", 1.15, "Fetcher", {"tool_name": "fetch_rss", "tool_args": {"url": "https://example.org/news.rss"}},
parent_seq=b0, node_id="tool:Fetcher/fetch_rss", branch_id="gather/0")
r.emit("tool_call_start", 1.16, "Fetcher", {"tool_name": "search_papers", "tool_args": {"query": "agent tracing"}},
parent_seq=b1, node_id="tool:Fetcher/search_papers", branch_id="gather/1")
r.emit("tool_call_end", 2.4, "Fetcher", {"tool_name": "fetch_rss", "result": "12 items", "duration_ms": 1250.0, "error": None},
parent_seq=r.seq - 2, node_id="tool:Fetcher/fetch_rss", branch_id="gather/0")
r.emit("tool_call_end", 3.0, "Fetcher", {"tool_name": "search_papers", "result": "5 papers", "duration_ms": 1840.0, "error": None},
parent_seq=r.seq - 2, node_id="tool:Fetcher/search_papers", branch_id="gather/1", cost_usd=0.001)
r.emit("step_end", 2.45, "Fetcher", {"status": "completed", "duration_ms": 1350.0, "output_preview": "12 items"},
parent_seq=b0, node_id="step:news", branch_id="gather/0")
r.emit("step_end", 3.05, "Fetcher", {"status": "completed", "duration_ms": 1950.0, "output_preview": "5 papers"},
parent_seq=b1, node_id="step:papers", branch_id="gather/1")
r.emit("step_end", 3.1, "", {"status": "completed", "duration_ms": 2050.0}, parent_seq=g, node_id="step:gather")
# an event type this reader has never heard of, plus an unknown field
r.emit("context_rebalance", 3.12, "", {"note": "future event type"}, parent_seq=w, future_field={"x": 1})
lp = r.emit("step_start", 3.15, "", {"name": "review_loop", "pattern": "loop"}, parent_seq=w, node_id="step:review_loop")
t = 3.2
for i in range(3):
ps = r.emit("step_start", t, "Editor", {"name": "polish", "iteration": i + 1}, parent_seq=lp, node_id="step:polish")
r.llm(t + 0.02, "Editor", 0.7, ps, prompt=400 + 50 * i, completion=90, cost=0.000114 + 0.0000075 * i,
node="agent:Editor")
r.emit("step_end", t + 0.75, "Editor", {"status": "completed", "duration_ms": 750.0,
"output_preview": f"Draft v{i + 2}"}, parent_seq=ps, node_id="step:polish")
t += 0.8
r.emit("step_end", t, "", {"status": "completed", "duration_ms": round((t - 3.15) * 1000, 1)},
parent_seq=lp, node_id="step:review_loop")
d = r.emit("step_start", t + 0.05, "", {"name": "decide", "pattern": "route"}, parent_seq=w, node_id="step:decide")
r.emit("agent_handoff", t + 0.1, "Editor", {"from_agent": "Editor", "to_agent": "Publisher", "kind": "route",
"reason": "Draft approved by reviewer score 0.92",
"context_passed": {"score": 0.92}}, parent_seq=d, node_id="step:decide")
r.emit("step_end", t + 0.15, "", {"status": "completed", "duration_ms": 100.0, "route": "publish"},
parent_seq=d, node_id="step:decide")
pb = r.emit("step_start", t + 0.2, "Publisher", {"name": "publish"}, parent_seq=w, node_id="step:publish")
r.tool(t + 0.25, "Publisher", "post_to_blog", {"title": "Weekly digest"}, 0.6, pb, result="posted /digest/42")
r.emit("step_end", t + 0.9, "Publisher", {"status": "completed", "duration_ms": 700.0, "output_preview": "posted"},
parent_seq=pb, node_id="step:publish")
r.emit("workflow_end", t + 0.95, "", {"status": "completed", "duration_ms": round((t + 0.93) * 1000, 1)}, parent_seq=w)
r.emit("session_end", t + 1.0, "", {}, parent_seq=s0)
r.write("flow_parallel_loop.jsonl")
def legacy_v10() -> None:
"""A schema 1.0 file: no run_id, parent_seq, node_id; no workflow events."""
r = _Run("legacy-sess-10", None, schema="1.0")
r.emit("session_start", 0.0, "", {})
r.emit("agent_start", 0.1, "Assistant", {"role": "Assistant"})
r.emit("message_added", 0.15, "Assistant", {"role": "user", "content": "What is 17 * 23?"}, messages_count=1)
r.llm(0.2, "Assistant", 0.5, None, prompt=90, completion=20, cost=0.000026, finish="tool_calls")
r.tool(0.75, "Assistant", "calculator", {"expression": "17*23"}, 0.01, None, result="391")
r.llm(0.8, "Assistant", 0.4, None, prompt=130, completion=12, cost=0.000027)
r.emit("agent_handoff", 1.25, "Assistant", {"from_agent": "Assistant", "to_agent": "Checker",
"reason": "Double-check the arithmetic", "context_passed": {"answer": 391}})
r.emit("agent_end", 1.26, "Assistant", {})
r.emit("agent_start", 1.3, "Checker", {"role": "Checker"})
r.llm(1.35, "Checker", 0.45, None, prompt=60, completion=8, cost=0.000014)
r.emit("agent_end", 1.85, "Checker", {})
r.emit("session_end", 1.9, "", {})
r.write("legacy_v10.jsonl")
def team_outside() -> None:
"""Shape of a real recorded run: each agent's LLM span encloses its tool
calls (llm_request -> tool -> llm_response), and after the team finishes a
Triage agent hands off to Billing, neither of which is in the team graph.
"""
r = _Run("sess-outside-01", "team-outside-01")
graph = {
"schema": "praisonai.graph/1", "kind": "team", "name": "EV brief", "process": "sequential",
"nodes": [
{"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}},
{"id": "task:research", "kind": "task", "label": "research", "parent": None, "meta": {}},
{"id": "task:write", "kind": "task", "label": "write", "parent": None, "meta": {}},
{"id": "agent:Researcher", "kind": "agent", "label": "Researcher", "parent": None, "meta": {}},
{"id": "agent:Writer", "kind": "agent", "label": "Writer", "parent": None, "meta": {}},
{"id": "tool:Researcher/lookup_fact", "kind": "tool", "label": "lookup_fact", "parent": None, "meta": {}},
{"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}},
],
"edges": [
{"id": "start:begin->task:research:next", "source": "start:begin", "target": "task:research", "kind": "next", "label": None},
{"id": "task:research->task:write:next", "source": "task:research", "target": "task:write", "kind": "next", "label": None},
{"id": "task:research->agent:Researcher:assigned", "source": "task:research", "target": "agent:Researcher", "kind": "assigned", "label": None},
{"id": "task:write->agent:Writer:assigned", "source": "task:write", "target": "agent:Writer", "kind": "assigned", "label": None},
{"id": "agent:Researcher->tool:Researcher/lookup_fact:uses", "source": "agent:Researcher", "target": "tool:Researcher/lookup_fact", "kind": "uses", "label": None},
{"id": "task:write->end:done:next", "source": "task:write", "target": "end:done", "kind": "next", "label": None},
],
}
s0 = r.emit("session_start", 0.0, "", {})
w = r.emit("workflow_start", 0.01, "", {"kind": "team", "name": "EV brief", "process": "sequential", "graph": graph}, parent_seq=s0)
t1 = r.emit("task_start", 0.02, "Researcher", {"name": "research", "agent": "Researcher",
"description_preview": "Get one fact about electric vehicles"},
parent_seq=w, node_id="task:research")
a1 = r.emit("agent_start", 0.03, "Researcher", {}, parent_seq=t1, node_id="agent:Researcher")
r.emit("llm_request", 0.05, "Researcher", {"model": "gpt-4o-mini"}, parent_seq=a1, node_id="agent:Researcher")
ts = r.emit("tool_call_start", 2.6, "Researcher", {"tool_name": "lookup_fact", "tool_args": {"topic": "electric vehicles"}},
parent_seq=a1, node_id="tool:Researcher/lookup_fact")
r.emit("tool_call_end", 3.5, "Researcher", {"tool_name": "lookup_fact", "result": "EV sales passed 17 million in 2024",
"duration_ms": 880.0, "error": None},
parent_seq=ts, node_id="tool:Researcher/lookup_fact")
r.emit("llm_response", 5.0, "Researcher", {"response_tokens": 14, "duration_ms": 4950.0, "model": "gpt-4o-mini",
"response_content": "EV sales passed 17 million in 2024."},
parent_seq=a1, node_id="agent:Researcher", prompt_tokens=310, completion_tokens=14, cost_usd=0.000055)
r.emit("agent_end", 5.01, "Researcher", {}, parent_seq=a1, node_id="agent:Researcher")
r.emit("task_end", 5.02, "Researcher", {"status": "completed", "retries": 0, "duration_ms": 5000.0,
"output_preview": "EV sales passed 17 million in 2024."}, parent_seq=t1, node_id="task:research")
t2 = r.emit("task_start", 5.03, "Writer", {"name": "write", "agent": "Writer",
"description_preview": "Write one sentence using the fact"},
parent_seq=w, node_id="task:write")
a2 = r.emit("agent_start", 5.04, "Writer", {}, parent_seq=t2, node_id="agent:Writer")
r.llm(5.05, "Writer", 1.05, a2, prompt=200, completion=25, cost=0.000045, node="agent:Writer",
content="Global EV sales passed 17 million in 2024.")
r.emit("agent_end", 6.11, "Writer", {}, parent_seq=a2, node_id="agent:Writer")
r.emit("task_end", 6.12, "Writer", {"status": "completed", "retries": 0, "duration_ms": 1090.0,
"output_preview": "Global EV sales passed 17 million in 2024."}, parent_seq=t2, node_id="task:write")
r.emit("workflow_end", 6.13, "", {"status": "completed", "duration_ms": 6120.0}, parent_seq=w)
a3 = r.emit("agent_start", 6.2, "Triage", {}, parent_seq=s0, node_id="agent:Triage")
r.emit("llm_request", 6.25, "Triage", {"model": "gpt-4o-mini"}, parent_seq=a3, node_id="agent:Triage")
tt = r.emit("tool_call_start", 7.7, "Triage", {"tool_name": "transfer_to_billing", "tool_args": {}},
parent_seq=a3, node_id="tool:Triage/transfer_to_billing")
r.emit("agent_handoff", 7.71, "Triage", {"from_agent": "Triage", "to_agent": "Billing", "kind": "handoff",
"reason": "Billing question", "context_passed": {"context_policy": "summary"}},
parent_seq=tt, node_id="agent:Billing")
a4 = r.emit("agent_start", 7.72, "Billing", {}, parent_seq=tt, node_id="agent:Billing")
r.llm(7.75, "Billing", 0.92, a4, prompt=150, completion=19, cost=0.00003, node="agent:Billing",
content="Please check your statement for a duplicate charge.")
r.emit("agent_end", 8.68, "Billing", {}, parent_seq=a4, node_id="agent:Billing")
r.emit("tool_call_end", 8.7, "Triage", {"tool_name": "transfer_to_billing", "result": "Handoff successful",
"duration_ms": 1000.0, "error": None},
parent_seq=tt, node_id="tool:Triage/transfer_to_billing")
r.emit("llm_response", 9.6, "Triage", {"response_tokens": 22, "duration_ms": 3350.0, "model": "gpt-4o-mini"},
parent_seq=a3, node_id="agent:Triage", prompt_tokens=240, completion_tokens=22, cost_usd=0.00005)
r.emit("agent_end", 9.61, "Triage", {}, parent_seq=a3, node_id="agent:Triage")
r.emit("session_end", 9.7, "", {}, parent_seq=s0)
r.write("team_outside.jsonl")
if __name__ == "__main__":
team_handoff()
flow_parallel_loop()
legacy_v10()
team_outside()
-36
View File
@@ -1,36 +0,0 @@
{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-team-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"source":"record"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01"}
{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.05,"session_id":"sess-team-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"team","name":"Solar sail brief","process":"hierarchical","graph":{"schema":"praisonai.graph/1","kind":"team","name":"Solar sail brief","process":"hierarchical","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"manager:Manager","kind":"manager","label":"Manager","parent":null,"meta":{}},{"id":"task:research","kind":"task","label":"research","parent":null,"meta":{}},{"id":"task:write","kind":"task","label":"write","parent":null,"meta":{}},{"id":"agent:Researcher","kind":"agent","label":"Researcher","parent":null,"meta":{}},{"id":"agent:Writer","kind":"agent","label":"Writer","parent":null,"meta":{}},{"id":"tool:Researcher/web_search","kind":"tool","label":"web_search","parent":null,"meta":{}},{"id":"tool:Writer/write_file","kind":"tool","label":"write_file","parent":null,"meta":{}},{"id":"human:you","kind":"human","label":"You","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->manager:Manager:next","source":"start:begin","target":"manager:Manager","kind":"next","label":null},{"id":"manager:Manager->task:research:delegates","source":"manager:Manager","target":"task:research","kind":"delegates","label":null},{"id":"manager:Manager->task:write:delegates","source":"manager:Manager","target":"task:write","kind":"delegates","label":null},{"id":"task:write->task:research:depends_on","source":"task:write","target":"task:research","kind":"depends_on","label":null},{"id":"task:research->agent:Researcher:assigned","source":"task:research","target":"agent:Researcher","kind":"assigned","label":null},{"id":"task:write->agent:Writer:assigned","source":"task:write","target":"agent:Writer","kind":"assigned","label":null},{"id":"agent:Researcher->agent:Writer:handoff","source":"agent:Researcher","target":"agent:Writer","kind":"handoff","label":null},{"id":"agent:Researcher->tool:Researcher/web_search:uses","source":"agent:Researcher","target":"tool:Researcher/web_search","kind":"uses","label":null},{"id":"agent:Writer->tool:Writer/write_file:uses","source":"agent:Writer","target":"tool:Writer/write_file","kind":"uses","label":null},{"id":"human:you->tool:Writer/write_file:reviews","source":"human:you","target":"tool:Writer/write_file","kind":"reviews","label":null},{"id":"agent:Writer->end:done:next","source":"agent:Writer","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":0}
{"schema_version":"1.1","event_type":"message_added","timestamp":1767225600.1,"session_id":"sess-team-01","agent_name":"","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"user","content":"Write a short brief on solar sails."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.2,"session_id":"sess-team-01","agent_name":"Manager","sequence_num":3,"messages_count":3,"tokens_used":210,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"manager:Manager"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225600.8,"session_id":"sess-team-01","agent_name":"Manager","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":48,"duration_ms":600.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Delegate research first, then writing."},"prompt_tokens":210,"completion_tokens":48,"cost_usd":6e-05,"run_id":"team-handoff-01","parent_seq":1,"node_id":"manager:Manager"}
{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225600.85,"session_id":"sess-team-01","agent_name":"Manager","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Manager","to_agent":"Researcher","kind":"delegate","reason":"Research has to come before writing.","context_passed":{"task_snapshot":{"task":"research","description":"Find three facts about solar sails","expected_output":"3 bullet facts","status":"not started"}}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"manager:Manager"}
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225600.9,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"research","agent":"Researcher","description_preview":"Find three facts about solar sails"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"task:research"}
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.95,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Researcher","goal":"Find facts"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":6,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"message_added","timestamp":1767225601.0,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"user","content":"Find three facts about solar sails"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225601.05,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":9,"messages_count":3,"tokens_used":320,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225601.95,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":40,"duration_ms":900.0,"finish_reason":"tool_calls","model":"gpt-4o-mini"},"prompt_tokens":320,"completion_tokens":40,"cost_usd":7.2e-05,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225602.0,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","tool_args":{"query":"solar sail facts"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"tool:Researcher/web_search"}
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.2,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":12,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","result":"IKAROS (2010) was the first interplanetary solar sail.","duration_ms":1200.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.002,"run_id":"team-handoff-01","parent_seq":11,"node_id":"tool:Researcher/web_search"}
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225603.25,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","tool_args":{"query":"solar sail missions"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"tool:Researcher/web_search"}
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225604.35,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","result":"LightSail 2 raised its orbit using sunlight in 2019.","duration_ms":1100.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.002,"run_id":"team-handoff-01","parent_seq":13,"node_id":"tool:Researcher/web_search"}
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225604.4,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","tool_args":{"query":"solar sail physics"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"tool:Researcher/web_search"}
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225605.3,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"web_search","result":"Photon pressure at 1 AU is about 9 micronewtons per square metre.","duration_ms":900.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.002,"run_id":"team-handoff-01","parent_seq":15,"node_id":"tool:Researcher/web_search"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225605.35,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":17,"messages_count":3,"tokens_used":880,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225606.75,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":160,"duration_ms":1400.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2"},"prompt_tokens":880,"completion_tokens":160,"cost_usd":0.000228,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225606.8,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":7,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225606.85,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5950.0,"output_preview":"1. IKAROS ... 2. LightSail 2 ... 3. ~9 uN/m^2"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":6,"node_id":"task:research"}
{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225606.9,"session_id":"sess-team-01","agent_name":"Researcher","sequence_num":21,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Researcher","to_agent":"Writer","kind":"handoff","reason":"Facts gathered; needs drafting.","context_passed":{"facts":["IKAROS 2010","LightSail 2 2019","~9 uN/m^2 at 1 AU"]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225606.95,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":22,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"write","agent":"Writer","description_preview":"Draft a 100-word brief into draft.md"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1,"node_id":"task:write"}
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225607.0,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"role":"Writer"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":22,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225607.05,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":24,"messages_count":3,"tokens_used":640,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225608.65,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":25,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":220,"duration_ms":1600.0,"finish_reason":"tool_calls","model":"gpt-4o-mini"},"prompt_tokens":640,"completion_tokens":220,"cost_usd":0.000228,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"approval_requested","timestamp":1767225608.7,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"approval_id":"appr-0001","tool":"write_file","args":{"path":"draft.md","content":"[REDACTED]"},"risk":"high","kind":"tool_call"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"tool:Writer/write_file"}
{"schema_version":"1.1","event_type":"approval_decided","timestamp":1767225611.2,"session_id":"sess-team-01","agent_name":"","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"approval_id":"appr-0001","approved":true,"reason":"Use the docs folder.","approver":"user","modified":true},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":26,"node_id":"tool:Writer/write_file"}
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225611.25,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"write_file","tool_args":{"path":"docs/draft.md","content":"[REDACTED]"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"tool:Writer/write_file"}
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225611.3,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":29,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"write_file","result":"Wrote 642 bytes to docs/draft.md","duration_ms":50.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":28,"node_id":"tool:Writer/write_file"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225611.35,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":30,"messages_count":3,"tokens_used":900,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225612.05,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":31,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":30,"duration_ms":700.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Saved the brief to docs/draft.md."},"prompt_tokens":900,"completion_tokens":30,"cost_usd":0.000153,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225612.1,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":32,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":23,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225612.15,"session_id":"sess-team-01","agent_name":"Writer","sequence_num":33,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5200.0,"output_preview":"Saved the brief to docs/draft.md."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":22,"node_id":"task:write"}
{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225612.2,"session_id":"sess-team-01","agent_name":"","sequence_num":34,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":12150.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":1}
{"schema_version":"1.1","event_type":"session_end","timestamp":1767225612.25,"session_id":"sess-team-01","agent_name":"","sequence_num":35,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-handoff-01","parent_seq":0}
-29
View File
@@ -1,29 +0,0 @@
{"schema_version":"1.1","event_type":"session_start","timestamp":1767225600.0,"session_id":"sess-outside-01","agent_name":"","sequence_num":0,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01"}
{"schema_version":"1.1","event_type":"workflow_start","timestamp":1767225600.01,"session_id":"sess-outside-01","agent_name":"","sequence_num":1,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"kind":"team","name":"EV brief","process":"sequential","graph":{"schema":"praisonai.graph/1","kind":"team","name":"EV brief","process":"sequential","nodes":[{"id":"start:begin","kind":"start","label":"Brief","parent":null,"meta":{}},{"id":"task:research","kind":"task","label":"research","parent":null,"meta":{}},{"id":"task:write","kind":"task","label":"write","parent":null,"meta":{}},{"id":"agent:Researcher","kind":"agent","label":"Researcher","parent":null,"meta":{}},{"id":"agent:Writer","kind":"agent","label":"Writer","parent":null,"meta":{}},{"id":"tool:Researcher/lookup_fact","kind":"tool","label":"lookup_fact","parent":null,"meta":{}},{"id":"end:done","kind":"end","label":"Delivered","parent":null,"meta":{}}],"edges":[{"id":"start:begin->task:research:next","source":"start:begin","target":"task:research","kind":"next","label":null},{"id":"task:research->task:write:next","source":"task:research","target":"task:write","kind":"next","label":null},{"id":"task:research->agent:Researcher:assigned","source":"task:research","target":"agent:Researcher","kind":"assigned","label":null},{"id":"task:write->agent:Writer:assigned","source":"task:write","target":"agent:Writer","kind":"assigned","label":null},{"id":"agent:Researcher->tool:Researcher/lookup_fact:uses","source":"agent:Researcher","target":"tool:Researcher/lookup_fact","kind":"uses","label":null},{"id":"task:write->end:done:next","source":"task:write","target":"end:done","kind":"next","label":null}]}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0}
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225600.02,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":2,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"research","agent":"Researcher","description_preview":"Get one fact about electric vehicles"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1,"node_id":"task:research"}
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225600.03,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":3,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":2,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225600.05,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":4,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225602.6,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":5,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"lookup_fact","tool_args":{"topic":"electric vehicles"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"tool:Researcher/lookup_fact"}
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225603.5,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":6,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"lookup_fact","result":"EV sales passed 17 million in 2024","duration_ms":880.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":5,"node_id":"tool:Researcher/lookup_fact"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225605.0,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":7,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":14,"duration_ms":4950.0,"model":"gpt-4o-mini","response_content":"EV sales passed 17 million in 2024."},"prompt_tokens":310,"completion_tokens":14,"cost_usd":5.5e-05,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225605.01,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":8,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":3,"node_id":"agent:Researcher"}
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225605.02,"session_id":"sess-outside-01","agent_name":"Researcher","sequence_num":9,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":5000.0,"output_preview":"EV sales passed 17 million in 2024."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":2,"node_id":"task:research"}
{"schema_version":"1.1","event_type":"task_start","timestamp":1767225605.03,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":10,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"name":"write","agent":"Writer","description_preview":"Write one sentence using the fact"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1,"node_id":"task:write"}
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225605.04,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":11,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":10,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225605.05,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":12,"messages_count":3,"tokens_used":200,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225606.1,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":13,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":25,"duration_ms":1050.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Global EV sales passed 17 million in 2024."},"prompt_tokens":200,"completion_tokens":25,"cost_usd":4.5e-05,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225606.11,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":14,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":11,"node_id":"agent:Writer"}
{"schema_version":"1.1","event_type":"task_end","timestamp":1767225606.12,"session_id":"sess-outside-01","agent_name":"Writer","sequence_num":15,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","retries":0,"duration_ms":1090.0,"output_preview":"Global EV sales passed 17 million in 2024."},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":10,"node_id":"task:write"}
{"schema_version":"1.1","event_type":"workflow_end","timestamp":1767225606.13,"session_id":"sess-outside-01","agent_name":"","sequence_num":16,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"status":"completed","duration_ms":6120.0},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":1}
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225606.2,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":17,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0,"node_id":"agent:Triage"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225606.25,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":18,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"}
{"schema_version":"1.1","event_type":"tool_call_start","timestamp":1767225607.7,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":19,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"transfer_to_billing","tool_args":{}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"tool:Triage/transfer_to_billing"}
{"schema_version":"1.1","event_type":"agent_handoff","timestamp":1767225607.71,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":20,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"from_agent":"Triage","to_agent":"Billing","kind":"handoff","reason":"Billing question","context_passed":{"context_policy":"summary"}},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"agent:Billing"}
{"schema_version":"1.1","event_type":"agent_start","timestamp":1767225607.72,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":21,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"agent:Billing"}
{"schema_version":"1.1","event_type":"llm_request","timestamp":1767225607.75,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":22,"messages_count":3,"tokens_used":150,"tokens_budget":128000,"data":{"model":"gpt-4o-mini"},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225608.67,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":23,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":19,"duration_ms":920.0,"finish_reason":"stop","model":"gpt-4o-mini","response_content":"Please check your statement for a duplicate charge."},"prompt_tokens":150,"completion_tokens":19,"cost_usd":3e-05,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"}
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225608.68,"session_id":"sess-outside-01","agent_name":"Billing","sequence_num":24,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":21,"node_id":"agent:Billing"}
{"schema_version":"1.1","event_type":"tool_call_end","timestamp":1767225608.7,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":25,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"tool_name":"transfer_to_billing","result":"Handoff successful","duration_ms":1000.0,"error":null},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":19,"node_id":"tool:Triage/transfer_to_billing"}
{"schema_version":"1.1","event_type":"llm_response","timestamp":1767225609.6,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":26,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{"response_tokens":22,"duration_ms":3350.0,"model":"gpt-4o-mini"},"prompt_tokens":240,"completion_tokens":22,"cost_usd":5e-05,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"}
{"schema_version":"1.1","event_type":"agent_end","timestamp":1767225609.61,"session_id":"sess-outside-01","agent_name":"Triage","sequence_num":27,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":17,"node_id":"agent:Triage"}
{"schema_version":"1.1","event_type":"session_end","timestamp":1767225609.7,"session_id":"sess-outside-01","agent_name":"","sequence_num":28,"messages_count":0,"tokens_used":0,"tokens_budget":0,"data":{},"prompt_tokens":0,"completion_tokens":0,"cost_usd":0.0,"run_id":"team-outside-01","parent_seq":0}
-509
View File
@@ -1,509 +0,0 @@
// node --test tests/js/replay-core.test.mjs
// Pure-function tests for the PraisonAI Replay model (no DOM, no network).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..', '..');
const coreUrl = pathToFileURL(path.join(root, 'src/praisonaiui/templates/frontend/plugins/views/replay-core.js'));
const core = await import(coreUrl.href);
const { buildModel, narrate, layoutMap, deriveGraph, formatCost, formatDuration } = core;
function load(name) {
const text = fs.readFileSync(path.join(root, 'tests/fixtures/replay', `${name}.jsonl`), 'utf8');
const events = text.split('\n').filter(Boolean).map((l) => JSON.parse(l));
const ws = events.find((e) => e.event_type === 'workflow_start');
return { events, graph: ws && ws.data && ws.data.graph ? ws.data.graph : null };
}
// ── buildModel: lanes ────────────────────────────────────────────────
test('team run: You and Manager lanes come first, agents follow graph order', () => {
const { events, graph } = load('team_handoff');
const m = buildModel(events, graph);
assert.deepEqual(m.lanes.map((l) => l.id), ['human:you', 'manager:Manager', 'agent:Researcher', 'agent:Writer']);
assert.deepEqual(m.lanes.map((l) => l.shape), ['diamond', 'square', 'circle', 'circle']);
assert.equal(m.lanes[0].label, 'You');
});
test('parallel branches of one agent become sub-lanes labelled by step', () => {
const { events, graph } = load('flow_parallel_loop');
const m = buildModel(events, graph);
const subs = m.lanes.filter((l) => l.parent === 'agent:Fetcher');
assert.deepEqual(subs.map((l) => [l.id, l.branch, l.label]), [
['agent:Fetcher#gather/0', 'gather/0', 'news'],
['agent:Fetcher#gather/1', 'gather/1', 'papers'],
]);
const tools = m.blocks.filter((b) => b.kind === 'tool' && b.agent === 'Fetcher');
assert.deepEqual(tools.map((b) => b.lane), ['agent:Fetcher#gather/0', 'agent:Fetcher#gather/1']);
// the two branches overlap in time: that is why they need their own rows
assert.ok(tools[1].start < tools[0].end);
});
test('a 1.0 legacy file still yields lanes, blocks and a hand-off', () => {
const { events } = load('legacy_v10');
const m = buildModel(events, null);
assert.deepEqual(m.lanes.map((l) => l.id), ['human:you', 'agent:Assistant', 'agent:Checker']);
assert.deepEqual(m.blocks.map((b) => `${b.kind}:${b.label}`), ['llm:gpt-4o-mini', 'tool:calculator', 'llm:gpt-4o-mini', 'llm:gpt-4o-mini']);
assert.equal(m.arrows.length, 1);
assert.equal(m.arrows[0].kind, 'handoff');
assert.equal(m.chapters.length, 0);
});
// ── buildModel: arrows ───────────────────────────────────────────────
test('delegate and hand-off arrows connect the right lanes', () => {
const { events, graph } = load('team_handoff');
const m = buildModel(events, graph);
const flows = m.arrows.map((a) => `${a.kind}:${a.from}->${a.to}`);
assert.deepEqual(flows, [
'delegate:manager:Manager->agent:Researcher',
'handoff:agent:Researcher->agent:Writer',
'ask:agent:Writer->human:you',
'answer:human:you->agent:Writer',
]);
assert.equal(m.arrows[0].label, 'delegates');
assert.equal(m.arrows[3].label, 'approved');
});
test('route arrows from a flow', () => {
const { events, graph } = load('flow_parallel_loop');
const m = buildModel(events, graph);
assert.deepEqual(m.arrows.map((a) => [a.kind, a.from, a.to]), [['route', 'agent:Editor', 'agent:Publisher']]);
});
// ── buildModel: xN collapse ──────────────────────────────────────────
test('three consecutive web_search calls collapse to one block x3', () => {
const { events, graph } = load('team_handoff');
const m = buildModel(events, graph);
const ws = m.blocks.filter((b) => b.label === 'web_search');
assert.equal(ws.length, 1);
assert.equal(ws[0].count, 3);
assert.equal(ws[0].members.length, 3);
assert.ok(Math.abs(ws[0].cost - 0.006) < 1e-9);
assert.ok(Math.abs(ws[0].durationMs - 3200) < 1e-6);
// every member's events map to the collapsed block
const lines = m.narration.filter((n) => n.text.includes('web_search'));
assert.equal(lines.length, 3);
assert.ok(lines.every((n) => n.blockId === ws[0].id));
});
test('loop passes collapse: polish chapter x3 and Editor LLM block x3', () => {
const { events, graph } = load('flow_parallel_loop');
const m = buildModel(events, graph);
const polish = m.chapters.filter((c) => c.nodeId === 'step:polish');
assert.equal(polish.length, 1);
assert.equal(polish[0].count, 3);
assert.equal(polish[0].depth, 1);
const editor = m.blocks.filter((b) => b.agent === 'Editor');
assert.equal(editor.length, 1);
assert.equal(editor[0].count, 3);
assert.equal(editor[0].tokens, 490 + 540 + 590);
});
test('non-adjacent repeats are not collapsed', () => {
const { events, graph } = load('team_handoff');
const m = buildModel(events, graph);
const researcherLlm = m.blocks.filter((b) => b.agent === 'Researcher' && b.kind === 'llm');
assert.equal(researcherLlm.length, 2); // llm, tool x3, llm
assert.ok(researcherLlm.every((b) => b.count === 1));
});
// ── buildModel: chapters, approvals, totals, live ────────────────────
test('chapters carry depth, rows and the chosen route', () => {
const { events, graph } = load('flow_parallel_loop');
const m = buildModel(events, graph);
const byNode = Object.fromEntries(m.chapters.map((c) => [c.nodeId, c]));
assert.equal(byNode['step:gather'].depth, 0);
assert.equal(byNode['step:news'].depth, 1);
assert.notEqual(byNode['step:news'].row, byNode['step:papers'].row); // overlapping -> own rows
assert.equal(byNode['step:decide'].route, 'publish');
assert.equal(m.chapterRows, 3);
});
test('approval sits in the You lane with its decision', () => {
const { events, graph } = load('team_handoff');
const m = buildModel(events, graph);
const ap = m.blocks.find((b) => b.kind === 'approval');
assert.equal(ap.lane, 'human:you');
assert.equal(ap.status, 'approved');
assert.equal(ap.approvalId, 'appr-0001');
assert.equal(m.pending.length, 0);
});
test('a run cut before the decision has one pending approval and open blocks', () => {
const { events, graph } = load('team_handoff');
const cut = events.slice(0, events.findIndex((e) => e.event_type === 'approval_decided'));
const m = buildModel(cut, graph);
assert.equal(m.pending.length, 1);
assert.equal(m.pending[0].approvalId, 'appr-0001');
assert.equal(m.pending[0].open, true);
const half = events.slice(0, events.findIndex((e) => e.event_type === 'tool_call_end'));
const m2 = buildModel(half, graph);
const running = m2.blocks.find((b) => b.kind === 'tool');
assert.equal(running.open, true);
assert.equal(running.status, 'running');
assert.match(m2.narration.at(-1).text, /^Researcher is asking web_search for 'solar sail facts'…$/);
});
test('totals per agent and for the run', () => {
const { events, graph } = load('team_handoff');
const m = buildModel(events, graph);
assert.equal(m.totals.llm_calls, 5);
assert.equal(m.totals.tool_calls, 4);
assert.ok(Math.abs(m.totals.cost_usd - 0.006741) < 1e-9);
const r = m.totals.agents['agent:Researcher'];
assert.ok(Math.abs(r.cost_usd - (0.000072 + 0.006 + 0.000228)) < 1e-9);
assert.equal(r.tool_calls, 3);
assert.ok(Math.abs(m.totals.duration_ms - 12250) < 1e-6);
});
test('sub-lane work rolls up to the parent agent total', () => {
const { events, graph } = load('flow_parallel_loop');
const m = buildModel(events, graph);
const f = m.totals.agents['agent:Fetcher'];
assert.equal(f.tool_calls, 2);
assert.ok(Math.abs(f.cost_usd - 0.001) < 1e-9);
assert.equal(m.totals.agents['agent:Fetcher#gather/0'], undefined);
});
test('unknown event types and fields are tolerated and skipped', () => {
const { events, graph } = load('flow_parallel_loop');
assert.ok(events.some((e) => e.event_type === 'context_rebalance'));
const m = buildModel(events, graph);
assert.ok(!m.narration.some((n) => n.type === 'context_rebalance'));
assert.equal(m.events.length, events.length);
});
test('duplicates (same session:seq, e.g. after an SSE reconnect) are dropped; order follows sequence_num', () => {
const { events, graph } = load('legacy_v10');
const shuffled = [...events].reverse().concat(events.slice(0, 3));
const m = buildModel(shuffled, graph);
assert.equal(m.events.length, events.length);
assert.deepEqual(m.events.map((e) => e.sequence_num), events.map((e) => e.sequence_num));
});
test('empty input is fine', () => {
const m = buildModel([], null);
assert.equal(m.blocks.length, 0);
assert.equal(m.narration.length, 0);
assert.deepEqual(m.lanes.map((l) => l.id), ['human:you']);
});
// ── narrate ──────────────────────────────────────────────────────────
const ev = (event_type, data = {}, extra = {}) => ({ event_type, data, agent_name: 'Researcher', timestamp: 100, sequence_num: 1, session_id: 's', ...extra });
test('narrate: tool call with its end gives duration and cost', () => {
const start = ev('tool_call_start', { tool_name: 'web_search', tool_args: { query: 'X' } });
const end = ev('tool_call_end', { tool_name: 'web_search', duration_ms: 1200 }, { cost_usd: 0.002, timestamp: 101.2 });
assert.equal(narrate(start, end), "Researcher asked web_search for 'X' — 1.2 s, $0.002.");
assert.equal(narrate(start), "Researcher is asking web_search for 'X'…");
const failed = ev('tool_call_end', { tool_name: 'web_search', duration_ms: 300, error: 'HTTP 500' });
assert.equal(narrate(start, failed), "Researcher asked web_search for 'X' — failed after 300 ms: HTTP 500.");
});
test('narrate: hand-off kinds', () => {
assert.equal(
narrate(ev('agent_handoff', { from_agent: 'Researcher', to_agent: 'Writer', reason: 'Facts gathered.' })),
"Researcher handed off to Writer: 'Facts gathered'.",
);
assert.equal(narrate(ev('agent_handoff', { from_agent: 'Manager', to_agent: 'Writer', kind: 'delegate' }, { agent_name: 'Manager' })), 'Manager delegated to Writer.');
assert.equal(narrate(ev('agent_handoff', { from_agent: 'A', to_agent: 'B', kind: 'route' })), 'A routed to B.');
assert.equal(narrate(ev('agent_handoff', { from_agent: 'A', to_agent: 'B', kind: 'spawn' })), 'A spawned B.');
assert.equal(narrate(ev('agent_handoff', { from_agent: 'A', to_agent: 'B', kind: 'weird' })), 'A handed off to B.');
});
test('narrate: LLM response with model, tokens, cost', () => {
const r = ev('llm_response', { duration_ms: 900, model: 'gpt-4o-mini' }, { prompt_tokens: 120, completion_tokens: 40, cost_usd: 0.0004 });
assert.equal(narrate(r), 'Researcher thought with gpt-4o-mini — 900 ms, 160 tokens, $0.0004.');
});
test('narrate: approvals, human and automatic', () => {
const req = ev('approval_requested', { approval_id: 'a1', tool: 'write_file', risk: 'high', kind: 'tool_call' }, { agent_name: 'Writer' });
assert.equal(narrate(req), 'Writer asked you to approve write_file (high risk).');
assert.equal(narrate(ev('approval_decided', { approved: true, approver: 'user', modified: true }), req), 'You approved write_file with edited arguments.');
assert.equal(narrate(ev('approval_decided', { approved: true, approver: 'env' }), req), 'write_file was approved automatically (env).');
assert.equal(narrate(ev('approval_decided', { approved: false, approver: 'user', reason: 'Not now' }), req), "You denied write_file: 'Not now'.");
assert.equal(narrate(ev('approval_decided', { approved: false, approver: 'timeout' }), req), 'write_file was denied: no answer before the timeout.');
});
test('narrate: tasks, steps, routes, workflow', () => {
assert.equal(narrate(ev('task_start', { name: 'research', agent: 'Researcher' })), "Task 'research' went to Researcher.");
assert.equal(narrate(ev('task_end', { status: 'failed', duration_ms: 2500, retries: 2 }, { node_id: 'task:research' })), "Task 'research' failed after 2.5 s, 2 retries.");
assert.equal(narrate(ev('step_end', { route: 'publish' }), ev('step_start', { name: 'decide' })), "Step 'decide' chose route 'publish'.");
assert.equal(narrate(ev('step_end', { status: 'cached' }, { node_id: 'step:fetch' })), "Step 'fetch' reused a cached result.");
assert.equal(narrate(ev('step_start', { name: 'gather', pattern: 'parallel' })), "Step 'gather' fanned out in parallel.");
assert.equal(narrate(ev('workflow_start', { kind: 'team', name: 'Brief', process: 'hierarchical' })), "Team 'Brief' started (hierarchical).");
assert.equal(narrate(ev('workflow_end', { status: 'completed', duration_ms: 125000 })), 'The run completed after 2 min 05 s.');
});
test('narrate: unknown and quiet events give empty strings', () => {
assert.equal(narrate(ev('context_snapshot')), '');
assert.equal(narrate(ev('something_new')), '');
assert.equal(narrate(null), '');
assert.equal(narrate(ev('message_added', { role: 'assistant', content: 'hi' })), '');
assert.equal(narrate(ev('message_added', { role: 'user', content: 'Hello!' }, { agent_name: '' })), "You said: 'Hello'.");
});
test('narrate is deterministic', () => {
const { events, graph } = load('team_handoff');
const a = buildModel(events, graph).narration.map((n) => n.text);
const b = buildModel(events, graph).narration.map((n) => n.text);
assert.deepEqual(a, b);
assert.ok(a.includes("Researcher asked web_search for 'solar sail facts' — 1.2 s, $0.002."));
});
test('formatters', () => {
assert.equal(formatDuration(850), '850 ms');
assert.equal(formatDuration(1200), '1.2 s');
assert.equal(formatCost(0.002), '$0.002');
assert.equal(formatCost(0.00001), '<$0.0001');
assert.equal(formatCost(1.5), '$1.50');
assert.equal(formatCost(0), '');
});
// ── layoutMap / deriveGraph ──────────────────────────────────────────
test('layoutMap: layers flow left to right and every node gets a box', () => {
const { graph } = load('team_handoff');
const lay = layoutMap(graph);
const byId = Object.fromEntries(lay.nodes.map((n) => [n.id, n]));
assert.equal(lay.nodes.length, graph.nodes.length);
assert.ok(byId['start:begin'].layer < byId['manager:Manager'].layer);
assert.ok(byId['manager:Manager'].layer < byId['task:research'].layer);
// depends_on (write depends on research) puts research first
assert.ok(byId['task:research'].layer < byId['task:write'].layer);
assert.ok(byId['agent:Researcher'].layer < byId['agent:Writer'].layer);
for (const n of lay.nodes) {
assert.ok(n.x >= 0 && n.y >= 0 && n.x + n.w <= lay.width && n.y + n.h <= lay.height, n.id);
}
assert.deepEqual([...new Set(lay.nodes.map((n) => n.shape))].sort(), ['chevron', 'circle', 'diamond', 'flag', 'note', 'rounded', 'square']);
});
test('layoutMap: no two nodes overlap', () => {
for (const name of ['team_handoff', 'flow_parallel_loop']) {
const lay = layoutMap(load(name).graph);
for (let i = 0; i < lay.nodes.length; i++) {
for (let j = i + 1; j < lay.nodes.length; j++) {
const a = lay.nodes[i];
const b = lay.nodes[j];
const apart = a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y;
assert.ok(apart, `${a.id} overlaps ${b.id}`);
}
}
}
});
test('layoutMap: a loop edge is a back edge, routed underneath, labels kept', () => {
const lay = layoutMap(load('flow_parallel_loop').graph);
const back = lay.edges.filter((e) => e.back);
assert.deepEqual(back.map((e) => e.id), ['step:polish->step:review_loop:loop']);
assert.equal(back[0].points.length, 4);
const routes = lay.edges.filter((e) => e.kind === 'routes').map((e) => e.label).sort();
assert.deepEqual(routes, ['archive', 'publish']);
assert.equal(core.edgeText(lay.edges.find((e) => e.kind === 'routes' && e.label === 'publish')), 'routes: publish');
});
test('layoutMap: deterministic and tolerant of bad input', () => {
const g = load('flow_parallel_loop').graph;
assert.deepEqual(layoutMap(g), layoutMap(g));
const empty = layoutMap(null);
assert.equal(empty.nodes.length, 0);
const dangling = layoutMap({ nodes: [{ id: 'agent:A', kind: 'agent' }], edges: [{ source: 'agent:A', target: 'nope', kind: 'next' }] });
assert.equal(dangling.edges.length, 0);
});
test('deriveGraph: who talked to whom for a 1.0 file', () => {
const g = deriveGraph(load('legacy_v10').events);
assert.equal(g.derived, true);
assert.deepEqual(g.nodes.map((n) => n.id).sort(), ['agent:Assistant', 'agent:Checker', 'tool:Assistant/calculator']);
assert.deepEqual(g.edges.map((e) => e.id).sort(), [
'agent:Assistant->agent:Checker:handoff',
'agent:Assistant->tool:Assistant/calculator:uses',
]);
const lay = layoutMap(g);
assert.equal(lay.nodes.length, 3);
});
test('deriveGraph counts repeats and includes you for approvals', () => {
const { events } = load('team_handoff');
const g = deriveGraph(events);
const ws = g.edges.find((e) => e.id === 'agent:Researcher->tool:Researcher/web_search:uses');
assert.equal(ws.label, '×3');
assert.ok(g.nodes.some((n) => n.id === 'human:you' && n.kind === 'human'));
assert.ok(g.edges.some((e) => e.kind === 'delegates' && e.source === 'manager:Manager'));
});
test('module also registers itself on globalThis for the standalone build', () => {
assert.equal(typeof globalThis.PraisonReplayCore.buildModel, 'function');
});
// ── observed graph / run map ─────────────────────────────────────────
test('observedGraph: agents seen in events but not in the graph are "outside"', () => {
const { events, graph } = load('team_outside');
const g = core.observedGraph(graph, events);
assert.deepEqual([...g.outside].sort(), ['agent:Billing', 'agent:Triage', 'tool:Triage/transfer_to_billing']);
// team members keep their recorded ids; nothing duplicated
assert.equal(g.nodes.filter((n) => n.id === 'agent:Researcher').length, 1);
assert.ok(g.edges.some((e) => e.id === 'agent:Triage->agent:Billing:handoff' && e.meta.observed));
// the recorded uses-edge is not added twice
assert.equal(g.edges.filter((e) => e.source === 'agent:Researcher' && e.target === 'tool:Researcher/lookup_fact').length, 1);
});
test('observedGraph without a recorded graph is the derived map', () => {
const { events } = load('legacy_v10');
assert.equal(core.observedGraph(null, events).derived, true);
});
test('layoutRunMap: outside agents form their own framed block under the team', () => {
const { events, graph } = load('team_outside');
const lay = core.layoutRunMap(graph, events);
assert.equal(lay.groups.length, 1);
const grp = lay.groups[0];
assert.equal(grp.label, 'Outside the team');
const inside = lay.nodes.filter((n) => !n.outside);
const outside = lay.nodes.filter((n) => n.outside);
assert.deepEqual(outside.map((n) => n.id).sort(), ['agent:Billing', 'agent:Triage', 'tool:Triage/transfer_to_billing']);
const teamBottom = Math.max(...inside.map((n) => n.y + n.h));
for (const n of outside) {
assert.ok(n.y > teamBottom, `${n.id} sits below the team`);
assert.ok(n.x >= grp.x && n.x + n.w <= grp.x + grp.w && n.y >= grp.y && n.y + n.h <= grp.y + grp.h, `${n.id} inside the frame`);
}
for (const n of lay.nodes) assert.ok(n.x + n.w <= lay.width && n.y + n.h <= lay.height, `${n.id} within bounds`);
assert.ok(lay.edges.some((e) => e.id === 'agent:Triage->agent:Billing:handoff'));
});
test('layoutRunMap: no outside group when the graph covers everyone', () => {
const { events, graph } = load('team_handoff');
const lay = core.layoutRunMap(graph, events);
assert.equal(lay.groups.length, 0);
assert.equal(lay.nodes.length, graph.nodes.length);
});
test('real-run shape: LLM span encloses the tool call in the same lane', () => {
const { events, graph } = load('team_outside');
const m = buildModel(events, graph);
const llm = m.blocks.find((b) => b.agent === 'Researcher' && b.kind === 'llm');
const tool = m.blocks.find((b) => b.agent === 'Researcher' && b.kind === 'tool');
assert.equal(llm.lane, tool.lane);
assert.ok(llm.start < tool.start && tool.end < llm.end); // the view must put them on separate rows
assert.ok(m.lanes.some((l) => l.id === 'agent:Triage') && m.lanes.some((l) => l.id === 'agent:Billing'));
assert.deepEqual(m.arrows.map((a) => `${a.kind}:${a.from}->${a.to}`), ['handoff:agent:Triage->agent:Billing']);
});
// ── nodeStates: the map at the cursor ─────────────────────────────────
const sev = (seq, event_type, extra = {}) => ({ sequence_num: seq, timestamp: 100 + seq, event_type, ...extra });
test('nodeStates: a step is running between its start and end, then done', () => {
const events = [
sev(0, 'workflow_start'),
sev(1, 'step_start', { node_id: 'step:research' }),
sev(2, 'step_end', { node_id: 'step:research', data: { status: 'completed' } }),
sev(3, 'step_start', { node_id: 'step:draft' }),
];
const nodes = [{ id: 'start', kind: 'start' }, { id: 'step:research' }, { id: 'step:draft' }, { id: 'end', kind: 'end' }];
const at = (i) => Object.fromEntries([...core.nodeStates(events, i, nodes)].map(([k, v]) => [k, v.state]));
assert.deepEqual(at(1), { 'step:research': 'running', start: 'done' });
assert.deepEqual(at(2), { 'step:research': 'done', start: 'done' });
assert.deepEqual(at(3), { 'step:research': 'done', 'step:draft': 'running', start: 'done' });
assert.equal(core.nodeStates(events, -1, nodes).size, 0, 'nothing is reached before the first step');
});
test('nodeStates: end node is reached when the run ends; failed and skipped come from status', () => {
const events = [
sev(0, 'step_start', { node_id: 'step:a' }),
sev(1, 'step_end', { node_id: 'step:a', data: { status: 'failed' } }),
sev(2, 'step_start', { node_id: 'step:b' }),
sev(3, 'step_end', { node_id: 'step:b', data: { status: 'skipped' } }),
sev(4, 'workflow_end'),
];
const st = core.nodeStates(events, Infinity, [{ id: 'end', kind: 'end' }]);
assert.equal(st.get('step:a').state, 'failed');
assert.equal(st.get('step:b').state, 'skipped');
assert.equal(st.get('end').state, 'done');
});
test('nodeStates: runs count only on the node an end event belongs to', () => {
const events = [
sev(0, 'task_start', { node_id: 'task:research', agent_name: 'Researcher' }),
sev(1, 'agent_start', { agent_name: 'Researcher' }),
sev(2, 'agent_end', { agent_name: 'Researcher' }),
sev(3, 'task_end', { node_id: 'task:research', agent_name: 'Researcher', data: { status: 'completed' } }),
sev(4, 'step_start', { node_id: 'step:loop' }),
sev(5, 'step_end', { node_id: 'step:loop' }),
sev(6, 'step_start', { node_id: 'step:loop' }),
sev(7, 'step_end', { node_id: 'step:loop' }),
];
const st = core.nodeStates(events);
assert.equal(st.get('agent:Researcher').runs, 1, 'task_end must not count as another agent run');
assert.equal(st.get('task:research').runs, 1);
assert.equal(st.get('step:loop').runs, 2);
assert.equal(st.get('agent:Researcher').state, 'done');
});
test('nodeStates: a recorded flow fixture ends with every visited node done or failed', () => {
const { events, graph } = load('flow_parallel_loop');
const m = buildModel(events, graph);
const st = core.nodeStates(m.events, Infinity, graph.nodes);
assert.ok(st.size > 0);
for (const [id, v] of st) assert.notEqual(v.state, 'running', `${id} still running after the run ended`);
});
// ── unnamed agents (Agent(instructions=...), agent_name null) ────────────
const unnamedRun = (newIds) => [
{ sequence_num: 0, timestamp: 1, event_type: 'session_start', agent_name: null },
{ sequence_num: 1, timestamp: 2, event_type: 'agent_start', agent_name: null, node_id: newIds ? 'agent:Agent' : null },
{ sequence_num: 2, timestamp: 3, event_type: 'llm_request', agent_name: null, node_id: newIds ? 'agent:Agent' : null },
{ sequence_num: 3, timestamp: 4, event_type: 'llm_response', agent_name: null, node_id: newIds ? 'agent:Agent' : null },
{ sequence_num: 4, timestamp: 5, event_type: 'tool_call_start', agent_name: null, node_id: newIds ? 'tool:Agent/search' : 'tool:None/search', data: { tool_name: 'search' } },
{ sequence_num: 5, timestamp: 6, event_type: 'tool_call_end', agent_name: null, node_id: newIds ? 'tool:Agent/search' : 'tool:None/search', data: { tool_name: 'search' } },
{ sequence_num: 6, timestamp: 7, event_type: 'agent_end', agent_name: null, node_id: newIds ? 'agent:Agent' : null, data: newIds ? { status: 'completed' } : {} },
{ sequence_num: 7, timestamp: 8, event_type: 'session_end', agent_name: null },
];
for (const newIds of [true, false]) {
test(`unnamed agent is mapped as "Agent" (${newIds ? 'new' : '4.7.19'} trace ids)`, () => {
const g = deriveGraph(unnamedRun(newIds));
assert.deepEqual(g.nodes.map((n) => [n.id, n.label]), [['agent:Agent', 'Agent'], ['tool:Agent/search', 'search']]);
assert.deepEqual(g.edges.map((e) => `${e.source}->${e.target}:${e.kind}`), ['agent:Agent->tool:Agent/search:uses']);
const st = core.nodeStates(unnamedRun(newIds), Infinity, g.nodes);
assert.equal(st.get('agent:Agent').state, 'done');
assert.equal(st.get('tool:Agent/search').state, 'done');
assert.ok(![...st.keys()].some((k) => k.includes('None')), 'no tool:None ids leak into the map');
});
}
test('a failed unnamed agent run is red on the map', () => {
const evs = unnamedRun(true);
evs[6] = { ...evs[6], data: { status: 'failed' } };
assert.equal(core.nodeStates(evs, Infinity, deriveGraph(evs).nodes).get('agent:Agent').state, 'failed');
});
test('unnamed agents do not reappear "outside" a recorded team graph', () => {
const graph = { schema: 'praisonai.graph/1', kind: 'team', nodes: [
{ id: 'start:begin', kind: 'start', label: 'Brief' }, { id: 'task:t', kind: 'task', label: 't' }, { id: 'end:done', kind: 'end', label: 'Delivered' }],
edges: [{ source: 'start:begin', target: 'task:t', kind: 'next' }, { source: 'task:t', target: 'end:done', kind: 'next' }] };
const og = core.observedGraph(graph, unnamedRun(true));
assert.deepEqual(og.nodes.map((n) => n.id), ['start:begin', 'task:t', 'end:done']);
assert.deepEqual(og.outside, []);
});
test('named agents are unchanged', () => {
const evs = unnamedRun(true).map((e) => ({ ...e, agent_name: e.agent_name === null && e.event_type !== 'session_start' && e.event_type !== 'session_end' ? 'Writer' : e.agent_name, node_id: e.node_id && e.node_id.replace('Agent', 'Writer') }));
assert.deepEqual(deriveGraph(evs).nodes.map((n) => n.id), ['agent:Writer', 'tool:Writer/search']);
});
test('an unnamed agent is narrated as "Agent", not "The run"', () => {
const evs = unnamedRun(true);
assert.equal(narrate(evs[1]), 'Agent started.');
assert.equal(narrate(evs[7]).startsWith('Recording'), true);
});
+1 -1
View File
@@ -141,7 +141,7 @@ check("GET nonexistent plugin", client.get("/api/marketplace/plugins/nonexistent
print("\n═══ Code Execution API ═══")
check("GET /api/code/languages", client.get("/api/code/languages"), 200, ["languages", "count"])
r = client.post("/api/code/execute", json={"code": "print('hello')", "language": "python"})
check("POST /api/code/execute", r, 200, ["status", "language"])
check("POST /api/code/execute (pending approval)", r, 202, ["status", "approval_id"])
r = client.post("/api/code/execute", json={"code": "ls", "language": "ruby"})
check("POST execute disallowed lang", r, 400, ["error"])
-235
View File
@@ -1,235 +0,0 @@
"""_UIStoreBackend waits for a human decision (contract v1 §5, approvals).
Before: request_approval_sync returned ``approved=False, "Awaiting UI
approval"`` immediately, so a tool needing approval was always denied and the
UI's Approve button did nothing for the agent; ``request.approval_id`` and
``modified_args`` were dropped.
"""
from __future__ import annotations
import asyncio
import threading
import time
import pytest
from starlette.applications import Starlette
from starlette.testclient import TestClient
protocols = pytest.importorskip("praisonaiagents.approval.protocols")
ApprovalRequest = protocols.ApprovalRequest
def _request(approval_id: str = "appr-1", **kw) -> "ApprovalRequest":
return ApprovalRequest(
tool_name=kw.pop("tool_name", "write_file"),
arguments=kw.pop("arguments", {"path": "draft.md"}),
risk_level=kw.pop("risk_level", "high"),
agent_name=kw.pop("agent_name", "Writer"),
approval_id=approval_id,
**kw,
)
@pytest.fixture()
def store():
from praisonaiui.features.approvals import SimpleApprovalManager
return SimpleApprovalManager()
@pytest.fixture()
def backend(store):
from praisonaiui.features.approvals import _UIStoreBackend
return _UIStoreBackend(store, timeout=5.0)
def _wait_pending(store, approval_id: str, timeout: float = 3.0) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for entry in store.list_pending():
if entry["id"] == approval_id:
return entry
time.sleep(0.01)
raise AssertionError(f"{approval_id} never became pending")
def _in_thread(fn, *args):
box = {}
def run():
box["result"] = fn(*args)
t = threading.Thread(target=run, daemon=True)
t.start()
return t, box
def test_sync_blocks_until_approved_with_modified_args(store, backend):
t, box = _in_thread(backend.request_approval_sync, _request("appr-1"))
entry = _wait_pending(store, "appr-1") # honours request.approval_id
assert entry["tool_name"] == "write_file" and entry["agent_name"] == "Writer"
time.sleep(0.1)
assert t.is_alive(), "backend must block while the decision is pending"
store.approve("appr-1", reason="Use docs/", modified_args={"path": "docs/draft.md"})
t.join(timeout=3)
assert not t.is_alive()
decision = box["result"]
assert decision.approved is True
assert decision.modified_args == {"path": "docs/draft.md"}
assert decision.reason == "Use docs/"
def test_sync_deny_resolves_with_reason(store, backend):
t, box = _in_thread(backend.request_approval_sync, _request("appr-2"))
_wait_pending(store, "appr-2")
store.deny("appr-2", reason="Not today")
t.join(timeout=3)
assert box["result"].approved is False
assert box["result"].reason == "Not today"
def test_sync_timeout_denies_and_clears_queue(store):
from praisonaiui.features.approvals import _UIStoreBackend
backend = _UIStoreBackend(store, timeout=0.2)
started = time.monotonic()
decision = backend.request_approval_sync(_request("appr-3"))
assert time.monotonic() - started >= 0.2
assert decision.approved is False
assert decision.approver == "timeout"
assert store.list_pending() == []
assert store.get("appr-3")["status"] == "denied"
def test_per_id_isolation(store, backend):
t1, b1 = _in_thread(backend.request_approval_sync, _request("a"))
t2, b2 = _in_thread(backend.request_approval_sync, _request("b"))
_wait_pending(store, "a")
_wait_pending(store, "b")
store.approve("b")
t2.join(timeout=3)
assert b2["result"].approved is True
assert t1.is_alive(), "deciding b must not release a"
store.deny("a")
t1.join(timeout=3)
assert b1["result"].approved is False
def test_async_waits_without_blocking_the_loop(store, backend):
async def main():
task = asyncio.create_task(backend.request_approval(_request("appr-4")))
ticks = 0
while not store.list_pending():
ticks += 1
await asyncio.sleep(0.01)
# the loop keeps running while waiting
for _ in range(5):
ticks += 1
await asyncio.sleep(0.01)
assert not task.done()
# decision arrives from another thread (the server handling POST /approve)
threading.Timer(0.05, lambda: store.approve("appr-4", modified_args={"x": 1})).start()
decision = await asyncio.wait_for(task, 3)
return decision, ticks
decision, ticks = asyncio.run(main())
assert decision.approved is True
assert decision.modified_args == {"x": 1}
assert ticks >= 5
def test_async_decision_from_same_loop(store, backend):
async def main():
task = asyncio.create_task(backend.request_approval(_request("appr-5")))
while not store.list_pending():
await asyncio.sleep(0.01)
store.approve("appr-5")
return await asyncio.wait_for(task, 3)
assert asyncio.run(main()).approved is True
def test_async_timeout_denies(store):
from praisonaiui.features.approvals import _UIStoreBackend
backend = _UIStoreBackend(store, timeout=0.15)
decision = asyncio.run(backend.request_approval(_request("appr-6")))
assert decision.approved is False
assert store.get("appr-6")["status"] == "denied"
def test_async_cancel_by_registry_wait_for_expires_entry(store, backend):
async def main():
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(backend.request_approval(_request("appr-7")), 0.1)
asyncio.run(main())
assert store.list_pending() == []
assert store.get("appr-7")["status"] == "denied"
def test_sync_call_on_event_loop_thread_fails_closed_fast(store, backend):
async def main():
started = time.monotonic()
decision = backend.request_approval_sync(_request("appr-8"))
return decision, time.monotonic() - started
decision, elapsed = asyncio.run(main())
assert decision.approved is False
assert elapsed < 1.0
assert store.list_pending() == []
def test_auto_policy_still_resolves_immediately(store, backend):
store.update_policies({"auto_approve_tools": ["write_file"]})
decision = backend.request_approval_sync(_request("appr-9"))
assert decision.approved is True
assert decision.approver == "auto-policy"
def test_approve_endpoint_passes_modified_args(store, backend, monkeypatch):
from praisonaiui.features import approvals
monkeypatch.setattr(approvals, "_approval_manager", store)
client = TestClient(Starlette(routes=approvals.ApprovalsFeature().routes()))
t, box = _in_thread(backend.request_approval_sync, _request("appr-10"))
_wait_pending(store, "appr-10")
assert client.get("/api/approvals/appr-10").json()["arguments"] == {"path": "draft.md"}
r = client.post("/api/approvals/appr-10/approve", json={"modified_args": {"path": "docs/x.md"}, "reason": "ok"})
assert r.status_code == 200
assert r.json()["modified_args"] == {"path": "docs/x.md"}
t.join(timeout=3)
assert box["result"].approved is True
assert box["result"].modified_args == {"path": "docs/x.md"}
def test_approve_endpoint_rejects_non_object_modified_args(store, monkeypatch):
from praisonaiui.features import approvals
monkeypatch.setattr(approvals, "_approval_manager", store)
store.request_approval({"id": "z", "tool_name": "t", "risk_level": "high"})
client = TestClient(Starlette(routes=approvals.ApprovalsFeature().routes()))
r = client.post("/api/approvals/z/approve", json={"modified_args": [1, 2]})
assert r.status_code == 400
assert store.get("z")["status"] == "pending"
# plain approve without modified_args still works (and keeps old managers working)
assert client.post("/api/approvals/z/approve", json={}).status_code == 200
def test_sdk_manager_approve_forwards_modified_args():
pytest.importorskip("praisonaiagents.approval")
from praisonaiagents.approval import get_approval_registry
from praisonaiui.features.approvals import SDKApprovalManager
registry = get_approval_registry()
previous = registry.get_backend()
try:
mgr = SDKApprovalManager()
mgr._simple.request_approval({"id": "s1", "tool_name": "t", "risk_level": "high"})
entry = mgr.approve("s1", modified_args={"k": "v"})
assert entry["modified_args"] == {"k": "v"}
finally:
registry.set_backend(previous)
-54
View File
@@ -1,54 +0,0 @@
"""Regression tests for #322 — Chat must fill the dashboard main column.
Chat pages declare ``layout: "full-bleed"``; ``selectPage()`` then adds
``db-fill`` to ``#db-main-content`` and skips the duplicate page header, and
``.chat-root`` sizes from flex allocation instead of ``calc(100vh - 140px)``.
"""
from __future__ import annotations
import re
from pathlib import Path
PLUGINS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
def _select_page_body() -> str:
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
m = re.search(r"async function selectPage\(pageId\)\s*\{(.*?)^}", src, re.DOTALL | re.MULTILINE)
assert m
return m.group(1)
def test_chat_pages_declare_full_bleed():
from starlette.testclient import TestClient
from praisonaiui.server import create_app, reset_state
reset_state() # isolate from page whitelists set by other tests
pages = {p["id"]: p for p in TestClient(create_app()).get("/api/pages").json()["pages"]}
for pid in ("chat", "chat-canvas"):
assert pages[pid].get("layout") == "full-bleed"
assert pages["overview"].get("layout") is None
def test_select_page_toggles_fill_and_skips_header():
body = _select_page_body()
assert "page.layout === 'full-bleed'" in body
assert "classList.toggle('db-fill'" in body
assert re.search(r"showPageHeader = .*&& !fullBleed", body)
def test_fill_css_defined():
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
assert ".db-main.db-fill > #db-page-content" in src
def test_chat_root_fills_column_in_fill_mode():
chat = (PLUGINS / "views" / "chat.js").read_text(encoding="utf-8")
assert re.search(r"\.db-fill \.chat-root \{[^}]*flex:1;[^}]*min-height:0", chat)
canvas = (PLUGINS / "views" / "chat-canvas.js").read_text(encoding="utf-8")
assert ".db-fill .chat-canvas-root" in canvas
@@ -1,31 +0,0 @@
"""Tests for the in-rail session search in the chat view (issue #324).
The chat sidebar must expose a search input that filters the already
fetched session list client-side, without refetching from the API.
"""
from pathlib import Path
CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js")
class TestChatSessionRailSearch:
def test_search_input_rendered_in_rail(self):
src = CHAT.read_text()
assert 'id="chat-session-search"' in src
assert src.index('id="chat-session-search"') < src.index('id="chat-sessions-list"')
def test_input_wired_to_client_side_render(self):
src = CHAT.read_text()
assert "sessionSearch.addEventListener('input', renderSessionList)" in src
def test_load_sessions_caches_and_delegates_render(self):
src = CHAT.read_text()
assert "allSessions = Array.isArray(fetched) ? fetched : [];" in src
body = src.split("async function loadSessions()", 1)[1].split("\n}\n", 1)[0]
assert "renderSessionList();" in body
def test_filter_matches_title_and_id_fields(self):
src = CHAT.read_text()
assert "function _sessionMatches(s, q)" in src
assert "No matching sessions" in src
-49
View File
@@ -1,49 +0,0 @@
"""Issue #331 — rename a session from the chat rail via the existing PATCH route."""
from __future__ import annotations
import asyncio
from pathlib import Path
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
import praisonaiui.server as server
from praisonaiui.datastore import MemoryDataStore
CHAT = Path("src/praisonaiui/templates/frontend/plugins/views/chat.js")
def _client(monkeypatch):
ds = MemoryDataStore()
monkeypatch.setattr(server, "_datastore", ds)
app = Starlette(routes=[Route("/sessions/{session_id}", server.patch_session, methods=["PATCH"])])
sid = asyncio.run(ds.create_session())["id"]
return TestClient(app), ds, sid
def test_patch_renames_and_trims(monkeypatch):
client, ds, sid = _client(monkeypatch)
r = client.patch(f"/sessions/{sid}", json={"title": " My chat "})
assert r.status_code == 200
assert asyncio.run(ds.get_session(sid))["title"] == "My chat"
def test_patch_rejects_blank_or_non_string_title(monkeypatch):
client, _, sid = _client(monkeypatch)
assert client.patch(f"/sessions/{sid}", json={"title": " "}).status_code == 400
assert client.patch(f"/sessions/{sid}", json={"title": 123}).status_code == 400
def test_patch_unknown_session_404(monkeypatch):
client, _, _ = _client(monkeypatch)
assert client.patch("/sessions/nope", json={"title": "x"}).status_code == 404
def test_rail_has_rename_button_wired_to_patch():
src = CHAT.read_text()
assert "async function renameSession(sessionId, currentTitle)" in src
assert "method: 'PATCH'" in src
assert "renameSession(id, title)" in src
assert "e.stopPropagation()" in src
+74 -2
View File
@@ -2,6 +2,7 @@
from pathlib import Path
import pytest
from starlette.testclient import TestClient
from praisonaiui.server import create_app
@@ -34,16 +35,87 @@ class TestCodeExecutionApi:
d = r.json()
assert "languages" in d and "count" in d
def test_execute(self):
@pytest.fixture(autouse=True)
def _fresh_approvals(self, monkeypatch):
from praisonaiui.features import approvals
monkeypatch.setattr(approvals, "_approval_manager", approvals.SimpleApprovalManager())
return approvals._approval_manager
def test_execute_requires_approval(self, _fresh_approvals):
client = TestClient(create_app())
payload = {"code": "print('hi')", "language": "python", "timeout": 30}
r = client.post("/api/code/execute", json=payload)
assert r.status_code == 202
d = r.json()
assert d["status"] == "pending_approval"
approval_id = d["approval_id"]
assert _fresh_approvals.get(approval_id)["tool_name"] == "execute_code"
# Not yet approved
r = client.post("/api/code/execute", json={**payload, "approval_id": approval_id})
assert r.status_code == 403
assert client.post(f"/api/approvals/{approval_id}/approve", json={}).status_code == 200
# Approval is bound to the exact code
r = client.post(
"/api/code/execute",
json={"code": "print('hi')", "language": "python", "timeout": 30},
json={**payload, "code": "print('x')", "approval_id": approval_id},
)
assert r.status_code == 403
r = client.post("/api/code/execute", json={**payload, "approval_id": approval_id})
assert r.status_code == 200
d = r.json()
assert "status" in d and "language" in d
# Single use
r = client.post("/api/code/execute", json={**payload, "approval_id": approval_id})
assert r.status_code == 403
def test_execute_denied(self, _fresh_approvals):
client = TestClient(create_app())
r = client.post("/api/code/execute", json={"code": "1", "language": "python"})
approval_id = r.json()["approval_id"]
client.post(f"/api/approvals/{approval_id}/deny", json={})
r = client.post(
"/api/code/execute",
json={"code": "1", "language": "python", "approval_id": approval_id},
)
assert r.status_code == 403
def test_execute_rejects_non_gate_approval(self, _fresh_approvals):
# A low-risk request filed via /api/approvals is auto-approved by the
# default policy; it must not unlock execution.
client = TestClient(create_app())
r = client.post(
"/api/approvals",
json={
"tool_name": "execute_code",
"arguments": {"code": "1", "language": "python"},
"risk_level": "low",
},
)
assert r.json()["status"] == "approved"
r = client.post(
"/api/code/execute",
json={"code": "1", "language": "python", "approval_id": r.json()["id"]},
)
assert r.status_code == 403
def test_execute_auto_approved_by_policy(self, _fresh_approvals):
_fresh_approvals.update_policies({"auto_approve_tools": ["execute_code"]})
client = TestClient(create_app())
r = client.post("/api/code/execute", json={"code": "1", "language": "python"})
assert r.status_code == 200
assert _fresh_approvals.list_history()[0]["approver"] == "auto-policy"
def test_execute_disallowed_language(self, _fresh_approvals):
client = TestClient(create_app())
r = client.post("/api/code/execute", json={"code": "ls", "language": "ruby"})
assert r.status_code == 400
assert _fresh_approvals.list_pending() == []
class TestCodeStudioFrontend:
def test_view_file_exists(self):
-31
View File
@@ -1,31 +0,0 @@
"""Issue #323 — Jobs rows link to their chat session when ``session_id`` is set.
Source-parsing test, mirroring ``test_session_search_dashboard.py``.
"""
from __future__ import annotations
from pathlib import Path
JOBS_JS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "jobs.js"
)
def _src() -> str:
return JOBS_JS.read_text(encoding="utf-8")
def test_open_session_button_only_when_session_id():
src = _src()
assert "job.session_id ? `<button" in src
assert 'data-session-id="${escapeAttr(job.session_id)}"' in src
def test_open_session_reuses_session_select_event():
src = _src()
assert "function openJobSession(sessionId)" in src
assert "selectPage('chat')" in src
assert "'aiui:session-select'" in src
assert "window.aiuiOpenJobSession = openJobSession;" in src
-166
View File
@@ -1,166 +0,0 @@
"""create_replay_app(): the Replay view mounted inside another app, with an optional Run box."""
from __future__ import annotations
import json
import shutil
import threading
import time
from pathlib import Path
import pytest
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.testclient import TestClient
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "replay"
@pytest.fixture()
def traces(tmp_path, monkeypatch):
d = tmp_path / "traces"
d.mkdir()
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d))
monkeypatch.delenv("PRAISON_HOME", raising=False)
return d
def _host(sub) -> TestClient:
"""A host app with the replay app mounted at /replay, like app.mount() in FastAPI."""
return TestClient(Starlette(routes=[Mount("/replay", app=sub)]))
def _cfg(page: str) -> dict:
line = next(l for l in page.splitlines() if l.startswith("render("))
return json.loads(line[line.index(",") + 1 : line.rindex(")")].strip())
def test_page_mounts_the_view_relative_to_the_mount(traces):
from praisonaiui.features.replay import create_replay_app
c = _host(create_replay_app())
r = c.get("/replay/")
assert r.status_code == 200
assert 'from "/replay/replay.js"' in r.text
cfg = _cfg(r.text)
assert cfg == {"base": "/replay", "approve": False} # no run list pin, no Run box
assert "aiui" not in r.text.lower() # no dashboard around it
for name in ("replay.js", "replay-core.js"):
js = c.get(f"/replay/{name}")
assert js.status_code == 200 and js.headers["content-type"].startswith("text/javascript")
def test_page_pins_a_valid_run_and_ignores_a_bad_one(traces):
from praisonaiui.features.replay import create_replay_app
c = _host(create_replay_app())
assert _cfg(c.get("/replay/?run=run-abc").text)["run"] == "run-abc"
page = c.get("/replay/?run=../../etc/passwd").text
assert "run" not in _cfg(page) and "passwd" not in page
def test_api_routes_answer_under_the_mount(traces):
from praisonaiui.features.replay import create_replay_app
shutil.copy(FIXTURES / "team_handoff.jsonl", traces / "team_handoff.jsonl")
c = _host(create_replay_app())
rows = c.get("/replay/api/replay/runs").json()
assert [r["run_id"] for r in rows] == ["team_handoff"]
assert c.get("/replay/api/replay/runs/team_handoff/events").json()["events"]
def test_events_wait_param_answers_a_missing_run_without_404(traces):
from praisonaiui.features.replay import create_replay_app
c = _host(create_replay_app())
assert c.get("/replay/api/replay/runs/run-later/events").status_code == 404
r = c.get("/replay/api/replay/runs/run-later/events?wait=1")
assert r.status_code == 200 and r.json() == {"events": [], "graph": None, "run": None}
def test_no_run_route_without_a_runner(traces):
from praisonaiui.features.replay import create_replay_app
c = _host(create_replay_app())
assert c.post("/replay/api/replay/run", json={"input": "x"}).status_code in (404, 405)
def test_run_box_calls_the_runner_with_a_new_run_id(traces):
from praisonaiui.features.replay import create_replay_app
calls = []
def runner(text, run_id):
(traces / f"{run_id}.jsonl").write_text(json.dumps({"event_type": "session_start", "sequence_num": 0, "timestamp": time.time()}) + "\n")
calls.append((text, run_id))
c = _host(create_replay_app(runner, input="solar sails", placeholder="Topic"))
cfg = _cfg(c.get("/replay/").text)
assert cfg["runner"] == {"input": "solar sails", "placeholder": "Topic", "ask": True}
r = c.post("/replay/api/replay/run", json={"input": "comets"})
assert r.status_code == 200
run_id = r.json()["run_id"]
assert (traces / f"{run_id}.jsonl").is_file(), "answers once the trace exists"
for _ in range(100):
if calls:
break
time.sleep(0.01)
assert calls == [("comets", run_id)]
assert run_id.startswith("run-") and len(run_id) == 16
def test_run_box_without_input_box(traces):
from praisonaiui.features.replay import create_replay_app
cfg = _cfg(_host(create_replay_app(lambda t, r: None, input=None)).get("/replay/").text)
assert cfg["runner"]["ask"] is False
def test_run_rejects_non_json_bad_input_and_a_second_run(traces):
from praisonaiui.features.replay import MAX_RUN_INPUT, create_replay_app
gate = threading.Event()
c = _host(create_replay_app(lambda text, run_id: gate.wait(5)))
assert c.post("/replay/api/replay/run", data="input=x", headers={"content-type": "application/x-www-form-urlencoded"}).status_code == 415
assert c.post("/replay/api/replay/run", content="input=x", headers={"content-type": "text/plain"}).status_code == 415
assert c.post("/replay/api/replay/run", json={"input": 3}).status_code == 400
assert c.post("/replay/api/replay/run", json={"input": "x" * (MAX_RUN_INPUT + 1)}).status_code == 400
assert c.post("/replay/api/replay/run", json={"input": "a"}).status_code == 200
busy = c.post("/replay/api/replay/run", json={"input": "b"})
assert busy.status_code == 409 and "already" in busy.json()["error"]
gate.set()
for _ in range(100): # the lock is released when the runner returns
r = c.post("/replay/api/replay/run", json={"input": "c"})
if r.status_code == 200:
break
time.sleep(0.02)
assert r.status_code == 200
def test_a_failing_runner_frees_the_run_box(traces, caplog):
from praisonaiui.features.replay import create_replay_app
def boom(text, run_id):
raise RuntimeError("no key")
c = _host(create_replay_app(boom))
assert c.post("/replay/api/replay/run", json={"input": ""}).status_code == 200
for _ in range(100):
r = c.post("/replay/api/replay/run", json={"input": ""})
if r.status_code == 200:
break
time.sleep(0.02)
assert r.status_code == 200
def test_async_runner_is_awaited(traces):
from praisonaiui.features.replay import create_replay_app
done = threading.Event()
async def runner(text, run_id):
done.set()
c = _host(create_replay_app(runner))
assert c.post("/replay/api/replay/run", json={"input": ""}).status_code == 200
assert done.wait(3)
-439
View File
@@ -1,439 +0,0 @@
"""PraisonAI Replay feature: runs listing, events, SSE tail, safety (contract v1 §1, §5)."""
from __future__ import annotations
import json
import shutil
import threading
import time
from pathlib import Path
import pytest
from starlette.applications import Starlette
from starlette.testclient import TestClient
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "replay"
NAMES = ("team_handoff", "flow_parallel_loop", "legacy_v10")
def _lines(name: str) -> list[str]:
return (FIXTURES / f"{name}.jsonl").read_text(encoding="utf-8").splitlines(keepends=True)
@pytest.fixture()
def traces(tmp_path, monkeypatch):
d = tmp_path / "traces"
d.mkdir()
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d))
monkeypatch.delenv("PRAISON_HOME", raising=False)
return d
@pytest.fixture()
def isolated_server(tmp_path, monkeypatch):
"""create_app() writes a config store; keep it out of the developer's home."""
monkeypatch.setenv("AIUI_DATA_DIR", str(tmp_path / "aiui-data"))
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "praisonai-home"))
@pytest.fixture()
def feature():
from praisonaiui.features.replay import ReplayFeature
f = ReplayFeature()
f.poll_interval = 0.02
f.ping_interval = 0.15
f.quiet_period = 0.3
return f
@pytest.fixture()
def client(feature):
app = Starlette(routes=feature.routes())
return TestClient(app)
def _copy_all(d: Path) -> None:
for name in NAMES:
dst = d / f"{name}.jsonl"
shutil.copy(FIXTURES / f"{name}.jsonl", dst)
# ── traces dir resolution ────────────────────────────────────────────
def test_traces_dir_precedence(tmp_path, monkeypatch):
from praisonaiui.features import _trace_jsonl as tj
monkeypatch.delenv("PRAISONAI_TRACES_DIR", raising=False)
monkeypatch.delenv("PRAISON_HOME", raising=False)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
assert tj.traces_dir() == tmp_path / "home" / ".praison" / "traces"
monkeypatch.setenv("PRAISON_HOME", str(tmp_path / "ph"))
assert tj.traces_dir() == tmp_path / "ph" / "traces"
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "explicit"))
assert tj.traces_dir() == tmp_path / "explicit"
# ── /api/replay/runs ─────────────────────────────────────────────────
def test_runs_lists_fixtures_newest_first(traces, client):
_copy_all(traces)
r = client.get("/api/replay/runs")
assert r.status_code == 200
rows = r.json()
assert isinstance(rows, list)
by_id = {row["run_id"]: row for row in rows}
assert set(by_id) == set(NAMES)
# newest first by started_at
started = [row["started_at"] for row in rows]
assert started == sorted(started, reverse=True)
team = by_id["team_handoff"]
assert team["session_id"] == "sess-team-01"
assert team["kind"] == "team"
assert team["name"] == "Solar sail brief"
assert team["status"] == "completed"
assert team["live"] is False
assert team["events"] == 36
assert team["agents"] == ["Manager", "Researcher", "Writer"]
assert team["cost_usd"] == pytest.approx(0.006741, abs=1e-9)
assert team["ended_at"] == pytest.approx(1767225612.25)
assert team["path"].endswith("team_handoff.jsonl")
assert team["event_run_id"] == "team-handoff-01"
legacy = by_id["legacy_v10"] # schema 1.0: no workflow_start, still listed
assert legacy["kind"] == "session"
assert legacy["status"] == "completed"
assert legacy["schema_version"] == "1.0"
assert legacy["agents"] == ["Assistant", "Checker"]
assert legacy["events"] == 16
def test_runs_empty_or_missing_dir(tmp_path, monkeypatch, client):
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "nope"))
r = client.get("/api/replay/runs")
assert r.status_code == 200
assert r.json() == []
def test_run_without_end_marker_is_live(traces, client):
lines = _lines("team_handoff")
(traces / "half.jsonl").write_text("".join(lines[:20]), encoding="utf-8")
row = client.get("/api/replay/runs").json()[0]
assert row["run_id"] == "half"
assert row["live"] is True
assert row["status"] == "running"
assert "ended_at" not in row
def test_ignores_non_jsonl_and_unsafe_names(traces, client):
_copy_all(traces)
(traces / "notes.txt").write_text("x", encoding="utf-8")
(traces / "bad name.jsonl").write_text(_lines("legacy_v10")[0], encoding="utf-8")
ids = {row["run_id"] for row in client.get("/api/replay/runs").json()}
assert ids == set(NAMES)
# ── /api/replay/runs/{id}/events ─────────────────────────────────────
def test_events_and_graph(traces, client):
_copy_all(traces)
data = client.get("/api/replay/runs/team_handoff/events").json()
assert len(data["events"]) == 36
assert data["graph"]["schema"] == "praisonai.graph/1"
assert data["graph"]["process"] == "hierarchical"
assert data["run"]["run_id"] == "team_handoff"
legacy = client.get("/api/replay/runs/legacy_v10/events").json()
assert legacy["graph"] is None
assert len(legacy["events"]) == 16
def test_events_after_seq(traces, client):
_copy_all(traces)
data = client.get("/api/replay/runs/team_handoff/events?after_seq=30").json()
assert [e["sequence_num"] for e in data["events"]] == [31, 32, 33, 34, 35]
# graph still comes from the (earlier) workflow_start
assert data["graph"] is not None
assert client.get("/api/replay/runs/team_handoff/events?after_seq=-1").json()["events"][0]["sequence_num"] == 0
assert client.get("/api/replay/runs/team_handoff/events?after_seq=abc").status_code == 400
def test_unknown_event_types_and_fields_pass_through(traces, client):
_copy_all(traces)
events = client.get("/api/replay/runs/flow_parallel_loop/events").json()["events"]
odd = [e for e in events if e["event_type"] == "context_rebalance"]
assert len(odd) == 1
assert odd[0]["future_field"] == {"x": 1}
def test_partial_last_line_ignored_until_complete(traces, client):
lines = _lines("legacy_v10")
path = traces / "growing.jsonl"
last = lines[-1]
path.write_text("".join(lines[:-1]) + last[:25], encoding="utf-8")
first = client.get("/api/replay/runs/growing/events").json()["events"]
assert len(first) == 15 # half-written session_end is not surfaced
assert first[-1]["event_type"] == "agent_end"
with open(path, "a", encoding="utf-8") as fh:
fh.write(last[25:])
second = client.get("/api/replay/runs/growing/events").json()["events"]
assert len(second) == 16
assert second[-1]["event_type"] == "session_end"
def test_complete_last_line_without_newline_is_read(traces, client):
text = "".join(_lines("legacy_v10")).rstrip("\n")
(traces / "nonl.jsonl").write_text(text, encoding="utf-8")
events = client.get("/api/replay/runs/nonl/events").json()["events"]
assert len(events) == 16
def test_garbage_lines_are_skipped(traces, client):
lines = _lines("legacy_v10")
(traces / "dirty.jsonl").write_text(lines[0] + "not json\n[1,2]\n\n" + "".join(lines[1:]), encoding="utf-8")
assert len(client.get("/api/replay/runs/dirty/events").json()["events"]) == 16
# ── safety ───────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"run_id",
["..", ".", "a b", "a;b", "%2e%2e", "..%2Fsecret", "x%00y", "ü"],
)
def test_path_traversal_ids_rejected(traces, client, run_id):
secret = traces.parent / "secret.jsonl"
secret.write_text(_lines("legacy_v10")[0], encoding="utf-8")
for suffix in ("events", "stream"):
r = client.get(f"/api/replay/runs/{run_id}/{suffix}")
assert r.status_code in (400, 404), (run_id, suffix, r.status_code)
assert "legacy-sess-10" not in r.text
def test_run_path_validation_unit(tmp_path):
from praisonaiui.features import _trace_jsonl as tj
assert tj.run_path("../etc/passwd", tmp_path) is None
assert tj.run_path("..", tmp_path) is None
assert tj.run_path("a/b", tmp_path) is None
assert tj.run_path("ok-run_1.2", tmp_path) == tmp_path / "ok-run_1.2.jsonl"
def test_symlink_escaping_dir_is_rejected(traces, client, tmp_path):
outside = tmp_path / "outside"
outside.mkdir()
target = outside / "x.jsonl"
target.write_text(_lines("legacy_v10")[0], encoding="utf-8")
link = traces / "sneaky.jsonl"
try:
link.symlink_to(target)
except (OSError, NotImplementedError):
pytest.skip("symlinks unavailable")
assert client.get("/api/replay/runs/sneaky/events").status_code == 400
def test_unknown_run_is_404(traces, client):
assert client.get("/api/replay/runs/nope/events").status_code == 404
assert client.get("/api/replay/runs/nope/stream").status_code == 404
# ── SSE stream ───────────────────────────────────────────────────────
def _parse_sse(text: str) -> list[tuple[str, str, str | None]]:
out = []
for block in text.split("\n\n"):
if not block.strip():
continue
name, data, ident = "message", [], None
for line in block.split("\n"):
if line.startswith("event:"):
name = line[6:].strip()
elif line.startswith("data:"):
data.append(line[5:].strip())
elif line.startswith("id:"):
ident = line[3:].strip()
if data or name != "message":
out.append((name, "\n".join(data), ident))
return out
def test_stream_tails_appended_lines(traces, client, monkeypatch):
"""Lines written while the stream is open (including one written in two
halves) arrive exactly once, in order; the stream ends after session_end."""
from praisonaiui.features import _trace_jsonl as tj
monkeypatch.setattr(tj, "LIVE_WINDOW_S", 8) # safety net: never hang the suite
lines = _lines("team_handoff")
path = traces / "live-run.jsonl"
path.write_text("".join(lines[:10]), encoding="utf-8")
def writer():
time.sleep(0.15)
for i, line in enumerate(lines[10:]):
with open(path, "a", encoding="utf-8") as fh:
if i == 5: # a line split across two writes
fh.write(line[:30])
fh.flush()
time.sleep(0.08)
fh.write(line[30:])
else:
fh.write(line)
time.sleep(0.01)
t = threading.Thread(target=writer, daemon=True)
t.start()
started = time.monotonic()
with client.stream("GET", "/api/replay/runs/live-run/stream") as r:
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/event-stream")
body = "".join(r.iter_text())
elapsed = time.monotonic() - started
t.join(timeout=5)
msgs = _parse_sse(body)
ctx = [json.loads(d) for name, d, _ in msgs if name == "ctx"]
assert [e["sequence_num"] for e in ctx] == list(range(36))
assert [ident for name, _, ident in msgs if name == "ctx"] == [str(i) for i in range(36)]
assert msgs[-1][0] == "end"
assert any(name == "ping" for name, _, _ in msgs)
assert elapsed < 7, "stream should end shortly after session_end + quiet period"
def test_stream_after_seq_and_last_event_id(traces, client):
shutil.copy(FIXTURES / "legacy_v10.jsonl", traces / "done.jsonl")
with client.stream("GET", "/api/replay/runs/done/stream?after_seq=12") as r:
body = "".join(r.iter_text())
seqs = [json.loads(d)["sequence_num"] for n, d, _ in _parse_sse(body) if n == "ctx"]
assert seqs == [13, 14, 15]
# reconnect: Last-Event-ID beats an older after_seq in the URL
with client.stream("GET", "/api/replay/runs/done/stream?after_seq=2", headers={"Last-Event-ID": "14"}) as r:
body = "".join(r.iter_text())
seqs = [json.loads(d)["sequence_num"] for n, d, _ in _parse_sse(body) if n == "ctx"]
assert seqs == [15]
def test_stream_ends_for_stale_unfinished_file(traces, client, monkeypatch):
from praisonaiui.features import _trace_jsonl as tj
monkeypatch.setattr(tj, "LIVE_WINDOW_S", 0.4)
(traces / "stale.jsonl").write_text("".join(_lines("team_handoff")[:5]), encoding="utf-8")
with client.stream("GET", "/api/replay/runs/stale/stream") as r:
body = "".join(r.iter_text())
msgs = _parse_sse(body)
assert len([m for m in msgs if m[0] == "ctx"]) == 5
assert msgs[-1][0] == "end" and "stale" in msgs[-1][1]
def test_stream_restarts_when_file_is_truncated(tmp_path):
from praisonaiui.features import _trace_jsonl as tj
path = tmp_path / "r.jsonl"
lines = _lines("legacy_v10")
path.write_text("".join(lines), encoding="utf-8")
tail = tj.TailReader(path)
assert len(tail.poll()) == 16
path.write_text(lines[0], encoding="utf-8") # writer restarted the file
again = tail.poll()
assert len(again) == 1 and again[0]["sequence_num"] == 0
# ── registration / entry point ───────────────────────────────────────
def test_feature_registered_by_default():
from praisonaiui.features import auto_register_defaults, get_feature
auto_register_defaults()
f = get_feature("replay")
assert f is not None
paths = {r.path for r in f.routes()}
assert paths == {
"/api/replay/runs",
"/api/replay/runs/{run_id}/events",
"/api/replay/runs/{run_id}/stream",
}
def test_replay_page_and_routes_mounted_in_server(traces, isolated_server):
import praisonaiui as aiui
import praisonaiui.server as server
_copy_all(traces)
server.reset_state()
aiui.set_style("dashboard")
app = server.create_app()
c = TestClient(app)
pages = c.get("/api/pages").json()["pages"]
page = next((p for p in pages if p["id"] == "replay"), None)
assert page is not None
assert page["title"] == "Replay"
assert page["group"] == "Control"
assert page["api_endpoint"] == "/api/replay/runs"
assert len(c.get("/api/replay/runs").json()) == 3
assert c.get("/plugins/views/replay.js").status_code == 200
assert c.get("/plugins/views/replay-core.js").status_code == 200
def test_replay_app_whitelists_replay_first(traces, isolated_server):
import praisonaiui.server as server
from praisonaiui.features.replay import REPLAY_PAGES, configure_replay_app
server.reset_state()
configure_replay_app()
try:
app = server.create_app()
pages = TestClient(app).get("/api/pages").json()["pages"]
builtin = [p["id"] for p in pages if p["id"] in REPLAY_PAGES]
assert builtin[0] == "replay"
assert set(builtin) == set(REPLAY_PAGES)
assert "chat" not in {p["id"] for p in pages}
finally:
server.reset_state()
def test_replay_app_module_is_importable_and_documented():
import importlib
src = (Path(__file__).resolve().parents[2] / "src" / "praisonaiui" / "replay_app.py").read_text(encoding="utf-8")
assert "aiui run" in src and "/replay?run=" in src
import praisonaiui.server as server
try:
importlib.import_module("praisonaiui.replay_app")
assert server.get_style() == "dashboard"
finally:
server.reset_state()
def test_cli_list(traces, feature):
_copy_all(traces)
out = feature._cli_list()
assert "team_handoff" in out and "Solar sail brief" in out
# ── standalone export helper ─────────────────────────────────────────
def test_standalone_html_is_self_contained():
from praisonaiui.features.replay import standalone_html
events = [json.loads(line) for line in _lines("team_handoff")]
events[3]["data"]["evil"] = "</script><script>alert(1)</script>"
html = standalone_html(events, title="Run <1>")
assert html.count("<script") == 3 # data + core + view, nothing injected
assert "</script><script>alert" not in html
assert "window.__REPLAY_DATA__" in html
assert "export function buildModel" in html and "export async function render" in html
assert "<title>Run &lt;1&gt;</title>" in html
for banned in ("http://", "https://", "cdn"):
# SVG namespace URI is a name, not a fetch
assert banned not in html.replace("http://www.w3.org/2000/svg", ""), banned
# graph is picked up from workflow_start when not passed
payload = html.split("window.__REPLAY_DATA__ = ", 1)[1].split(";</script>", 1)[0]
data = json.loads(payload)
assert data["graph"]["schema"] == "praisonai.graph/1"
assert len(data["events"]) == 36
-149
View File
@@ -1,149 +0,0 @@
"""PraisonAI Replay dashboard view: registration and wiring (source-grep, like test_run_recorder)."""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
VIEWS = FRONTEND / "views"
CORE = VIEWS / "replay-core.js"
VIEW = VIEWS / "replay.js"
def test_view_files_exist():
assert CORE.exists()
assert VIEW.exists()
def test_replay_registered_in_builtin_views():
source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
block = source.split("const BUILTIN_VIEWS = {", 1)[1].split("};", 1)[0]
assert re.search(r"^\s*replay:\s*'/plugins/views/replay\.js',", block, re.M)
def test_dashboard_accepts_hash_deep_links():
source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert "location.hash.match(" in source
assert "history.replaceState(null, '', `/${hashRoute[1]}" in source
def test_core_exports_pure_api():
source = CORE.read_text(encoding="utf-8")
for fn in (
"export function buildModel",
"export function narrate",
"export function layoutMap",
"export function deriveGraph",
):
assert fn in source, fn
assert "globalThis.PraisonReplayCore = api" in source
# pure: no DOM and no network
for banned in ("document.", "window.", "fetch(", "EventSource", "localStorage"):
assert banned not in source, banned
def test_view_exports_render_and_cleanup():
source = VIEW.read_text(encoding="utf-8")
assert "export async function render" in source
assert "export function cleanup" in source
assert "abort" in source.lower()
assert "es.close()" in source
def test_view_uses_contract_endpoints():
source = VIEW.read_text(encoding="utf-8")
for url in (
"/api/replay/runs",
"/api/replay/runs/${encodeURIComponent(id)}/events",
"/api/replay/runs/${encodeURIComponent(id)}/stream",
"/api/approvals/${encodeURIComponent(b.approvalId)}/${action}",
"modified_args",
):
assert url in source, url
def test_no_cdn_or_third_party_assets():
for path in (CORE, VIEW):
text = path.read_text(encoding="utf-8")
stripped = text.replace("http://www.w3.org/2000/svg", "")
assert "http://" not in stripped and "https://" not in stripped, path.name
assert not re.search(r"cdn[\w-]*\.[a-z]{2,}", stripped.lower()), path.name
assert "mermaid" not in stripped.lower(), path.name
assert not re.search(r"^\s*import .* from ['\"](?!\./|/plugins/)", text, re.M), path.name
def test_palette_and_shapes_follow_contract():
core = CORE.read_text(encoding="utf-8")
for colour in ("#8B0000", "#0F7287", "#8A5A00", "#3C4A5C"):
assert colour in core
assert "#189AB4" not in core + VIEW.read_text(encoding="utf-8")
for pair in ("agent: 'circle'", "tool: 'rounded'", "human: 'diamond'", "manager: 'square'"):
assert pair in core
def test_accessibility_hooks_present():
view = VIEW.read_text(encoding="utf-8")
assert "prefers-reduced-motion" in view
assert ":focus-visible" in view
assert "aria-live" in view
assert "role: 'tablist'" in view and "role: 'tab'" in view and "role: 'tabpanel'" in view
assert "aria-current" in view
assert "'ArrowLeft'" in view and "'ArrowRight'" in view
assert "praisonai replay view agents.yaml" in view
assert "with record(" in view
assert "Studio" not in view and "__start__" not in view
def test_standalone_boot_contract():
view = VIEW.read_text(encoding="utf-8")
assert "globalThis.__REPLAY_DATA__" in view
assert "replay-root" in view
assert "globalThis.PraisonReplayCore" in view
def test_replay_core_node_suite():
"""Runs tests/js/replay-core.test.mjs (buildModel, narrate, layoutMap) when Node is present."""
node = shutil.which("node")
if not node:
pytest.skip("node not installed")
proc = subprocess.run(
[node, "--test", str(ROOT / "tests" / "js" / "replay-core.test.mjs")],
cwd=ROOT,
capture_output=True,
text=True,
timeout=120,
)
assert proc.returncode == 0, proc.stdout[-4000:] + proc.stderr[-2000:]
assert re.search(r"# fail 0", proc.stdout)
def test_replay_page_uses_own_svg_mark_not_emoji_heading():
from praisonaiui.features.replay import REPLAY_MARK_SVG
assert REPLAY_MARK_SVG.startswith('<svg xmlns="http://www.w3.org/2000/svg"')
for banned in ("<script", "foreignObject", "onload", "javascript:"):
assert banned not in REPLAY_MARK_SVG
server_src = (ROOT / "src" / "praisonaiui" / "server.py").read_text(encoding="utf-8")
assert '"icon_svg": REPLAY_MARK_SVG' in server_src
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert "function safeSvgIcon" in dash and "parsererror, script, foreignObject" in dash
assert "setPageIcon(iconSpan, page" in dash
def test_dashboard_mobile_shell_gives_content_the_width():
dash = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert ".db-sidebar { position: fixed !important;" in dash
assert "#db-shell-header-slot { flex: 0 0 100%;" in dash
assert ".db-main { padding: 64px 16px 16px; }" in dash
def test_theme_defaults_to_system():
view = VIEW.read_text(encoding="utf-8")
assert "@media (prefers-color-scheme:dark){.rp-root[data-theme=\"system\"]" in view
assert "'Theme: system'" in view
-153
View File
@@ -1,153 +0,0 @@
"""Tracing feature reads recorded runs from the context-trace JSONL directory.
The old SDKTracingManager looked up ``praisonaiagents.trace.list_traces`` /
``list_spans`` / ``get_trace``, which do not exist, so recorded runs never
showed up and it silently fell back to an empty in-memory store.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from starlette.applications import Starlette
from starlette.testclient import TestClient
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "replay"
@pytest.fixture()
def traces(tmp_path, monkeypatch):
d = tmp_path / "traces"
d.mkdir()
for name in ("team_handoff", "flow_parallel_loop", "legacy_v10"):
shutil.copy(FIXTURES / f"{name}.jsonl", d / f"{name}.jsonl")
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d))
from praisonaiui.features import tracing
monkeypatch.setattr(tracing, "_tracing_manager", None)
return d
@pytest.fixture()
def client(traces):
from praisonaiui.features.tracing import TracingFeature
return TestClient(Starlette(routes=TracingFeature().routes()))
def test_recorded_runs_are_listed_as_traces(client):
data = client.get("/api/traces").json()
ids = {t["id"] for t in data["traces"]}
assert {"team_handoff", "flow_parallel_loop", "legacy_v10"} <= ids
team = next(t for t in data["traces"] if t["id"] == "team_handoff")
assert team["source"] == "jsonl"
assert team["name"] == "Solar sail brief"
assert team["status"] == "completed"
assert team["duration_ms"] == pytest.approx(12250, abs=1)
assert team["span_count"] > 0
assert team["metadata"]["session_id"] == "sess-team-01"
def test_agent_filter(client):
data = client.get("/api/traces?agent_id=Checker").json()
assert [t["id"] for t in data["traces"]] == ["legacy_v10"]
def test_spans_from_start_end_pairs_via_parent_seq(client):
spans = client.get("/api/traces/spans?trace_id=team_handoff&limit=500").json()["spans"]
kinds = {}
for s in spans:
kinds[s["kind"]] = kinds.get(s["kind"], 0) + 1
assert kinds == {"session": 1, "workflow": 1, "llm": 5, "agent": 2, "task": 2, "tool": 4, "approval": 1}
by_id = {s["id"]: s for s in spans}
# tool span nests under the agent span that was open (parent_seq on the start)
tool = next(s for s in spans if s["kind"] == "tool" and s["name"] == "write_file")
parent = by_id[tool["parent_span_id"]]
assert parent["kind"] == "agent" and parent["agent_id"] == "Writer"
assert tool["status"] == "ok"
assert tool["duration_ms"] == pytest.approx(50.0)
assert tool["attributes"]["input"] == {"path": "docs/draft.md", "content": "[REDACTED]"}
# approval pairs requested -> decided by parent_seq even though the decision has no agent
appr = next(s for s in spans if s["kind"] == "approval")
assert appr["status"] == "ok"
assert appr["end_time"] == pytest.approx(1767225611.2)
# every span closed
assert all(s["status"] != "open" for s in spans)
assert all(s["trace_id"] == "team_handoff" for s in spans)
def test_spans_for_legacy_file_fall_back_to_sequence_order(client):
spans = client.get("/api/traces/spans?trace_id=legacy_v10").json()["spans"]
by_id = {s["id"]: s for s in spans}
names = [(s["kind"], s["agent_id"]) for s in spans]
assert names == [
("session", ""),
("agent", "Assistant"),
("llm", "Assistant"),
("tool", "Assistant"),
("llm", "Assistant"),
("agent", "Checker"),
("llm", "Checker"),
]
calc = next(s for s in spans if s["kind"] == "tool")
assert by_id[calc["parent_span_id"]]["kind"] == "agent"
assert by_id[calc["parent_span_id"]]["agent_id"] == "Assistant"
checker_llm = spans[-1]
assert by_id[checker_llm["parent_span_id"]]["agent_id"] == "Checker"
assert all(s["status"] == "ok" for s in spans)
def test_parallel_branch_spans_pair_within_branch(client):
spans = client.get("/api/traces/spans?trace_id=flow_parallel_loop&limit=500").json()["spans"]
tools = {s["name"]: s for s in spans if s["kind"] == "tool"}
assert tools["fetch_rss"]["duration_ms"] == pytest.approx(1250.0)
assert tools["search_papers"]["duration_ms"] == pytest.approx(1840.0)
assert tools["search_papers"]["attributes"]["branch_id"] == "gather/1"
assert tools["search_papers"]["attributes"]["cost_usd"] == pytest.approx(0.001)
def test_get_trace_and_unknown(client):
r = client.get("/api/traces/legacy_v10")
assert r.status_code == 200
body = r.json()
assert body["trace"]["id"] == "legacy_v10"
assert len(body["spans"]) == 7
assert client.get("/api/traces/does-not-exist").status_code == 404
assert client.get("/api/traces/../../etc/passwd").status_code == 404
def test_health_reports_source(client, tmp_path, monkeypatch):
status = client.get("/api/traces/status").json()
assert status["source"] == "jsonl"
assert status["jsonl_traces"] == 3
from praisonaiui.features import tracing
monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "empty"))
monkeypatch.setattr(tracing, "_tracing_manager", None)
assert client.get("/api/traces/status").json()["source"] == "memory"
def test_recorded_in_memory_traces_still_work(client):
r = client.post("/api/traces/record", json={"type": "trace", "id": "mem-1", "name": "manual"})
assert r.status_code == 200
ids = [t["id"] for t in client.get("/api/traces?limit=100").json()["traces"]]
assert "mem-1" in ids and "team_handoff" in ids
assert client.get("/api/traces/mem-1").json()["trace"]["name"] == "manual"
def test_no_dead_sdk_lookups_remain():
src = (Path(__file__).resolve().parents[2] / "src" / "praisonaiui" / "features" / "tracing.py").read_text(encoding="utf-8")
for dead in ('"list_traces", None', '"list_spans", None', '"get_trace", None', "trace_module"):
assert dead not in src
from praisonaiui.features.tracing import JsonlTracingManager, SDKTracingManager
assert SDKTracingManager is JsonlTracingManager # deprecated alias kept for imports
def test_cli_status_mentions_source(traces):
from praisonaiui.features.tracing import TracingFeature
out = TracingFeature()._cli_status()
assert "Source: jsonl" in out
Generated
+2 -2
View File
@@ -1,5 +1,5 @@
version = 1
revision = 5
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.151"
version = "0.3.147"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },