Subagents (C4): the tree in the activity panel, read-only sessions, findings, stop; agent chats' setting
On the server's /api/v2 (3.4.0, server dev 5d7b79c):
- The activity panel reads GET /chats/{chat}/tree beside the activity and
draws ui/js/tree.js's rows in a Subagents section, the server panel's words
and layout: nested by parent, position, phase, context %, state, model,
mode + request options, messages and the last one, open slots, the ended
ones folded and dimmed. A team's members are no longer drawn again among
the tasks. The stream's `tree` frame redraws it.
- A subagent's conversation (/subagents/{id}/transcript, read again every 3 s
while it works) and its findings open read-only in the panel; Stop asks,
then POSTs /stop. With an agent chat's device away: the kept tree with the
server's note, and its 409 sentence for the rest.
- A new agent chat's Subagents chip from /models?kind=agent&device= (each
model's `subagents`, `agent_subagents.offered` and `device_runs`); an open
agent chat's from its own `subagents`, as before.
- An approval item's `subagent` names it on the card.
- Progress keys are the spec's own ({{field}}), as the server's catalogues
have them; SERVER_PENDING is gone, every sentence is translated.
tests/ui reads the server's contract fixtures (scripts/fetch-fixtures.sh ->
tests/ui/fixtures, SOURCE says the commit): status frames, tree, tree frame,
transcript, findings, models?kind=agent, a subagent's approval; six C4 cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
22 files changed
+1400
-181
No files matched your search
@@ -0,0 +1 @@
|
||||
LLeMbas origin/dev (5d7b79c5c9dc0d9c3df9a8b401c02fdd67927c2d) tests/fixtures/api_v2
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "chat2",
|
||||
"kind": "agent",
|
||||
"title": "Waybread",
|
||||
"model": "qwen",
|
||||
"folder_id": null,
|
||||
"pinned": false,
|
||||
"archived": false,
|
||||
"temporary": false,
|
||||
"unread": true,
|
||||
"running": true,
|
||||
"mode": "edit",
|
||||
"effort": "low",
|
||||
"device": {
|
||||
"token_id": "token1",
|
||||
"directory": "/home/dev/project"
|
||||
},
|
||||
"device_session_id": "ses_01waybread",
|
||||
"parent_chat_id": null,
|
||||
"agents": {
|
||||
"min": 1,
|
||||
"max": 4
|
||||
},
|
||||
"subagents": {
|
||||
"offered": true,
|
||||
"named_only": false,
|
||||
"cap": 32
|
||||
},
|
||||
"created_at": "2026-01-02T03:04:05+00:00",
|
||||
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"cards": [
|
||||
{
|
||||
"id": "card1",
|
||||
"kind": "question",
|
||||
"why": "",
|
||||
"dismissable": false,
|
||||
"expires_in": null,
|
||||
"items": [
|
||||
{
|
||||
"index": 0,
|
||||
"key": "q0",
|
||||
"kind": "question",
|
||||
"tool": "ask_user",
|
||||
"title": "Honey or not?",
|
||||
"header": "Taste",
|
||||
"detail": "",
|
||||
"reason": "",
|
||||
"purpose": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "Honey",
|
||||
"description": "",
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"label": "Plain",
|
||||
"description": "As the elves have it",
|
||||
"recommended": false
|
||||
}
|
||||
],
|
||||
"multiple": false,
|
||||
"free_text": true,
|
||||
"editable": false,
|
||||
"plan": null,
|
||||
"mode_choice": true,
|
||||
"job": "",
|
||||
"subagent": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card2",
|
||||
"kind": "approval",
|
||||
"why": "",
|
||||
"dismissable": false,
|
||||
"expires_in": null,
|
||||
"items": [
|
||||
{
|
||||
"index": 0,
|
||||
"key": "0",
|
||||
"kind": "approval",
|
||||
"tool": "bash",
|
||||
"title": "Run a command",
|
||||
"header": "",
|
||||
"detail": "rm -rf build",
|
||||
"reason": "Edit mode asks before anything that runs a command.",
|
||||
"purpose": "Start clean",
|
||||
"options": [],
|
||||
"multiple": false,
|
||||
"free_text": false,
|
||||
"editable": true,
|
||||
"plan": null,
|
||||
"mode_choice": true,
|
||||
"job": "explore (job1)",
|
||||
"subagent": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card3",
|
||||
"kind": "plan",
|
||||
"why": "",
|
||||
"dismissable": true,
|
||||
"expires_in": null,
|
||||
"items": [
|
||||
{
|
||||
"index": 0,
|
||||
"key": "p0",
|
||||
"kind": "plan",
|
||||
"tool": "plan_submit",
|
||||
"title": "Bake lembas",
|
||||
"header": "",
|
||||
"detail": "",
|
||||
"reason": "",
|
||||
"purpose": "",
|
||||
"options": [],
|
||||
"multiple": false,
|
||||
"free_text": true,
|
||||
"editable": false,
|
||||
"plan": {
|
||||
"version": 2,
|
||||
"title": "Bake lembas",
|
||||
"summary": "",
|
||||
"findings": [],
|
||||
"objectives": [],
|
||||
"phases": [
|
||||
{
|
||||
"id": "p1",
|
||||
"title": "Plan",
|
||||
"status": "pending",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "t1",
|
||||
"text": "Gather mallorn leaves",
|
||||
"status": "todo",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "t2",
|
||||
"text": "Bake slowly",
|
||||
"status": "todo",
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
"Gather mallorn leaves",
|
||||
"Bake slowly"
|
||||
]
|
||||
},
|
||||
"mode_choice": true,
|
||||
"job": "",
|
||||
"subagent": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card4",
|
||||
"kind": "approval",
|
||||
"why": "",
|
||||
"dismissable": false,
|
||||
"expires_in": null,
|
||||
"items": [
|
||||
{
|
||||
"index": 0,
|
||||
"key": "0",
|
||||
"kind": "approval",
|
||||
"tool": "bash",
|
||||
"title": "Run a command",
|
||||
"header": "",
|
||||
"detail": "git push",
|
||||
"reason": "Edit mode asks before anything that runs a command.",
|
||||
"purpose": "",
|
||||
"options": [],
|
||||
"multiple": false,
|
||||
"free_text": false,
|
||||
"editable": true,
|
||||
"plan": null,
|
||||
"mode_choice": true,
|
||||
"job": "",
|
||||
"subagent": {
|
||||
"id": "s1",
|
||||
"name": "parser",
|
||||
"position": "reviews the parser"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"text": "Running bake --slow",
|
||||
"progress": {
|
||||
"phase": "running_tool",
|
||||
"since": 1767323045000,
|
||||
"tool": "bash",
|
||||
"what": "bake --slow"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"text": "",
|
||||
"progress": null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"text": "Waiting for you to allow bash…",
|
||||
"progress": {
|
||||
"phase": "waiting_for_person",
|
||||
"since": 1767323045000,
|
||||
"what": "question"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"text": "",
|
||||
"progress": {
|
||||
"phase": "stopped",
|
||||
"since": 1767323045000,
|
||||
"reason": "person"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"tree": {
|
||||
"min": 1,
|
||||
"max": 4,
|
||||
"size": 2,
|
||||
"open_slots": 1,
|
||||
"agents": [
|
||||
{
|
||||
"id": "lead",
|
||||
"name": "lead",
|
||||
"position": "the main agent",
|
||||
"parent": null,
|
||||
"state": "working",
|
||||
"model": "qwen-27b",
|
||||
"mode": "edit",
|
||||
"request": [],
|
||||
"manager": true,
|
||||
"context": {
|
||||
"used": 41200,
|
||||
"window": 131072
|
||||
},
|
||||
"progress": {
|
||||
"phase": "thinking",
|
||||
"since": 1767323045000
|
||||
},
|
||||
"findings": 0,
|
||||
"messages": 2
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"name": "parser",
|
||||
"position": "reviews the parser",
|
||||
"parent": "lead",
|
||||
"state": "working",
|
||||
"model": "qwen-27b",
|
||||
"mode": "manual",
|
||||
"task": "Review src/parse.ts.",
|
||||
"request": [
|
||||
"write"
|
||||
],
|
||||
"manager": false,
|
||||
"context": {
|
||||
"used": 9100,
|
||||
"window": 131072
|
||||
},
|
||||
"progress": {
|
||||
"phase": "running_tool",
|
||||
"since": 1767323045000,
|
||||
"tool": "read",
|
||||
"what": "src/parse.ts"
|
||||
},
|
||||
"findings": 2,
|
||||
"messages": 3
|
||||
}
|
||||
],
|
||||
"ended": [
|
||||
{
|
||||
"id": "s1",
|
||||
"name": "scout",
|
||||
"position": "maps the code",
|
||||
"parent": "lead",
|
||||
"state": "done",
|
||||
"model": "qwen-27b",
|
||||
"mode": "edit",
|
||||
"task": "Map it.",
|
||||
"request": [],
|
||||
"manager": false,
|
||||
"progress": null,
|
||||
"findings": 1,
|
||||
"messages": 1,
|
||||
"ended": 1767323045000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "qwen",
|
||||
"name": "Qwen",
|
||||
"vision": false,
|
||||
"tools": true,
|
||||
"efforts": [
|
||||
"medium"
|
||||
],
|
||||
"thinking_switch": true,
|
||||
"levels": [
|
||||
"off",
|
||||
"medium"
|
||||
],
|
||||
"effort": "medium",
|
||||
"control": "switch",
|
||||
"parallel": 2,
|
||||
"context": 131072,
|
||||
"image": "/api/v2/models/images/0a1b2c3d4e5f.png",
|
||||
"default": true,
|
||||
"subagents": {
|
||||
"offered": true,
|
||||
"named_only": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-oss",
|
||||
"name": "gpt-oss",
|
||||
"vision": false,
|
||||
"tools": true,
|
||||
"efforts": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
],
|
||||
"thinking_switch": false,
|
||||
"levels": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
],
|
||||
"effort": "high",
|
||||
"control": "slider",
|
||||
"parallel": 1,
|
||||
"context": 0,
|
||||
"image": null,
|
||||
"default": false,
|
||||
"subagents": {
|
||||
"offered": true,
|
||||
"named_only": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vision-small",
|
||||
"name": "Vision",
|
||||
"vision": true,
|
||||
"tools": false,
|
||||
"efforts": [],
|
||||
"thinking_switch": false,
|
||||
"levels": [
|
||||
"default"
|
||||
],
|
||||
"effort": "default",
|
||||
"control": "none",
|
||||
"parallel": null,
|
||||
"context": 8192,
|
||||
"image": null,
|
||||
"default": false,
|
||||
"subagents": {
|
||||
"offered": true,
|
||||
"named_only": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"subagents_cap": 32,
|
||||
"agent_subagents": {
|
||||
"offered": true,
|
||||
"device_runs": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "s2",
|
||||
"findings": [
|
||||
{
|
||||
"n": 1,
|
||||
"text": "Login is POST /session.",
|
||||
"time": 1767323045000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"id": "s2",
|
||||
"state": "working",
|
||||
"turns": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Write the routes.",
|
||||
"machine": true
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Reading the API first.",
|
||||
"tools": [
|
||||
{
|
||||
"name": "read",
|
||||
"title": "src/api.py",
|
||||
"isError": false
|
||||
}
|
||||
],
|
||||
"error": "",
|
||||
"running": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"tree": {
|
||||
"min": 2,
|
||||
"max": 4,
|
||||
"size": 3,
|
||||
"open_slots": 0,
|
||||
"agents": [
|
||||
{
|
||||
"id": "lead",
|
||||
"name": "lead",
|
||||
"position": "the main agent",
|
||||
"parent": null,
|
||||
"state": "working",
|
||||
"model": "local/qwen",
|
||||
"mode": "auto",
|
||||
"request": [],
|
||||
"manager": true,
|
||||
"progress": {
|
||||
"phase": "waiting_for_subagents",
|
||||
"since": 1767323045000,
|
||||
"running": 2
|
||||
},
|
||||
"findings": 0,
|
||||
"messages": 1
|
||||
},
|
||||
{
|
||||
"id": "s1",
|
||||
"name": "area",
|
||||
"position": "owns the API",
|
||||
"parent": "lead",
|
||||
"state": "working",
|
||||
"model": "local/qwen",
|
||||
"mode": "auto",
|
||||
"request": [],
|
||||
"manager": true,
|
||||
"task": "Direct it.",
|
||||
"progress": null,
|
||||
"findings": 0,
|
||||
"messages": 1
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"name": "routes",
|
||||
"position": "writes the routes",
|
||||
"parent": "area",
|
||||
"state": "working",
|
||||
"model": "local/qwen",
|
||||
"mode": "edit",
|
||||
"request": [
|
||||
"execute"
|
||||
],
|
||||
"manager": false,
|
||||
"task": "Write the routes.",
|
||||
"context": {
|
||||
"used": 9100,
|
||||
"window": 131072
|
||||
},
|
||||
"progress": {
|
||||
"phase": "running_tool",
|
||||
"since": 1767323045000,
|
||||
"tool": "read",
|
||||
"what": "src/api.py"
|
||||
},
|
||||
"findings": 1,
|
||||
"messages": 1
|
||||
}
|
||||
],
|
||||
"ended": [
|
||||
{
|
||||
"id": "s3",
|
||||
"name": "scout",
|
||||
"position": "maps the code",
|
||||
"parent": "lead",
|
||||
"state": "done",
|
||||
"model": "local/qwen",
|
||||
"mode": "auto",
|
||||
"request": [],
|
||||
"manager": false,
|
||||
"task": "Map it.",
|
||||
"progress": null,
|
||||
"findings": 0,
|
||||
"messages": 1,
|
||||
"ended": 1767323045000
|
||||
}
|
||||
]
|
||||
},
|
||||
"room": [
|
||||
{
|
||||
"n": 1,
|
||||
"from": "routes",
|
||||
"to": [
|
||||
"area"
|
||||
],
|
||||
"text": "Routes are in.",
|
||||
"report": false,
|
||||
"kind": "message",
|
||||
"time": 1767323045000
|
||||
},
|
||||
{
|
||||
"n": 2,
|
||||
"from": "scout",
|
||||
"to": [
|
||||
"lead"
|
||||
],
|
||||
"text": "Mapped: see map.md.",
|
||||
"report": true,
|
||||
"kind": "report",
|
||||
"time": 1767323045000
|
||||
}
|
||||
],
|
||||
"live": true
|
||||
}
|
||||
+166
-46
@@ -50,6 +50,9 @@ async function launch() {
|
||||
throw new Error("tests/ui: no Chromium found (set CHROMIUM)")
|
||||
}
|
||||
|
||||
/** One of the server's /api/v2 contract fixtures, as copied by scripts/fetch-fixtures.sh. */
|
||||
const fixture = (name) => JSON.parse(readFileSync(join(HERE, "fixtures", `${name}.json`), "utf8"))
|
||||
|
||||
const TYPES = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".mjs": "text/javascript", ".json": "application/json" }
|
||||
|
||||
// --- The server, as the tests' table -----------------------------------------------------------
|
||||
@@ -636,9 +639,14 @@ test("B1: a reply being written draws the stream's progress, its clock moving, a
|
||||
const p = await page(agentChatOpen([["status", { text: "Writing…" }]]))
|
||||
await openChatRow(p, "chat2")
|
||||
await p.waitForSelector(".msg.is-live .msg__status")
|
||||
await p.evaluate((path) => {
|
||||
window.__mock.frame(path, "status", { text: "Running bake --slow", progress: { phase: "running_tool", since: Date.now() - 65000, tool: "bash", what: "bake --slow" } })
|
||||
}, STREAM2)
|
||||
// An older server sends no progress at all: its status text, as before.
|
||||
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Running bake --slow" }), STREAM2)
|
||||
assert.equal(await p.textContent(".msg.is-live .msg__status"), "Running bake --slow")
|
||||
// The server's status frame (event_status.json), its clock set 65 s back from now.
|
||||
const status = fixture("event_status")
|
||||
await p.evaluate(([path, f]) => {
|
||||
window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() - 65000 } })
|
||||
}, [STREAM2, status])
|
||||
const line = () => p.textContent(".msg.is-live .msg__status")
|
||||
assert.equal(await line(), "Running bash: bake --slow · 1m 05s")
|
||||
assert.equal(await p.getAttribute(".msg.is-live .msg__status", "data-phase"), "running_tool")
|
||||
@@ -646,59 +654,171 @@ test("B1: a reply being written draws the stream's progress, its clock moving, a
|
||||
// A data-only frame with progress (the ask frame carries it too): the line follows.
|
||||
await p.evaluate((path) => window.__mock.frame(path, "ask", { cards: [], progress: { phase: "waiting_for_person", since: Date.now(), what: "approval", tool: "bash" } }), STREAM2)
|
||||
assert.equal(await line(), "Waiting for you: allow bash? · 0s")
|
||||
// Nothing running: `progress: null`, and the status text is drawn again.
|
||||
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Writing…", progress: null }), STREAM2)
|
||||
// Nothing running (event_status_idle.json: no text, `progress: null`): the plain words again.
|
||||
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_idle")])
|
||||
assert.equal(await line(), "Writing…")
|
||||
// An older server sends no progress at all: its status text, as before.
|
||||
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Running bake --slow" }), STREAM2)
|
||||
assert.equal(await line(), "Running bake --slow")
|
||||
// Waiting for a person's answer (event_status_person.json): the phase, not the text.
|
||||
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() } }), [STREAM2, fixture("event_status_person")])
|
||||
assert.equal(await line(), "Waiting for you: a question · 0s")
|
||||
// Stopped and failed have no clock.
|
||||
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Stopped.", progress: { phase: "stopped", since: Date.now() - 4000, reason: "person" } }), STREAM2)
|
||||
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_stopped")])
|
||||
assert.equal(await line(), "Stopped: by you")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// --- C4 (ahead of the server): the subagent tree as spec 3.4.0 §12 gives it ---------------------
|
||||
// --- C4: subagents (spec 3.4.0) ---------------------------------------------------------------
|
||||
//
|
||||
// ui/js/tree.js draws subagents.md §12's data; the activity panel takes it up once the server's
|
||||
// /api/v2 carries the tree. Until then it is checked here on the spec's own example.
|
||||
// The server's /api/v2 (its fixtures, copied into tests/ui/fixtures): GET /chats/{chat}/tree ->
|
||||
// {tree, room, live}, the stream's `tree` frame, a subagent's transcript and findings, POST
|
||||
// .../stop; an agent chat's device away -> the kept tree (`live` false) and 409 for the rest;
|
||||
// /models?kind=agent&device= for a new agent chat's Subagents; `subagent` on an approval item.
|
||||
|
||||
const TREE = {
|
||||
min: 2, max: 4, size: 3, open_slots: 1,
|
||||
agents: [
|
||||
{ id: "lead", name: "lead", position: "the main agent", parent: null, state: "working", model: "qwen-27b", mode: "edit", request: [], manager: true, context: { used: 41200, window: 131072 }, progress: { phase: "thinking", since: 0 } },
|
||||
{ id: "s1", name: "parser", position: "reviews the parser", parent: "lead", state: "working", model: "qwen-27b", mode: "manual", request: ["write"], manager: true, task: "Read src/parse.ts", context: { used: 9100, window: 131072 }, progress: { phase: "running_tool", since: 0, tool: "read", what: "src/parse.ts" }, findings: 2, messages: 3 },
|
||||
{ id: "s3", name: "lexer", position: "the lexer's tests", parent: "s1", state: "waiting", model: "qwen-27b", mode: "manual", request: [], manager: false, context: { used: 0, window: 131072 }, progress: null, findings: 0, messages: 0 },
|
||||
],
|
||||
ended: [{ id: "s0", name: "scout", position: "maps the repository", parent: "lead", state: "out_of_context", ended: 0, findings: 1 }],
|
||||
const ACTIVITY = { activity: { version: "v1", summary: { active: 0 }, agents: [], context: null, outputs: [], team: null, plan: null } }
|
||||
|
||||
function c4(over = {}) {
|
||||
return b3({
|
||||
"GET /chats/chat2/activity": ACTIVITY,
|
||||
"GET /chats/chat2/tree": fixture("tree"),
|
||||
"GET /chats/chat2/subagents/s2/transcript": fixture("subagent_transcript"),
|
||||
"GET /chats/chat2/subagents/s2/findings": fixture("subagent_findings"),
|
||||
"POST /chats/chat2/subagents/s2/stop": { stopped: ["s2"] },
|
||||
...over,
|
||||
})
|
||||
}
|
||||
|
||||
test("C4: the tree in order, each agent's position, state, mode, context and phase; slots; the ended ones apart", async () => {
|
||||
const p = await page(b3())
|
||||
const got = await p.evaluate(async (tree) => {
|
||||
const m = await import("/js/tree.js")
|
||||
const calls = []
|
||||
const box = m.treePart(tree, { room: [{ n: 1, from: "lead", to: ["parser"], text: "Start with the grammar.\nThen the lexer." }], now: 65000, act: { open: (a) => calls.push(["open", a.id]), findings: (a) => calls.push(["findings", a.id]), stop: (a) => calls.push(["stop", a.id]) } })
|
||||
document.body.append(box)
|
||||
const rows = [...box.querySelectorAll(".subagents__list > .subagent:not(.subagent--slot)")].map((r) => ({ id: r.dataset.id, depth: r.style.getPropertyValue("--depth"), text: r.textContent }))
|
||||
box.querySelector('[data-id="s1"] .subagent__actions .btn').click()
|
||||
box.querySelector('[data-id="s0"] .subagent__actions .btn:last-child').click()
|
||||
box.querySelector('[data-id="s1"] .agent__stop').click()
|
||||
return { rows, slots: box.querySelectorAll(".subagent--slot").length, endedStop: box.querySelectorAll('[data-id="s0"] button.agent__stop').length, calls }
|
||||
}, TREE)
|
||||
assert.deepEqual(got.rows.map((r) => [r.id, r.depth]), [["lead", "0"], ["s1", "1"], ["s3", "2"], ["s0", "0"]])
|
||||
const parser = got.rows[1].text
|
||||
assert.match(parser, /parser \(s1\) — reviews the parser/)
|
||||
assert.match(parser, /6%/, "context: floor(9100 × 100 / 131072)")
|
||||
assert.match(parser, /manual \+ write/)
|
||||
assert.match(parser, /Running read: src\/parse\.ts · 1m 05s/)
|
||||
assert.match(parser, /Messages: 3/)
|
||||
assert.match(parser, /lead: Start with the grammar\./)
|
||||
assert.match(got.rows[2].text, /waiting for a slot/)
|
||||
assert.match(got.rows[3].text, /out of context/)
|
||||
assert.equal(got.slots, 1)
|
||||
assert.equal(got.endedStop, 0, "an ended subagent cannot be stopped")
|
||||
assert.deepEqual(got.calls, [["open", "s1"], ["findings", "s0"], ["stop", "s1"]])
|
||||
async function openTree(p) {
|
||||
await openChatRow(p, "chat2")
|
||||
await p.click("#activity-open")
|
||||
await p.waitForSelector("#activity .subagents .subagent")
|
||||
}
|
||||
|
||||
const rows = (p) => p.evaluate(() => [...document.querySelectorAll("#activity .subagents__list > .subagent")].map((r) => [r.dataset.id, r.style.getPropertyValue("--depth")]))
|
||||
|
||||
test("C4: the activity panel draws the server's tree: nested by parent, slots, the ended ones apart", async () => {
|
||||
const p = await page(c4())
|
||||
await openTree(p)
|
||||
assert.deepEqual(await rows(p), [["lead", "0"], ["s1", "1"], ["s2", "2"]])
|
||||
const routes = await p.textContent('#activity .subagent[data-id="s2"]')
|
||||
assert.match(routes, /routes/)
|
||||
assert.match(routes, /writes the routes/)
|
||||
assert.match(routes, /Running read: src\/api\.py/)
|
||||
assert.match(routes, /6%/, "context: floor(9100 × 100 / 131072)")
|
||||
assert.match(routes, /edit \+ execute/)
|
||||
assert.match(routes, /messages: 1/)
|
||||
assert.match(routes, /routes → area: Routes are in\./, "the last room message to or from it")
|
||||
assert.match(await p.textContent('#activity .subagent[data-id="lead"]'), /the main agent/)
|
||||
assert.match(await p.textContent("#activity .subagents"), /3 of 4/)
|
||||
assert.match(await p.textContent("#activity .subagents"), /They run on the computer this chat runs on\./)
|
||||
// The ended one, folded apart, with how it ended, and no stop square.
|
||||
await p.click("#activity .subagents__ended > summary")
|
||||
const scout = await p.textContent('#activity .subagents__ended .subagent[data-id="s3"]')
|
||||
assert.match(scout, /done/)
|
||||
assert.equal(await p.locator('#activity .subagent[data-id="s3"] button.agent__stop').count(), 0)
|
||||
assert.equal(await p.locator('#activity .subagent[data-id="lead"] button.agent__stop').count(), 0, "the main agent is not stopped from here")
|
||||
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-1280.png") })
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("C4: a subagent's conversation and findings open read-only; Stop asks, then stops it", async () => {
|
||||
const p = await page(c4())
|
||||
await openTree(p)
|
||||
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||||
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
|
||||
await p.waitForSelector("#activity .convo .convo__turn")
|
||||
const convo = await p.textContent("#activity-body")
|
||||
assert.match(convo, /Write the routes\./)
|
||||
assert.match(convo, /Reading the API first\./)
|
||||
assert.match(convo, /src\/api\.py/)
|
||||
assert.equal(await p.locator("#activity-body textarea, #activity-body input").count(), 0, "nothing to type into")
|
||||
await p.click("#activity-back")
|
||||
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Findings')
|
||||
await p.waitForSelector("#activity .findings")
|
||||
assert.match(await p.textContent("#activity .findings"), /Login is POST \/session\./)
|
||||
await p.click("#activity-back")
|
||||
await p.click('#activity .subagent[data-id="s2"] button.agent__stop')
|
||||
await p.waitForSelector("dialog[open]")
|
||||
assert.match(await p.textContent("dialog[open]"), /Stop routes and the subagents under it\?/)
|
||||
await p.click("dialog[open] .btn--danger")
|
||||
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/subagents/s2/stop").length === 1)
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("C4: the stream's tree frame redraws the panel; with the device away the kept tree shows and the rest says why", async () => {
|
||||
const away = { ...fixture("tree"), live: false }
|
||||
const p = await page(c4({ "GET /chats/chat2/tree": away, "GET /chats/chat2/subagents/s2/transcript": { __error: { status: 409, detail: "That is kept on the computer this chat runs on, which is not linked right now." } } }))
|
||||
await openTree(p)
|
||||
assert.match(await p.textContent("#activity .subagents"), /This device is not linked right now\./)
|
||||
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||||
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
|
||||
await p.waitForSelector("#activity-body .is-error")
|
||||
assert.match(await p.textContent("#activity-body"), /not linked right now/)
|
||||
await p.click("#activity-back")
|
||||
// A reply streams; its `tree` frame (event_tree.json) is the tree now.
|
||||
await p.evaluate(() => window.__mock.frame("/chats/chat2/messages/m2/stream", "tree", null))
|
||||
await p.context().close()
|
||||
const live = await page({ ...c4(), routes: { ...c4().routes, "GET /chats/chat2": { chat: agentChat(), messages: [userMessage("m1", "Go")], more: false, live: "m2" } }, streams: { [STREAM2]: [] } })
|
||||
await openTree(live)
|
||||
await live.evaluate(([path, f]) => window.__mock.frame(path, "tree", f), [STREAM2, fixture("event_tree")])
|
||||
await live.waitForFunction(() => !!document.querySelector('#activity .subagent[data-id="s2"]') && /parser/.test(document.querySelector('#activity .subagent[data-id="s2"]').textContent))
|
||||
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /reviews the parser/)
|
||||
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /manual \+ write/)
|
||||
assert.deepEqual(live.errors, [])
|
||||
})
|
||||
|
||||
test("C4: an approval a subagent asks for names it", async () => {
|
||||
const card = fixture("event_ask").cards.find((c) => c.id === "card4")
|
||||
const p = await page(agentChatOpen([["ask", { cards: [card] }]], { "POST /chats/chat2/interactions/card4": { answered: true } }))
|
||||
await openChatRow(p, "chat2")
|
||||
await p.waitForSelector("#cards .ask__subagent")
|
||||
assert.equal(await p.textContent("#cards .ask__subagent"), "Subagent parser (s1), reviews the parser, asks:")
|
||||
assert.equal(await p.inputValue("#cards .ask__edit"), "git push", "the command, correctable, as for the main agent's")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("C4: Subagents for a new agent chat as /models?kind=agent says for this device; for an open one as the chat says", async () => {
|
||||
const facts = fixture("models_agent")
|
||||
const newAgent = async (models) => {
|
||||
const p = await page(c4({ "GET /models?kind=agent&device=token1": models }))
|
||||
await p.click("#new-chat-btn")
|
||||
await p.click(".kind__tab >> text=Agent")
|
||||
await p.waitForSelector("#composer-chips .cchip")
|
||||
await p.waitForTimeout(200)
|
||||
return p
|
||||
}
|
||||
let p = await newAgent(facts)
|
||||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 1, "offered, and the device runs them")
|
||||
await p.click("#composer-chips .cchip--subagents")
|
||||
await p.waitForSelector(".range2")
|
||||
assert.equal(await p.getAttribute(".range2__input--min", "max"), "32", "the server's cap")
|
||||
await p.context().close()
|
||||
p = await newAgent({ ...facts, agent_subagents: { offered: true, device_runs: false } })
|
||||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "this computer's CLI cannot run them")
|
||||
await p.context().close()
|
||||
p = await newAgent({ ...facts, agent_subagents: { offered: false, device_runs: true } })
|
||||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "not offered")
|
||||
await p.context().close()
|
||||
p = await newAgent({ __error: { status: 400, detail: "older server" } })
|
||||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "a server before 3.4.0: none, as before")
|
||||
await p.context().close()
|
||||
// An open agent chat: its own `subagents` (chat_agent.json offers them).
|
||||
const chat = fixture("chat_agent")
|
||||
p = await page(c4({ "GET /chats/chat2": { chat: { ...agentChat({ running: false }), subagents: chat.subagents, agents: chat.agents }, messages: [userMessage("m1", "Go")], more: false, live: null } }))
|
||||
await openChatRow(p, "chat2")
|
||||
await p.waitForSelector("#composer-chips .cchip--subagents")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("C4: the tree at 390 px stays inside the window", async () => {
|
||||
const p = await page(c4(), { width: 390, height: 844 })
|
||||
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
|
||||
await p.waitForSelector("#activity-open")
|
||||
await p.click("#activity-open")
|
||||
await p.waitForSelector("#activity .subagents .subagent")
|
||||
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||||
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
|
||||
const panel = await p.evaluate(() => { const b = document.querySelector("#activity-body"); return b.scrollWidth <= b.clientWidth })
|
||||
assert.ok(panel, "the panel does not scroll sideways")
|
||||
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-390.png") })
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user