From d226f95f3acce47a1a2e48a6019cfb1b63b279a6 Mon Sep 17 00:00:00 2001 From: Brian H Date: Sat, 19 Sep 2026 07:06:01 +0000 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20storyb00k=20projects=20=E2=80=94?= =?UTF-8?q?=20planning=20stage,=20refinement=20questions,=20full=20transpa?= =?UTF-8?q?rency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry of 249837b onto post-#34 main: - project_store.py: per-thread project (title/goal/requirements/locked, verbatim prompt log, LLM thinking log, QA history) persisted beside charts; endpoints GET /projects[/{id}], POST /projects/rename - ask_user tool: multiple-choice refinement questions as AG-UI interrupts (options ride responseSchema defaults); answers recorded as QA, run continues - system prompt workflow: THINK β†’ ASK β†’ 'Locked in:' β†’ RENDER; server-side lock detection - THINKING_* events: reasoning_content as πŸ’­ thinking blocks, captured server-side - UI: project banner (editable title, locked badge, QA, expandable prompt+thinking log) - live E2E verified: 2 refinement rounds -> locked in -> k8s panel rendered - tests: 38 python, 15 vitest --- .b00t/tasks.json | 8 + LINKS.md | 23 +++ .../kr0ki-storyb00k-agent/Containerfile | 1 + .../kr0ki-storyb00k-agent/project_store.py | 166 ++++++++++++++++ containers/kr0ki-storyb00k-agent/server.py | 162 +++++++++++++++- .../test_planning_flow.py | 128 ++++++++++++ .../test_project_store.py | 79 ++++++++ playbook/src/components/StoryB00k.vue | 183 +++++++++++++++++- 8 files changed, 737 insertions(+), 13 deletions(-) create mode 100644 LINKS.md create mode 100644 containers/kr0ki-storyb00k-agent/project_store.py create mode 100644 containers/kr0ki-storyb00k-agent/test_planning_flow.py create mode 100644 containers/kr0ki-storyb00k-agent/test_project_store.py diff --git a/.b00t/tasks.json b/.b00t/tasks.json index 0d5917a..7115a5f 100644 --- a/.b00t/tasks.json +++ b/.b00t/tasks.json @@ -284,6 +284,14 @@ "priority": 3, "tags": [], "created_at": "2026-09-19T03:50:39Z" + }, + { + "id": 35, + "title": "kr0ki commit aed6788 on PR #34: EDIT regression fixed (format/route lost from agent renders); revisionGraph.js prompt/edit/fork DAG + vue-flow panel (time travel/fork per node); editable prompts with regenerate; chart_store.py jj-snapshotted chart persistence (/charts/* endpoints, KR0KI_CHARTS_DIR volume, jujutsu in image); live round-trip verified save/list/load/history/restore against pod; gates 26py+15vitest+19cargo+check", + "status": "pending", + "priority": 3, + "tags": [], + "created_at": "2026-09-19T05:53:52Z" } ], "version": "1" diff --git a/LINKS.md b/LINKS.md new file mode 100644 index 0000000..e36289f --- /dev/null +++ b/LINKS.md @@ -0,0 +1,23 @@ +https://github.com/AI4MBSE/MBSE-AI-SysML-V2 + +https://arxiv.org/html/2609.16252v1 +Models as Governed Interfaces for AI-Native MBSE: +Read-Side Adequacy and Write-Side Admissibility + + + +https://github.com/ieshan/ag-ui-vue + + +https://docs.ag-ui.com/introduction + + +https://histoire.dev/guide/ + + +https://tse1.mm.bing.net/th/id/OIP.GrSkv6SiYsIVdJweY2fqcgHaEt?r=0&pid=ImgDet&w=179&h=113&c=7&dpr=1.3&o=7&rm=3 +https://www.researchgate.net/profile/Rupert-England-2/publication/318373765/figure/fig1/AS:515291479068672@1499866470127/SysML-Diagrams-based-on-extended-UML-v2-subset.png +https://arxiv.org/pdf/2508.16181v1 +LLM-Assisted Semantic Alignment and Integration +in Collaborative Model-Based Systems Engineering +Using SysML v2 diff --git a/containers/kr0ki-storyb00k-agent/Containerfile b/containers/kr0ki-storyb00k-agent/Containerfile index 33babed..84f403d 100644 --- a/containers/kr0ki-storyb00k-agent/Containerfile +++ b/containers/kr0ki-storyb00k-agent/Containerfile @@ -14,6 +14,7 @@ COPY containers/kr0ki-storyb00k-agent/llm_client.py ./llm_client.py COPY containers/kr0ki-storyb00k-agent/draft_graph.py ./draft_graph.py COPY containers/kr0ki-storyb00k-agent/session_log.py ./session_log.py COPY containers/kr0ki-storyb00k-agent/chart_store.py ./chart_store.py +COPY containers/kr0ki-storyb00k-agent/project_store.py ./project_store.py COPY containers/kr0ki-storyb00k-agent/skills/ ./skills/ COPY containers/kr0ki-mcp/manifest_dispatch.py ./manifest_dispatch.py diff --git a/containers/kr0ki-storyb00k-agent/project_store.py b/containers/kr0ki-storyb00k-agent/project_store.py new file mode 100644 index 0000000..d1786cf --- /dev/null +++ b/containers/kr0ki-storyb00k-agent/project_store.py @@ -0,0 +1,166 @@ +"""Per-thread PROJECT state for storyb00k. + +A project is the conceptual unit the user works on within a session: + { + "threadId": ..., + "title": short name (first prompt, editable), + "goal": the user's initial desire, + "requirements": "Locked in:" summary the agent produced (None until locked), + "qa": [ {"question", "answer", "ts"} ... ], # refinement history + "prompts": [ {"role", "text", "ts", "run"} ... ], # full prompt log + "thinking": [ {"text", "ts", "run"} ... ], # LLM reasoning captured server-side + "createdAt", "updatedAt", + } + +Persistence: JSON file beside the chart store (//project.json) +so a project and its charts travel together and survive restarts. Also kept in +memory for cheap reads. Nothing here talks to the LLM. +""" + +import json +import os +import threading +import time +from pathlib import Path + +CHARTS_DIR = Path(os.environ.get("KR0KI_CHARTS_DIR", "/var/lib/kr0ki/charts")) +MAX_TEXT = 20_000 +MAX_LOG = 500 # entries per list + +_lock = threading.Lock() +_memory = {} # thread_id -> project dict + + +def _thread_dir(thread_id): + from chart_store import slugify + root = CHARTS_DIR.resolve() + target = (root / slugify(thread_id)).resolve() + if not str(target).startswith(str(root) + os.sep): + raise ValueError(f"thread path escapes charts root: {thread_id!r}") + return target + + +def _path(thread_id): + return _thread_dir(thread_id) / "project.json" + + +def get_project(thread_id, create=True): + """Load (or lazily create) the project for a thread.""" + with _lock: + project = _memory.get(thread_id) + if project is None: + path = _path(thread_id) + if path.is_file(): + try: + project = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + project = None + if project is None: + if not create: + return None + project = { + "threadId": thread_id, + "title": "Untitled project", + "goal": None, + "requirements": None, + "locked": False, + "qa": [], + "prompts": [], + "thinking": [], + "createdAt": time.time(), + "updatedAt": time.time(), + } + _memory[thread_id] = project + assert project is not None + return project + + +def save_project(thread_id, project): + project["updatedAt"] = time.time() + with _lock: + _memory[thread_id] = project + try: + path = _path(thread_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(project, indent=2, ensure_ascii=False), encoding="utf-8") + except (OSError, ValueError): + pass # in-memory copy remains authoritative for the live session + return project + + +def set_goal(thread_id, goal): + project = get_project(thread_id) + if project.get("goal") is None: + project["goal"] = goal[:MAX_TEXT] + project["title"] = goal.strip().splitlines()[0][:80] or "Untitled project" + return save_project(thread_id, project) + + +def add_prompt(thread_id, role, text, run=None): + """Full prompt/response log β€” user prompts, assistant narration, verbatim.""" + project = get_project(thread_id) + entry = {"role": role, "text": (text or "")[:MAX_TEXT], "ts": time.time()} + if run: + entry["run"] = run + project["prompts"].append(entry) + del project["prompts"][:-MAX_LOG] + if role == "user": + set_goal(thread_id, text or "") + else: + save_project(thread_id, project) + return entry + + +def add_thinking(thread_id, text, run=None): + """Capture the LLM's reasoning server-side (always, even if the browser + never renders it) so sessions are fully auditable.""" + project = get_project(thread_id) + entry = {"text": (text or "")[:MAX_TEXT], "ts": time.time()} + if run: + entry["run"] = run + project["thinking"].append(entry) + del project["thinking"][:-MAX_LOG] + return save_project(thread_id, project) + + +def add_qa(thread_id, question, answer): + project = get_project(thread_id) + project["qa"].append({"question": question[:MAX_TEXT], "answer": answer[:MAX_TEXT], "ts": time.time()}) + del project["qa"][:-MAX_LOG] + return save_project(thread_id, project) + + +def set_requirements(thread_id, requirements): + project = get_project(thread_id) + project["requirements"] = (requirements or "")[:MAX_TEXT] + project["locked"] = bool(requirements) + return save_project(thread_id, project) + + +def rename(thread_id, title): + project = get_project(thread_id) + project["title"] = (title or "").strip()[:120] or project["title"] + return save_project(thread_id, project) + + +def list_projects(): + out = [] + if not CHARTS_DIR.is_dir(): + return out + for entry in sorted(CHARTS_DIR.iterdir()): + path = entry / "project.json" + if entry.is_dir() and path.is_file(): + try: + p = json.loads(path.read_text(encoding="utf-8")) + out.append({ + "threadId": p.get("threadId", entry.name), + "title": p.get("title"), + "goal": p.get("goal"), + "locked": p.get("locked", False), + "qaCount": len(p.get("qa", [])), + "promptCount": len(p.get("prompts", [])), + "updatedAt": p.get("updatedAt"), + }) + except (OSError, ValueError): + continue + return out diff --git a/containers/kr0ki-storyb00k-agent/server.py b/containers/kr0ki-storyb00k-agent/server.py index fa03b3f..1a72ab3 100644 --- a/containers/kr0ki-storyb00k-agent/server.py +++ b/containers/kr0ki-storyb00k-agent/server.py @@ -25,6 +25,7 @@ import llm_client from draft_graph import DraftGraph import chart_store +import project_store import session_log try: from manifest_dispatch import apply_binding, fetch_manifest, find_tool, http_call @@ -38,6 +39,7 @@ MAX_OUTPUT_CHARS = int(os.environ.get("KR0KI_STORYB00K_MAX_OUTPUT_CHARS", "20000")) THREAD_TTL_SECS = int(os.environ.get("KR0KI_STORYB00K_THREAD_TTL_SECS", str(6 * 3600))) _drafts = {} # thread_id -> {"graph": DraftGraph, "last_used": epoch} +_pending_questions = {} # thread_id -> ask_user question awaiting an answer ALLOWED_ORIGINS = frozenset( origin.strip() for origin in os.environ.get( @@ -48,13 +50,55 @@ ) SYSTEM_PREAMBLE = ( - "You are storyb00k, a read-only narrator for a live SysML/Kroki model service. " - "Use the provided tools to fetch model facts and render diagrams. Prefer tool " - "evidence over guessing: if you lack a fact, call a tool before answering. When " - "the user asks for a change, propose it with the propose_draft_change tool β€” " - "never claim to have modified the authoritative model. Keep narration concise." + "You are storyb00k, the planning front-end for a live SysML/Kroki model service " + "and chart workspace. You work on PROJECTS: one project = one diagram goal the " + "user is refining with you.\n\n" + "WORKFLOW β€” every request goes through a planning stage first:\n" + "1. THINK: use your thinking channel to restate the goal, list what you know, " + "and list what is ambiguous (diagram type? scope? level of detail? naming? layout?).\n" + "2. ASK: if anything material is ambiguous, use the ask_user tool to put ONE " + "multiple-choice question to the user. Each answer refines the shared " + "understanding. Ask as many rounds as needed β€” but batch what you can and never " + "re-ask something already answered.\n" + "3. LOCK: once you can restate the user's desire precisely, summarize the " + "requirements in one short paragraph and call it 'Locked in:' β€” the user then " + "sees exactly what will be built. Only after locking in, fetch model facts and " + "render the diagram.\n" + "4. RENDER: use the tools to read the live model and render. Prefer tool " + "evidence over guessing: if you lack a fact, call a tool before answering.\n\n" + "When the user asks for a change to the authoritative model, propose it with the " + "propose_draft_change tool β€” never claim to have modified the authoritative " + "model. Keep narration concise." ) +# Multiple-choice refinement question, surfaced to the user as an interrupt. +ask_user_tool = { + "type": "function", + "function": { + "name": "ask_user", + "description": ( + "Ask the user ONE multiple-choice question to refine the project " + "requirements. Use this during planning whenever the goal is ambiguous; " + "keep asking until you can state the desire precisely (locked in)." + ), + "parameters": { + "type": "object", + "required": ["question", "options"], + "properties": { + "question": {"type": "string", "description": "The question to put to the user."}, + "options": { + "type": "array", + "minItems": 2, + "maxItems": 6, + "items": {"type": "string"}, + "description": "2–6 concise candidate answers.", + }, + "allowFreeText": {"type": "boolean", "description": "Whether the user may answer in their own words. Default true."}, + }, + }, + }, +} + def load_skills(): return {path.stem: path.read_text() for path in SKILLS_DIR.glob("*.md")} @@ -181,6 +225,16 @@ def text_message(self, content, role="assistant"): self.try_write({"type": "TEXT_MESSAGE_CONTENT", "threadId": self.thread_id, "runId": self.run_id, "messageId": message_id, "delta": content}) self.try_write({"type": "TEXT_MESSAGE_END", "threadId": self.thread_id, "runId": self.run_id, "messageId": message_id}) + def thinking_message(self, content): + """Emit the model's chain of thought as THINKING_* events. The + @ag-ui/client maps these to a `reasoning` part on the assistant item, + so the transcript can show what the model was thinking.""" + if not content: + return + self.try_write({"type": "THINKING_TEXT_MESSAGE_START", "threadId": self.thread_id, "runId": self.run_id}) + self.try_write({"type": "THINKING_TEXT_MESSAGE_CONTENT", "threadId": self.thread_id, "runId": self.run_id, "delta": content}) + self.try_write({"type": "THINKING_TEXT_MESSAGE_END", "threadId": self.thread_id, "runId": self.run_id}) + class Handler(BaseHTTPRequestHandler): def log_message(self, _format, *_args): @@ -224,6 +278,14 @@ def do_GET(self): self._json(200, {"sessions": session_log.list_sessions()}) elif self.path == "/charts": self._json(200, {"charts": chart_store.list_charts()}) + elif self.path == "/projects": + self._json(200, {"projects": project_store.list_projects()}) + elif self.path.startswith("/projects/"): + thread_id = self.path[len("/projects/"):] or "default" + project = project_store.get_project(thread_id, create=False) + if project is None: + return self._json(404, {"error": "project_not_found"}) + self._json(200, project) elif self.path.startswith("/charts/history/"): thread_id = self.path[len("/charts/history/"):] or "default" self._json(200, {"thread": thread_id, "log": chart_store.history(thread_id)}) @@ -259,6 +321,11 @@ def do_POST(self): payload = json.loads(self.rfile.read(length)) if length else {} except (ValueError, json.JSONDecodeError): return self._json(400, {"error": "invalid_json"}) + if self.path == "/projects/rename": + thread_id = payload.get("threadId", "default") + project_store.rename(thread_id, payload.get("title", "")) + renamed = project_store.get_project(thread_id) + return self._json(200, {"status": "ok", "title": renamed.get("title") if renamed else None}) if self.path == "/charts/save": try: result = chart_store.save_chart( @@ -278,9 +345,23 @@ def do_POST(self): return self._json(400, {"error": "commitId required"}) return self._json(200, chart_store.restore(thread_id, commit_id)) if self.path == "/respond-to-interrupt": - draft = _drafts.get(payload.get("threadId", "default")) - if draft is None: + thread_id = payload.get("threadId", "default") + draft = _drafts.get(thread_id) + question = _pending_questions.get(thread_id) + is_answer = question is not None and payload.get("interruptId") == question.get("id") + if draft is None and not is_answer: return self._json(404, {"error": "draft_session_not_found"}) + if is_answer and question is not None: + # Planning refinement answer: record it in the project QA + # history, hand it back to the caller (the UI immediately + # re-runs with the answer appended as a user message, + # continuing the planning loop). + answer = str(payload.get("answer") or "").strip()[:2000] + if not answer: + return self._json(400, {"error": "answer_required"}) + _pending_questions.pop(thread_id, None) + project_store.add_qa(thread_id, question.get("question", ""), answer) + return self._json(200, {"status": "ok", "answered": question.get("id"), "question": question.get("question"), "answer": answer}) try: (draft["graph"].apply if payload.get("approved") else draft["graph"].decline)(payload["interruptId"]) except KeyError: @@ -292,6 +373,13 @@ def do_POST(self): run_id = payload.get("runId", "unknown") thread_id = payload.get("threadId", "default") draft = thread_draft(thread_id) + # Project capture: every user prompt is logged verbatim; the first + # prompt of a thread becomes the project goal. + for msg in payload.get("messages") or []: + if msg.get("role") == "user": + text = (msg.get("content") or "").strip() + if text: + project_store.add_prompt(thread_id, "user", text, run=run_id) self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") @@ -315,6 +403,7 @@ def _run(self, stream, payload, thread_id, draft, run_log): manifest = fetch_manifest(KR0KI_URL) tools = [{"type": "function", "function": {"name": tool["name"], "description": tool["description"], "parameters": tool["inputSchema"]}} for tool in manifest] tools.append(local_draft_tool()) + tools.append(ask_user_tool) messages = [{"role": "system", "content": SYSTEM_PREAMBLE + "\n\n" + "\n\n".join(load_skills().values())}] messages += messages_from_payload(payload) client = llm_client.OpenAICompatibleClient.from_env() @@ -332,6 +421,21 @@ def _run(self, stream, payload, thread_id, draft, run_log): usage={"promptTokens": usage.get("prompt_tokens"), "completionTokens": usage.get("completion_tokens")}, model=response.get("model")) message = response["choices"][0]["message"] + if message.get("reasoning_content"): + # Deep-think models expose their chain of thought here; surface + # it through AG-UI THINKING_* events (rendered by the client as + # reasoning parts) instead of swallowing it. Also captured in + # the project log so sessions stay auditable server-side. + stream.thinking_message(message["reasoning_content"]) + project_store.add_thinking(thread_id, message["reasoning_content"], run=stream.run_id) + if message.get("content"): + project_store.add_prompt(thread_id, "assistant", message["content"], run=stream.run_id) + # Locked-in detection: the workflow contract says the agent + # prefixes its requirement summary with "Locked in:". + content_text = message["content"] + marker = "Locked in:" + if marker in content_text and not (project_store.get_project(thread_id) or {}).get("locked"): + project_store.set_requirements(thread_id, content_text.split(marker, 1)[1].strip()) messages.append({ "role": "assistant", "content": message.get("content") or "", @@ -355,6 +459,50 @@ def _run(self, stream, payload, thread_id, draft, run_log): arguments = {} tool_call_id = call.get("id", name) parent_message_id = f"msg-tool-{tool_call_id}" + if name == "ask_user": + # Planning refinement: surface the question as an interrupt. + # The client answers via /respond-to-interrupt; the answer + # lands in _pending_answers so the next /run call (which + # carries the full conversation again) sees it via the + # messages the client appends. We park the run here. + question_id = f"ask-{uuid.uuid4().hex[:12]}" + options = arguments.get("options") or [] + stream.try_write({ + "type": "TOOL_CALL_END", "threadId": thread_id, "runId": stream.run_id, "toolCallId": tool_call_id, + }) + stream.try_write({ + "type": "TOOL_CALL_RESULT", "threadId": thread_id, "runId": stream.run_id, + "messageId": parent_message_id, "toolCallId": tool_call_id, + "content": f"Question put to user: {arguments.get('question', '')}", "role": "tool", + }) + _pending_questions[thread_id] = { + "id": question_id, + "question": arguments.get("question", ""), + "options": options, + "allowFreeText": bool(arguments.get("allowFreeText", True)), + "toolCallId": tool_call_id, + "ts": time.time(), + } + run_log.event("plan.question", {"id": question_id, "question": arguments.get("question", ""), "options": options}) + # Interrupt outcome shape per RunFinishedInterruptOutcomeSchema. + stream.try_write(lifecycle_event("RUN_FINISHED", thread_id, stream.run_id, outcome={ + "type": "interrupt", + "interrupts": [{ + "id": question_id, + "reason": arguments.get("question", ""), + "responseSchema": { + "type": "object", + "properties": { + "answer": {"type": "string", "description": "Chosen option or free text"}, + "optionIndex": {"type": "integer"}, + "options": {"type": "array", "items": {"type": "string"}, "default": options, "description": "Candidate answers offered"}, + "allowFreeText": {"type": "boolean", "default": bool(arguments.get("allowFreeText", True))}, + }, + "required": ["answer"], + }, + }], + })) + return if name == "propose_draft_change": proposal_id = draft.propose(arguments.get("subject", ""), arguments.get("predicate", ""), arguments.get("object", "")) stream.try_write({"type": "STATE_DELTA", "threadId": thread_id, "runId": stream.run_id, "delta": [{"op": "add", "path": "/drafts/-", "value": {"id": proposal_id, "status": "pending", **arguments}}]}) diff --git a/containers/kr0ki-storyb00k-agent/test_planning_flow.py b/containers/kr0ki-storyb00k-agent/test_planning_flow.py new file mode 100644 index 0000000..6f07bbd --- /dev/null +++ b/containers/kr0ki-storyb00k-agent/test_planning_flow.py @@ -0,0 +1,128 @@ +"""End-to-end test of the planning flow over HTTP: /run with an ask_user +tool call (LLM mocked) β†’ interrupt frame β†’ /respond-to-interrupt answer β†’ +project QA recorded. Runs the real ThreadingHTTPServer on an ephemeral port. +""" + +import json +import tempfile +import threading +import unittest +import urllib.error +import urllib.request +from pathlib import Path +from unittest.mock import patch + +import server + + +def _post(url, payload): + req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.status, json.loads(resp.read()) + + +def _post_stream(url, payload): + req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.read().decode("utf-8") + + +class PlanningFlowTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="kr0ki-plan-e2e-") + for mod in (server, server.chart_store, server.project_store, server.session_log): + if hasattr(mod, "CHARTS_DIR"): + patcher = patch.object(mod, "CHARTS_DIR", Path(self.tmp) / "charts") + patcher.start() + self.addCleanup(patcher.stop) + if hasattr(mod, "DEBUG_LOG_DIR"): + patcher = patch.object(mod, "DEBUG_LOG_DIR", Path(self.tmp) / "debug") + patcher.start() + self.addCleanup(patcher.stop) + server.project_store._memory.clear() + server._pending_questions.clear() + server._drafts.clear() + + # Mock LLM: first call asks the user a multiple-choice question. + self.llm = patch("llm_client.OpenAICompatibleClient") + mock_cls = self.llm.start() + self.addCleanup(self.llm.stop) + self.client = mock_cls.from_env.return_value + self.client.chat_completion.return_value = { + "model": "test-model", + "choices": [{ + "message": { + "content": "Let me think about what you want.", + "reasoning_content": "Goal is ambiguous: which diagram type?", + "tool_calls": [{ + "id": "call-1", + "function": { + "name": "ask_user", + "arguments": json.dumps({ + "question": "Which diagram type?", + "options": ["d2", "mermaid", "k8s-topology"], + }), + }, + }], + }, + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, + } + + httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler) + self.port = httpd.server_address[1] + threading.Thread(target=httpd.serve_forever, daemon=True).start() + self.addCleanup(httpd.shutdown) + + def test_ask_user_interrupt_and_answer(self): + base = f"http://127.0.0.1:{self.port}" + # 1. Run: agent asks a question β†’ interrupt frame, run finishes. + raw = _post_stream(f"{base}/run", { + "threadId": "plan-1", "runId": "r1", + "messages": [{"role": "user", "content": "Draw my cluster"}], + }) + frames = [json.loads(line[5:]) for line in raw.splitlines() if line.startswith("data: ")] + types = [f["type"] for f in frames] + self.assertIn("THINKING_TEXT_MESSAGE_CONTENT", types, types) + self.assertIn("RUN_FINISHED", types) + finished = next(f for f in frames if f["type"] == "RUN_FINISHED") + self.assertEqual(finished["outcome"]["type"], "interrupt") + interrupt = finished["outcome"]["interrupts"][0] + self.assertEqual(interrupt["reason"], "Which diagram type?") + self.assertEqual(len(interrupt["responseSchema"]["properties"]["options"]["enum"]) if "enum" in interrupt["responseSchema"].get("properties", {}).get("options", {}) else 0, 0) # options live in the pending question, not the schema + + # 2. Answer: /respond-to-interrupt records the QA and returns the answer. + status, body = _post(f"{base}/respond-to-interrupt", { + "threadId": "plan-1", "interruptId": interrupt["id"], "answer": "d2", + }) + self.assertEqual(status, 200) + self.assertEqual(body["answer"], "d2") + + # 3. Project captured the prompt, thinking, and QA. + project = server.project_store.get_project("plan-1") + self.assertEqual(project["goal"], "Draw my cluster") + self.assertEqual(project["qa"], [{"question": "Which diagram type?", "answer": "d2", "ts": project["qa"][0]["ts"]}]) + self.assertTrue(any("ambiguous" in t["text"] for t in project["thinking"])) + self.assertTrue(any(p["role"] == "user" and p["text"] == "Draw my cluster" for p in project["prompts"])) + self.assertTrue(any(p["role"] == "assistant" for p in project["prompts"])) + + # 4. GET /projects and /projects/plan-1 expose the project. + status, body = _post(f"{base}/projects/rename", {"threadId": "plan-1", "title": "Cluster map"}) + self.assertEqual(body["title"], "Cluster map") + + def test_answer_requires_pending_question(self): + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}/respond-to-interrupt", + data=json.dumps({"threadId": "no-question", "interruptId": "ask-none", "answer": "x"}).encode(), + headers={"Content-Type": "application/json"}, + ) + try: + urllib.request.urlopen(req, timeout=10) + self.fail("expected 404") + except urllib.error.HTTPError as err: + self.assertEqual(err.code, 404) + self.assertEqual(json.loads(err.read())["error"], "draft_session_not_found") + + +if __name__ == "__main__": + unittest.main() diff --git a/containers/kr0ki-storyb00k-agent/test_project_store.py b/containers/kr0ki-storyb00k-agent/test_project_store.py new file mode 100644 index 0000000..c1b1724 --- /dev/null +++ b/containers/kr0ki-storyb00k-agent/test_project_store.py @@ -0,0 +1,79 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import project_store + + +class ProjectStoreTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="kr0ki-project-test-") + patcher = patch.object(project_store, "CHARTS_DIR", Path(self.tmp) / "charts") + patcher.start() + self.addCleanup(patcher.stop) + project_store._memory.clear() + + def test_goal_and_title_from_first_prompt(self): + project_store.add_prompt("t1", "user", "Render the cluster topology\nwith annotations") + p = project_store.get_project("t1") + self.assertEqual(p["goal"], "Render the cluster topology\nwith annotations") + self.assertEqual(p["title"], "Render the cluster topology") + + def test_title_not_overwritten_by_later_prompts(self): + project_store.add_prompt("t2", "user", "First goal") + project_store.add_prompt("t2", "user", "Second goal") + p = project_store.get_project("t2") + self.assertEqual(p["goal"], "First goal") + self.assertEqual(p["prompts"][1]["role"], "user") + + def test_full_prompt_log(self): + project_store.add_prompt("t3", "user", "hello") + project_store.add_prompt("t3", "assistant", "hi there", run="run-1") + p = project_store.get_project("t3") + self.assertEqual([e["role"] for e in p["prompts"]], ["user", "assistant"]) + self.assertEqual(p["prompts"][1]["run"], "run-1") + + def test_thinking_capture(self): + project_store.add_thinking("t4", "the user probably wants d2", run="run-9") + p = project_store.get_project("t4") + self.assertEqual(p["thinking"][0]["text"], "the user probably wants d2") + + def test_qa_and_lock(self): + project_store.add_qa("t5", "Which diagram type?", "d2") + project_store.set_requirements("t5", "A d2 diagram of the cluster.") + p = project_store.get_project("t5") + self.assertEqual(p["qa"][0]["answer"], "d2") + self.assertTrue(p["locked"]) + self.assertEqual(p["requirements"], "A d2 diagram of the cluster.") + + def test_rename(self): + project_store.add_prompt("t6", "user", "goal") + project_store.rename("t6", "Cluster map") + self.assertEqual(project_store.get_project("t6")["title"], "Cluster map") + + def test_persistence_round_trip(self): + project_store.add_prompt("t7", "user", "persist me") + project_store._memory.clear() + p = project_store.get_project("t7", create=False) + self.assertIsNotNone(p) + self.assertEqual(p["prompts"][0]["text"], "persist me") + + def test_get_missing_no_create(self): + self.assertIsNone(project_store.get_project("nope", create=False)) + + def test_list_projects(self): + project_store.add_prompt("list-a", "user", "alpha") + project_store.add_prompt("list-b", "user", "beta") + names = {p["threadId"] for p in project_store.list_projects()} + self.assertIn("list-a", names) + self.assertIn("list-b", names) + + def test_traversal_rejected(self): + with self.assertRaises(ValueError): + project_store.add_prompt("../evil", "user", "x") + + +if __name__ == "__main__": + unittest.main() diff --git a/playbook/src/components/StoryB00k.vue b/playbook/src/components/StoryB00k.vue index 5a7821c..3a127c8 100644 --- a/playbook/src/components/StoryB00k.vue +++ b/playbook/src/components/StoryB00k.vue @@ -60,6 +60,52 @@ const toolActivity = computed(() => Array.from(toolCallTrackers.value?.values() failed: t.state === 'output-error' || t.state === 'output-denied', output: t.output, }))) +// ---- Project: the conceptual unit of work within a session ---- +// The agent tracks goal/Q&A/prompts server-side (project_store.py); the UI +// mirrors it so the user can see (and edit) what was asked and answered. +const project = ref(null) +const projectError = ref('') +const editingTitle = ref(false) +const editedTitle = ref('') +const showPromptLog = ref(false) +const promptLog = computed(() => project.value?.prompts || []) +const thinkingLog = computed(() => project.value?.thinking || []) + +async function loadProject() { + try { + const res = await fetch(`${agentUrl}/projects/${threadId.value}`) + if (res.status === 404) { project.value = null; return } + if (!res.ok) throw new Error(`HTTP ${res.status}`) + project.value = await res.json() + } catch (err) { + projectError.value = err.message + } +} + +watch(threadId, () => { project.value = null; loadProject() }) +loadProject() + +async function renameProject() { + const title = editedTitle.value.trim() + editingTitle.value = false + if (!title) return + try { + const res = await fetch(`${agentUrl}/projects/rename`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId: threadId.value, title }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + if (project.value) project.value.title = title + } catch (err) { + projectError.value = `rename failed: ${err.message}` + } +} + +// Refresh the project mirror after each run completes (new prompts/Q&A/thinking). +watch(status, (next, prev) => { + if (prev !== 'ready' && next === 'ready') loadProject() +}) + const statusLabel = computed(() => ({ ready: 'Ready', submitted: 'Thinking…', @@ -187,6 +233,50 @@ async function answerInterrupt(interrupt, approved) { chat.respondToInterrupt(interrupt.id, { approved }) } +// Planning questions (ask_user interrupts): pick an option or type free text, +// then continue the run with the answer appended as a user message. +const answeringQuestion = ref(null) // interrupt currently being answered +const questionChoice = ref('') +const questionFreeText = ref('') + +function isQuestionInterrupt(interrupt) { + return typeof interrupt.id === 'string' && interrupt.id.startsWith('ask-') +} + +// zod strips unknown keys from interrupt objects, so the options ride in the +// responseSchema's `options.default` (defaults survive parsing). +function optionsFor(interrupt) { + return interrupt.responseSchema?.properties?.options?.default || [] +} + +function startAnswer(interrupt) { + answeringQuestion.value = interrupt + questionChoice.value = '' + questionFreeText.value = '' +} + +async function submitAnswer() { + const interrupt = answeringQuestion.value + if (!interrupt) return + const answer = questionFreeText.value.trim() || questionChoice.value + if (!answer) return + answeringQuestion.value = null + try { + const res = await fetch(`${agentUrl}/respond-to-interrupt`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId: threadId.value, interruptId: interrupt.id, answer }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + chat.respondToInterrupt(interrupt.id, { answer }) + // Continue the planning loop: re-run with the answer in the conversation. + await chat.send(answer) + loadProject() + } catch (err) { + console.error('[storyb00k] answer failed:', err?.message ?? err) + projectError.value = `answer failed: ${err.message}` + } +} + function decideDraft(draft, approved) { // Mirror the interrupt flow for drafts surfaced in state (e.g. after reconnect). return fetch(`${agentUrl}/respond-to-interrupt`, { @@ -238,6 +328,40 @@ function formatTokens(u) { {{ statusLabel }} {{ formatTokens(usage[usage.length - 1]) }} + + +
+
+ + + πŸ”’ locked +
+

{{ project.goal }}

+

Locked in: {{ project.requirements }}

+

+ {{ project.qa.length }} refinement answer{{ project.qa.length === 1 ? '' : 's' }} +

+ +
+

Nothing logged yet.

+
+ {{ entry.role }} +
{{ entry.text }}
+
+
+ thinking +
{{ entry.text }}
+
+
+

{{ projectError }}

+

Read the live model, assemble evidence panels, and narrate without changing the authoritative model.

@@ -258,9 +382,14 @@ function formatTokens(u) {