mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(kanban): make Done-card results actionable
This commit is contained in:
parent
deae8e3b4d
commit
98b4562947
3 changed files with 110 additions and 21 deletions
35
plugins/kanban/dashboard/dist/index.js
vendored
35
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -1093,6 +1093,7 @@
|
|||
taskId: selectedTaskId,
|
||||
boardSlug: board,
|
||||
onClose: function () { setSelectedTaskId(null); },
|
||||
onOpenTask: setSelectedTaskId,
|
||||
onRefresh: loadBoard,
|
||||
renderMarkdown: renderMd,
|
||||
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
|
||||
|
|
@ -3222,6 +3223,10 @@
|
|||
onDeleteAttachment: handleDeleteAttachment,
|
||||
uploadBusy: uploadBusy,
|
||||
uploadErr: uploadErr,
|
||||
onOpenTask: function (taskId) {
|
||||
props.onClose();
|
||||
if (props.onOpenTask) props.onOpenTask(taskId);
|
||||
},
|
||||
}) : null,
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
|
|
@ -3358,6 +3363,7 @@
|
|||
const events = props.data.events || [];
|
||||
const attachments = props.data.attachments || [];
|
||||
const links = props.data.links || { parents: [], children: [] };
|
||||
const childResults = props.data.child_results || [];
|
||||
|
||||
return h("div", { className: "hermes-kanban-drawer-body" },
|
||||
h("div", { className: "hermes-kanban-drawer-title" },
|
||||
|
|
@ -3429,9 +3435,9 @@
|
|||
onRemoveChild: props.onRemoveChild,
|
||||
}),
|
||||
(function () {
|
||||
var finalResult = t.final_result || t.result || t.latest_summary || null;
|
||||
var finalResult = t.result || t.latest_summary || null;
|
||||
var isDone = t.status === "done";
|
||||
var isParent = (t.link_counts && t.link_counts.children > 0);
|
||||
var isParent = links.children.length > 0;
|
||||
if (finalResult) {
|
||||
var label = t.result
|
||||
? tx(i18n, "result", "Result")
|
||||
|
|
@ -3446,7 +3452,7 @@
|
|||
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."),
|
||||
"This card is an orchestrator / parent task. Review the child results section for the substantive work."),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -3461,6 +3467,29 @@
|
|||
}
|
||||
return null;
|
||||
})(),
|
||||
childResults.length > 0 ? h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" },
|
||||
`${tx(i18n, "childResults", "Child Results")} (${childResults.length})`),
|
||||
childResults.map(function (child) {
|
||||
var childResult = child.result || child.latest_summary || null;
|
||||
return h("div", { key: child.id, className: "hermes-kanban-comment" },
|
||||
h("div", { className: "hermes-kanban-comment-head" },
|
||||
h("span", { className: "hermes-kanban-comment-author" },
|
||||
`${child.id} · ${child.title || tx(i18n, "untitled", "(untitled)")}`),
|
||||
h(Badge, { variant: "outline" }, child.status),
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "hermes-kanban-diag-action-btn",
|
||||
onClick: function () { if (props.onOpenTask) props.onOpenTask(child.id); },
|
||||
}, tx(i18n, "open", "Open")),
|
||||
),
|
||||
childResult
|
||||
? h(MarkdownBlock, { source: childResult, enabled: props.renderMarkdown })
|
||||
: h("div", { className: "text-xs text-muted-foreground" },
|
||||
tx(i18n, "noChildResult", "No result recorded yet.")),
|
||||
);
|
||||
}),
|
||||
) : null,
|
||||
h(AttachmentsSection, {
|
||||
attachments: attachments,
|
||||
boardSlug: props.boardSlug,
|
||||
|
|
|
|||
|
|
@ -172,7 +172,6 @@ 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
|
||||
|
||||
|
|
@ -547,6 +546,21 @@ def get_task(
|
|||
# a second round-trip. Cards on /board carry a 200-char preview.
|
||||
full_summary = kanban_db.latest_summary(conn, task_id)
|
||||
task_d = _task_dict(task, latest_summary=full_summary)
|
||||
links = _links_for(conn, task_id)
|
||||
child_ids = links["children"]
|
||||
child_summaries = kanban_db.latest_summaries(conn, child_ids)
|
||||
child_results = []
|
||||
for child_id in child_ids:
|
||||
child = kanban_db.get_task(conn, child_id)
|
||||
if child is None:
|
||||
continue
|
||||
child_results.append({
|
||||
"id": child.id,
|
||||
"title": child.title,
|
||||
"status": child.status,
|
||||
"latest_summary": child_summaries.get(child.id),
|
||||
"result": child.result,
|
||||
})
|
||||
# Attach diagnostics so the drawer's Diagnostics section can
|
||||
# render recovery actions without a second round-trip.
|
||||
diags = _compute_task_diagnostics(conn, task_ids=[task_id])
|
||||
|
|
@ -559,7 +573,8 @@ def get_task(
|
|||
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
|
||||
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
|
||||
"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)],
|
||||
"links": _links_for(conn, task_id),
|
||||
"links": links,
|
||||
"child_results": child_results,
|
||||
"runs": [
|
||||
_run_dict(r)
|
||||
for r in kanban_db.list_runs(
|
||||
|
|
|
|||
|
|
@ -2368,8 +2368,8 @@ def test_dashboard_failed_card_highlight_class_exists():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_task_dict_final_result_uses_tasks_result_first(client):
|
||||
"""When tasks.result is set, final_result equals it (top priority)."""
|
||||
def test_task_detail_exposes_result_and_latest_summary_separately(client):
|
||||
"""The drawer receives both source fields without a duplicate alias."""
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/tasks",
|
||||
json={"title": "Task with explicit result"},
|
||||
|
|
@ -2383,11 +2383,12 @@ def test_task_dict_final_result_uses_tasks_result_first(client):
|
|||
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."
|
||||
assert data["latest_summary"] == "short handoff"
|
||||
assert "final_result" not in data
|
||||
|
||||
|
||||
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."""
|
||||
def test_task_detail_exposes_latest_summary_when_result_is_empty(client):
|
||||
"""Summary-only completions remain available to the drawer fallback."""
|
||||
conn = kb.connect()
|
||||
task_id = kb.create_task(conn, title="Task with only run summary")
|
||||
kb.claim_task(conn, task_id)
|
||||
|
|
@ -2399,11 +2400,11 @@ def test_task_dict_final_result_falls_back_to_latest_summary(client):
|
|||
data = r.json()["task"]
|
||||
assert data["status"] == "done"
|
||||
assert not data["result"]
|
||||
assert data["final_result"] == "Report written to /output/report.md"
|
||||
assert data["latest_summary"] == "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."""
|
||||
def test_task_detail_latest_summary_none_when_nothing_recorded(client):
|
||||
"""When no run summary exists, the existing field remains None."""
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/tasks",
|
||||
json={"title": "Task with no result at all"},
|
||||
|
|
@ -2411,11 +2412,11 @@ def test_task_dict_final_result_none_when_nothing_recorded(client):
|
|||
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
|
||||
assert r.json()["task"]["latest_summary"] is None
|
||||
|
||||
|
||||
def test_board_tasks_include_final_result_field(client):
|
||||
"""final_result must appear on board-level task cards too."""
|
||||
def test_board_tasks_include_latest_summary(client):
|
||||
"""Board cards already expose the summary used by the drawer fallback."""
|
||||
conn = kb.connect()
|
||||
task_id = kb.create_task(conn, title="Board card with summary only")
|
||||
kb.claim_task(conn, task_id)
|
||||
|
|
@ -2427,19 +2428,63 @@ def test_board_tasks_include_final_result_field(client):
|
|||
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"]
|
||||
assert "Done: see attachment" in card["latest_summary"]
|
||||
|
||||
|
||||
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 "t.result || t.latest_summary" 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_task_detail_includes_child_result_summaries(client):
|
||||
"""Parent drawers should receive the child results they need to render."""
|
||||
with kb.connect() as conn:
|
||||
parent = kb.create_task(conn, title="Research topic")
|
||||
child = kb.create_task(conn, title="Collect sources")
|
||||
kb.link_tasks(conn, parent, child)
|
||||
kb.complete_task(conn, parent, summary="Delegated research to child tasks.")
|
||||
kb.recompute_ready(conn)
|
||||
kb.complete_task(conn, child, summary="Collected five primary sources.")
|
||||
|
||||
response = client.get(f"/api/plugins/kanban/tasks/{parent}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["child_results"] == [
|
||||
{
|
||||
"id": child,
|
||||
"title": "Collect sources",
|
||||
"status": "done",
|
||||
"latest_summary": "Collected five primary sources.",
|
||||
"result": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_dashboard_final_result_uses_existing_fields_without_alias():
|
||||
"""The drawer should not duplicate result/summary into another API field."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
||||
api = (repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py").read_text()
|
||||
|
||||
assert "var finalResult = t.result || t.latest_summary || null;" in dist
|
||||
assert "t.final_result" not in dist
|
||||
assert 'd["final_result"]' not in api
|
||||
|
||||
|
||||
def test_dashboard_parent_notice_and_child_results_use_detail_links():
|
||||
"""Parent detection must use links.children, which exists in task detail."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text()
|
||||
detail = dist[dist.index("function TaskDetail"):]
|
||||
|
||||
assert "links.children.length > 0" in detail
|
||||
assert "t.link_counts" not in detail
|
||||
assert "Child Results" in detail
|
||||
assert "props.data.child_results" in detail
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue