From deae8e3b4d72f6fab1be96d539f35f334494482f Mon Sep 17 00:00:00 2001 From: iborazzi Date: Mon, 8 Jun 2026 08:38:20 +0300 Subject: [PATCH] feat(kanban): surface final_result for Done cards; show run summary when task.result is empty --- plugins/kanban/dashboard/dist/index.js | 37 ++++++++- plugins/kanban/dashboard/plugin_api.py | 1 + tests/plugins/test_kanban_dashboard_plugin.py | 81 +++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 8ecde8612ed2..2fedaba8b68e 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -3428,10 +3428,39 @@ onAddChild: props.onAddChild, onRemoveChild: props.onRemoveChild, }), - t.result ? h("div", { className: "hermes-kanban-section" }, - h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")), - h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }), - ) : null, + (function () { + var finalResult = t.final_result || t.result || t.latest_summary || null; + var isDone = t.status === "done"; + var isParent = (t.link_counts && t.link_counts.children > 0); + if (finalResult) { + var label = t.result + ? tx(i18n, "result", "Result") + : tx(i18n, "finalResult", "Final Result (run summary)"); + return h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, label), + h(MarkdownBlock, { source: finalResult, enabled: props.renderMarkdown }), + ); + } + if (isDone && isParent) { + return h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")), + h("div", { className: "hermes-kanban-done-no-result hermes-kanban-done-parent-note" }, + tx(i18n, "doneParentNote", + "This card is an orchestrator / parent task. The substantive results are in the child cards listed in Dependencies below."), + ), + ); + } + if (isDone) { + return h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")), + h("div", { className: "hermes-kanban-done-no-result" }, + tx(i18n, "doneNoResult", + "No final result was recorded. Check Run History, Logs, or Child Tasks for the worker output."), + ), + ); + } + return null; + })(), h(AttachmentsSection, { attachments: attachments, boardSlug: props.boardSlug, diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 793f9ab98182..5164ff67dcac 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -172,6 +172,7 @@ def _task_dict( # ``task_runs.summary`` (the kanban-worker pattern) instead of # ``tasks.result``. ``None`` when no run has produced a summary yet. d["latest_summary"] = latest_summary + d["final_result"] = task.result or latest_summary or None # Keep body short on list endpoints; full body comes from /tasks/:id. return d diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index b7ab24ec2952..cc12875d20d1 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -2362,3 +2362,84 @@ def test_dashboard_failed_card_highlight_class_exists(): assert "hermes-kanban-card--failed" in js assert "hermes-kanban-card--failed" in css assert "failedIds" in js + +# --------------------------------------------------------------------------- +# Final result visibility for Done cards +# --------------------------------------------------------------------------- + + +def test_task_dict_final_result_uses_tasks_result_first(client): + """When tasks.result is set, final_result equals it (top priority).""" + r = client.post( + "/api/plugins/kanban/tasks", + json={"title": "Task with explicit result"}, + ) + task_id = r.json()["task"]["id"] + client.patch( + f"/api/plugins/kanban/tasks/{task_id}", + json={"status": "done", "result": "The final answer is 42.", "summary": "short handoff"}, + ) + r = client.get(f"/api/plugins/kanban/tasks/{task_id}") + assert r.status_code == 200 + data = r.json()["task"] + assert data["result"] == "The final answer is 42." + assert data["final_result"] == "The final answer is 42." + + +def test_task_dict_final_result_falls_back_to_latest_summary(client): + """When tasks.result is None but a run summary exists, final_result returns it.""" + conn = kb.connect() + task_id = kb.create_task(conn, title="Task with only run summary") + kb.claim_task(conn, task_id) + kb.complete_task(conn, task_id, summary="Report written to /output/report.md") + conn.close() + + r = client.get(f"/api/plugins/kanban/tasks/{task_id}") + assert r.status_code == 200 + data = r.json()["task"] + assert data["status"] == "done" + assert not data["result"] + assert data["final_result"] == "Report written to /output/report.md" + + +def test_task_dict_final_result_none_when_nothing_recorded(client): + """When neither tasks.result nor any run summary exists, final_result is None.""" + r = client.post( + "/api/plugins/kanban/tasks", + json={"title": "Task with no result at all"}, + ) + task_id = r.json()["task"]["id"] + r = client.get(f"/api/plugins/kanban/tasks/{task_id}") + assert r.status_code == 200 + assert r.json()["task"]["final_result"] is None + + +def test_board_tasks_include_final_result_field(client): + """final_result must appear on board-level task cards too.""" + conn = kb.connect() + task_id = kb.create_task(conn, title="Board card with summary only") + kb.claim_task(conn, task_id) + kb.complete_task(conn, task_id, summary="Done: see attachment") + conn.close() + + r = client.get("/api/plugins/kanban/board") + assert r.status_code == 200 + done_col = next(c for c in r.json()["columns"] if c["name"] == "done") + card = next((t for t in done_col["tasks"] if t["id"] == task_id), None) + assert card is not None + assert "Done: see attachment" in card["final_result"] + + +def test_dashboard_done_final_result_section_rendered_from_summary(): + """Frontend must render Final Result section from run summary when task.result is empty.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + assert "final_result" in dist + assert "Final Result (run summary)" in dist + assert "No final result was recorded" in dist + assert "orchestrator" in dist or "parent task" in dist +# --------------------------------------------------------------------------- +# Final result visibility for Done cards +# --------------------------------------------------------------------------- + +