Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 f723e82745 fix: link job rows to their chat session (fixes #323)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:41:01 +00:00
Mervin PraisonandClaude Opus 5.5 7002e956fb ci: bump github-automation-template to v1.1.20 (#320)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-29 02:38:22 +01:00
MervinPraison ebbb5f863e Release v0.3.149 2026-10-27 10:17:57 +00:00
Mervin Praison 34a01c1738 Merge pull request #318 from MervinPraison/fix/replay-unnamed-agents
fix(replay): map and narrate unnamed agents (Agent(instructions=...))
2026-10-27 11:14:51 +01:00
Mervin Praison 0d2b7b1d09 Merge branch 'main' into fix/replay-unnamed-agents 2026-10-27 11:13:04 +01:00
Mervin Praison 4ab1eee24f Merge pull request #319 from MervinPraison/fix/frontend-typecheck
fix(frontend): make the dashboard build pass again (26 type errors since April)
2026-10-27 11:12:54 +01:00
MervinPraisonandClaude Opus 5.5 a89b00da5b fix(replay): map and narrate unnamed agents (Agent(instructions=...))
An agent created without name= records agent_name null. The Replay map
skipped every such event, so watch(Agent(instructions=...)) showed
"Nothing to map yet" and its steps were narrated as "The run".

- agentOf(ev): agent_name, else the package's id (agent:Agent from the
  companion core fix), else tool:None/<tool> or nothing in 4.7.19 traces,
  falling back to "Agent" for an agent's own run events.
- deriveGraph / nodeIdsForEvent / narrate use it, so the agent and its tools
  are drawn and lit, and steps read "Agent started.".
- Only when no graph was recorded: a team graph leaves unnamed agents out on
  purpose, and they must not reappear as "Outside the team".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-27 09:46:43 +00:00
14 changed files with 158 additions and 34 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].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
+2 -2
View File
@@ -16,8 +16,8 @@ permissions:
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
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].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
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].19
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
- 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].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
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].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
+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].19
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].20
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
- 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].19
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].20
secrets: inherit
with:
hub_ref: v1.1.19
hub_ref: v1.1.20
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.148"
version = "0.3.149"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.148"
__version__ = "0.3.149"
@@ -164,6 +164,20 @@ 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);
@@ -199,6 +213,7 @@ 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>
@@ -599,6 +614,7 @@ function checkForJobsPage(root) {
window.aiuiCancelJob = cancelJob;
window.aiuiDeleteJob = deleteJob;
window.aiuiViewJob = viewJob;
window.aiuiOpenJobSession = openJobSession;
export default {
name: 'jobs',
@@ -51,6 +51,9 @@ const PAIRS = {
};
const END_OF = Object.fromEntries(Object.entries(PAIRS).map(([s, e]) => [e, s]));
/** Events an agent run emits for itself (lifecycle, model and tool calls). */
const AGENT_RUN_EVENTS = new Set(['agent_start', 'agent_end', 'llm_request', 'llm_response', 'tool_call_start', 'tool_call_end']);
/** Approvers that mean "a person decided", anything else is an automatic source. */
const HUMAN_APPROVERS = new Set(['user', 'human', 'you', 'ui', 'replay', 'console', 'web', 'dashboard']);
@@ -172,7 +175,7 @@ export function narrate(ev, related = null) {
if (!ev || typeof ev !== 'object') return '';
const d = dataOf(ev);
const rd = dataOf(related);
const who = str(ev.agent_name) || 'The run';
const who = str(ev.agent_name) || (AGENT_RUN_EVENTS.has(ev.event_type) ? agentOf(ev) : '') || 'The run';
switch (ev.event_type) {
case 'session_start':
return 'Recording started.';
@@ -699,14 +702,32 @@ export function buildModel(events, graph = null) {
// ── graph helpers ─────────────────────────────────────────────────────
/** Graph node ids an event touches (node_id, else derived from agent/tool names). */
/**
* The agent an event belongs to. An unnamed agent (``Agent(instructions=...)``)
* has no agent_name: newer traces give it the id ``agent:Agent``, older ones
* ``tool:None/<tool>`` or nothing, so its own run events fall back to "Agent".
*/
export function agentOf(ev) {
if (!ev) return null;
if (ev.agent_name) return ev.agent_name;
const id = str(ev.node_id);
if (id.startsWith('agent:')) return id.slice(6) || 'Agent';
if (id.startsWith('tool:') && AGENT_RUN_EVENTS.has(ev.event_type)) {
const who = id.slice(5).split('/')[0];
return who && who !== 'None' ? who : 'Agent';
}
return AGENT_RUN_EVENTS.has(ev.event_type) ? 'Agent' : null;
}
export function nodeIdsForEvent(ev) {
const out = [];
if (!ev) return out;
if (ev.node_id) out.push(ev.node_id);
if (ev.node_id && !str(ev.node_id).startsWith('tool:None/')) out.push(ev.node_id);
const d = dataOf(ev);
if (ev.agent_name) {
out.push(ev.agent_name === 'Manager' ? MANAGER_LANE : `agent:${ev.agent_name}`);
if (d.tool_name) out.push(`tool:${ev.agent_name}/${d.tool_name}`);
const who = agentOf(ev);
if (who) {
out.push(who === 'Manager' ? MANAGER_LANE : `agent:${who}`);
if (d.tool_name) out.push(`tool:${who}/${d.tool_name}`);
}
if (String(ev.event_type).startsWith('approval_')) out.push(HUMAN_LANE);
return [...new Set(out)];
@@ -717,8 +738,11 @@ export function nodeIdsForEvent(ev) {
* sessions): agents, their tools, the manager and you, with hand-off/uses/reviews
* edges labelled with how often they happened.
*/
export function deriveGraph(events) {
export function deriveGraph(events, opts = {}) {
const evs = sortEvents(events);
// Unnamed agents count only when there is no recorded graph: a team graph
// leaves them out on purpose, and they must not reappear as "outside".
const nameOf = opts.unnamed === false ? (ev) => ev.agent_name || null : agentOf;
const nodes = new Map();
const edges = new Map();
const node = (id, kind, label) => { if (!nodes.has(id)) nodes.set(id, { id, kind, label, parent: null, meta: {} }); return id; };
@@ -732,16 +756,17 @@ export function deriveGraph(events) {
};
evs.forEach((ev) => {
const d = dataOf(ev);
if (ev.agent_name) agentNode(ev.agent_name);
if (ev.event_type === 'agent_handoff' && (d.from_agent || ev.agent_name) && d.to_agent) {
const who = nameOf(ev);
if (who) agentNode(who);
if (ev.event_type === 'agent_handoff' && (d.from_agent || who) && d.to_agent) {
const kind = { delegate: 'delegates', route: 'routes', spawn: 'handoff' }[d.kind] || 'handoff';
edge(agentNode(d.from_agent || ev.agent_name), agentNode(d.to_agent), kind);
} else if (ev.event_type === 'tool_call_start' && ev.agent_name && d.tool_name) {
const tool = node(`tool:${ev.agent_name}/${d.tool_name}`, 'tool', d.tool_name);
edge(agentNode(ev.agent_name), tool, 'uses');
edge(agentNode(d.from_agent || who), agentNode(d.to_agent), kind);
} else if (ev.event_type === 'tool_call_start' && who && d.tool_name) {
const tool = node(`tool:${who}/${d.tool_name}`, 'tool', d.tool_name);
edge(agentNode(who), tool, 'uses');
} else if (ev.event_type === 'approval_requested') {
const you = node(HUMAN_LANE, 'human', 'You');
const target = d.tool && ev.agent_name ? node(`tool:${ev.agent_name}/${d.tool}`, 'tool', d.tool) : (ev.agent_name ? agentNode(ev.agent_name) : null);
const target = d.tool && who ? node(`tool:${who}/${d.tool}`, 'tool', d.tool) : (who ? agentNode(who) : null);
if (target) edge(you, target, 'reviews');
}
});
@@ -929,7 +954,8 @@ export function layoutMap(graph, opts = {}) {
* them as "Outside the team". Without a recorded graph this is deriveGraph().
*/
export function observedGraph(graph, events) {
const derived = deriveGraph(events);
const hasGraph = Boolean(graph && Array.isArray(graph.nodes) && graph.nodes.length);
const derived = deriveGraph(events, { unnamed: !hasGraph });
if (!graph || !Array.isArray(graph.nodes) || !graph.nodes.length) return derived;
const nodes = graph.nodes.filter((n) => n && n.id).map((n) => ({ ...n, meta: { ...(n.meta || {}) } }));
const edges = (Array.isArray(graph.edges) ? graph.edges : []).filter((e) => e && e.source && e.target).map((e) => ({ ...e }));
@@ -1095,7 +1121,7 @@ export function edgeText(edge) {
const api = {
PALETTE, SHAPES, HUMAN_LANE, MANAGER_LANE, EDGE_WORDS,
buildModel, narrate, layoutMap, layoutRunMap, observedGraph, deriveGraph, nodeIdsForEvent, nodeStates, pairEvents, sortEvents,
buildModel, narrate, layoutMap, layoutRunMap, observedGraph, deriveGraph, nodeIdsForEvent, agentOf, nodeStates, pairEvents, sortEvents,
eventId, formatDuration, formatCost, argPreview, truncate, stripPrefix, edgeText,
};
if (typeof globalThis !== 'undefined') globalThis.PraisonReplayCore = api;
+51
View File
@@ -456,3 +456,54 @@ test('nodeStates: a recorded flow fixture ends with every visited node done or f
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);
});
+31
View File
@@ -0,0 +1,31 @@
"""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
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.148"
version = "0.3.149"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },