diff --git a/docs/tasks-and-automation.md b/docs/tasks-and-automation.md index 11958ff..ccfaf38 100644 --- a/docs/tasks-and-automation.md +++ b/docs/tasks-and-automation.md @@ -13,6 +13,8 @@ Task IDs and records are actor/workspace/session scoped. The web task API expose Simple work does not require a plan, and a plan is optional for medium work. Complex direct work uses a durable Working Plan with runtime-owned stable IDs and explicit acceptance criteria. The typed `update_plan` boundary applies revision-checked batches of add, update, cancel, reorder, dependency, split, merge, replace, block, and unblock operations; legacy `complexity`/`tasks` input is adapted through the same validation path. Splitting, merging, or replacing retains superseded tasks as cancelled history and records both directions of the relationship. Blocking requires a reason and prevents scheduling until explicitly unblocked with a reason. Each structural revision and its optional rationale are written atomically with a complete plan/task snapshot in the event history. A changed task must still be pending and unexecuted; executed task records and evidence are preserved. Scheduling follows plan order and waits for every dependency to succeed. A plan completes only when every task succeeds with acceptance evidence; recovery also reconciles a crash between the final task write and the plan status write. Clearing an active plan cancels its unfinished steps. The foreground scheduler runs consecutive calls concurrently only when every call is catalog-marked safe and read-only; all other calls preserve response order, and task receipts are reconciled in that order. Assistant answers remain available when a complex turn ends with unfinished work; its runtime outcome is reported separately as completed, incomplete, blocked, or cancelled. This outcome uses only that turn's Working Plan, so an unrelated task ledger cannot make a simple answer appear unfinished. The native foreground loop uses typed provider calls; the legacy text protocol remains available through its compatibility adapter. +The TUI and Web chat show the current Working Plan revision, its user-facing revision reason, and tasks in scheduler order. Progress is updated from typed task events; successful tasks are shown as verified, and cancelled replacements remain marked as superseded. The Web view restores the active plan snapshot when a saved session is reopened. These observer projections contain task descriptions, statuses, supersession links, and rationale only; execution evidence and tool arguments remain private. + The tool executor makes at most one automatic retry, and only when a typed error marks a normal-risk read or an allowlisted built-in network lookup as retriable. It rechecks task binding and child cancellation before dispatching the retry. Filesystem, process, Git, stateful, custom network, and other mutation-capable calls are never automatically retried. After interruption or restart, unfinished side effects remain uncertain; inspect the current workspace and recorded receipts before requesting the mutation again. Plan Mode can produce a versioned Strategic Plan before execution. Acceptance records the exact objectives, acceptance criteria, workspace paths, capabilities, effect categories, and risk ceiling alongside its linked Working Plan. A step may also carry a visible, fingerprinted `execution` contract for one exact `write_file`, `edit_file`, or `apply_patch` call; delegated file mutations require this explicit contract and still pass assigned-file, workspace-bound, and tool-approval checks. `apply_patch` requires the hash from a structured read and ordered `{old_text, new_text}` hunks, each matching exactly once without overlap. Each criterion should include a `verification` action and exact object arguments. A `read_file` verifier must also include one case-sensitive `expect` predicate (`equals` or nonempty `contains`); the returned text must satisfy it before the criterion is recorded. Plain-text criteria remain readable for legacy proposals but cannot satisfy the per-criterion completion gate without a verifier. Runtime tool dispatch checks each tool's catalog policy and supplied paths against those accepted bounds; shell processes are denied because this runtime cannot confine arbitrary commands to those paths. An unbounded legacy proposal receives read-only defaults. Strategic acceptance does not grant capabilities, bypass tool approvals, or establish completion evidence. diff --git a/openkyrozen/agent/events.py b/openkyrozen/agent/events.py index 5368813..31b310a 100644 --- a/openkyrozen/agent/events.py +++ b/openkyrozen/agent/events.py @@ -35,7 +35,19 @@ def runtime_event_as_legacy(event: "RuntimeEvent") -> dict[str, Any] | None: if event.kind == "tool.result": return {"event": "tool_receipt", "tool_receipt": payload} if event.kind == "task.snapshot": - return {"event": "tasks", "tasks": payload.get("tasks", [])} + return {"event": "tasks", "tasks": payload.get("tasks", []), + "working_plan": payload.get("working_plan")} + if event.kind.startswith("working_plan."): + return {"event": "working_plan", "session_id": event.session_id, "working_plan": payload} + if event.kind.startswith("task.") and event.task_id: + name = event.kind.partition(".")[2] + status = {"created": "pending", "claimed": "running", "recovered": "pending", + "resumed": "pending"}.get(name, name) + if status not in {"pending", "running", "succeeded", "failed", "blocked", "cancelled"}: + status = None + return {"event": "task_update", "task_id": event.task_id, + "session_id": event.session_id, "status": status, "description": payload.get("description"), + "reason": payload.get("reason", "")} if event.kind == "interaction.updated": return {"event": "interaction", "interaction": payload.get("interaction", payload)} if event.kind == "security.permission_check": @@ -199,6 +211,9 @@ def publish_legacy(self, legacy_event: dict[str, Any], *, model_request_id: str from .types import _bounded_redacted payload = _bounded_redacted(dict(fields.get("tool_receipt") or {}), budget=12_000) legacy_event = {"event": "tool_receipt", "tool_receipt": payload} + elif name == "tasks": + legacy_event = {"event": "tasks", "tasks": fields.get("tasks", []), + "working_plan": fields.get("working_plan")} call_id = payload.get("call_id") if isinstance(payload.get("call_id"), str) else None task_id = payload.get("task_id") if isinstance(payload.get("task_id"), str) else None event = self.publish(kind_map.get(name, f"legacy.{name}"), payload, diff --git a/openkyrozen/agent/executor.py b/openkyrozen/agent/executor.py index 8cfd211..dd9840f 100644 --- a/openkyrozen/agent/executor.py +++ b/openkyrozen/agent/executor.py @@ -885,7 +885,7 @@ def _record_turn_receipt(self, receipt: ExecutionReceipt, *, allow_legacy_task_m self._emit_stream_event({"event": "tasks", "tasks": [ {"id": item["id"], "description": item["description"], "status": item["status"]} for item in self.tasks.tasks - ]}) + ], "working_plan": self.tasks.working_plan_snapshot()}) return result diff --git a/openkyrozen/interfaces/tui/commands.py b/openkyrozen/interfaces/tui/commands.py index f46182c..a56053b 100644 --- a/openkyrozen/interfaces/tui/commands.py +++ b/openkyrozen/interfaces/tui/commands.py @@ -133,7 +133,7 @@ def _command(self, name: str, args: Any, request_id: str) -> None: self.emit("tasks", request_id, tasks=[ {"id": task["id"], "description": task["description"], "status": task["status"]} for task in runtime.tasks.tasks - ]) + ], working_plan=runtime.tasks.working_plan_snapshot()) self.emit("response", request_id, text=runtime.tasks.format()) elif command in {"/agents", "agents"}: coordinator = runtime.delegation() diff --git a/openkyrozen/interfaces/tui/navigation.py b/openkyrozen/interfaces/tui/navigation.py index adb838b..f61e1f3 100644 --- a/openkyrozen/interfaces/tui/navigation.py +++ b/openkyrozen/interfaces/tui/navigation.py @@ -180,7 +180,7 @@ def _emit_bound_state(self, request_id: str) -> None: self.emit("tasks", request_id, tasks=[ {"id": item["id"], "description": item["description"], "status": item["status"]} for item in runtime.tasks.tasks - ]) + ], working_plan=runtime.tasks.working_plan_snapshot()) self.interaction(request_id) self.graph_state(request_id) self.emit("ready", request_id, configured=runtime.llm_provider is not None, diff --git a/openkyrozen/interfaces/tui/onboarding.py b/openkyrozen/interfaces/tui/onboarding.py index 1e32637..bd17cfd 100644 --- a/openkyrozen/interfaces/tui/onboarding.py +++ b/openkyrozen/interfaces/tui/onboarding.py @@ -56,7 +56,7 @@ def start(self, payload: dict[str, Any], request_id: str) -> None: self.emit("tasks", request_id, tasks=[ {"id": item["id"], "description": item["description"], "status": item["status"]} for item in runtime.tasks.tasks - ]) + ], working_plan=runtime.tasks.working_plan_snapshot()) self.emit( "ready", request_id, configured=configured, provider=getattr(config, "provider", ""), diff --git a/openkyrozen/interfaces/tui/turns.py b/openkyrozen/interfaces/tui/turns.py index bcab0f9..4e804ed 100644 --- a/openkyrozen/interfaces/tui/turns.py +++ b/openkyrozen/interfaces/tui/turns.py @@ -144,7 +144,15 @@ def callback(event: dict[str, Any] | RuntimeEvent) -> None: elif kind == "tool_receipt": self.emit("tool_receipt", request_id, receipt=event.get("tool_receipt", {})) elif kind == "tasks": - self.emit("tasks", request_id, tasks=event.get("tasks", [])) + self.emit("tasks", request_id, tasks=event.get("tasks", []), + working_plan=event.get("working_plan")) + elif kind == "working_plan": + self.emit("working_plan", request_id, session_id=event.get("session_id"), + working_plan=event.get("working_plan")) + elif kind == "task_update": + self.emit("task_update", request_id, session_id=event.get("session_id"), + task_id=event.get("task_id"), + status=event.get("status"), reason=event.get("reason", "")) elif kind == "interaction": self.emit("interaction", request_id, interaction=event.get("interaction", {})) elif kind == "permission_check": @@ -310,7 +318,7 @@ def _run_submit(self, text: str, request_id: str, plan_acceptance: dict[str, Any self.emit("tasks", request_id, tasks=[ {"id": task["id"], "description": task["description"], "status": task["status"]} for task in runtime.tasks.tasks - ]) + ], working_plan=runtime.tasks.working_plan_snapshot()) self.interaction(request_id) self.usage(request_id) self.status("ready", "Ready", request_id) diff --git a/openkyrozen/interfaces/web/chat.py b/openkyrozen/interfaces/web/chat.py index 40d492a..4a38b12 100644 --- a/openkyrozen/interfaces/web/chat.py +++ b/openkyrozen/interfaces/web/chat.py @@ -555,7 +555,9 @@ async def generate(): elif kind == "tool_receipt": yield f"data: {json.dumps({'event': 'tool_receipt', 'tool_receipt': event.get('tool_receipt')}, ensure_ascii=False)}\n\n" elif kind == "tasks": - yield f"data: {json.dumps({'event': 'tasks', 'tasks': event.get('tasks', [])}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'event': 'tasks', 'tasks': event.get('tasks', []), 'working_plan': event.get('working_plan')}, ensure_ascii=False)}\n\n" + elif kind in {"working_plan", "task_update"}: + yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" elif kind == "subagent": yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" elif kind == "runtime_event": diff --git a/openkyrozen/interfaces/web/history.py b/openkyrozen/interfaces/web/history.py index b7c7ebf..6c752ef 100644 --- a/openkyrozen/interfaces/web/history.py +++ b/openkyrozen/interfaces/web/history.py @@ -79,5 +79,8 @@ async def api_v2_session(self, session_id: str): session_id = service._normalise_session_id(session_id) with service._chat_lock: session = service._get_or_create_session(session_id, service._SERVER_ACTOR_ID) + bound = service._agent.open_session(session_id, user_id=service._SERVER_ACTOR_ID) + working_plan = bound.tasks.working_plan_snapshot() return {"session_id": session_id, "messages": session["messages"], "updated": session.get("updated"), - "interaction": service._interaction_for_session(session).envelope(), "context": session.get("context")} + "interaction": service._interaction_for_session(session).envelope(), "context": session.get("context"), + "working_plan": working_plan} diff --git a/openkyrozen/interfaces/web/templates/chat.html b/openkyrozen/interfaces/web/templates/chat.html index 61d322c..e6ac212 100644 --- a/openkyrozen/interfaces/web/templates/chat.html +++ b/openkyrozen/interfaces/web/templates/chat.html @@ -22,6 +22,7 @@ #chat:empty::before{content:'Start a conversation or type / for commands.';display:block;color:var(--muted);text-align:center;padding:12vh 0} #interaction-card{width:min(100%,1024px);margin:14px auto 0;padding:14px 17px;background:var(--surface-hi);border:1px solid var(--brand);border-radius:10px} #interaction-card[hidden]{display:none}#interaction-card h2{font-size:15px;color:var(--brand);margin-bottom:8px}#interaction-card fieldset{border:0;margin:12px 0}#interaction-card legend{font-weight:700;margin-bottom:6px}#interaction-card label{display:block;margin:6px 0;color:var(--text)}#interaction-card input[type=text]{width:100%;background:var(--ink);border:1px solid var(--line);border-radius:6px;color:var(--text);padding:8px;margin-top:5px}#interaction-card ol{padding-left:22px;margin:8px 0}#interaction-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px} +#working-plan{width:min(100%,1024px);margin:14px auto 0;padding:14px 17px;background:var(--surface);border:1px solid var(--line);border-radius:10px}#working-plan[hidden]{display:none}#working-plan h2{font-size:15px;color:var(--brand)}#working-plan p{color:var(--muted);margin:5px 0}#working-plan ol{padding-left:24px;margin-top:8px}#working-plan li{padding:3px 0}#working-plan li.cancelled,#working-plan li.superseded{color:var(--muted);text-decoration:line-through}#working-plan .plan-status{font-size:12px;color:var(--muted)} .msg{margin-bottom:22px;max-width:min(88%,860px)} .msg.user{margin-left:auto} .msg .role{font-size:11px;letter-spacing:.08em;color:var(--muted);margin-bottom:6px} @@ -95,6 +96,7 @@

Conversation history

+
@@ -111,6 +113,7 @@

Conversation history

let interactionState = {preference_mode: 'auto', effective_mode: 'ask', system_one_backend: 'off', fast_backend: 'off', pending_question: null, pending_plan: null}; let decisionAssistState = {backend: 'off', kev_private_consent: false}; let systemOneStatus = {system_one_backend: 'off'}; +let workingPlan = null; class AuthRequiredError extends Error {} @@ -220,6 +223,44 @@

Conversation history

} } +function renderWorkingPlan(value) { + if (value && workingPlan && value.plan_id === workingPlan.plan_id && value.revision < workingPlan.revision) return; + if (value && workingPlan && value.updated_at && workingPlan.updated_at && value.updated_at < workingPlan.updated_at) return; + workingPlan = value && typeof value === 'object' ? value : null; + const panel = document.getElementById('working-plan'); + panel.replaceChildren(); + panel.hidden = !workingPlan; + if (!workingPlan) return; + const title = document.createElement('h2'); + title.textContent = `Working Plan · revision ${workingPlan.revision} · ${workingPlan.status}`; + panel.appendChild(title); + if (workingPlan.reason) { + const reason = document.createElement('p'); + reason.textContent = `Revision reason: ${workingPlan.reason}`; + panel.appendChild(reason); + } + const list = document.createElement('ol'); + for (const task of workingPlan.tasks || []) { + const item = document.createElement('li'); + const links = task.superseded_by?.length + ? ` · superseded by ${task.superseded_by.map(id => (workingPlan.tasks || []).find(other => other.id === id)?.description || id).join(', ')}` : ''; + const blocker = task.status === 'blocked' && task.reason ? ` · ${task.reason}` : ''; + item.textContent = `${task.description} · ${task.status}${links}${blocker}`; + if (task.status === 'cancelled') item.classList.add(task.superseded_by?.length ? 'superseded' : 'cancelled'); + list.appendChild(item); + } + panel.appendChild(list); +} + +function updateWorkingPlanTask(value) { + if (!workingPlan || !value || typeof value.task_id !== 'string') return; + const task = (workingPlan.tasks || []).find(item => item.id === value.task_id); + if (!task) return; + if (typeof value.status === 'string') task.status = value.status; + if (typeof value.reason === 'string' && value.reason) task.reason = value.reason; + renderWorkingPlan(workingPlan); +} + function renderSystemOneStatus(value) { systemOneStatus = value || systemOneStatus; const backend = systemOneStatus.system_one_backend || systemOneStatus.fast_backend || 'off'; @@ -265,6 +306,7 @@

Conversation history

if (data.interaction) renderSystemOneStatus({...systemOneStatus, system_one_backend: data.interaction.system_one_backend, decision_assist: data.decision_assist || decisionAssistState}); renderInteraction(data.interaction); + renderWorkingPlan(data.working_plan); renderContext(data.context); document.getElementById('status').textContent = 'Ready'; } catch (error) { addMessage('assistant', 'Error: ' + error.message, false); renderInteraction(interactionState); document.getElementById('status').textContent = 'Error'; } @@ -436,6 +478,7 @@

Conversation history

clearChat(); messages.forEach(message => addMessage(message.role, String(message.content), false)); renderInteraction(data.interaction); + renderWorkingPlan(data.working_plan); renderContext(data.context); renderSessionList(recentSessions); document.getElementById('status').textContent = messages.length @@ -454,6 +497,7 @@

Conversation history

saveActiveSession(); clearChat(); renderInteraction({preference_mode: 'auto', effective_mode: 'ask', system_one_backend: 'off', fast_backend: 'off', pending_question: null, pending_plan: null}); + renderWorkingPlan(null); renderContext(null); renderSessionList(recentSessions); document.getElementById('status').textContent = 'New conversation ready.'; @@ -578,6 +622,7 @@

Conversation history

} try { const parsed = JSON.parse(data); + if (parsed.session_id && parsed.session_id !== sessionId) return; if (parsed.event === 'completion' && Object.prototype.hasOwnProperty.call(parsed, 'reply')) { const finalReply = String(parsed.reply); if (full !== finalReply) { @@ -593,6 +638,9 @@

Conversation history

const name = parsed.backend === 'jev' ? 'Jev' : 'Kev'; addMessage('assistant', `Routing advice from ${name}.`, false); } + if (parsed.event === 'working_plan') renderWorkingPlan(parsed.working_plan); + if (parsed.event === 'tasks') renderWorkingPlan(parsed.working_plan); + if (parsed.event === 'task_update') updateWorkingPlanTask(parsed); if (parsed.event === 'subagent' && parsed.agent) { const agent = parsed.agent; addMessage('assistant', `${agent.name} · ${agent.profile} · ${agent.status}: ${agent.assignment.objective}`, false); diff --git a/openkyrozen/tasks/engine.py b/openkyrozen/tasks/engine.py index 60b2aaf..7595d0e 100644 --- a/openkyrozen/tasks/engine.py +++ b/openkyrozen/tasks/engine.py @@ -36,6 +36,56 @@ def _reload_task(self, task_id: str) -> dict[str, Any] | None: user_id=self.user_id, workspace_id=self.workspace_id, session_id=self.session_id, limit=10000, ) if item["id"] == task_id), None) + def working_plan_snapshot(self) -> dict[str, Any] | None: + """Return the ordered, user-visible Working Plan projection.""" + plan = self.working_plan + if plan is None: + plan = self.store.get_working_plan( + user_id=self.user_id, workspace_id=self.workspace_id, session_id=self.session_id or "", + ) + if plan is None: + return None + task_rows = self.store.list_working_plan_tasks( + plan["id"], user_id=self.user_id, workspace_id=self.workspace_id, + session_id=self.session_id or "", + ) + plan_id, revision, status, updated_at = ( + plan["id"], int(plan.get("revision", 0)), plan["status"], plan["updated_at"], + ) + task_ids = [task["id"] for task in task_rows] + tasks_by_id = {task["id"]: task for task in task_rows} + else: + plan_id, revision, status, updated_at = plan.id, plan.revision, plan.status, plan.updated_at + task_ids = plan.task_ids + tasks_by_id = {task["id"]: task for task in self.tasks} + tasks = [] + for task_id in task_ids: + task = tasks_by_id.get(task_id) + if task is None: + continue + checkpoint = task.get("checkpoint") or {} + block = checkpoint.get("plan_block") if isinstance(checkpoint, dict) else None + tasks.append({ + "id": task_id, + "description": str(task.get("description", "")), + "status": str(task.get("status", "pending")), + "supersedes": list(checkpoint.get("supersedes", [])) if isinstance(checkpoint, dict) else [], + "superseded_by": list(checkpoint.get("superseded_by", [])) if isinstance(checkpoint, dict) else [], + "reason": str(block.get("reason", "")) if isinstance(block, dict) else "", + }) + reason = "" + events = self.store.list_events( + "working_plan.updated", limit=20, user_id=self.user_id, + workspace_id=self.workspace_id, session_id=self.session_id, + ) + for event in events: + payload = event.get("payload", {}) + if payload.get("plan_id") == plan_id and payload.get("revision") == revision: + reason = str(payload.get("reason", "")) + break + return {"plan_id": plan_id, "revision": revision, "status": status, + "updated_at": updated_at, "reason": reason, "tasks": tasks} + @staticmethod def _dependencies_succeeded(task: dict[str, Any], tasks: list[dict[str, Any]]) -> bool: statuses = {item["id"]: canonical_status(item.get("status", "pending")) for item in tasks} @@ -151,9 +201,9 @@ def set_status(self, idx: int, status: str, *, evidence: dict[str, Any] | None = revision=self.working_plan.revision, ) if plan_status == "completed": - self._event(task, "working_plan.completed", {"plan_id": self.working_plan.id}) + self._event(task, "working_plan.completed", self.working_plan_snapshot()) else: - self._event(task, "working_plan.cancelled", {"plan_id": self.working_plan.id}) + self._event(task, "working_plan.cancelled", self.working_plan_snapshot()) return True @staticmethod @@ -582,13 +632,19 @@ def resolve(value: Any) -> str: self.tasks = tasks + [task for task in self.tasks if task["id"] not in original_ids] payload = {"plan_id": plan_id, "revision": revision, "task_ids": list(working_plan.task_ids), "status": plan_status} + snapshot = self.working_plan_snapshot() or { + "plan_id": plan_id, "revision": revision, "status": plan_status, + "reason": "", "tasks": [], + } + if reason: + snapshot["reason"] = reason.strip() callback = self._runtime_event_callback.get() if callable(callback): try: event_type = ("working_plan.completed" if plan_status == "completed" else "working_plan.cancelled" if plan_status == "cancelled" else "working_plan.updated") - callback(event_type, payload, tasks[-1]["id"] if tasks else None) + callback(event_type, snapshot, tasks[-1]["id"] if tasks else None) except Exception: pass return {**payload, "complexity": complexity, "tasks": deepcopy(tasks), @@ -1046,7 +1102,7 @@ def clear(self) -> None: ): plan_task = next((task for task in self.tasks if task["id"] in plan_ids), None) if plan_task is not None: - self._event(plan_task, "working_plan.cancelled", {"plan_id": plan.id}) + self._event(plan_task, "working_plan.cancelled", self.working_plan_snapshot()) elif plan.status == "active": return self.working_plan = None diff --git a/tests/test_runtime_events.py b/tests/test_runtime_events.py index b2467a7..e6d2ffe 100644 --- a/tests/test_runtime_events.py +++ b/tests/test_runtime_events.py @@ -6,10 +6,28 @@ from pathlib import Path from unittest.mock import patch -from openkyrozen.agent.events import RuntimeEvent, RuntimeEventPublisher +from openkyrozen.agent.events import RuntimeEvent, RuntimeEventPublisher, runtime_event_as_legacy class RuntimeEventContractTests(unittest.TestCase): + def test_working_plan_and_task_events_project_only_user_visible_fields(self): + plan = RuntimeEvent( + kind="working_plan.updated", user_id="u", workspace_id="w", session_id="s", + sequence=1, payload={"plan_id": "plan-1", "revision": 2, "reason": "Split the work", "tasks": []}, + ) + task = RuntimeEvent( + kind="task.succeeded", user_id="u", workspace_id="w", session_id="s", + sequence=2, task_id="task-1", payload={"evidence": {"private": "not for observers"}}, + ) + + self.assertEqual(runtime_event_as_legacy(plan), { + "event": "working_plan", "session_id": "s", "working_plan": plan.payload, + }) + projected = runtime_event_as_legacy(task) + self.assertEqual(projected["event"], "task_update") + self.assertEqual(projected["status"], "succeeded") + self.assertNotIn("evidence", projected) + def test_event_payload_is_validated_and_defensively_snapshotted(self): payload = {"nested": {"value": "before"}} event = RuntimeEvent( diff --git a/tests/test_server.py b/tests/test_server.py index feaf09d..a841c26 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -276,6 +276,17 @@ def test_chat_interaction_controls_persist_and_restore_without_new_endpoint(self restored = client.get("/api/v2/sessions/interaction-session") self.assertEqual(restored.status_code, 200, restored.text) self.assertIn("interaction", restored.json()) + bound = server._agent.open_session("interaction-session", user_id=server._SERVER_ACTOR_ID) + bound.tasks.edit_plan(complexity="complex", expected_revision=0, operations=[ + {"op": "add", "ref": "inspect", "task": { + "description": "Inspect restored plan", "acceptance": ["Source checked"], + }}, + ], reason="Restore the current strategy") + restored = client.get("/api/v2/sessions/interaction-session") + self.assertEqual(restored.status_code, 200, restored.text) + self.assertEqual(restored.json()["working_plan"]["revision"], 1) + self.assertEqual(restored.json()["working_plan"]["tasks"][0]["description"], + "Inspect restored plan") finally: server._sessions = original_sessions diff --git a/tests/test_web_sessions.py b/tests/test_web_sessions.py index 2642c33..17b90af 100644 --- a/tests/test_web_sessions.py +++ b/tests/test_web_sessions.py @@ -11,6 +11,7 @@ from pathlib import Path from openkyrozen.persistence.store import EventStore +from openkyrozen.tasks.engine import TaskManager from openkyrozen.workspace.context import source_scope_id @@ -92,6 +93,18 @@ def test_browser_restores_switches_and_retains_durable_sessions_after_restart(se "session.message", {"role": role, "content": content}, user_id="local", workspace_id="default", session_id=session_id, ) + tasks = TaskManager(store, workspace_id=interaction_workspace_id, session_id="session-one") + first = tasks.edit_plan(complexity="complex", expected_revision=0, operations=[ + {"op": "add", "ref": "broad", "task": { + "description": "Investigate all", "acceptance": ["Source is inspected"], + }}, + ], reason="Start with a broad inspection") + tasks.edit_plan(plan_id=first["plan_id"], expected_revision=1, operations=[ + {"op": "split", "task": first["tasks"][0]["id"], "children": [ + {"ref": "inspect", "task": {"description": "Inspect source", "acceptance": ["Source checked"]}}, + {"ref": "verify", "task": {"description": "Verify behavior", "acceptance": ["Behavior checked"]}}, + ]}, + ], reason="Separate inspection from verification") store.append_event( "interaction.question_requested", { "request_id": "browser-question", "original_input": "Implement the feature", @@ -121,6 +134,11 @@ def test_browser_restores_switches_and_retains_durable_sessions_after_restart(se page.goto(base_url, wait_until="domcontentloaded") page.wait_for_function("document.body.innerText.includes('UI_你好_✅')") page.wait_for_function("document.body.innerText.includes('Which browser target?')") + page.wait_for_function("document.body.innerText.includes('Working Plan · revision 2 · active')") + self.assertIn("Separate inspection from verification", page.locator("#working-plan").inner_text()) + self.assertIn("Inspect source", page.locator("#working-plan").inner_text()) + self.assertIn("Verify behavior", page.locator("#working-plan").inner_text()) + self.assertIn("superseded by", page.locator("#working-plan").inner_text()) self.assertEqual(page.locator('#interaction-card input[type="radio"]').count(), 2) self.assertEqual(page.locator('#interaction-card input[placeholder="Other (optional)"]').count(), 1) self.assertEqual(page.get_by_role("button", name="Submit answers").count(), 1) diff --git a/tests/test_working_plans.py b/tests/test_working_plans.py index 5b5b2a3..06193e2 100644 --- a/tests/test_working_plans.py +++ b/tests/test_working_plans.py @@ -16,6 +16,42 @@ class WorkingPlanModelTests(unittest.TestCase): + def test_working_plan_snapshot_is_ordered_redacted_and_includes_revision_reason(self): + with tempfile.TemporaryDirectory() as directory: + store = EventStore(Path(directory) / "state.sqlite3") + manager = TaskManager(store, workspace_id="project", session_id="plan-snapshot") + plan = manager.edit_plan(complexity="complex", expected_revision=0, operations=[ + {"op": "add", "ref": "broad", "task": {"description": "Investigate all", "acceptance": ["result"]}}, + ], reason="Start with a broad inspection") + broad = plan["tasks"][0]["id"] + revised = manager.edit_plan(plan_id=plan["plan_id"], expected_revision=1, operations=[ + {"op": "split", "task": broad, "children": [ + {"ref": "inspect", "task": {"description": "Inspect source", "acceptance": ["source checked"]}}, + {"ref": "verify", "task": {"description": "Verify behavior", "acceptance": ["behavior checked"]}}, + ]}, + ], reason="Separate inspection from verification") + + snapshot = manager.working_plan_snapshot() + + self.assertEqual(snapshot["plan_id"], plan["plan_id"]) + self.assertEqual(snapshot["revision"], 2) + self.assertEqual(snapshot["reason"], "Separate inspection from verification") + self.assertEqual([task["description"] for task in snapshot["tasks"]], [ + "Investigate all", "Inspect source", "Verify behavior", + ]) + self.assertEqual(snapshot["tasks"][0]["superseded_by"], [ + revised["references"]["inspect"], revised["references"]["verify"], + ]) + self.assertEqual(snapshot["tasks"][1]["supersedes"], [broad]) + self.assertEqual(set(snapshot["tasks"][0]), { + "id", "description", "status", "supersedes", "superseded_by", "reason", + }) + reopened = TaskManager(store, workspace_id="project", session_id="plan-snapshot") + restored = reopened.working_plan_snapshot() + self.assertEqual(restored["revision"], snapshot["revision"]) + self.assertEqual(restored["reason"], snapshot["reason"]) + self.assertEqual(restored["tasks"], snapshot["tasks"]) + def test_plan_supersession_blocking_and_revision_history_survive_recovery(self): with tempfile.TemporaryDirectory() as directory: store = EventStore(Path(directory) / "state.sqlite3") diff --git a/tui/chat_view.go b/tui/chat_view.go index f12e1b0..8630fe4 100644 --- a/tui/chat_view.go +++ b/tui/chat_view.go @@ -385,12 +385,24 @@ func (m model) activityRail() string { if height >= 22 { mapHeight = 5 } + taskList := m.tasks + planTitle := fmt.Sprintf("TASKS %d", len(m.tasks)) + if m.workingPlan != nil { + taskList = m.workingPlan.tasks + planTitle = fmt.Sprintf("WORKING PLAN v%d · %s", m.workingPlan.revision, m.workingPlan.status) + } lines := []string{ sectionStyle.Render("PROJECT GRAPH") + " " + graphStatusStyle(graphStatus).Render(strings.ToUpper(graphStatus)), m.graphMini(minInt(22, textWidth), mapHeight), mutedStyle.Render(fmt.Sprintf("%d nodes · %d edges", m.graph.nodes, m.graph.edges)), "", - sectionStyle.Render(fmt.Sprintf("TASKS %d", len(m.tasks))), + sectionStyle.Render(planTitle), + } + if m.workingPlan != nil && m.workingPlan.reason != "" { + reason := lipgloss.NewStyle().Width(textWidth).MaxWidth(textWidth).Render("Reason: " + m.workingPlan.reason) + for _, line := range strings.Split(reason, "\n") { + lines = append(lines, mutedStyle.Render(line)) + } } if len(m.agents) > 0 { agents := m.activityAgents() @@ -407,21 +419,45 @@ func (m model) activityRail() string { } lines = append(agentLines, append([]string{""}, lines...)...) } - if len(m.tasks) == 0 { + if len(taskList) == 0 { lines = append(lines, mutedStyle.Render("No active tasks")) } maxTasks := maxInt(1, height-mapHeight-12) - for index, task := range m.tasks { + planTaskNames := make(map[string]string, len(taskList)) + for _, task := range taskList { + planTaskNames[task.id] = task.description + } + for index, task := range taskList { if index >= maxTasks { - lines = append(lines, mutedStyle.Render(fmt.Sprintf("+%d more", len(m.tasks)-index))) + lines = append(lines, mutedStyle.Render(fmt.Sprintf("+%d more", len(taskList)-index))) break } icon, stateStyle, label := taskState(task.status, m.motionFrame) + if task.status == "succeeded" { + label = "verified" + } + if task.status == "cancelled" && len(task.supersededBy) > 0 { + label = "superseded" + } if task.id == m.taskFlashID && m.taskFlashTick > 0 && task.status == "succeeded" { - label = "completed ·" + label = "verified ·" } row := stateStyle.Render(icon) + " " + softStyle.Render(compactText(task.description, maxInt(4, textWidth-lipgloss.Width(label)-4))) + " " + stateStyle.Render(label) lines = append(lines, row) + if len(task.supersededBy) > 0 { + replacements := make([]string, 0, len(task.supersededBy)) + for _, replacement := range task.supersededBy { + if description := planTaskNames[replacement]; description != "" { + replacements = append(replacements, description) + } + } + if len(replacements) > 0 { + lines = append(lines, mutedStyle.Render(compactText(" Replaced by: "+strings.Join(replacements, ", "), textWidth))) + } + } + if task.status == "blocked" && task.reason != "" { + lines = append(lines, mutedStyle.Render(compactText(" "+task.reason, textWidth))) + } } workspace := "global workspace" if m.workspace != "" { diff --git a/tui/events.go b/tui/events.go index 8e06443..675b696 100644 --- a/tui/events.go +++ b/tui/events.go @@ -13,6 +13,17 @@ func stringValue(event map[string]any, key string) string { return value } +func stringList(value any) []string { + items, _ := value.([]any) + result := make([]string, 0, len(items)) + for _, item := range items { + if text, ok := item.(string); ok { + result = append(result, text) + } + } + return result +} + func (m *model) handleBackendEvent(event backendEvent) { switch stringValue(event, "event") { case "update_available": @@ -179,6 +190,7 @@ func (m *model) handleBackendEvent(event backendEvent) { previous[task.id] = task.status } m.tasks = parseTasks(event["tasks"]) + m.workingPlan = parseWorkingPlan(event["working_plan"]) if !m.reducedMotion { for _, task := range m.tasks { if previous[task.id] == "running" && task.status == "succeeded" { @@ -187,6 +199,37 @@ func (m *model) handleBackendEvent(event backendEvent) { } } } + case "working_plan": + if sessionID := stringValue(event, "session_id"); sessionID != "" && m.activeSessionID != "" && sessionID != m.activeSessionID { + return + } + incoming := parseWorkingPlan(event["working_plan"]) + if m.workingPlan != nil && incoming != nil && incoming.updatedAt < m.workingPlan.updatedAt { + return + } + m.workingPlan = incoming + case "task_update": + if sessionID := stringValue(event, "session_id"); sessionID != "" && m.activeSessionID != "" && sessionID != m.activeSessionID { + return + } + taskID, status := stringValue(event, "task_id"), stringValue(event, "status") + for index := range m.tasks { + if m.tasks[index].id == taskID && status != "" { + m.tasks[index].status = status + } + } + if m.workingPlan != nil { + for index := range m.workingPlan.tasks { + if m.workingPlan.tasks[index].id == taskID { + if status != "" { + m.workingPlan.tasks[index].status = status + } + if reason := stringValue(event, "reason"); reason != "" { + m.workingPlan.tasks[index].reason = reason + } + } + } + } case "navigation": m.applyNavigation(event) case "interaction": @@ -345,15 +388,36 @@ func parseTasks(value any) []taskItem { if !ok { continue } - tasks = append(tasks, taskItem{id: stringValue(item, "id"), description: stringValue(item, "description"), status: stringValue(item, "status")}) + tasks = append(tasks, taskItem{ + id: stringValue(item, "id"), description: stringValue(item, "description"), + status: stringValue(item, "status"), reason: stringValue(item, "reason"), + supersedes: stringList(item["supersedes"]), supersededBy: stringList(item["superseded_by"]), + }) } return tasks } +func parseWorkingPlan(value any) *workingPlanView { + if value == nil { + return nil + } + item, ok := value.(map[string]any) + if !ok { + return nil + } + return &workingPlanView{ + planID: stringValue(item, "plan_id"), revision: numberValue(item["revision"]), + status: stringValue(item, "status"), reason: stringValue(item, "reason"), + updatedAt: stringValue(item, "updated_at"), + tasks: parseTasks(item["tasks"]), + } +} + func (m *model) applyNavigation(event backendEvent) { if stringValue(event, "active_session_id") != m.activeSessionID || stringValue(event, "active_scope_id") != m.activeScopeID { m.agents = nil m.agentSelected, m.agentScroll = 0, 0 + m.workingPlan = nil } m.activeSessionID = stringValue(event, "active_session_id") m.activeScopeID = stringValue(event, "active_scope_id") diff --git a/tui/main_test.go b/tui/main_test.go index 53f46b8..ac54a64 100644 --- a/tui/main_test.go +++ b/tui/main_test.go @@ -93,6 +93,30 @@ func TestReducerProjectsStreamingResponseTasksAndApproval(t *testing.T) { } } +func TestWorkingPlanRevisionsRenderRationaleSupersessionAndVerifiedProgress(t *testing.T) { + m := initialModel(".", false) + m.width, m.height, m.screen = 120, 40, screenChat + m.workingPlan = parseWorkingPlan(map[string]any{ + "plan_id": "plan-1", "revision": 2, "status": "active", + "reason": "Separate inspection from verification", + "tasks": []any{ + map[string]any{"id": "old", "description": "Investigate all", "status": "cancelled", "superseded_by": []any{"inspect", "verify"}}, + map[string]any{"id": "inspect", "description": "Inspect source", "status": "pending", "supersedes": []any{"old"}}, + map[string]any{"id": "verify", "description": "Verify behavior", "status": "pending", "supersedes": []any{"old"}}, + }, + }) + view := m.activityRail() + for _, want := range []string{"WORKING PLAN", "Separate", "verification", "superseded", "Replaced by", "Inspect", "Verify"} { + if !strings.Contains(view, want) { + t.Fatalf("working plan view omitted %q: %s", want, view) + } + } + m.handleBackendEvent(backendEvent{"event": "task_update", "task_id": "inspect", "status": "succeeded"}) + if view = m.activityRail(); !strings.Contains(view, "verified") { + t.Fatalf("successful task was not presented as verified: %s", view) + } +} + func TestApprovalModalKeepsStructuredArgumentsScrollable(t *testing.T) { m := initialModel(".", false) m.width, m.height = 100, 30 diff --git a/tui/model.go b/tui/model.go index 0e39d26..5e20902 100644 --- a/tui/model.go +++ b/tui/model.go @@ -58,9 +58,18 @@ type chatMessage struct { } type taskItem struct { - id string - description string - status string + id string + description string + status string + reason string + supersedes []string + supersededBy []string +} + +type workingPlanView struct { + planID, status, reason, updatedAt string + revision int + tasks []taskItem } type navigationChat struct{ id, title, updatedAt string } @@ -145,6 +154,7 @@ type model struct { pendingDeleteTitle string messages []chatMessage tasks []taskItem + workingPlan *workingPlanView agents []map[string]any agentSelected int agentScroll int