Merge pull request #11 from bnema/feat/tasker-extension
feat(extensions): add session-aware extension APIs
38 files · +4315 -111
M README.md +1 -1
| @@ -954,7 +954,7 @@ An extension may ship only a theme: `extension.json` plus `theme.json` (or `them | |||
| 954 | 954 | ||
| 955 | 955 | ### Reference | |
| 956 | 956 | ||
| 957 | - | `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap. | |
| 957 | + | `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell, including `tasked-phases` for spec-driven phase/checklist tracking. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap. | |
| 958 | 958 | ||
| 959 | 959 | ## Skills | |
| 960 | 960 | ||
M docs/extensions.md +99 -11
| @@ -6,13 +6,17 @@ its stdin/stdout. Extensions can be written in **any language** that | |||
| 6 | 6 | can read and write JSON lines from stdio — Go, TypeScript, Python, | |
| 7 | 7 | Rust, shell with `jq`, anything. | |
| 8 | 8 | ||
| 9 | - | Four phases shipped so far: | |
| 9 | + | Six phases shipped so far: | |
| 10 | 10 | ||
| 11 | 11 | - **Phase 1**: slash commands, chat notifications, and host alerts. | |
| 12 | 12 | - **Phase 2**: tools the LLM can call. | |
| 13 | 13 | - **Phase 3**: lifecycle event subscriptions + tool-call interception | |
| 14 | 14 | for guardrail extensions. | |
| 15 | 15 | - **Phase 4**: interactive extension-owned panels rendered inside zot. | |
| 16 | + | - **Phase 5**: branch-aware session lifecycle events and opaque extension | |
| 17 | + | state persisted with session files. | |
| 18 | + | - **Phase 6**: hidden per-turn context, persistent status/widgets, and | |
| 19 | + | extension-bundled skills. | |
| 16 | 20 | - **Theme-only extensions**: ship `theme.json` without launching a | |
| 17 | 21 | subprocess. See [themes.md](themes.md). | |
| 18 | 22 | ||
| @@ -118,6 +122,7 @@ manifest tells zot how to launch it: | |||
| 118 | 122 | "args": ["--mode", "daemon"], | |
| 119 | 123 | "language": "go", | |
| 120 | 124 | "description": "current weather for any city", | |
| 125 | + | "skills": ["skills"], | |
| 121 | 126 | "enabled": true | |
| 122 | 127 | } | |
| 123 | 128 | ``` | |
| @@ -130,6 +135,7 @@ manifest tells zot how to launch it: | |||
| 130 | 135 | | `args` | optional. extra argv passed to `exec`. | | |
| 131 | 136 | | `language` | optional. informational only (`go`, `python`, `typescript`, ...). | | |
| 132 | 137 | | `description` | optional. shown in `zot ext list`. | | |
| 138 | + | | `skills` | optional. relative directories containing bundled `<name>/SKILL.md` files or a direct `SKILL.md`. Paths must remain inside the extension directory. | | |
| 133 | 139 | | `enabled` | optional, defaults to `true`. set to `false` to disable without removing. | | |
| 134 | 140 | ||
| 135 | 141 | ## Lifecycle | |
| @@ -149,10 +155,12 @@ manifest tells zot how to launch it: | |||
| 149 | 155 | (logged in the extension's own log file). | |
| 150 | 156 | 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames | |
| 151 | 157 | when the user runs a registered command; the extension responds | |
| 152 | - | with `command_response`. Extensions can also push `notify` and | |
| 153 | - | structured `alert` frames during runtime. Panel-capable extensions | |
| 154 | - | may open an interactive panel, receive key events, and push redraws | |
| 155 | - | while the panel is focused. | |
| 158 | + | with `command_response`. Extensions can also push `notify`, persistent | |
| 159 | + | status/widget, and structured `alert` frames during runtime. Extensions | |
| 160 | + | subscribed to session lifecycle events receive the active branch identity | |
| 161 | + | and their own restored opaque state. Panel-capable extensions may open an | |
| 162 | + | interactive panel, receive key events, and push redraws while the panel is | |
| 163 | + | focused. | |
| 156 | 164 | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | |
| 157 | 165 | 2s for the extension to send `shutdown_ack`. Holdouts are | |
| 158 | 166 | SIGTERM'd, then SIGKILL'd. | |
| @@ -236,9 +244,17 @@ registered deferred tools that become available after this result. | |||
| 236 | 244 | ||
| 237 | 245 | ```json | |
| 238 | 246 | {"type":"tool_result","id":"...", | |
| 239 | - | "content":[{"type":"text","text":"Berlin: 16°C, fog"}]} | |
| 247 | + | "content":[{"type":"text","text":"Berlin: 16°C, fog"}], | |
| 248 | + | "details":{"state":{"version":1}}} | |
| 240 | 249 | ``` | |
| 241 | 250 | ||
| 251 | + | `details` is opaque extension metadata. Zot excludes it from provider | |
| 252 | + | requests and persists it as the latest extension state for the active | |
| 253 | + | session branch. Keep it valid JSON and reasonably small; the host caps | |
| 254 | + | persisted snapshots at 256 KiB. The next `session_opened`, | |
| 255 | + | `session_switched`, `session_forked`, or `session_compacted` event returns | |
| 256 | + | the extension's own snapshot in `state`. | |
| 257 | + | ||
| 242 | 258 | #### `subscribe` | |
| 243 | 259 | ||
| 244 | 260 | Declares which lifecycle events the extension wants to observe and | |
| @@ -250,8 +266,14 @@ which it wants to intercept. Send once after `hello`, before `ready`. | |||
| 250 | 266 | "intercept":["tool_call","turn_start","assistant_message"]} | |
| 251 | 267 | ``` | |
| 252 | 268 | ||
| 253 | - | Recognised event names: `session_start`, `turn_start`, `turn_end`, | |
| 254 | - | `tool_call`, `tool_confirmation_requested`, `assistant_message`. | |
| 269 | + | Recognised event names include `session_start`, `session_opened`, | |
| 270 | + | `session_switched`, `session_forked`, `session_compacted`, `turn_start`, | |
| 271 | + | `tool_call`, `tool_confirmation_requested`, `assistant_message`, and | |
| 272 | + | `turn_end`. | |
| 273 | + | ||
| 274 | + | Session events contain `session` with the current branch ID, parent ID, | |
| 275 | + | path, cwd, and fork point. They also contain `state`, but only for the | |
| 276 | + | receiving extension. Zot never exposes another extension's state. | |
| 255 | 277 | ||
| 256 | 278 | `tool_confirmation_requested` fires only when zot is about to wait for | |
| 257 | 279 | interactive approval. Calls running in yolo mode, calls covered by a | |
| @@ -282,6 +304,7 @@ Reply to an `event_intercept` from the host. All fields default to | |||
| 282 | 304 | | `reason` | refusal text (on block) or pass-through note. | | |
| 283 | 305 | | `modified_args` | for `tool_call`: rewritten JSON args the tool will actually see. Must be a valid JSON object. Ignored when `block` is true. | | |
| 284 | 306 | | `replace_text` | for `assistant_message`: replaces the user-visible text. The model's original output still lives in the transcript. Ignored when `block` is true. | | |
| 307 | + | | `context` | for `turn_start`: bounded hidden context sent only in the upcoming provider request. It is not a user message and is not persisted in the transcript. | | |
| 285 | 308 | ||
| 286 | 309 | Missing the response within 5s is treated as "allow" (i.e. an | |
| 287 | 310 | unresponsive extension never stalls the agent). When multiple | |
| @@ -298,6 +321,9 @@ subsequent interceptor sees the previous one's output. | |||
| 298 | 321 | ||
| 299 | 322 | {"type":"event_intercept_response","id":"...", | |
| 300 | 323 | "replace_text":"[redacted]"} | |
| 324 | + | ||
| 325 | + | {"type":"event_intercept_response","id":"...", | |
| 326 | + | "context":"Current phase: parse files\nRemaining: validate inputs"} | |
| 301 | 327 | ``` | |
| 302 | 328 | ||
| 303 | 329 | #### `command_response` (reply to `command_invoked`) | |
| @@ -369,6 +395,32 @@ Closes a previously-open panel. | |||
| 369 | 395 | {"type":"panel_close","panel_id":"todos-main"} | |
| 370 | 396 | ``` | |
| 371 | 397 | ||
| 398 | + | #### `status` (one-way, persistent) | |
| 399 | + | ||
| 400 | + | Sets or replaces one status item owned by the extension. Sending an empty | |
| 401 | + | `text` clears the item. | |
| 402 | + | ||
| 403 | + | ```json | |
| 404 | + | {"type":"status","key":"progress","level":"success", | |
| 405 | + | "text":"2/4 tasks checked"} | |
| 406 | + | ``` | |
| 407 | + | ||
| 408 | + | #### `widget` (one-way, persistent) | |
| 409 | + | ||
| 410 | + | Sets or replaces a compact widget. Interactive zot currently renders widgets | |
| 411 | + | above the input; other hosts may ignore the placement hint. | |
| 412 | + | ||
| 413 | + | ```json | |
| 414 | + | {"type":"widget","id":"plan","position":"above_input", | |
| 415 | + | "title":"Tasked phases","lines":["Current phase: parse","[ ] validate inputs"]} | |
| 416 | + | ``` | |
| 417 | + | ||
| 418 | + | #### `widget_clear` (one-way) | |
| 419 | + | ||
| 420 | + | ```json | |
| 421 | + | {"type":"widget_clear","id":"plan"} | |
| 422 | + | ``` | |
| 423 | + | ||
| 372 | 424 | #### `notify` (one-way, any time) | |
| 373 | 425 | ||
| 374 | 426 | ```json | |
| @@ -429,7 +481,10 @@ Sent in response to `shutdown`. Extension should exit promptly after. | |||
| 429 | 481 | "zot_version":"0.0.7","provider":"anthropic", | |
| 430 | 482 | "model":"claude-opus-4-7","cwd":"/Users/pat/Developer/zot", | |
| 431 | 483 | "extension_dir":"/Users/pat/Developer/zot/.zot/extensions/todos", | |
| 432 | - | "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"} | |
| 484 | + | "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos", | |
| 485 | + | "session":{"id":"branch-1","parent_id":"root-1", | |
| 486 | + | "path":".../session.jsonl","cwd":"/Users/pat/Developer/zot", | |
| 487 | + | "fork_point":4}} | |
| 433 | 488 | ``` | |
| 434 | 489 | ||
| 435 | 490 | Sent immediately after `hello`. Wait for this frame before sending | |
| @@ -473,6 +528,9 @@ Lifecycle notification for events the extension subscribed to via | |||
| 473 | 528 | `subscribe`. One-way — no response expected. | |
| 474 | 529 | ||
| 475 | 530 | ```json | |
| 531 | + | {"type":"event","event":"session_opened", | |
| 532 | + | "session":{"id":"branch-1","parent_id":"root-1","fork_point":4}, | |
| 533 | + | "state":{"version":1}} | |
| 476 | 534 | {"type":"event","event":"turn_start","step":1} | |
| 477 | 535 | {"type":"event","event":"tool_call", | |
| 478 | 536 | "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}} | |
| @@ -622,6 +680,16 @@ host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`, | |||
| 622 | 680 | `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for | |
| 623 | 681 | `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`. | |
| 624 | 682 | ||
| 683 | + | Session-aware extensions can subscribe to `session_opened`, | |
| 684 | + | `session_switched`, `session_forked`, and `session_compacted`; `Event.Session` | |
| 685 | + | identifies the active branch and `Event.State` contains that extension's | |
| 686 | + | latest persisted snapshot. A tool can return opaque state with | |
| 687 | + | `result.Details = ext.JSONDetails(value)`. | |
| 688 | + | ||
| 689 | + | `TurnStartDecision.Context` supplies hidden bounded context for the next model | |
| 690 | + | request. `SetStatus`, `SetWidget`, and their clear methods update persistent | |
| 691 | + | interactive chrome without entering the transcript. | |
| 692 | + | ||
| 625 | 693 | The SDK has four interceptor hooks, all optional: | |
| 626 | 694 | ||
| 627 | 695 | ```go | |
| @@ -662,8 +730,17 @@ See: | |||
| 662 | 730 | - `examples/extensions/guard/` — event subscriptions + tool-call | |
| 663 | 731 | interception (refuses dangerous bash patterns) | |
| 664 | 732 | - `examples/extensions/todo/` — interactive persistent panel + tool | |
| 733 | + | - `examples/extensions/tasked-phases/` — spec, phased checklist, persistent tool, and `/phases` panel | |
| 665 | 734 | - `examples/extensions/scratchpad/` — source-run TypeScript commands + tool | |
| 666 | 735 | ||
| 736 | + | The `tasked-phases` example bundles its companion skill under | |
| 737 | + | `examples/extensions/tasked-phases/skills/` and declares it in | |
| 738 | + | `extension.json`. Installing the extension therefore installs the workflow | |
| 739 | + | skill automatically. The standalone copy at `examples/skills/tasked-phases/` | |
| 740 | + | remains useful for project-local skill installation. Its state is restored per | |
| 741 | + | session branch when zot session persistence is enabled; the extension keeps a | |
| 742 | + | project-file fallback for hosts that do not persist sessions. | |
| 743 | + | ||
| 667 | 744 | ### Hot reload | |
| 668 | 745 | ||
| 669 | 746 | Type `/reload-ext` in the TUI to tear down every running extension | |
| @@ -713,8 +790,9 @@ Phase 2 (shipped): | |||
| 713 | 790 | - [x] tool result attribution surfaces extension name in details | |
| 714 | 791 | ||
| 715 | 792 | Phase 3 (shipped): | |
| 716 | - | - [x] event subscriptions (`session_start`, `turn_start`, `turn_end`, | |
| 717 | - | `tool_call`, `assistant_message`) | |
| 793 | + | - [x] event subscriptions (`session_start`, `session_opened`, | |
| 794 | + | `session_switched`, `session_forked`, `session_compacted`, `turn_start`, | |
| 795 | + | `turn_end`, `tool_call`, `assistant_message`) | |
| 718 | 796 | - [x] tool-call interception (block before execution) | |
| 719 | 797 | ||
| 720 | 798 | Phase 4 (shipped): | |
| @@ -724,6 +802,16 @@ Phase 4 (shipped): | |||
| 724 | 802 | - [x] rewrite user-visible assistant text via `replace_text` | |
| 725 | 803 | - [x] `/reload-ext` slash command (hot-reload without restarting zot) | |
| 726 | 804 | ||
| 805 | + | Phase 5 (shipped): | |
| 806 | + | - [x] session/branch identity in `hello_ack` and lifecycle events | |
| 807 | + | - [x] session open/switch/fork/compaction notifications | |
| 808 | + | - [x] opaque extension state persisted with session branches | |
| 809 | + | ||
| 810 | + | Phase 6 (shipped): | |
| 811 | + | - [x] hidden bounded context returned from `turn_start` interception | |
| 812 | + | - [x] persistent extension status and widget frames | |
| 813 | + | - [x] manifest-declared bundled skill discovery with safe path validation | |
| 814 | + | ||
| 727 | 815 | Future (no firm timeline): | |
| 728 | 816 | - [ ] TypeScript and Python SDK packages (currently the wire format | |
| 729 | 817 | is stable enough to hand-roll, see the Python quick-start) | |
M docs/skills.md +13 -1
| @@ -66,6 +66,17 @@ skill ecosystem works in zot unchanged. Drop your existing | |||
| 66 | 66 | `.claude/skills/` or `.agents/skills/` directories into a project and | |
| 67 | 67 | zot will pick them up. | |
| 68 | 68 | ||
| 69 | + | Enabled extensions may bundle skills by declaring relative directories in | |
| 70 | + | `extension.json`, for example `"skills": ["skills"]`, with files at | |
| 71 | + | `skills/<name>/SKILL.md`. A declared directory may also contain a single | |
| 72 | + | `SKILL.md` directly; its directory name supplies the fallback skill name and | |
| 73 | + | child directories are not scanned. Bundled skills are copied by `zot ext | |
| 74 | + | install` and loaded automatically. User/project skills take precedence over | |
| 75 | + | bundled skills; bundled skills take precedence over embedded built-ins. When | |
| 76 | + | two extensions provide the same skill name, the first declaration wins. The | |
| 77 | + | host rejects manifest paths that escape the extension directory, including | |
| 78 | + | symlinked skill files that resolve outside it. | |
| 79 | + | ||
| 69 | 80 | When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to | |
| 70 | 81 | `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/` | |
| 71 | 82 | on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows. | |
| @@ -113,9 +124,10 @@ are omitted from the model's startup context. | |||
| 113 | 124 | ||
| 114 | 125 | ## Examples | |
| 115 | 126 | ||
| 116 | - | See `examples/skills/` for two starter skills: | |
| 127 | + | See `examples/skills/` for starter skills: | |
| 117 | 128 | ||
| 118 | 129 | - `code-review/` — self-review pass on a recent diff | |
| 130 | + | - `tasked-phases/` — spec-to-phase checklist workflow for the `tasked-phases` extension and its `tasked_phases` tool | |
| 119 | 131 | - `test-fix/` — diagnose + minimally fix a failing test | |
| 120 | 132 | ||
| 121 | 133 | ## Comparison to other discovery layouts | |
A examples/extensions/tasked-phases/README.md +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | # tasked-phases | |
| 2 | + | ||
| 3 | + | A zot extension for keeping a spec, phased plan, and checklist in one place. | |
| 4 | + | It provides: | |
| 5 | + | ||
| 6 | + | - the `tasked_phases` tool for persistent spec/phase/task state; | |
| 7 | + | - `/phases` for a full interactive panel; | |
| 8 | + | - compact mutation results and full `get_status` output; | |
| 9 | + | - project-scoped JSON persistence under the extension data directory. | |
| 10 | + | ||
| 11 | + | The tool actions are: | |
| 12 | + | ||
| 13 | + | ```text | |
| 14 | + | get_status set_spec replace_plan | |
| 15 | + | add_phase update_phase remove_phase | |
| 16 | + | add_task update_task remove_task | |
| 17 | + | set_current_phase set_task_checked set_phase_checked | |
| 18 | + | clear | |
| 19 | + | ``` | |
| 20 | + | ||
| 21 | + | ## Build and run | |
| 22 | + | ||
| 23 | + | From the zot repository: | |
| 24 | + | ||
| 25 | + | ```bash | |
| 26 | + | cd examples/extensions/tasked-phases | |
| 27 | + | go build -o tasked-phases . | |
| 28 | + | ||
| 29 | + | # One-run development load, launched from the project you want to track: | |
| 30 | + | cd /path/to/project | |
| 31 | + | zot --no-ext --ext /path/to/zot/examples/extensions/tasked-phases | |
| 32 | + | ||
| 33 | + | # Or install it globally (run this from the extension directory): | |
| 34 | + | cd /path/to/zot/examples/extensions/tasked-phases | |
| 35 | + | zot ext install . | |
| 36 | + | ``` | |
| 37 | + | ||
| 38 | + | Then use `/phases` or ask the model to create a spec and phased checklist with | |
| 39 | + | `tasked_phases`. The extension keys its durable state by the host-reported | |
| 40 | + | project CWD. | |
| 41 | + | ||
| 42 | + | The extension bundles its workflow skill under `skills/tasked-phases/`, so | |
| 43 | + | `zot ext install` makes the guidance available automatically. The standalone | |
| 44 | + | copy at `examples/skills/tasked-phases/SKILL.md` remains useful when you want | |
| 45 | + | the same workflow without installing the extension. | |
| 46 | + | ||
| 47 | + | ## Persistence and turn focus | |
| 48 | + | ||
| 49 | + | The extension stores one restrictive state file per project CWD as a fallback | |
| 50 | + | for hosts that do not persist sessions. When zot session persistence is | |
| 51 | + | available, the plan is also stored as extension-owned state on each session | |
| 52 | + | branch and restored when sessions are opened, switched, or forked. | |
| 53 | + | ||
| 54 | + | Before each model turn, the extension supplies only the active phase, its goal, | |
| 55 | + | and a bounded set of incomplete tasks as hidden context. Completed phases and | |
| 56 | + | future phases are not repeated in every provider request; use `get_status` or | |
| 57 | + | `/phases` when the full checklist is needed. | |
A examples/extensions/tasked-phases/extension.json +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "tasked-phases", | |
| 3 | + | "version": "1.0.0", | |
| 4 | + | "exec": "./tasked-phases", | |
| 5 | + | "args": [], | |
| 6 | + | "language": "go", | |
| 7 | + | "description": "persistent phased spec and checklist tracking", | |
| 8 | + | "skills": ["skills/tasked-phases"], | |
| 9 | + | "enabled": true | |
| 10 | + | } | |
A examples/extensions/tasked-phases/main.go +244 -0
| @@ -0,0 +1,244 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | extensionName = "tasked-phases" | |
| 15 | + | extensionVersion = "1.0.0" | |
| 16 | + | toolName = "tasked_phases" | |
| 17 | + | panelID = "tasked-phases" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const toolSchema = `{ | |
| 21 | + | "type": "object", | |
| 22 | + | "additionalProperties": false, | |
| 23 | + | "properties": { | |
| 24 | + | "action": { | |
| 25 | + | "type": "string", | |
| 26 | + | "enum": ["get_status", "set_spec", "replace_plan", "add_phase", "update_phase", "remove_phase", "add_task", "update_task", "remove_task", "set_current_phase", "set_task_checked", "set_phase_checked", "clear"], | |
| 27 | + | "description": "State operation to perform. Closed plans must be restarted with clear, or set_spec immediately followed by replace_plan." | |
| 28 | + | }, | |
| 29 | + | "spec": {"type": "string", "description": "Spec text used by set_spec"}, | |
| 30 | + | "phaseId": {"type": "string", "description": "Target phase id, raw id without the brackets shown in summaries"}, | |
| 31 | + | "phaseTitle": {"type": "string", "description": "Phase title for add_phase or update_phase"}, | |
| 32 | + | "phaseGoal": {"type": "string", "description": "Phase goal for add_phase or update_phase"}, | |
| 33 | + | "taskId": {"type": "string", "description": "Target task id"}, | |
| 34 | + | "taskText": {"type": "string", "description": "Task text for add_task or update_task"}, | |
| 35 | + | "checked": {"type": "boolean", "description": "Checked state for set_task_checked or set_phase_checked"}, | |
| 36 | + | "phases": { | |
| 37 | + | "type": "array", | |
| 38 | + | "description": "Full plan replacement used by replace_plan", | |
| 39 | + | "items": { | |
| 40 | + | "type": "object", | |
| 41 | + | "additionalProperties": false, | |
| 42 | + | "properties": { | |
| 43 | + | "id": {"type": "string", "description": "Optional phase id. If omitted, one is generated."}, | |
| 44 | + | "title": {"type": "string", "description": "Phase title"}, | |
| 45 | + | "goal": {"type": "string", "description": "Optional short goal for the phase"}, | |
| 46 | + | "tasks": { | |
| 47 | + | "type": "array", | |
| 48 | + | "description": "Checklist tasks for the phase", | |
| 49 | + | "items": { | |
| 50 | + | "type": "object", | |
| 51 | + | "additionalProperties": false, | |
| 52 | + | "properties": { | |
| 53 | + | "id": {"type": "string", "description": "Optional task id. If omitted, one is generated."}, | |
| 54 | + | "text": {"type": "string", "description": "Checklist task text"}, | |
| 55 | + | "checked": {"type": "boolean", "description": "Whether the task starts checked"} | |
| 56 | + | }, | |
| 57 | + | "required": ["text"] | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | }, | |
| 61 | + | "required": ["title"] | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | }, | |
| 65 | + | "required": ["action"] | |
| 66 | + | }` | |
| 67 | + | ||
| 68 | + | type app struct { | |
| 69 | + | ext *ext.Extension | |
| 70 | + | store *stateStore | |
| 71 | + | ||
| 72 | + | panelMu sync.Mutex | |
| 73 | + | panelOpen bool | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func newApp(e *ext.Extension) *app { | |
| 77 | + | return &app{ext: e, store: newStateStore()} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func main() { | |
| 81 | + | e := ext.New(extensionName, extensionVersion) | |
| 82 | + | a := newApp(e) | |
| 83 | + | ||
| 84 | + | e.OnHello(func(host ext.HostInfo) { | |
| 85 | + | dataDir := host.DataDir | |
| 86 | + | if dataDir == "" { | |
| 87 | + | dataDir = host.ExtensionDir | |
| 88 | + | } | |
| 89 | + | if err := a.store.load(dataDir, host.CWD); err != nil { | |
| 90 | + | e.Logf("load project state: %v", err) | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | if state, err := a.store.snapshot(); err == nil { | |
| 94 | + | a.publishChrome(state) | |
| 95 | + | } | |
| 96 | + | }) | |
| 97 | + | restoreSession := func(event ext.Event) { | |
| 98 | + | if err := a.store.activateSession(event.State); err != nil { | |
| 99 | + | e.Logf("restore session state: %v", err) | |
| 100 | + | a.publishUnavailableChrome() | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | if state, err := a.store.snapshot(); err == nil { | |
| 104 | + | a.publishChrome(state) | |
| 105 | + | a.renderPanelIfOpen(state) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | e.On("session_opened", restoreSession) | |
| 109 | + | e.On("session_switched", restoreSession) | |
| 110 | + | e.On("session_forked", restoreSession) | |
| 111 | + | e.InterceptTurnStart(func(_ int) ext.TurnStartDecision { | |
| 112 | + | state, err := a.store.snapshot() | |
| 113 | + | if err != nil { | |
| 114 | + | e.Logf("load turn focus: %v", err) | |
| 115 | + | return ext.TurnStartDecision{} | |
| 116 | + | } | |
| 117 | + | return ext.TurnStartDecision{Context: buildTurnContext(state)} | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | e.Tool(toolName, | |
| 121 | + | "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Use it for spec-driven planning and progress tracking.", | |
| 122 | + | json.RawMessage(toolSchema), a.handleTool) | |
| 123 | + | e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand) | |
| 124 | + | e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose) | |
| 125 | + | ||
| 126 | + | if err := e.Run(); err != nil { | |
| 127 | + | fmt.Fprintln(os.Stderr, err) | |
| 128 | + | os.Exit(1) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (a *app) handleCommand(_ string) ext.Response { | |
| 133 | + | state, err := a.store.snapshot() | |
| 134 | + | if err != nil { | |
| 135 | + | return ext.Errorf("load tasked phases: %v", err) | |
| 136 | + | } | |
| 137 | + | a.publishChrome(state) | |
| 138 | + | a.panelMu.Lock() | |
| 139 | + | a.panelOpen = true | |
| 140 | + | a.panelMu.Unlock() | |
| 141 | + | return ext.OpenPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (a *app) handlePanelKey(key, _ string) { | |
| 145 | + | if key == "esc" { | |
| 146 | + | a.ext.ClosePanel(panelID) | |
| 147 | + | a.setPanelOpen(false) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func (a *app) handlePanelClose() { a.setPanelOpen(false) } | |
| 152 | + | ||
| 153 | + | func (a *app) setPanelOpen(open bool) { | |
| 154 | + | a.panelMu.Lock() | |
| 155 | + | a.panelOpen = open | |
| 156 | + | a.panelMu.Unlock() | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (a *app) renderPanelIfOpen(state PlanState) { | |
| 160 | + | a.panelMu.Lock() | |
| 161 | + | open := a.panelOpen | |
| 162 | + | a.panelMu.Unlock() | |
| 163 | + | if open { | |
| 164 | + | a.ext.RenderPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel") | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (a *app) handleTool(args json.RawMessage) ext.ToolResult { | |
| 169 | + | var params toolArgs | |
| 170 | + | if err := json.Unmarshal(args, ¶ms); err != nil { | |
| 171 | + | return ext.TextErrorResult("invalid args: " + err.Error()) | |
| 172 | + | } | |
| 173 | + | if !isKnownAction(params.Action) { | |
| 174 | + | return ext.TextErrorResult("unsupported action: " + params.Action) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | if params.Action == actionGetStatus { | |
| 178 | + | state, err := a.store.snapshot() | |
| 179 | + | if err != nil { | |
| 180 | + | return toolResult(params.Action, PlanState{}, "", err) | |
| 181 | + | } | |
| 182 | + | a.publishChrome(state) | |
| 183 | + | a.renderPanelIfOpen(state) | |
| 184 | + | return toolResult(params.Action, state, "Current phased plan status", nil) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | state, headline, err := a.store.transact(func(current PlanState) (PlanState, string, error) { | |
| 188 | + | return applyAction(current, params) | |
| 189 | + | }) | |
| 190 | + | // A zero state means transact could not obtain a loaded snapshot. Do not | |
| 191 | + | // replace persistent chrome with an invented empty plan. For action and | |
| 192 | + | // persistence errors, transact returns the unchanged usable state, which | |
| 193 | + | // should remain visible to help the user recover. | |
| 194 | + | if err == nil || state.Version != 0 { | |
| 195 | + | a.publishChrome(state) | |
| 196 | + | } | |
| 197 | + | if err == nil { | |
| 198 | + | a.renderPanelIfOpen(state) | |
| 199 | + | } | |
| 200 | + | return toolResult(params.Action, state, headline, err) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (a *app) publishUnavailableChrome() { | |
| 204 | + | a.ext.SetStatus("progress", "plan unavailable") | |
| 205 | + | a.ext.SetWidget("plan", "above_input", "Tasked phases", []string{"Plan state could not be restored for this session."}) | |
| 206 | + | a.panelMu.Lock() | |
| 207 | + | open := a.panelOpen | |
| 208 | + | a.panelMu.Unlock() | |
| 209 | + | if open { | |
| 210 | + | a.ext.RenderPanel(panelID, "Tasked phases", []string{"Plan state could not be restored for this session."}, "Escape closes this panel") | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func (a *app) publishChrome(state PlanState) { | |
| 215 | + | if isPlanClosed(state) { | |
| 216 | + | a.ext.SetStatus("progress", "closed: "+state.ClosedSummary) | |
| 217 | + | } else if done, total := getPlanProgress(state); total > 0 { | |
| 218 | + | a.ext.SetStatus("progress", fmt.Sprintf("%d/%d tasks checked", done, total)) | |
| 219 | + | } else { | |
| 220 | + | a.ext.SetStatus("progress", "no active plan") | |
| 221 | + | } | |
| 222 | + | a.ext.SetWidget("plan", "above_input", "Tasked phases", strings.Split(buildCompactSummary(state), "\n")) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func toolResult(action string, state PlanState, headline string, err error) ext.ToolResult { | |
| 226 | + | text := buildToolResultText(action, state, headline, err) | |
| 227 | + | var result ext.ToolResult | |
| 228 | + | if err != nil { | |
| 229 | + | result = ext.TextErrorResult(text) | |
| 230 | + | } else { | |
| 231 | + | result = ext.TextResult(text) | |
| 232 | + | } | |
| 233 | + | result.Details = ext.JSONDetails(state) | |
| 234 | + | return result | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func isKnownAction(action string) bool { | |
| 238 | + | for _, known := range toolActions { | |
| 239 | + | if action == known { | |
| 240 | + | return true | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | return false | |
| 244 | + | } | |
A examples/extensions/tasked-phases/manifest_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "slices" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestManifestMatchesExtensionConstants(t *testing.T) { | |
| 12 | + | data, err := os.ReadFile("extension.json") | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | var manifest struct { | |
| 17 | + | Name string `json:"name"` | |
| 18 | + | Version string `json:"version"` | |
| 19 | + | Skills []string `json:"skills"` | |
| 20 | + | } | |
| 21 | + | if err := json.Unmarshal(data, &manifest); err != nil { | |
| 22 | + | t.Fatalf("extension.json is not valid JSON: %v", err) | |
| 23 | + | } | |
| 24 | + | if manifest.Name != extensionName || manifest.Version != extensionVersion { | |
| 25 | + | t.Fatalf("manifest = %s/%s, constants = %s/%s", manifest.Name, manifest.Version, extensionName, extensionVersion) | |
| 26 | + | } | |
| 27 | + | for _, skill := range manifest.Skills { | |
| 28 | + | if _, err := os.Stat(filepath.Join(skill, "SKILL.md")); err != nil { | |
| 29 | + | t.Errorf("declared skill %q has no SKILL.md: %v", skill, err) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestToolSchemaEnumMatchesToolActions(t *testing.T) { | |
| 35 | + | var schema struct { | |
| 36 | + | Properties struct { | |
| 37 | + | Action struct { | |
| 38 | + | Enum []string `json:"enum"` | |
| 39 | + | } `json:"action"` | |
| 40 | + | } `json:"properties"` | |
| 41 | + | } | |
| 42 | + | if err := json.Unmarshal([]byte(toolSchema), &schema); err != nil { | |
| 43 | + | t.Fatalf("tool schema is not valid JSON: %v", err) | |
| 44 | + | } | |
| 45 | + | if !slices.Equal(schema.Properties.Action.Enum, toolActions) { | |
| 46 | + | t.Fatalf("schema enum = %v, toolActions = %v", schema.Properties.Action.Enum, toolActions) | |
| 47 | + | } | |
| 48 | + | } | |
A examples/extensions/tasked-phases/skill_sync_test.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestBundledSkillMatchesStandaloneCopy(t *testing.T) { | |
| 11 | + | extensionSkill := filepath.Join("skills", "tasked-phases", "SKILL.md") | |
| 12 | + | standaloneSkill := filepath.Join("..", "..", "skills", "tasked-phases", "SKILL.md") | |
| 13 | + | extension, err := os.ReadFile(extensionSkill) | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | standalone, err := os.ReadFile(standaloneSkill) | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | if !bytes.Equal(extension, standalone) { | |
| 22 | + | t.Fatal("bundled and standalone tasked-phases skills have diverged") | |
| 23 | + | } | |
| 24 | + | } | |
A examples/extensions/tasked-phases/skills/tasked-phases/SKILL.md +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | --- | |
| 2 | + | name: tasked-phases | |
| 3 | + | description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | # Tasked Phases | |
| 7 | + | ||
| 8 | + | Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress. | |
| 9 | + | ||
| 10 | + | ## Workflow | |
| 11 | + | ||
| 12 | + | 1. Clarify missing requirements when the spec is ambiguous. | |
| 13 | + | 2. Save the accepted spec with `tasked_phases` using `set_spec`. | |
| 14 | + | 3. Create or materially revise the plan with `replace_plan`. | |
| 15 | + | 4. Keep phases small and tasks concrete enough to check off. | |
| 16 | + | 5. Set the focus with `set_current_phase` and pass the target `phaseId`. | |
| 17 | + | 6. Call `set_task_checked` immediately after each checklist task is completed. | |
| 18 | + | 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task. | |
| 19 | + | 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases. | |
| 20 | + | ||
| 21 | + | ## Planning rules | |
| 22 | + | ||
| 23 | + | - Prefer 3–7 phases unless the task is tiny. | |
| 24 | + | - Each task should describe an observable outcome. | |
| 25 | + | - Avoid vague tasks such as “work on this” or “finish implementation”. | |
| 26 | + | - Keep the stored spec and plan concise and operational. | |
| 27 | + | ||
| 28 | + | ## Tool guidance | |
| 29 | + | ||
| 30 | + | - Use `get_status` before relying on remembered state or revising a long-running plan. | |
| 31 | + | - Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes. | |
| 32 | + | - Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output. | |
| 33 | + | - Do not rely on prose alone for completion state; update the tool after each completed task. | |
A examples/extensions/tasked-phases/state.go +1182 -0
| @@ -0,0 +1,1182 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "regexp" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | "time" | |
| 16 | + | "unicode" | |
| 17 | + | "unicode/utf8" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | actionGetStatus = "get_status" | |
| 22 | + | actionSetSpec = "set_spec" | |
| 23 | + | actionReplacePlan = "replace_plan" | |
| 24 | + | actionAddPhase = "add_phase" | |
| 25 | + | actionUpdatePhase = "update_phase" | |
| 26 | + | actionRemovePhase = "remove_phase" | |
| 27 | + | actionAddTask = "add_task" | |
| 28 | + | actionUpdateTask = "update_task" | |
| 29 | + | actionRemoveTask = "remove_task" | |
| 30 | + | actionSetCurrentPhase = "set_current_phase" | |
| 31 | + | actionSetTaskChecked = "set_task_checked" | |
| 32 | + | actionSetPhaseChecked = "set_phase_checked" | |
| 33 | + | actionClear = "clear" | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | var ( | |
| 37 | + | phaseIDPattern = regexp.MustCompile(`^phase-(\d+)$`) | |
| 38 | + | taskIDPattern = regexp.MustCompile(`^task-(\d+)$`) | |
| 39 | + | bracketedIDPattern = regexp.MustCompile(`\[([^\]]+)\]`) | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | var toolActions = []string{ | |
| 43 | + | actionGetStatus, | |
| 44 | + | actionSetSpec, | |
| 45 | + | actionReplacePlan, | |
| 46 | + | actionAddPhase, | |
| 47 | + | actionUpdatePhase, | |
| 48 | + | actionRemovePhase, | |
| 49 | + | actionAddTask, | |
| 50 | + | actionUpdateTask, | |
| 51 | + | actionRemoveTask, | |
| 52 | + | actionSetCurrentPhase, | |
| 53 | + | actionSetTaskChecked, | |
| 54 | + | actionSetPhaseChecked, | |
| 55 | + | actionClear, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var closedPlanBlockedActions = map[string]bool{ | |
| 59 | + | actionReplacePlan: true, | |
| 60 | + | actionAddPhase: true, | |
| 61 | + | actionUpdatePhase: true, | |
| 62 | + | actionRemovePhase: true, | |
| 63 | + | actionAddTask: true, | |
| 64 | + | actionUpdateTask: true, | |
| 65 | + | actionRemoveTask: true, | |
| 66 | + | actionSetCurrentPhase: true, | |
| 67 | + | actionSetTaskChecked: true, | |
| 68 | + | actionSetPhaseChecked: true, | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | type PhaseTask struct { | |
| 72 | + | ID string `json:"id"` | |
| 73 | + | Text string `json:"text"` | |
| 74 | + | Checked bool `json:"checked"` | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | type Phase struct { | |
| 78 | + | ID string `json:"id"` | |
| 79 | + | Title string `json:"title"` | |
| 80 | + | Goal string `json:"goal,omitempty"` | |
| 81 | + | Tasks []PhaseTask `json:"tasks"` | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | type PlanState struct { | |
| 85 | + | Version int `json:"version"` | |
| 86 | + | Spec string `json:"spec,omitempty"` | |
| 87 | + | Phases []Phase `json:"phases"` | |
| 88 | + | CurrentPhaseID string `json:"currentPhaseId,omitempty"` | |
| 89 | + | ClosedAt *int64 `json:"closedAt,omitempty"` | |
| 90 | + | ClosedSummary string `json:"closedSummary,omitempty"` | |
| 91 | + | NextPhaseNumber int `json:"nextPhaseNumber"` | |
| 92 | + | NextTaskNumber int `json:"nextTaskNumber"` | |
| 93 | + | UpdatedAt int64 `json:"updatedAt"` | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | type TaskInput struct { | |
| 97 | + | ID *string `json:"id"` | |
| 98 | + | Text string `json:"text"` | |
| 99 | + | Checked *bool `json:"checked"` | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | type PhaseInput struct { | |
| 103 | + | ID *string `json:"id"` | |
| 104 | + | Title string `json:"title"` | |
| 105 | + | Goal *string `json:"goal"` | |
| 106 | + | Tasks *[]TaskInput `json:"tasks"` | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | type toolArgs struct { | |
| 110 | + | Action string `json:"action"` | |
| 111 | + | Spec *string `json:"spec"` | |
| 112 | + | PhaseID string `json:"phaseId"` | |
| 113 | + | PhaseTitle *string `json:"phaseTitle"` | |
| 114 | + | PhaseGoal *string `json:"phaseGoal"` | |
| 115 | + | TaskID string `json:"taskId"` | |
| 116 | + | TaskText *string `json:"taskText"` | |
| 117 | + | Checked *bool `json:"checked"` | |
| 118 | + | Phases *[]PhaseInput `json:"phases"` | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func nowMillis() int64 { return time.Now().UnixMilli() } | |
| 122 | + | ||
| 123 | + | func createEmptyState() PlanState { | |
| 124 | + | return PlanState{ | |
| 125 | + | Version: 1, | |
| 126 | + | Phases: []Phase{}, | |
| 127 | + | NextPhaseNumber: 1, | |
| 128 | + | NextTaskNumber: 1, | |
| 129 | + | UpdatedAt: nowMillis(), | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func cloneState(state PlanState) PlanState { | |
| 134 | + | cloned := state | |
| 135 | + | cloned.Phases = make([]Phase, len(state.Phases)) | |
| 136 | + | for i, phase := range state.Phases { | |
| 137 | + | cloned.Phases[i] = phase | |
| 138 | + | cloned.Phases[i].Tasks = append([]PhaseTask(nil), phase.Tasks...) | |
| 139 | + | } | |
| 140 | + | return cloned | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func normalizeOptionalText(value string) string { | |
| 144 | + | return strings.TrimSpace(value) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // sanitizeDisplayText removes terminal escape sequences and non-printing | |
| 148 | + | // control characters at the rendering boundary. Persisted state is never | |
| 149 | + | // modified, so the original extension-supplied text remains recoverable. | |
| 150 | + | func sanitizeDisplayText(text string) string { | |
| 151 | + | var out strings.Builder | |
| 152 | + | for index := 0; index < len(text); { | |
| 153 | + | if text[index] == 0x1b { | |
| 154 | + | index++ | |
| 155 | + | if index >= len(text) { | |
| 156 | + | break | |
| 157 | + | } | |
| 158 | + | switch text[index] { | |
| 159 | + | case '[': // CSI sequence. | |
| 160 | + | index++ | |
| 161 | + | for index < len(text) { | |
| 162 | + | final := text[index] | |
| 163 | + | index++ | |
| 164 | + | if final >= 0x40 && final <= 0x7e { | |
| 165 | + | break | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | case ']': // OSC sequence, terminated by BEL or ST. | |
| 169 | + | index++ | |
| 170 | + | for index < len(text) { | |
| 171 | + | if text[index] == 0x07 { | |
| 172 | + | index++ | |
| 173 | + | break | |
| 174 | + | } | |
| 175 | + | if text[index] == 0x1b && index+1 < len(text) && text[index+1] == '\\' { | |
| 176 | + | index += 2 | |
| 177 | + | break | |
| 178 | + | } | |
| 179 | + | index++ | |
| 180 | + | } | |
| 181 | + | default: | |
| 182 | + | index++ | |
| 183 | + | } | |
| 184 | + | continue | |
| 185 | + | } | |
| 186 | + | r, size := utf8.DecodeRuneInString(text[index:]) | |
| 187 | + | index += size | |
| 188 | + | if r != '\n' && (unicode.IsControl(r) || r == 0x7f) { | |
| 189 | + | continue | |
| 190 | + | } | |
| 191 | + | out.WriteRune(r) | |
| 192 | + | } | |
| 193 | + | return out.String() | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func singleLine(text string) string { | |
| 197 | + | return strings.Join(strings.Fields(text), " ") | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func displaySingleLine(text string) string { | |
| 201 | + | return singleLine(sanitizeDisplayText(text)) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func truncatePlain(text string, maxLength int) string { | |
| 205 | + | if maxLength < 1 { | |
| 206 | + | return "" | |
| 207 | + | } | |
| 208 | + | runes := []rune(text) | |
| 209 | + | if len(runes) <= maxLength { | |
| 210 | + | return text | |
| 211 | + | } | |
| 212 | + | return strings.TrimRight(string(runes[:maxLength-1]), " \t\r\n") + "…" | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func getPhaseProgress(phase Phase) (done, total int) { | |
| 216 | + | total = len(phase.Tasks) | |
| 217 | + | for _, task := range phase.Tasks { | |
| 218 | + | if task.Checked { | |
| 219 | + | done++ | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | return done, total | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func getPlanProgress(state PlanState) (done, total int) { | |
| 226 | + | for _, phase := range state.Phases { | |
| 227 | + | phaseDone, phaseTotal := getPhaseProgress(phase) | |
| 228 | + | done += phaseDone | |
| 229 | + | total += phaseTotal | |
| 230 | + | } | |
| 231 | + | return done, total | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func isPhaseDone(phase Phase) bool { | |
| 235 | + | done, total := getPhaseProgress(phase) | |
| 236 | + | return total > 0 && done == total | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func hasStoredPlan(state PlanState) bool { | |
| 240 | + | return state.Spec != "" || len(state.Phases) > 0 | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func isPlanComplete(state PlanState) bool { | |
| 244 | + | if len(state.Phases) == 0 { | |
| 245 | + | return false | |
| 246 | + | } | |
| 247 | + | for _, phase := range state.Phases { | |
| 248 | + | if !isPhaseDone(phase) { | |
| 249 | + | return false | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | return true | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func isPlanClosed(state PlanState) bool { return state.ClosedAt != nil } | |
| 256 | + | ||
| 257 | + | func closePlanIfComplete(state *PlanState) { | |
| 258 | + | if !isPlanComplete(*state) { | |
| 259 | + | return | |
| 260 | + | } | |
| 261 | + | done, total := getPlanProgress(*state) | |
| 262 | + | if state.ClosedAt == nil { | |
| 263 | + | closedAt := nowMillis() | |
| 264 | + | state.ClosedAt = &closedAt | |
| 265 | + | } | |
| 266 | + | state.ClosedSummary = fmt.Sprintf("Completed %d/%d tasks across %d phase(s).", done, total, len(state.Phases)) | |
| 267 | + | state.CurrentPhaseID = "" | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func getCurrentPhase(state PlanState) *Phase { | |
| 271 | + | if state.CurrentPhaseID == "" { | |
| 272 | + | return nil | |
| 273 | + | } | |
| 274 | + | for i := range state.Phases { | |
| 275 | + | if state.Phases[i].ID == state.CurrentPhaseID { | |
| 276 | + | return &state.Phases[i] | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | return nil | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func getActivePhase(state PlanState) *Phase { | |
| 283 | + | if current := getCurrentPhase(state); current != nil { | |
| 284 | + | return current | |
| 285 | + | } | |
| 286 | + | for i := range state.Phases { | |
| 287 | + | if !isPhaseDone(state.Phases[i]) { | |
| 288 | + | return &state.Phases[i] | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | return nil | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func getSuggestedCurrentPhaseID(state PlanState) string { | |
| 295 | + | if state.CurrentPhaseID != "" { | |
| 296 | + | for _, phase := range state.Phases { | |
| 297 | + | if phase.ID == state.CurrentPhaseID { | |
| 298 | + | return state.CurrentPhaseID | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | for _, phase := range state.Phases { | |
| 303 | + | if !isPhaseDone(phase) { | |
| 304 | + | return phase.ID | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | if len(state.Phases) > 0 { | |
| 308 | + | return state.Phases[len(state.Phases)-1].ID | |
| 309 | + | } | |
| 310 | + | return "" | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func extractHighestIDNumber(prefix string, ids []string) int { | |
| 314 | + | highest := 0 | |
| 315 | + | pattern := taskIDPattern | |
| 316 | + | if prefix == "phase" { | |
| 317 | + | pattern = phaseIDPattern | |
| 318 | + | } | |
| 319 | + | for _, id := range ids { | |
| 320 | + | match := pattern.FindStringSubmatch(id) | |
| 321 | + | if len(match) != 2 { | |
| 322 | + | continue | |
| 323 | + | } | |
| 324 | + | number, err := strconv.Atoi(match[1]) | |
| 325 | + | if err == nil && number > highest { | |
| 326 | + | highest = number | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | return highest | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func ensureState(state PlanState) PlanState { | |
| 333 | + | normalized := cloneState(state) | |
| 334 | + | normalized.Version = 1 | |
| 335 | + | normalized.Spec = normalizeOptionalText(normalized.Spec) | |
| 336 | + | if normalized.Phases == nil { | |
| 337 | + | normalized.Phases = []Phase{} | |
| 338 | + | } | |
| 339 | + | for i := range normalized.Phases { | |
| 340 | + | normalized.Phases[i].Goal = optionalNormalized(normalized.Phases[i].Goal) | |
| 341 | + | if normalized.Phases[i].Tasks == nil { | |
| 342 | + | normalized.Phases[i].Tasks = []PhaseTask{} | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | normalized.ClosedSummary = optionalNormalized(normalized.ClosedSummary) | |
| 346 | + | if isPlanComplete(normalized) { | |
| 347 | + | closePlanIfComplete(&normalized) | |
| 348 | + | } else { | |
| 349 | + | normalized.ClosedAt = nil | |
| 350 | + | normalized.ClosedSummary = "" | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | highestPhase := make([]string, 0, len(normalized.Phases)) | |
| 354 | + | highestTask := make([]string, 0) | |
| 355 | + | for _, phase := range normalized.Phases { | |
| 356 | + | highestPhase = append(highestPhase, phase.ID) | |
| 357 | + | for _, task := range phase.Tasks { | |
| 358 | + | highestTask = append(highestTask, task.ID) | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | if normalized.NextPhaseNumber < 1 { | |
| 362 | + | normalized.NextPhaseNumber = 1 | |
| 363 | + | } | |
| 364 | + | if normalized.NextTaskNumber < 1 { | |
| 365 | + | normalized.NextTaskNumber = 1 | |
| 366 | + | } | |
| 367 | + | normalized.NextPhaseNumber = maxInt(normalized.NextPhaseNumber, extractHighestIDNumber("phase", highestPhase)+1) | |
| 368 | + | normalized.NextTaskNumber = maxInt(normalized.NextTaskNumber, extractHighestIDNumber("task", highestTask)+1) | |
| 369 | + | if isPlanClosed(normalized) { | |
| 370 | + | normalized.CurrentPhaseID = "" | |
| 371 | + | } else { | |
| 372 | + | normalized.CurrentPhaseID = getSuggestedCurrentPhaseID(normalized) | |
| 373 | + | } | |
| 374 | + | normalized.UpdatedAt = nowMillis() | |
| 375 | + | return normalized | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func optionalNormalized(value string) string { | |
| 379 | + | return normalizeOptionalText(value) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | func maxInt(a, b int) int { | |
| 383 | + | if a > b { | |
| 384 | + | return a | |
| 385 | + | } | |
| 386 | + | return b | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func nextPhaseID(state *PlanState) string { | |
| 390 | + | id := fmt.Sprintf("phase-%d", state.NextPhaseNumber) | |
| 391 | + | state.NextPhaseNumber++ | |
| 392 | + | return id | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func nextTaskID(state *PlanState) string { | |
| 396 | + | id := fmt.Sprintf("task-%d", state.NextTaskNumber) | |
| 397 | + | state.NextTaskNumber++ | |
| 398 | + | return id | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func extractBracketedPhaseID(value string) string { | |
| 402 | + | matches := bracketedIDPattern.FindAllStringSubmatch(value, -1) | |
| 403 | + | candidate := "" | |
| 404 | + | for _, match := range matches { | |
| 405 | + | if len(match) == 2 { | |
| 406 | + | if normalized := normalizeOptionalText(match[1]); normalized != "" { | |
| 407 | + | candidate = normalized | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | return candidate | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func findUniquePhaseByTitle(state PlanState, phaseTitle string) *Phase { | |
| 415 | + | normalizedTitle := singleLine(phaseTitle) | |
| 416 | + | var match *Phase | |
| 417 | + | matches := 0 | |
| 418 | + | for i := range state.Phases { | |
| 419 | + | if singleLine(state.Phases[i].Title) == normalizedTitle { | |
| 420 | + | match = &state.Phases[i] | |
| 421 | + | matches++ | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | if matches == 1 { | |
| 425 | + | return match | |
| 426 | + | } | |
| 427 | + | return nil | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | // findPhase returns a pointer into state.Phases, aliasing the caller's slice | |
| 431 | + | // backing array so mutations are applied to the caller's state copy. | |
| 432 | + | func findPhase(state PlanState, phaseID string) *Phase { | |
| 433 | + | candidate := normalizeOptionalText(phaseID) | |
| 434 | + | if candidate == "" { | |
| 435 | + | return nil | |
| 436 | + | } | |
| 437 | + | for i := range state.Phases { | |
| 438 | + | if state.Phases[i].ID == candidate { | |
| 439 | + | return &state.Phases[i] | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | if bracketedID := extractBracketedPhaseID(candidate); bracketedID != "" { | |
| 443 | + | for i := range state.Phases { | |
| 444 | + | if state.Phases[i].ID == bracketedID { | |
| 445 | + | return &state.Phases[i] | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | return findUniquePhaseByTitle(state, candidate) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func formatValidPhaseIDs(state PlanState) string { | |
| 453 | + | if len(state.Phases) == 0 { | |
| 454 | + | return "none" | |
| 455 | + | } | |
| 456 | + | parts := make([]string, 0, len(state.Phases)) | |
| 457 | + | for _, phase := range state.Phases { | |
| 458 | + | parts = append(parts, fmt.Sprintf("%s (%s)", phase.ID, singleLine(phase.Title))) | |
| 459 | + | } | |
| 460 | + | return strings.Join(parts, ", ") | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func phaseIDError(state PlanState, action, phaseID string) string { | |
| 464 | + | candidate := normalizeOptionalText(phaseID) | |
| 465 | + | validIDs := formatValidPhaseIDs(state) | |
| 466 | + | if candidate == "" { | |
| 467 | + | return fmt.Sprintf("phaseId is required for %s. Use a valid phase id. Valid phase ids: %s.", action, validIDs) | |
| 468 | + | } | |
| 469 | + | return fmt.Sprintf("phaseId %q was not found for %s. Use the raw id from brackets without brackets. Valid phase ids: %s.", singleLine(candidate), action, validIDs) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | func formatValidTaskIDs(state PlanState) string { | |
| 473 | + | parts := make([]string, 0) | |
| 474 | + | for _, phase := range state.Phases { | |
| 475 | + | for _, task := range phase.Tasks { | |
| 476 | + | if !task.Checked { | |
| 477 | + | parts = append(parts, fmt.Sprintf("%s (%s)", task.ID, displaySingleLine(phase.Title))) | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | if len(parts) == 0 { | |
| 482 | + | return "none" | |
| 483 | + | } | |
| 484 | + | return strings.Join(parts, ", ") | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func taskIDError(state PlanState, action, taskID string) string { | |
| 488 | + | candidate := normalizeOptionalText(taskID) | |
| 489 | + | validIDs := formatValidTaskIDs(state) | |
| 490 | + | if candidate == "" { | |
| 491 | + | return fmt.Sprintf("taskId is required for %s. Use a valid task id. Valid incomplete task ids: %s.", action, validIDs) | |
| 492 | + | } | |
| 493 | + | return fmt.Sprintf("taskId %q was not found for %s. Use the task id from brackets. Valid incomplete task ids: %s.", displaySingleLine(candidate), action, validIDs) | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // findTask returns pointers into state.Phases and their Tasks, aliasing the | |
| 497 | + | // caller's slice backing arrays so mutations are applied to the caller's state. | |
| 498 | + | func findTask(state PlanState, taskID, phaseID string) (phase *Phase, task *PhaseTask) { | |
| 499 | + | if taskID == "" { | |
| 500 | + | return nil, nil | |
| 501 | + | } | |
| 502 | + | if phaseID != "" { | |
| 503 | + | phase = findPhase(state, phaseID) | |
| 504 | + | if phase == nil { | |
| 505 | + | return nil, nil | |
| 506 | + | } | |
| 507 | + | for i := range phase.Tasks { | |
| 508 | + | if phase.Tasks[i].ID == taskID { | |
| 509 | + | return phase, &phase.Tasks[i] | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | return nil, nil | |
| 513 | + | } | |
| 514 | + | for i := range state.Phases { | |
| 515 | + | for j := range state.Phases[i].Tasks { | |
| 516 | + | if state.Phases[i].Tasks[j].ID == taskID { | |
| 517 | + | return &state.Phases[i], &state.Phases[i].Tasks[j] | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | return nil, nil | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func buildPhaseFromInput(state *PlanState, input PhaseInput, phaseIDs, taskIDs map[string]struct{}) (Phase, error) { | |
| 525 | + | id := "" | |
| 526 | + | if input.ID != nil { | |
| 527 | + | id = normalizeOptionalText(*input.ID) | |
| 528 | + | } | |
| 529 | + | if id == "" { | |
| 530 | + | id = nextPhaseID(state) | |
| 531 | + | } | |
| 532 | + | if _, exists := phaseIDs[id]; exists { | |
| 533 | + | return Phase{}, fmt.Errorf("duplicate phase id %q", id) | |
| 534 | + | } | |
| 535 | + | phaseIDs[id] = struct{}{} | |
| 536 | + | phase := Phase{ | |
| 537 | + | ID: id, | |
| 538 | + | Title: strings.TrimSpace(input.Title), | |
| 539 | + | Goal: optionalInputText(input.Goal), | |
| 540 | + | Tasks: []PhaseTask{}, | |
| 541 | + | } | |
| 542 | + | if input.Tasks != nil { | |
| 543 | + | phase.Tasks = make([]PhaseTask, 0, len(*input.Tasks)) | |
| 544 | + | for _, inputTask := range *input.Tasks { | |
| 545 | + | taskID := "" | |
| 546 | + | if inputTask.ID != nil { | |
| 547 | + | taskID = normalizeOptionalText(*inputTask.ID) | |
| 548 | + | } | |
| 549 | + | if taskID == "" { | |
| 550 | + | taskID = nextTaskID(state) | |
| 551 | + | } | |
| 552 | + | if _, exists := taskIDs[taskID]; exists { | |
| 553 | + | return Phase{}, fmt.Errorf("duplicate task id %q", taskID) | |
| 554 | + | } | |
| 555 | + | taskIDs[taskID] = struct{}{} | |
| 556 | + | checked := false | |
| 557 | + | if inputTask.Checked != nil { | |
| 558 | + | checked = *inputTask.Checked | |
| 559 | + | } | |
| 560 | + | phase.Tasks = append(phase.Tasks, PhaseTask{ID: taskID, Text: strings.TrimSpace(inputTask.Text), Checked: checked}) | |
| 561 | + | } | |
| 562 | + | } | |
| 563 | + | return phase, nil | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | func optionalInputText(value *string) string { | |
| 567 | + | if value == nil { | |
| 568 | + | return "" | |
| 569 | + | } | |
| 570 | + | return normalizeOptionalText(*value) | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | // applyAction is the state transition used by the protocol handler. It does | |
| 574 | + | // not persist the result; stateStore performs that step while holding its | |
| 575 | + | // mutex, so a failed write never publishes an unpersisted state. | |
| 576 | + | func applyAction(state PlanState, params toolArgs) (PlanState, string, error) { | |
| 577 | + | state = cloneState(state) | |
| 578 | + | nextState := cloneState(state) | |
| 579 | + | if isPlanClosed(state) && closedPlanBlockedActions[params.Action] { | |
| 580 | + | reopen := (params.Action == actionSetTaskChecked || params.Action == actionSetPhaseChecked) && params.Checked != nil && !*params.Checked | |
| 581 | + | if !reopen { | |
| 582 | + | return state, "Plan is closed", errors.New("this plan is already complete and closed; call clear before starting new work, or call set_spec immediately followed by replace_plan") | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | switch params.Action { | |
| 587 | + | case actionGetStatus: | |
| 588 | + | return state, "Current phased plan status", nil | |
| 589 | + | case actionSetSpec: | |
| 590 | + | spec := "" | |
| 591 | + | if params.Spec != nil { | |
| 592 | + | spec = normalizeOptionalText(*params.Spec) | |
| 593 | + | } | |
| 594 | + | if spec == "" { | |
| 595 | + | return state, "Spec not updated", errors.New("spec is required for set_spec") | |
| 596 | + | } | |
| 597 | + | nextState.Spec = spec | |
| 598 | + | if isPlanClosed(state) { | |
| 599 | + | nextState.Phases = []Phase{} | |
| 600 | + | nextState.CurrentPhaseID = "" | |
| 601 | + | nextState.NextPhaseNumber = 1 | |
| 602 | + | nextState.NextTaskNumber = 1 | |
| 603 | + | } | |
| 604 | + | nextState.ClosedAt = nil | |
| 605 | + | nextState.ClosedSummary = "" | |
| 606 | + | return nextState, "Saved spec", nil | |
| 607 | + | case actionReplacePlan: | |
| 608 | + | if params.Phases == nil { | |
| 609 | + | return state, "Plan not replaced", errors.New("phases is required for replace_plan") | |
| 610 | + | } | |
| 611 | + | nextState.Phases = []Phase{} | |
| 612 | + | nextState.CurrentPhaseID = "" | |
| 613 | + | nextState.NextPhaseNumber = 1 | |
| 614 | + | nextState.NextTaskNumber = 1 | |
| 615 | + | phaseIDs := map[string]struct{}{} | |
| 616 | + | taskIDs := map[string]struct{}{} | |
| 617 | + | for _, phaseInput := range *params.Phases { | |
| 618 | + | phase, err := buildPhaseFromInput(&nextState, phaseInput, phaseIDs, taskIDs) | |
| 619 | + | if err != nil { | |
| 620 | + | return state, "Plan not replaced", err | |
| 621 | + | } | |
| 622 | + | nextState.Phases = append(nextState.Phases, phase) | |
| 623 | + | } | |
| 624 | + | nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState) | |
| 625 | + | return nextState, fmt.Sprintf("Replaced plan with %d phase(s)", len(nextState.Phases)), nil | |
| 626 | + | case actionAddPhase: | |
| 627 | + | phaseTitle := "" | |
| 628 | + | if params.PhaseTitle != nil { | |
| 629 | + | phaseTitle = normalizeOptionalText(*params.PhaseTitle) | |
| 630 | + | } | |
| 631 | + | if phaseTitle == "" { | |
| 632 | + | return state, "Phase not added", errors.New("phaseTitle is required for add_phase") | |
| 633 | + | } | |
| 634 | + | nextState.Phases = append(nextState.Phases, Phase{ID: nextPhaseID(&nextState), Title: phaseTitle, Tasks: []PhaseTask{}}) | |
| 635 | + | if params.PhaseGoal != nil { | |
| 636 | + | nextState.Phases[len(nextState.Phases)-1].Goal = normalizeOptionalText(*params.PhaseGoal) | |
| 637 | + | } | |
| 638 | + | if nextState.CurrentPhaseID == "" { | |
| 639 | + | nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState) | |
| 640 | + | } | |
| 641 | + | return nextState, "Added phase " + phaseTitle, nil | |
| 642 | + | case actionUpdatePhase: | |
| 643 | + | phase := findPhase(nextState, params.PhaseID) | |
| 644 | + | if phase == nil { | |
| 645 | + | return state, "Phase not updated", errors.New(phaseIDError(nextState, actionUpdatePhase, params.PhaseID)) | |
| 646 | + | } | |
| 647 | + | if params.PhaseTitle != nil { | |
| 648 | + | if phaseTitle := normalizeOptionalText(*params.PhaseTitle); phaseTitle != "" { | |
| 649 | + | phase.Title = phaseTitle | |
| 650 | + | } | |
| 651 | + | } | |
| 652 | + | if params.PhaseGoal != nil { | |
| 653 | + | phase.Goal = normalizeOptionalText(*params.PhaseGoal) | |
| 654 | + | } | |
| 655 | + | return nextState, "Updated phase " + phase.Title, nil | |
| 656 | + | case actionRemovePhase: | |
| 657 | + | phase := findPhase(nextState, params.PhaseID) | |
| 658 | + | if phase == nil { | |
| 659 | + | return state, "Phase not removed", errors.New(phaseIDError(nextState, actionRemovePhase, params.PhaseID)) | |
| 660 | + | } | |
| 661 | + | removedID := phase.ID | |
| 662 | + | filtered := make([]Phase, 0, len(nextState.Phases)-1) | |
| 663 | + | for _, entry := range nextState.Phases { | |
| 664 | + | if entry.ID != removedID { | |
| 665 | + | filtered = append(filtered, entry) | |
| 666 | + | } | |
| 667 | + | } | |
| 668 | + | nextState.Phases = filtered | |
| 669 | + | nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState) | |
| 670 | + | return nextState, "Removed phase " + removedID, nil | |
| 671 | + | case actionAddTask: | |
| 672 | + | phase := findPhase(nextState, params.PhaseID) | |
| 673 | + | taskText := "" | |
| 674 | + | if params.TaskText != nil { | |
| 675 | + | taskText = normalizeOptionalText(*params.TaskText) | |
| 676 | + | } | |
| 677 | + | if phase == nil { | |
| 678 | + | return state, "Task not added", errors.New(phaseIDError(nextState, actionAddTask, params.PhaseID)) | |
| 679 | + | } | |
| 680 | + | if taskText == "" { | |
| 681 | + | return state, "Task not added", errors.New("taskText is required for add_task") | |
| 682 | + | } | |
| 683 | + | phase.Tasks = append(phase.Tasks, PhaseTask{ID: nextTaskID(&nextState), Text: taskText}) | |
| 684 | + | return nextState, "Added task to " + phase.Title, nil | |
| 685 | + | case actionUpdateTask: | |
| 686 | + | if params.TaskID == "" { | |
| 687 | + | return state, "Task not updated", errors.New("taskId is required for update_task") | |
| 688 | + | } | |
| 689 | + | _, task := findTask(nextState, params.TaskID, params.PhaseID) | |
| 690 | + | taskText := "" | |
| 691 | + | if params.TaskText != nil { | |
| 692 | + | taskText = normalizeOptionalText(*params.TaskText) | |
| 693 | + | } | |
| 694 | + | if task == nil { | |
| 695 | + | return state, "Task not updated", errors.New(taskIDError(nextState, actionUpdateTask, params.TaskID)) | |
| 696 | + | } | |
| 697 | + | if taskText == "" { | |
| 698 | + | return state, "Task not updated", errors.New("taskText is required for update_task") | |
| 699 | + | } | |
| 700 | + | task.Text = taskText | |
| 701 | + | return nextState, "Updated task " + task.ID, nil | |
| 702 | + | case actionRemoveTask: | |
| 703 | + | if params.TaskID == "" { | |
| 704 | + | return state, "Task not removed", errors.New("taskId is required") | |
| 705 | + | } | |
| 706 | + | phase, task := findTask(nextState, params.TaskID, params.PhaseID) | |
| 707 | + | if task == nil { | |
| 708 | + | return state, "Task not removed", errors.New("taskId was not found") | |
| 709 | + | } | |
| 710 | + | filtered := make([]PhaseTask, 0, len(phase.Tasks)-1) | |
| 711 | + | for _, entry := range phase.Tasks { | |
| 712 | + | if entry.ID != params.TaskID { | |
| 713 | + | filtered = append(filtered, entry) | |
| 714 | + | } | |
| 715 | + | } | |
| 716 | + | phase.Tasks = filtered | |
| 717 | + | return nextState, "Removed task " + params.TaskID, nil | |
| 718 | + | case actionSetCurrentPhase: | |
| 719 | + | phase := findPhase(nextState, params.PhaseID) | |
| 720 | + | if phase == nil { | |
| 721 | + | return state, "Current phase not updated", errors.New(phaseIDError(nextState, actionSetCurrentPhase, params.PhaseID)) | |
| 722 | + | } | |
| 723 | + | nextState.CurrentPhaseID = phase.ID | |
| 724 | + | return nextState, "Current phase set to " + phase.Title, nil | |
| 725 | + | case actionSetTaskChecked: | |
| 726 | + | if params.TaskID == "" { | |
| 727 | + | return state, "Task not updated", errors.New("taskId is required for set_task_checked") | |
| 728 | + | } | |
| 729 | + | phase, task := findTask(nextState, params.TaskID, params.PhaseID) | |
| 730 | + | if task == nil { | |
| 731 | + | return state, "Task not updated", errors.New(taskIDError(nextState, actionSetTaskChecked, params.TaskID)) | |
| 732 | + | } | |
| 733 | + | if params.Checked == nil { | |
| 734 | + | return state, "Task not updated", errors.New("checked must be provided for set_task_checked") | |
| 735 | + | } | |
| 736 | + | task.Checked = *params.Checked | |
| 737 | + | if *params.Checked && nextState.CurrentPhaseID == phase.ID && isPhaseDone(*phase) { | |
| 738 | + | nextState.CurrentPhaseID = "" | |
| 739 | + | } | |
| 740 | + | return nextState, fmt.Sprintf("%s task %s", checkedWord(*params.Checked), task.ID), nil | |
| 741 | + | case actionSetPhaseChecked: | |
| 742 | + | phase := findPhase(nextState, params.PhaseID) | |
| 743 | + | if phase == nil { | |
| 744 | + | return state, "Phase not updated", errors.New(phaseIDError(nextState, actionSetPhaseChecked, params.PhaseID)) | |
| 745 | + | } | |
| 746 | + | if params.Checked == nil { | |
| 747 | + | return state, "Phase not updated", errors.New("checked must be provided for set_phase_checked") | |
| 748 | + | } | |
| 749 | + | if len(phase.Tasks) == 0 { | |
| 750 | + | return state, "Phase not updated", errors.New("set_phase_checked requires the phase to have at least one task") | |
| 751 | + | } | |
| 752 | + | for i := range phase.Tasks { | |
| 753 | + | phase.Tasks[i].Checked = *params.Checked | |
| 754 | + | } | |
| 755 | + | if *params.Checked && nextState.CurrentPhaseID == phase.ID { | |
| 756 | + | nextState.CurrentPhaseID = "" | |
| 757 | + | } | |
| 758 | + | if !*params.Checked { | |
| 759 | + | nextState.CurrentPhaseID = phase.ID | |
| 760 | + | } | |
| 761 | + | return nextState, fmt.Sprintf("%s phase %s", checkedWord(*params.Checked), phase.Title), nil | |
| 762 | + | case actionClear: | |
| 763 | + | return createEmptyState(), "Cleared stored spec and phased checklist", nil | |
| 764 | + | default: | |
| 765 | + | return state, "Unsupported action", errors.New("unknown action") | |
| 766 | + | } | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | func checkedWord(checked bool) string { | |
| 770 | + | if checked { | |
| 771 | + | return "Checked" | |
| 772 | + | } | |
| 773 | + | return "Unchecked" | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | const ( | |
| 777 | + | compactVisibleIncompleteTasks = 4 | |
| 778 | + | turnContextVisibleIncompleteTasks = 3 | |
| 779 | + | maxTurnContextChars = 2048 | |
| 780 | + | ) | |
| 781 | + | ||
| 782 | + | func getIncompleteTasks(phase Phase) []PhaseTask { | |
| 783 | + | incomplete := make([]PhaseTask, 0) | |
| 784 | + | for _, task := range phase.Tasks { | |
| 785 | + | if !task.Checked { | |
| 786 | + | incomplete = append(incomplete, task) | |
| 787 | + | } | |
| 788 | + | } | |
| 789 | + | return incomplete | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | func formatRemainingTaskCount(count int) string { | |
| 793 | + | if count == 1 { | |
| 794 | + | return "1 remaining" | |
| 795 | + | } | |
| 796 | + | return fmt.Sprintf("%d remaining", count) | |
| 797 | + | } | |
| 798 | + | ||
| 799 | + | func formatPhaseTitle(phase Phase) string { return truncatePlain(displaySingleLine(phase.Title), 120) } | |
| 800 | + | func formatTaskText(task PhaseTask) string { return truncatePlain(displaySingleLine(task.Text), 180) } | |
| 801 | + | ||
| 802 | + | func formatTurnContextID(id string) string { return truncatePlain(singleLine(id), 96) } | |
| 803 | + | ||
| 804 | + | func appendIncompleteTaskLines(lines *[]string, tasks []PhaseTask, visibleCount int, indent string) { | |
| 805 | + | visible := tasks | |
| 806 | + | if len(visible) > visibleCount { | |
| 807 | + | visible = visible[:visibleCount] | |
| 808 | + | } | |
| 809 | + | for _, task := range visible { | |
| 810 | + | *lines = append(*lines, fmt.Sprintf("%s[ ] %s [%s]", indent, formatTaskText(task), formatTurnContextID(task.ID))) | |
| 811 | + | } | |
| 812 | + | if omitted := len(tasks) - len(visible); omitted > 0 { | |
| 813 | + | *lines = append(*lines, fmt.Sprintf("%s... %d more incomplete task(s) omitted; call tasked_phases get_status for the full checklist.", indent, omitted)) | |
| 814 | + | } | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | func buildSummary(state PlanState) string { | |
| 818 | + | if !hasStoredPlan(state) { | |
| 819 | + | return "No spec or phased checklist has been stored yet." | |
| 820 | + | } | |
| 821 | + | lines := make([]string, 0) | |
| 822 | + | if state.Spec != "" { | |
| 823 | + | lines = append(lines, "Spec:", sanitizeDisplayText(state.Spec), "") | |
| 824 | + | } | |
| 825 | + | if len(state.Phases) == 0 { | |
| 826 | + | return strings.Join(append(lines, "Phases: (none)"), "\n") | |
| 827 | + | } | |
| 828 | + | done, total := getPlanProgress(state) | |
| 829 | + | lines = append(lines, fmt.Sprintf("Plan progress: %d/%d tasks checked", done, total)) | |
| 830 | + | if isPlanClosed(state) { | |
| 831 | + | closedSummary := state.ClosedSummary | |
| 832 | + | if closedSummary == "" { | |
| 833 | + | closedSummary = "all phases complete" | |
| 834 | + | } | |
| 835 | + | lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary)) | |
| 836 | + | } | |
| 837 | + | lines = append(lines, "Phases:") | |
| 838 | + | for _, phase := range state.Phases { | |
| 839 | + | phaseDone, phaseTotal := getPhaseProgress(phase) | |
| 840 | + | currentMarker := " " | |
| 841 | + | if phase.ID == state.CurrentPhaseID { | |
| 842 | + | currentMarker = ">" | |
| 843 | + | } | |
| 844 | + | phaseMarker := "[ ]" | |
| 845 | + | if isPhaseDone(phase) { | |
| 846 | + | phaseMarker = "[x]" | |
| 847 | + | } | |
| 848 | + | goalSuffix := "" | |
| 849 | + | if phase.Goal != "" { | |
| 850 | + | goalSuffix = " - " + displaySingleLine(phase.Goal) | |
| 851 | + | } | |
| 852 | + | lines = append(lines, fmt.Sprintf("%s %s %s [%s] (%d/%d)%s", currentMarker, phaseMarker, formatPhaseTitle(phase), formatTurnContextID(phase.ID), phaseDone, phaseTotal, goalSuffix)) | |
| 853 | + | if len(phase.Tasks) == 0 { | |
| 854 | + | lines = append(lines, " - No tasks yet") | |
| 855 | + | continue | |
| 856 | + | } | |
| 857 | + | for _, task := range phase.Tasks { | |
| 858 | + | marker := "[ ]" | |
| 859 | + | if task.Checked { | |
| 860 | + | marker = "[x]" | |
| 861 | + | } | |
| 862 | + | lines = append(lines, fmt.Sprintf(" %s %s [%s]", marker, formatTaskText(task), formatTurnContextID(task.ID))) | |
| 863 | + | } | |
| 864 | + | } | |
| 865 | + | return strings.Join(lines, "\n") | |
| 866 | + | } | |
| 867 | + | ||
| 868 | + | func buildCompactSummary(state PlanState) string { | |
| 869 | + | if !hasStoredPlan(state) { | |
| 870 | + | return "No spec or phased checklist has been stored yet." | |
| 871 | + | } | |
| 872 | + | lines := make([]string, 0) | |
| 873 | + | if state.Spec != "" { | |
| 874 | + | lines = append(lines, "Spec: "+truncatePlain(displaySingleLine(state.Spec), 220)) | |
| 875 | + | } | |
| 876 | + | done, total := getPlanProgress(state) | |
| 877 | + | lines = append(lines, fmt.Sprintf("Progress: %d/%d tasks checked", done, total)) | |
| 878 | + | if isPlanClosed(state) { | |
| 879 | + | closedSummary := state.ClosedSummary | |
| 880 | + | if closedSummary == "" { | |
| 881 | + | closedSummary = "all phases complete" | |
| 882 | + | } | |
| 883 | + | lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary)) | |
| 884 | + | return strings.Join(lines, "\n") | |
| 885 | + | } | |
| 886 | + | if currentPhase := getActivePhase(state); currentPhase != nil { | |
| 887 | + | incompleteTasks := getIncompleteTasks(*currentPhase) | |
| 888 | + | goalSuffix := "" | |
| 889 | + | if currentPhase.Goal != "" { | |
| 890 | + | goalSuffix = " - " + truncatePlain(displaySingleLine(currentPhase.Goal), 120) | |
| 891 | + | } | |
| 892 | + | lines = append(lines, fmt.Sprintf("Current phase: %s [%s] (%s)%s", formatPhaseTitle(*currentPhase), formatTurnContextID(currentPhase.ID), formatRemainingTaskCount(len(incompleteTasks)), goalSuffix)) | |
| 893 | + | if len(incompleteTasks) > 0 { | |
| 894 | + | lines = append(lines, "Incomplete tasks:") | |
| 895 | + | appendIncompleteTaskLines(&lines, incompleteTasks, compactVisibleIncompleteTasks, " ") | |
| 896 | + | } | |
| 897 | + | } else if len(state.Phases) > 0 { | |
| 898 | + | lines = append(lines, "Current phase: none") | |
| 899 | + | } | |
| 900 | + | return strings.Join(lines, "\n") | |
| 901 | + | } | |
| 902 | + | ||
| 903 | + | // buildTurnContext returns the small amount of plan state the model needs at | |
| 904 | + | // the start of a turn. It deliberately excludes the spec, completed phases, | |
| 905 | + | // and future phases; get_status and /phases remain the full-state views. | |
| 906 | + | func buildTurnContext(state PlanState) string { | |
| 907 | + | if !hasStoredPlan(state) { | |
| 908 | + | return "" | |
| 909 | + | } | |
| 910 | + | if isPlanClosed(state) { | |
| 911 | + | return truncatePlain("Tasked phases focus: the current plan is complete and closed. Use clear before creating a plan for unrelated work.", maxTurnContextChars) | |
| 912 | + | } | |
| 913 | + | phase := getActivePhase(state) | |
| 914 | + | if phase == nil { | |
| 915 | + | return "" | |
| 916 | + | } | |
| 917 | + | lines := []string{ | |
| 918 | + | "Tasked phases focus:", | |
| 919 | + | fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)), | |
| 920 | + | } | |
| 921 | + | if phase.Goal != "" { | |
| 922 | + | lines = append(lines, "Goal: "+truncatePlain(displaySingleLine(phase.Goal), 160)) | |
| 923 | + | } | |
| 924 | + | incompleteTasks := getIncompleteTasks(*phase) | |
| 925 | + | if len(incompleteTasks) == 0 { | |
| 926 | + | lines = append(lines, "No incomplete tasks remain in this phase; update the plan or advance the phase as appropriate.") | |
| 927 | + | return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars) | |
| 928 | + | } | |
| 929 | + | lines = append(lines, "Incomplete tasks:") | |
| 930 | + | appendIncompleteTaskLines(&lines, incompleteTasks, turnContextVisibleIncompleteTasks, " ") | |
| 931 | + | return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars) | |
| 932 | + | } | |
| 933 | + | ||
| 934 | + | func shouldReturnFullSummary(action string, err error) bool { | |
| 935 | + | return err != nil || action == actionGetStatus || action == actionReplacePlan || action == actionClear | |
| 936 | + | } | |
| 937 | + | ||
| 938 | + | func buildToolResultText(action string, state PlanState, headline string, err error) string { | |
| 939 | + | summary := buildCompactSummary(state) | |
| 940 | + | if shouldReturnFullSummary(action, err) { | |
| 941 | + | summary = buildSummary(state) | |
| 942 | + | } | |
| 943 | + | if err != nil { | |
| 944 | + | return "Error: " + err.Error() + "\n\n" + summary | |
| 945 | + | } | |
| 946 | + | return headline + "\n\n" + summary | |
| 947 | + | } | |
| 948 | + | ||
| 949 | + | func buildViewLines(state PlanState) []string { | |
| 950 | + | lines := []string{"", " Tasked phases ", ""} | |
| 951 | + | if !hasStoredPlan(state) { | |
| 952 | + | return append(lines, | |
| 953 | + | " No spec or phases stored yet.", | |
| 954 | + | "", | |
| 955 | + | " Ask the agent to create a spec and phased checklist.", | |
| 956 | + | "", | |
| 957 | + | " Press Escape to close", | |
| 958 | + | ) | |
| 959 | + | } | |
| 960 | + | if state.Spec != "" { | |
| 961 | + | lines = append(lines, " Spec") | |
| 962 | + | for _, specLine := range strings.Split(sanitizeDisplayText(state.Spec), "\n") { | |
| 963 | + | lines = append(lines, " "+specLine) | |
| 964 | + | } | |
| 965 | + | lines = append(lines, "") | |
| 966 | + | } | |
| 967 | + | done, total := getPlanProgress(state) | |
| 968 | + | lines = append(lines, fmt.Sprintf(" Progress: %d/%d tasks checked", done, total)) | |
| 969 | + | if isPlanClosed(state) { | |
| 970 | + | closedSummary := state.ClosedSummary | |
| 971 | + | if closedSummary == "" { | |
| 972 | + | closedSummary = "all phases complete" | |
| 973 | + | } | |
| 974 | + | lines = append(lines, | |
| 975 | + | " Closed: "+displaySingleLine(closedSummary), | |
| 976 | + | " New work should create a fresh plan instead of extending this one.", | |
| 977 | + | ) | |
| 978 | + | } | |
| 979 | + | lines = append(lines, "") | |
| 980 | + | for _, phase := range state.Phases { | |
| 981 | + | phaseDone, phaseTotal := getPhaseProgress(phase) | |
| 982 | + | marker := "[ ]" | |
| 983 | + | if isPhaseDone(phase) { | |
| 984 | + | marker = "[x]" | |
| 985 | + | } | |
| 986 | + | currentTitle := phase.Title | |
| 987 | + | if phase.ID == state.CurrentPhaseID { | |
| 988 | + | currentTitle = "> " + currentTitle | |
| 989 | + | } | |
| 990 | + | lines = append(lines, fmt.Sprintf(" %s %s [%s] (%d/%d)", marker, displaySingleLine(currentTitle), formatTurnContextID(phase.ID), phaseDone, phaseTotal)) | |
| 991 | + | if phase.Goal != "" { | |
| 992 | + | lines = append(lines, " "+displaySingleLine(phase.Goal)) | |
| 993 | + | } | |
| 994 | + | if len(phase.Tasks) == 0 { | |
| 995 | + | lines = append(lines, " - No tasks yet") | |
| 996 | + | continue | |
| 997 | + | } | |
| 998 | + | for _, task := range phase.Tasks { | |
| 999 | + | taskMarker := "[ ]" | |
| 1000 | + | if task.Checked { | |
| 1001 | + | taskMarker = "[x]" | |
| 1002 | + | } | |
| 1003 | + | lines = append(lines, fmt.Sprintf(" %s %s [%s]", taskMarker, formatTaskText(task), formatTurnContextID(task.ID))) | |
| 1004 | + | } | |
| 1005 | + | lines = append(lines, "") | |
| 1006 | + | } | |
| 1007 | + | return append(lines, " Press Escape to close") | |
| 1008 | + | } | |
| 1009 | + | ||
| 1010 | + | type stateStore struct { | |
| 1011 | + | mu sync.Mutex | |
| 1012 | + | state PlanState | |
| 1013 | + | path string | |
| 1014 | + | loaded bool | |
| 1015 | + | loadErr error | |
| 1016 | + | // pathErr records a persistence fault independently of the active | |
| 1017 | + | // session state. Activating a session must never clear this fault. | |
| 1018 | + | pathErr error | |
| 1019 | + | } | |
| 1020 | + | ||
| 1021 | + | func newStateStore() *stateStore { | |
| 1022 | + | return &stateStore{state: createEmptyState()} | |
| 1023 | + | } | |
| 1024 | + | ||
| 1025 | + | func projectStatePath(dataDir, cwd string) string { | |
| 1026 | + | key := sha256.Sum256([]byte(cwd)) | |
| 1027 | + | return filepath.Join(dataDir, "projects", hex.EncodeToString(key[:])+".json") | |
| 1028 | + | } | |
| 1029 | + | ||
| 1030 | + | func (s *stateStore) load(dataDir, cwd string) error { | |
| 1031 | + | s.mu.Lock() | |
| 1032 | + | defer s.mu.Unlock() | |
| 1033 | + | if s.loaded { | |
| 1034 | + | return s.loadErr | |
| 1035 | + | } | |
| 1036 | + | s.loaded = true | |
| 1037 | + | s.state = createEmptyState() | |
| 1038 | + | if strings.TrimSpace(dataDir) == "" { | |
| 1039 | + | s.loadErr = errors.New("host did not provide extension data_dir") | |
| 1040 | + | s.pathErr = s.loadErr | |
| 1041 | + | return s.loadErr | |
| 1042 | + | } | |
| 1043 | + | s.path = projectStatePath(dataDir, cwd) | |
| 1044 | + | if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { | |
| 1045 | + | s.loadErr = fmt.Errorf("create state directory: %w", err) | |
| 1046 | + | s.pathErr = s.loadErr | |
| 1047 | + | return s.loadErr | |
| 1048 | + | } | |
| 1049 | + | // #nosec G302 -- this is a directory; 0700 is the minimum mode that permits traversal. | |
| 1050 | + | if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil { | |
| 1051 | + | s.loadErr = fmt.Errorf("restrict state directory: %w", err) | |
| 1052 | + | s.pathErr = s.loadErr | |
| 1053 | + | return s.loadErr | |
| 1054 | + | } | |
| 1055 | + | data, err := os.ReadFile(s.path) | |
| 1056 | + | if os.IsNotExist(err) { | |
| 1057 | + | return nil | |
| 1058 | + | } | |
| 1059 | + | if err != nil { | |
| 1060 | + | s.loadErr = fmt.Errorf("read state: %w", err) | |
| 1061 | + | s.pathErr = s.loadErr | |
| 1062 | + | return s.loadErr | |
| 1063 | + | } | |
| 1064 | + | if err := os.Chmod(s.path, 0o600); err != nil { | |
| 1065 | + | s.loadErr = fmt.Errorf("restrict state file: %w", err) | |
| 1066 | + | s.pathErr = s.loadErr | |
| 1067 | + | return s.loadErr | |
| 1068 | + | } | |
| 1069 | + | if len(data) == 0 { | |
| 1070 | + | return nil | |
| 1071 | + | } | |
| 1072 | + | var state PlanState | |
| 1073 | + | if err := json.Unmarshal(data, &state); err != nil { | |
| 1074 | + | s.loadErr = fmt.Errorf("parse state: %w", err) | |
| 1075 | + | s.pathErr = s.loadErr | |
| 1076 | + | return s.loadErr | |
| 1077 | + | } | |
| 1078 | + | s.state = ensureState(state) | |
| 1079 | + | return nil | |
| 1080 | + | } | |
| 1081 | + | ||
| 1082 | + | // activateSession replaces the project fallback with the opaque snapshot | |
| 1083 | + | // supplied by zot for the active session branch. An empty snapshot means the | |
| 1084 | + | // branch has no extension state yet and therefore starts clean. | |
| 1085 | + | func (s *stateStore) activateSession(raw json.RawMessage) error { | |
| 1086 | + | s.mu.Lock() | |
| 1087 | + | defer s.mu.Unlock() | |
| 1088 | + | if !s.loaded { | |
| 1089 | + | return errors.New("state has not been loaded from hello") | |
| 1090 | + | } | |
| 1091 | + | if s.pathErr != nil { | |
| 1092 | + | return s.pathErr | |
| 1093 | + | } | |
| 1094 | + | if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" { | |
| 1095 | + | s.state = createEmptyState() | |
| 1096 | + | s.loadErr = nil | |
| 1097 | + | return nil | |
| 1098 | + | } | |
| 1099 | + | var state PlanState | |
| 1100 | + | if err := json.Unmarshal(raw, &state); err != nil { | |
| 1101 | + | return fmt.Errorf("parse session state: %w", err) | |
| 1102 | + | } | |
| 1103 | + | s.state = ensureState(state) | |
| 1104 | + | s.loadErr = nil | |
| 1105 | + | return nil | |
| 1106 | + | } | |
| 1107 | + | ||
| 1108 | + | func (s *stateStore) snapshot() (PlanState, error) { | |
| 1109 | + | s.mu.Lock() | |
| 1110 | + | defer s.mu.Unlock() | |
| 1111 | + | if !s.loaded { | |
| 1112 | + | return PlanState{}, errors.New("state has not been loaded from hello") | |
| 1113 | + | } | |
| 1114 | + | if s.loadErr != nil { | |
| 1115 | + | return cloneState(s.state), s.loadErr | |
| 1116 | + | } | |
| 1117 | + | return cloneState(s.state), nil | |
| 1118 | + | } | |
| 1119 | + | ||
| 1120 | + | func (s *stateStore) transact(fn func(PlanState) (PlanState, string, error)) (PlanState, string, error) { | |
| 1121 | + | s.mu.Lock() | |
| 1122 | + | defer s.mu.Unlock() | |
| 1123 | + | if !s.loaded { | |
| 1124 | + | return PlanState{}, "", errors.New("state has not been loaded from hello") | |
| 1125 | + | } | |
| 1126 | + | if s.pathErr != nil { | |
| 1127 | + | return cloneState(s.state), "", s.pathErr | |
| 1128 | + | } | |
| 1129 | + | if s.loadErr != nil { | |
| 1130 | + | return cloneState(s.state), "", s.loadErr | |
| 1131 | + | } | |
| 1132 | + | current := cloneState(s.state) | |
| 1133 | + | next, headline, err := fn(current) | |
| 1134 | + | if err != nil { | |
| 1135 | + | return current, headline, err | |
| 1136 | + | } | |
| 1137 | + | next = ensureState(next) | |
| 1138 | + | if err := writeJSONAtomic(s.path, next); err != nil { | |
| 1139 | + | return current, headline, fmt.Errorf("persist state: %w", err) | |
| 1140 | + | } | |
| 1141 | + | s.state = next | |
| 1142 | + | return cloneState(next), headline, nil | |
| 1143 | + | } | |
| 1144 | + | ||
| 1145 | + | func writeJSONAtomic(path string, value any) error { | |
| 1146 | + | if strings.TrimSpace(path) == "" { | |
| 1147 | + | return errors.New("state path is empty") | |
| 1148 | + | } | |
| 1149 | + | data, err := json.MarshalIndent(value, "", " ") | |
| 1150 | + | if err != nil { | |
| 1151 | + | return err | |
| 1152 | + | } | |
| 1153 | + | data = append(data, '\n') | |
| 1154 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 1155 | + | return err | |
| 1156 | + | } | |
| 1157 | + | tmp, err := os.CreateTemp(filepath.Dir(path), ".tasked-phases-*.tmp") | |
| 1158 | + | if err != nil { | |
| 1159 | + | return err | |
| 1160 | + | } | |
| 1161 | + | tmpName := tmp.Name() | |
| 1162 | + | defer os.Remove(tmpName) | |
| 1163 | + | if err := tmp.Chmod(0o600); err != nil { | |
| 1164 | + | _ = tmp.Close() | |
| 1165 | + | return err | |
| 1166 | + | } | |
| 1167 | + | if _, err := tmp.Write(data); err != nil { | |
| 1168 | + | _ = tmp.Close() | |
| 1169 | + | return err | |
| 1170 | + | } | |
| 1171 | + | if err := tmp.Sync(); err != nil { | |
| 1172 | + | _ = tmp.Close() | |
| 1173 | + | return err | |
| 1174 | + | } | |
| 1175 | + | if err := tmp.Close(); err != nil { | |
| 1176 | + | return err | |
| 1177 | + | } | |
| 1178 | + | if err := os.Rename(tmpName, path); err != nil { | |
| 1179 | + | return err | |
| 1180 | + | } | |
| 1181 | + | return nil | |
| 1182 | + | } | |
A examples/extensions/tasked-phases/state_test.go +463 -0
| @@ -0,0 +1,463 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "runtime" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func stringPtr(value string) *string { return &value } | |
| 14 | + | func boolPtr(value bool) *bool { return &value } | |
| 15 | + | ||
| 16 | + | func transition(t *testing.T, state PlanState, params toolArgs) PlanState { | |
| 17 | + | t.Helper() | |
| 18 | + | next, _, err := applyAction(ensureState(state), params) | |
| 19 | + | if err != nil { | |
| 20 | + | t.Fatalf("apply %s: %v", params.Action, err) | |
| 21 | + | } | |
| 22 | + | return ensureState(next) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func sampleState() PlanState { | |
| 26 | + | return ensureState(PlanState{ | |
| 27 | + | Version: 1, | |
| 28 | + | Spec: "Ship compact tasked phases output without losing source-of-truth state.", | |
| 29 | + | Phases: []Phase{ | |
| 30 | + | { | |
| 31 | + | ID: "phase-1", | |
| 32 | + | Title: "Finished discovery", | |
| 33 | + | Goal: "Understand the old behavior", | |
| 34 | + | Tasks: []PhaseTask{ | |
| 35 | + | {ID: "task-1", Text: "Completed discovery task", Checked: true}, | |
| 36 | + | {ID: "task-2", Text: "Completed contract task", Checked: true}, | |
| 37 | + | }, | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | ID: "phase-2", | |
| 41 | + | Title: "Implement compact output", | |
| 42 | + | Goal: "Reduce repeated context", | |
| 43 | + | Tasks: []PhaseTask{ | |
| 44 | + | {ID: "task-3", Text: "Completed implementation task", Checked: true}, | |
| 45 | + | {ID: "task-4", Text: "Remaining implementation task", Checked: false}, | |
| 46 | + | }, | |
| 47 | + | }, | |
| 48 | + | }, | |
| 49 | + | CurrentPhaseID: "phase-2", | |
| 50 | + | NextPhaseNumber: 3, | |
| 51 | + | NextTaskNumber: 5, | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestTransitionsGenerateIDsAndSelectCurrentPhase(t *testing.T) { | |
| 56 | + | state := transition(t, createEmptyState(), toolArgs{ | |
| 57 | + | Action: actionReplacePlan, | |
| 58 | + | Phases: &[]PhaseInput{ | |
| 59 | + | {Title: "First", Tasks: &[]TaskInput{{Text: "one"}}}, | |
| 60 | + | {Title: "Second", Tasks: &[]TaskInput{{Text: "two"}}}, | |
| 61 | + | }, | |
| 62 | + | }) | |
| 63 | + | if got := state.Phases[0].ID; got != "phase-1" { | |
| 64 | + | t.Fatalf("first generated phase id = %q", got) | |
| 65 | + | } | |
| 66 | + | if got := state.Phases[1].ID; got != "phase-2" { | |
| 67 | + | t.Fatalf("second generated phase id = %q", got) | |
| 68 | + | } | |
| 69 | + | if got := state.Phases[0].Tasks[0].ID; got != "task-1" { | |
| 70 | + | t.Fatalf("first generated task id = %q", got) | |
| 71 | + | } | |
| 72 | + | if state.CurrentPhaseID != "phase-1" || state.NextPhaseNumber != 3 || state.NextTaskNumber != 3 { | |
| 73 | + | t.Fatalf("generated counters/current phase = %q, %d, %d", state.CurrentPhaseID, state.NextPhaseNumber, state.NextTaskNumber) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)}) | |
| 77 | + | if state.CurrentPhaseID != "phase-2" { | |
| 78 | + | t.Fatalf("current phase did not advance to the first incomplete phase: %q", state.CurrentPhaseID) | |
| 79 | + | } | |
| 80 | + | state = transition(t, state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("Third")}) | |
| 81 | + | if got := state.Phases[2].ID; got != "phase-3" { | |
| 82 | + | t.Fatalf("added phase id = %q", got) | |
| 83 | + | } | |
| 84 | + | state = transition(t, state, toolArgs{Action: actionAddTask, PhaseID: "phase-3", TaskText: stringPtr("three")}) | |
| 85 | + | if got := state.Phases[2].Tasks[0].ID; got != "task-3" { | |
| 86 | + | t.Fatalf("added task id = %q", got) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestReplacePlanRejectsDuplicateIDs(t *testing.T) { | |
| 91 | + | state := createEmptyState() | |
| 92 | + | _, _, err := applyAction(state, toolArgs{ | |
| 93 | + | Action: actionReplacePlan, | |
| 94 | + | Phases: &[]PhaseInput{ | |
| 95 | + | {ID: stringPtr("phase-1"), Title: "First"}, | |
| 96 | + | {ID: stringPtr("phase-1"), Title: "Duplicate"}, | |
| 97 | + | }, | |
| 98 | + | }) | |
| 99 | + | if err == nil || !strings.Contains(err.Error(), "duplicate phase id") { | |
| 100 | + | t.Fatalf("duplicate phase id error = %v", err) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | _, _, err = applyAction(state, toolArgs{ | |
| 104 | + | Action: actionReplacePlan, | |
| 105 | + | Phases: &[]PhaseInput{ | |
| 106 | + | {Title: "First", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "one"}}}, | |
| 107 | + | {Title: "Second", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "duplicate"}}}, | |
| 108 | + | }, | |
| 109 | + | }) | |
| 110 | + | if err == nil || !strings.Contains(err.Error(), "duplicate task id") { | |
| 111 | + | t.Fatalf("duplicate task id error = %v", err) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestClosedPlanRulesAndReopen(t *testing.T) { | |
| 116 | + | state := transition(t, createEmptyState(), toolArgs{ | |
| 117 | + | Action: actionReplacePlan, | |
| 118 | + | Phases: &[]PhaseInput{{Title: "Done", Tasks: &[]TaskInput{{Text: "finished", Checked: boolPtr(true)}}}}, | |
| 119 | + | }) | |
| 120 | + | if !isPlanClosed(state) || state.CurrentPhaseID != "" { | |
| 121 | + | t.Fatalf("complete plan was not closed: %+v", state) | |
| 122 | + | } | |
| 123 | + | if !strings.Contains(buildSummary(state), "Plan closed: Completed 1/1 tasks across 1 phase(s).") { | |
| 124 | + | t.Fatalf("closed summary missing: %s", buildSummary(state)) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | if _, _, err := applyAction(state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("new")}); err == nil || !strings.Contains(err.Error(), "already complete and closed") { | |
| 128 | + | t.Fatalf("add to closed plan error = %v", err) | |
| 129 | + | } | |
| 130 | + | if _, _, err := applyAction(state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{}}); err == nil { | |
| 131 | + | t.Fatal("replace_plan unexpectedly bypassed closed-plan guard") | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(false)}) | |
| 135 | + | if isPlanClosed(state) || state.CurrentPhaseID != "phase-1" || state.Phases[0].Tasks[0].Checked { | |
| 136 | + | t.Fatalf("unchecked reopen produced %+v", state) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | state = transition(t, state, toolArgs{Action: actionSetSpec, Spec: stringPtr("new work")}) | |
| 140 | + | if len(state.Phases) != 1 { | |
| 141 | + | t.Fatalf("set_spec on reopened plan unexpectedly cleared phases") | |
| 142 | + | } | |
| 143 | + | closed := transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)}) | |
| 144 | + | if !isPlanClosed(closed) { | |
| 145 | + | t.Fatal("plan did not close again") | |
| 146 | + | } | |
| 147 | + | state = transition(t, closed, toolArgs{Action: actionSetSpec, Spec: stringPtr("fresh work")}) | |
| 148 | + | if len(state.Phases) != 0 || state.Spec != "fresh work" || state.NextPhaseNumber != 1 || state.NextTaskNumber != 1 { | |
| 149 | + | t.Fatalf("set_spec did not restart a closed plan: %+v", state) | |
| 150 | + | } | |
| 151 | + | state = transition(t, state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{{Title: "Fresh"}}}) | |
| 152 | + | if len(state.Phases) != 1 || isPlanClosed(state) { | |
| 153 | + | t.Fatalf("replace after set_spec failed: %+v", state) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestPhaseLookupAcceptsRawBracketedSummaryAndUniqueTitle(t *testing.T) { | |
| 158 | + | state := sampleState() | |
| 159 | + | for _, input := range []string{ | |
| 160 | + | "phase-2", | |
| 161 | + | "[phase-2]", | |
| 162 | + | "Implement compact output [phase-2]", | |
| 163 | + | "Implement compact output [phase-2] (1 remaining) - Reduce repeated context", | |
| 164 | + | "Implement compact output", | |
| 165 | + | } { | |
| 166 | + | if phase := findPhase(state, input); phase == nil || phase.ID != "phase-2" { | |
| 167 | + | t.Fatalf("findPhase(%q) = %#v", input, phase) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | if findPhase(state, "missing") != nil || findPhase(state, "") != nil { | |
| 171 | + | t.Fatal("missing phase lookup unexpectedly matched") | |
| 172 | + | } | |
| 173 | + | duplicate := cloneState(state) | |
| 174 | + | duplicate.Phases[0].Title = "Duplicate" | |
| 175 | + | duplicate.Phases[1].Title = "Duplicate" | |
| 176 | + | if findPhase(duplicate, "Duplicate") != nil { | |
| 177 | + | t.Fatal("ambiguous title unexpectedly matched") | |
| 178 | + | } | |
| 179 | + | if phase := findPhase(duplicate, "phase-2"); phase == nil || phase.ID != "phase-2" { | |
| 180 | + | t.Fatal("raw id did not win over duplicate title") | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) { | |
| 185 | + | state := sampleState() | |
| 186 | + | compact := buildToolResultText(actionSetTaskChecked, state, "Checked task task-3", nil) | |
| 187 | + | if !strings.Contains(compact, "Progress: 3/4 tasks checked") || !strings.Contains(compact, "[ ] Remaining implementation task [task-4]") { | |
| 188 | + | t.Fatalf("compact summary omitted useful active state: %s", compact) | |
| 189 | + | } | |
| 190 | + | if strings.Contains(compact, "Phases:") || strings.Contains(compact, "Completed discovery task") { | |
| 191 | + | t.Fatalf("compact summary included full checklist: %s", compact) | |
| 192 | + | } | |
| 193 | + | full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil) | |
| 194 | + | if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") { | |
| 195 | + | t.Fatalf("full summary omitted checklist: %s", full) | |
| 196 | + | } | |
| 197 | + | if !strings.Contains(buildSummary(state), "[phase-2] (1/2) - Reduce repeated context") { | |
| 198 | + | t.Fatal("phase goal/progress missing from full summary") | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) { | |
| 203 | + | state := sampleState() | |
| 204 | + | state.Phases[1].Tasks = append(state.Phases[1].Tasks, | |
| 205 | + | PhaseTask{ID: "task-5", Text: "Next focused task"}, | |
| 206 | + | PhaseTask{ID: "task-6", Text: "Another focused task"}, | |
| 207 | + | PhaseTask{ID: "task-7", Text: "Deferred focused task"}, | |
| 208 | + | ) | |
| 209 | + | state.Phases = append(state.Phases, Phase{ | |
| 210 | + | ID: "phase-3", | |
| 211 | + | Title: "Future phase", | |
| 212 | + | Tasks: []PhaseTask{{ID: "task-8", Text: "Future task"}}, | |
| 213 | + | }) | |
| 214 | + | context := buildTurnContext(state) | |
| 215 | + | for _, want := range []string{ | |
| 216 | + | "Tasked phases focus:", | |
| 217 | + | "Current phase: Implement compact output [phase-2]", | |
| 218 | + | "Goal: Reduce repeated context", | |
| 219 | + | "[ ] Remaining implementation task [task-4]", | |
| 220 | + | "[ ] Next focused task [task-5]", | |
| 221 | + | "[ ] Another focused task [task-6]", | |
| 222 | + | } { | |
| 223 | + | if !strings.Contains(context, want) { | |
| 224 | + | t.Errorf("turn context missing %q: %s", want, context) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | for _, unwanted := range []string{ | |
| 228 | + | "Ship compact tasked phases output", | |
| 229 | + | "Finished discovery", | |
| 230 | + | "Completed discovery task", | |
| 231 | + | "Future phase", | |
| 232 | + | "Future task", | |
| 233 | + | "Deferred focused task [task-7]", | |
| 234 | + | } { | |
| 235 | + | if strings.Contains(context, unwanted) { | |
| 236 | + | t.Errorf("turn context included %q: %s", unwanted, context) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) { | |
| 242 | + | state := sampleState() | |
| 243 | + | state.Phases[1].ID = strings.Repeat("phase-id-", 1000) | |
| 244 | + | state.CurrentPhaseID = state.Phases[1].ID | |
| 245 | + | state.Phases[1].Tasks[0].ID = strings.Repeat("task-id-", 1000) | |
| 246 | + | ||
| 247 | + | context := buildTurnContext(state) | |
| 248 | + | if got := len([]rune(context)); got > maxTurnContextChars { | |
| 249 | + | t.Fatalf("turn context length = %d; want at most %d", got, maxTurnContextChars) | |
| 250 | + | } | |
| 251 | + | if !strings.Contains(context, "Tasked phases focus:") || !strings.Contains(context, "Current phase:") || !strings.Contains(context, "Goal: Reduce repeated context") { | |
| 252 | + | t.Fatalf("bounded turn context lost its useful focus: %q", context) | |
| 253 | + | } | |
| 254 | + | if !strings.Contains(context, "Remaining implementation task") { | |
| 255 | + | t.Fatalf("per-field id bounding did not leave room for task lines: %q", context) | |
| 256 | + | } | |
| 257 | + | for _, line := range strings.Split(context, "\n") { | |
| 258 | + | if got := len([]rune(line)); got > 400 { | |
| 259 | + | t.Fatalf("unbounded field produced a %d-rune line: %q", got, line) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestDisplayRenderingSanitizesUntrustedTextWithoutMutatingState(t *testing.T) { | |
| 265 | + | state := sampleState() | |
| 266 | + | state.Spec = "spec \x1b[31mred\x1b[0m\x00" | |
| 267 | + | state.Phases[1].Title = "title \x1b]0;evil\a" | |
| 268 | + | state.Phases[1].Goal = "goal\r\t\x1b[2J" | |
| 269 | + | state.Phases[1].Tasks[0].Text = "task \x1b[?25l\x7f" | |
| 270 | + | original := state | |
| 271 | + | ||
| 272 | + | for _, rendered := range []string{ | |
| 273 | + | buildSummary(state), buildCompactSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"), | |
| 274 | + | } { | |
| 275 | + | if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') { | |
| 276 | + | t.Fatalf("rendered text contains terminal/control injection: %q", rendered) | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | if state.Spec != original.Spec || state.Phases[1].Title != original.Phases[1].Title || state.Phases[1].Tasks[0].Text != original.Phases[1].Tasks[0].Text { | |
| 280 | + | t.Fatal("render sanitization mutated persisted plan state") | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestTaskErrorsDistinguishMissingAndUnknownIDs(t *testing.T) { | |
| 285 | + | state := sampleState() | |
| 286 | + | for _, action := range []string{actionUpdateTask, actionSetTaskChecked} { | |
| 287 | + | if _, _, err := applyAction(state, toolArgs{Action: action}); err == nil || !strings.Contains(err.Error(), "taskId is required") { | |
| 288 | + | t.Fatalf("%s missing task id error = %v", action, err) | |
| 289 | + | } | |
| 290 | + | if _, _, err := applyAction(state, toolArgs{Action: action, TaskID: "missing"}); err == nil || !strings.Contains(err.Error(), "Valid incomplete task ids: task-4") { | |
| 291 | + | t.Fatalf("%s unknown task id error = %v", action, err) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func TestPanelRendersSpecGoalsTasksAndEmptyState(t *testing.T) { | |
| 297 | + | empty := strings.Join(buildViewLines(createEmptyState()), "\n") | |
| 298 | + | if !strings.Contains(empty, "No spec or phases stored yet.") || !strings.Contains(empty, "Press Escape to close") { | |
| 299 | + | t.Fatalf("empty panel = %s", empty) | |
| 300 | + | } | |
| 301 | + | panel := strings.Join(buildViewLines(sampleState()), "\n") | |
| 302 | + | for _, want := range []string{ | |
| 303 | + | "Tasked phases", | |
| 304 | + | "Spec", | |
| 305 | + | "Progress: 3/4 tasks checked", | |
| 306 | + | "[x] Completed discovery task [task-1]", | |
| 307 | + | "[ ] Remaining implementation task [task-4]", | |
| 308 | + | "Understand the old behavior", | |
| 309 | + | "Press Escape to close", | |
| 310 | + | } { | |
| 311 | + | if !strings.Contains(panel, want) { | |
| 312 | + | t.Errorf("panel missing %q: %s", want, panel) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func TestStatePersistenceIsProjectScopedAtomicAndRestrictive(t *testing.T) { | |
| 318 | + | dataDir := t.TempDir() | |
| 319 | + | first := newStateStore() | |
| 320 | + | if err := first.load(dataDir, "/projects/one"); err != nil { | |
| 321 | + | t.Fatal(err) | |
| 322 | + | } | |
| 323 | + | state, _, err := first.transact(func(current PlanState) (PlanState, string, error) { | |
| 324 | + | return applyAction(current, toolArgs{ | |
| 325 | + | Action: actionReplacePlan, | |
| 326 | + | Phases: &[]PhaseInput{{Title: "Persisted", Tasks: &[]TaskInput{{Text: "remember me"}}}}, | |
| 327 | + | }) | |
| 328 | + | }) | |
| 329 | + | if err != nil { | |
| 330 | + | t.Fatal(err) | |
| 331 | + | } | |
| 332 | + | if state.Phases[0].ID != "phase-1" { | |
| 333 | + | t.Fatalf("persisted state = %+v", state) | |
| 334 | + | } | |
| 335 | + | if runtime.GOOS != "windows" { | |
| 336 | + | info, err := os.Stat(first.path) | |
| 337 | + | if err != nil { | |
| 338 | + | t.Fatal(err) | |
| 339 | + | } | |
| 340 | + | if got := info.Mode().Perm(); got != 0o600 { | |
| 341 | + | t.Fatalf("state file mode = %o, want 600", got) | |
| 342 | + | } | |
| 343 | + | if got := (mustStat(t, filepath.Dir(first.path))).Mode().Perm(); got != 0o700 { | |
| 344 | + | t.Fatalf("state directory mode = %o, want 700", got) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | second := newStateStore() | |
| 349 | + | if err := second.load(dataDir, "/projects/one"); err != nil { | |
| 350 | + | t.Fatal(err) | |
| 351 | + | } | |
| 352 | + | loaded, err := second.snapshot() | |
| 353 | + | if err != nil { | |
| 354 | + | t.Fatal(err) | |
| 355 | + | } | |
| 356 | + | if loaded.Phases[0].Tasks[0].Text != "remember me" { | |
| 357 | + | t.Fatalf("loaded state = %+v", loaded) | |
| 358 | + | } | |
| 359 | + | otherProject := newStateStore() | |
| 360 | + | if err := otherProject.load(dataDir, "/projects/two"); err != nil { | |
| 361 | + | t.Fatal(err) | |
| 362 | + | } | |
| 363 | + | other, err := otherProject.snapshot() | |
| 364 | + | if err != nil { | |
| 365 | + | t.Fatal(err) | |
| 366 | + | } | |
| 367 | + | if hasStoredPlan(other) { | |
| 368 | + | t.Fatal("state leaked between project CWDs") | |
| 369 | + | } | |
| 370 | + | files, err := os.ReadDir(filepath.Dir(first.path)) | |
| 371 | + | if err != nil { | |
| 372 | + | t.Fatal(err) | |
| 373 | + | } | |
| 374 | + | for _, file := range files { | |
| 375 | + | if strings.HasSuffix(file.Name(), ".tmp") { | |
| 376 | + | t.Fatalf("temporary file left behind: %s", file.Name()) | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | var decoded PlanState | |
| 381 | + | data, err := os.ReadFile(first.path) | |
| 382 | + | if err != nil { | |
| 383 | + | t.Fatal(err) | |
| 384 | + | } | |
| 385 | + | if err := json.Unmarshal(data, &decoded); err != nil { | |
| 386 | + | t.Fatal(err) | |
| 387 | + | } | |
| 388 | + | if decoded.Phases[0].Tasks[0].ID != "task-1" { | |
| 389 | + | t.Fatalf("on-disk state = %+v", decoded) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func TestStateStoreDoesNotOverwriteCorruptStateAfterSessionActivation(t *testing.T) { | |
| 394 | + | dataDir := t.TempDir() | |
| 395 | + | cwd := "/projects/corrupt" | |
| 396 | + | path := projectStatePath(dataDir, cwd) | |
| 397 | + | original := []byte("not valid plan json") | |
| 398 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 399 | + | t.Fatal(err) | |
| 400 | + | } | |
| 401 | + | if err := os.WriteFile(path, original, 0o600); err != nil { | |
| 402 | + | t.Fatal(err) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | store := newStateStore() | |
| 406 | + | if err := store.load(dataDir, cwd); err == nil { | |
| 407 | + | t.Fatal("corrupt state unexpectedly loaded") | |
| 408 | + | } | |
| 409 | + | if err := store.activateSession(nil); err == nil { | |
| 410 | + | t.Fatal("session activation cleared the corrupt-state fault") | |
| 411 | + | } | |
| 412 | + | if _, _, err := store.transact(func(current PlanState) (PlanState, string, error) { | |
| 413 | + | return applyAction(current, toolArgs{Action: actionClear}) | |
| 414 | + | }); err == nil { | |
| 415 | + | t.Fatal("transaction unexpectedly wrote through a corrupt-state fault") | |
| 416 | + | } | |
| 417 | + | got, err := os.ReadFile(path) | |
| 418 | + | if err != nil { | |
| 419 | + | t.Fatal(err) | |
| 420 | + | } | |
| 421 | + | if string(got) != string(original) { | |
| 422 | + | t.Fatalf("corrupt state bytes changed from %q to %q", original, got) | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func TestStateStoreSerializesConcurrentTransitions(t *testing.T) { | |
| 427 | + | store := newStateStore() | |
| 428 | + | if err := store.load(t.TempDir(), "/projects/concurrent"); err != nil { | |
| 429 | + | t.Fatal(err) | |
| 430 | + | } | |
| 431 | + | const count = 12 | |
| 432 | + | var wg sync.WaitGroup | |
| 433 | + | for i := 0; i < count; i++ { | |
| 434 | + | wg.Add(1) | |
| 435 | + | go func(i int) { | |
| 436 | + | defer wg.Done() | |
| 437 | + | title := "phase " + string(rune('a'+i)) | |
| 438 | + | _, _, err := store.transact(func(current PlanState) (PlanState, string, error) { | |
| 439 | + | return applyAction(current, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr(title)}) | |
| 440 | + | }) | |
| 441 | + | if err != nil { | |
| 442 | + | t.Errorf("concurrent transition: %v", err) | |
| 443 | + | } | |
| 444 | + | }(i) | |
| 445 | + | } | |
| 446 | + | wg.Wait() | |
| 447 | + | state, err := store.snapshot() | |
| 448 | + | if err != nil { | |
| 449 | + | t.Fatal(err) | |
| 450 | + | } | |
| 451 | + | if len(state.Phases) != count { | |
| 452 | + | t.Fatalf("serialized phase count = %d, want %d", len(state.Phases), count) | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | func mustStat(t *testing.T, path string) os.FileInfo { | |
| 457 | + | t.Helper() | |
| 458 | + | info, err := os.Stat(path) | |
| 459 | + | if err != nil { | |
| 460 | + | t.Fatal(err) | |
| 461 | + | } | |
| 462 | + | return info | |
| 463 | + | } | |
A examples/skills/tasked-phases/SKILL.md +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | --- | |
| 2 | + | name: tasked-phases | |
| 3 | + | description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | # Tasked Phases | |
| 7 | + | ||
| 8 | + | Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress. | |
| 9 | + | ||
| 10 | + | ## Workflow | |
| 11 | + | ||
| 12 | + | 1. Clarify missing requirements when the spec is ambiguous. | |
| 13 | + | 2. Save the accepted spec with `tasked_phases` using `set_spec`. | |
| 14 | + | 3. Create or materially revise the plan with `replace_plan`. | |
| 15 | + | 4. Keep phases small and tasks concrete enough to check off. | |
| 16 | + | 5. Set the focus with `set_current_phase` and pass the target `phaseId`. | |
| 17 | + | 6. Call `set_task_checked` immediately after each checklist task is completed. | |
| 18 | + | 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task. | |
| 19 | + | 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases. | |
| 20 | + | ||
| 21 | + | ## Planning rules | |
| 22 | + | ||
| 23 | + | - Prefer 3–7 phases unless the task is tiny. | |
| 24 | + | - Each task should describe an observable outcome. | |
| 25 | + | - Avoid vague tasks such as “work on this” or “finish implementation”. | |
| 26 | + | - Keep the stored spec and plan concise and operational. | |
| 27 | + | ||
| 28 | + | ## Tool guidance | |
| 29 | + | ||
| 30 | + | - Use `get_status` before relying on remembered state or revising a long-running plan. | |
| 31 | + | - Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes. | |
| 32 | + | - Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output. | |
| 33 | + | - Do not rely on prose alone for completion state; update the tool after each completed task. | |
M packages/agent/build.go +83 -10
| @@ -61,11 +61,16 @@ type Resolved struct { | |||
| 61 | 61 | // messages to the provider transcript. | |
| 62 | 62 | ContextFiles []ContextFile | |
| 63 | 63 | ||
| 64 | + | // skillsEnabled is true when discovery ran, even when it found no | |
| 65 | + | // skills. It is false only for an explicit clean-room disable. | |
| 66 | + | skillsEnabled bool | |
| 67 | + | ||
| 64 | 68 | // Bookkeeping for MergeExtensionTools. Captured at Resolve time | |
| 65 | 69 | // so the system prompt can be rebuilt later without re-running | |
| 66 | 70 | // resolve. | |
| 67 | 71 | systemAppend []string | |
| 68 | 72 | systemCustom string | |
| 73 | + | skillAddendum string | |
| 69 | 74 | toolDescriptions map[string]string | |
| 70 | 75 | } | |
| 71 | 76 | ||
| @@ -83,33 +88,91 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||
| 83 | 88 | if mgr == nil { | |
| 84 | 89 | return | |
| 85 | 90 | } | |
| 86 | - | infos := mgr.Tools() | |
| 87 | - | if len(infos) == 0 { | |
| 88 | - | return | |
| 89 | - | } | |
| 90 | 91 | changed := false | |
| 91 | - | for _, info := range infos { | |
| 92 | + | for _, info := range mgr.Tools() { | |
| 92 | 93 | if _, exists := r.ToolRegistry[info.Name]; exists { | |
| 93 | 94 | continue | |
| 94 | 95 | } | |
| 95 | 96 | r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info) | |
| 96 | 97 | changed = true | |
| 97 | 98 | } | |
| 99 | + | ||
| 100 | + | // A nil SkillTool can mean discovery ran and found nothing, so use the | |
| 101 | + | // explicit disable flag rather than the discovery result as the gate. | |
| 102 | + | if source, ok := mgr.(ExtensionSkillSource); ok && r.skillsEnabled { | |
| 103 | + | if bundled := source.Skills(); len(bundled) > 0 { | |
| 104 | + | merged := mergeExtensionSkills(r.SkillTool, bundled) | |
| 105 | + | if r.SkillTool == nil { | |
| 106 | + | r.SkillTool = skills.NewTool(merged) | |
| 107 | + | r.ToolRegistry[r.SkillTool.Name()] = r.SkillTool | |
| 108 | + | } else { | |
| 109 | + | r.SkillTool.SetSkills(merged) | |
| 110 | + | } | |
| 111 | + | append_ := append([]string(nil), r.systemAppend...) | |
| 112 | + | if r.skillAddendum != "" { | |
| 113 | + | filtered := append_[:0] | |
| 114 | + | for _, item := range append_ { | |
| 115 | + | if item != r.skillAddendum { | |
| 116 | + | filtered = append(filtered, item) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | append_ = filtered | |
| 120 | + | } | |
| 121 | + | r.skillAddendum = skills.SystemPromptAddendum(merged) | |
| 122 | + | if r.skillAddendum != "" { | |
| 123 | + | append_ = append(append_, r.skillAddendum) | |
| 124 | + | } | |
| 125 | + | r.systemAppend = append_ | |
| 126 | + | changed = true | |
| 127 | + | } | |
| 128 | + | } | |
| 98 | 129 | if !changed { | |
| 99 | 130 | return | |
| 100 | 131 | } | |
| 101 | - | // Re-render the system prompt with the merged tool list. Skill | |
| 102 | - | // addendum is preserved by walking the existing append slice. | |
| 103 | - | append_ := r.systemAppend | |
| 132 | + | // Re-render the system prompt with the merged tool and skill lists. | |
| 133 | + | r.ToolSummary = toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions) | |
| 104 | 134 | r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{ | |
| 105 | 135 | CWD: r.CWD, | |
| 106 | - | Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions), | |
| 136 | + | Tools: r.ToolSummary, | |
| 107 | 137 | Custom: r.systemCustom, | |
| 108 | - | Append: append_, | |
| 138 | + | Append: r.systemAppend, | |
| 109 | 139 | ZotDocsDir: filepath.Join(ZotHome(), "docs"), | |
| 110 | 140 | }) | |
| 111 | 141 | } | |
| 112 | 142 | ||
| 143 | + | func mergeExtensionSkills(tool *skills.Tool, bundled []*skills.Skill) []*skills.Skill { | |
| 144 | + | var existing []*skills.Skill | |
| 145 | + | if tool != nil { | |
| 146 | + | existing = tool.Skills() | |
| 147 | + | } | |
| 148 | + | merged := make([]*skills.Skill, 0, len(existing)+len(bundled)) | |
| 149 | + | seen := map[string]bool{} | |
| 150 | + | add := func(list []*skills.Skill) { | |
| 151 | + | for _, skill := range list { | |
| 152 | + | if skill == nil || skill.Name == "" || seen[skill.Name] { | |
| 153 | + | continue | |
| 154 | + | } | |
| 155 | + | seen[skill.Name] = true | |
| 156 | + | merged = append(merged, skill) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | var user, builtins []*skills.Skill | |
| 160 | + | for _, skill := range existing { | |
| 161 | + | if skill == nil || strings.HasPrefix(skill.Source, "extension ") { | |
| 162 | + | continue | |
| 163 | + | } | |
| 164 | + | if skill.Builtin { | |
| 165 | + | builtins = append(builtins, skill) | |
| 166 | + | } else { | |
| 167 | + | user = append(user, skill) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | add(user) | |
| 171 | + | add(bundled) | |
| 172 | + | add(builtins) | |
| 173 | + | return merged | |
| 174 | + | } | |
| 175 | + | ||
| 113 | 176 | // ExtensionToolSource is the slice of the extension manager that | |
| 114 | 177 | // MergeExtensionTools needs. Lives here as an interface so the | |
| 115 | 178 | // build package doesn't import packages/agent/extensions (which | |
| @@ -119,6 +182,12 @@ type ExtensionToolSource interface { | |||
| 119 | 182 | NewExtensionTool(info ExtensionToolInfo) core.Tool | |
| 120 | 183 | } | |
| 121 | 184 | ||
| 185 | + | // ExtensionSkillSource exposes the optional bundled-skill surface without | |
| 186 | + | // forcing older ExtensionToolSource implementations to add it. | |
| 187 | + | type ExtensionSkillSource interface { | |
| 188 | + | Skills() []*skills.Skill | |
| 189 | + | } | |
| 190 | + | ||
| 122 | 191 | // ExtensionToolInfo mirrors extensions.ToolInfo so we can declare | |
| 123 | 192 | // ExtensionToolSource here without importing the extensions | |
| 124 | 193 | // package. The cli wires a tiny adapter to bridge them. | |
| @@ -640,8 +709,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 640 | 709 | discovered []*skills.Skill | |
| 641 | 710 | skillTool *skills.Tool | |
| 642 | 711 | skillAddendum string | |
| 712 | + | skillsEnabled bool | |
| 643 | 713 | ) | |
| 644 | 714 | if !args.NoSkill && (selectedProfile == nil || selectedProfile.InheritSkills == nil || *selectedProfile.InheritSkills) { | |
| 715 | + | skillsEnabled = true | |
| 645 | 716 | homeDir, _ := os.UserHomeDir() | |
| 646 | 717 | discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills) | |
| 647 | 718 | if len(discovered) > 0 { | |
| @@ -728,9 +799,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 728 | 799 | MaxOutput: resolvedModel.MaxOutput, | |
| 729 | 800 | Sandbox: sandbox, | |
| 730 | 801 | SkillTool: skillTool, | |
| 802 | + | skillsEnabled: skillsEnabled, | |
| 731 | 803 | ContextFiles: contextFiles, | |
| 732 | 804 | systemAppend: append_, | |
| 733 | 805 | systemCustom: custom, | |
| 806 | + | skillAddendum: skillAddendum, | |
| 734 | 807 | toolDescriptions: descMapFromSummaries(summaries), | |
| 735 | 808 | }, nil | |
| 736 | 809 | } | |
M packages/agent/build_test.go +48 -0
| @@ -9,10 +9,58 @@ import ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | + | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 12 | 13 | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 14 | + | "github.com/patriceckhart/zot/packages/core" | |
| 13 | 15 | "github.com/patriceckhart/zot/packages/provider" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 18 | + | type bundledSkillSource struct { | |
| 19 | + | bundled []*skills.Skill | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (s bundledSkillSource) Tools() []ExtensionToolInfo { return nil } | |
| 23 | + | func (s bundledSkillSource) NewExtensionTool(ExtensionToolInfo) core.Tool { | |
| 24 | + | return nil | |
| 25 | + | } | |
| 26 | + | func (s bundledSkillSource) Skills() []*skills.Skill { return s.bundled } | |
| 27 | + | ||
| 28 | + | func TestMergeExtensionToolsCreatesSkillToolWhenDiscoveryFoundNoSkills(t *testing.T) { | |
| 29 | + | r := Resolved{ | |
| 30 | + | CWD: t.TempDir(), | |
| 31 | + | ToolRegistry: make(core.Registry), | |
| 32 | + | skillsEnabled: true, | |
| 33 | + | } | |
| 34 | + | bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"} | |
| 35 | + | ||
| 36 | + | r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}}) | |
| 37 | + | ||
| 38 | + | if r.SkillTool == nil { | |
| 39 | + | t.Fatal("MergeExtensionTools did not create a skill tool for bundled skills") | |
| 40 | + | } | |
| 41 | + | if _, ok := r.ToolRegistry[r.SkillTool.Name()]; !ok { | |
| 42 | + | t.Fatalf("skill tool %q was not registered", r.SkillTool.Name()) | |
| 43 | + | } | |
| 44 | + | if got := r.SkillTool.Skills(); len(got) != 1 || got[0].Name != bundled.Name { | |
| 45 | + | t.Fatalf("merged bundled skills = %#v", got) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) { | |
| 50 | + | r := Resolved{ | |
| 51 | + | CWD: t.TempDir(), | |
| 52 | + | ToolRegistry: make(core.Registry), | |
| 53 | + | skillsEnabled: false, | |
| 54 | + | } | |
| 55 | + | bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"} | |
| 56 | + | ||
| 57 | + | r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}}) | |
| 58 | + | ||
| 59 | + | if r.SkillTool != nil { | |
| 60 | + | t.Fatal("disabled skill discovery created a skill tool") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 16 | 64 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |
| 17 | 65 | root := t.TempDir() | |
| 18 | 66 | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false) | |
M packages/agent/cli.go +203 -21
| @@ -37,9 +37,11 @@ import ( | |||
| 37 | 37 | const maxBufferedInteractiveAlerts = 64 | |
| 38 | 38 | ||
| 39 | 39 | type interactiveExtHooks struct { | |
| 40 | - | mu sync.Mutex | |
| 41 | - | interactive *modes.Interactive | |
| 42 | - | pendingAlerts []interactiveAlert | |
| 40 | + | mu sync.Mutex | |
| 41 | + | interactive *modes.Interactive | |
| 42 | + | pendingAlerts []interactiveAlert | |
| 43 | + | pendingStatuses map[string]interactiveStatus | |
| 44 | + | pendingWidgets map[string]interactiveWidget | |
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | 47 | type interactiveAlert struct { | |
| @@ -47,6 +49,21 @@ type interactiveAlert struct { | |||
| 47 | 49 | alert extproto.AlertRequest | |
| 48 | 50 | } | |
| 49 | 51 | ||
| 52 | + | type interactiveStatus struct { | |
| 53 | + | extName string | |
| 54 | + | key string | |
| 55 | + | level string | |
| 56 | + | text string | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type interactiveWidget struct { | |
| 60 | + | extName string | |
| 61 | + | id string | |
| 62 | + | position string | |
| 63 | + | title string | |
| 64 | + | lines []string | |
| 65 | + | } | |
| 66 | + | ||
| 50 | 67 | func (h *interactiveExtHooks) iv() *modes.Interactive { | |
| 51 | 68 | if h == nil { | |
| 52 | 69 | return nil | |
| @@ -62,12 +79,22 @@ func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) { | |||
| 62 | 79 | } | |
| 63 | 80 | h.mu.Lock() | |
| 64 | 81 | h.interactive = iv | |
| 65 | - | pending := h.pendingAlerts | |
| 82 | + | pendingAlerts := h.pendingAlerts | |
| 83 | + | pendingStatuses := h.pendingStatuses | |
| 84 | + | pendingWidgets := h.pendingWidgets | |
| 66 | 85 | h.pendingAlerts = nil | |
| 86 | + | h.pendingStatuses = nil | |
| 87 | + | h.pendingWidgets = nil | |
| 67 | 88 | h.mu.Unlock() | |
| 68 | - | for _, item := range pending { | |
| 89 | + | for _, item := range pendingAlerts { | |
| 69 | 90 | iv.Alert(item.extName, item.alert) | |
| 70 | 91 | } | |
| 92 | + | for _, item := range pendingStatuses { | |
| 93 | + | iv.SetStatus(item.extName, item.key, item.level, item.text) | |
| 94 | + | } | |
| 95 | + | for _, item := range pendingWidgets { | |
| 96 | + | iv.SetWidget(item.extName, item.id, item.position, item.title, item.lines) | |
| 97 | + | } | |
| 71 | 98 | } | |
| 72 | 99 | ||
| 73 | 100 | func (h *interactiveExtHooks) Notify(extName, level, message string) { | |
| @@ -131,6 +158,65 @@ func (h *interactiveExtHooks) ClosePanel(extName, panelID string) { | |||
| 131 | 158 | iv.ClosePanel(extName, panelID) | |
| 132 | 159 | } | |
| 133 | 160 | } | |
| 161 | + | func pendingChromeKey(extName, key string) string { return extName + "\x00" + key } | |
| 162 | + | ||
| 163 | + | func (h *interactiveExtHooks) SetStatus(extName, key, level, text string) { | |
| 164 | + | if h == nil { | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | h.mu.Lock() | |
| 168 | + | if h.interactive == nil { | |
| 169 | + | if h.pendingStatuses == nil { | |
| 170 | + | h.pendingStatuses = map[string]interactiveStatus{} | |
| 171 | + | } | |
| 172 | + | pendingKey := pendingChromeKey(extName, key) | |
| 173 | + | if strings.TrimSpace(text) == "" { | |
| 174 | + | delete(h.pendingStatuses, pendingKey) | |
| 175 | + | } else { | |
| 176 | + | h.pendingStatuses[pendingKey] = interactiveStatus{extName: extName, key: key, level: level, text: text} | |
| 177 | + | } | |
| 178 | + | h.mu.Unlock() | |
| 179 | + | return | |
| 180 | + | } | |
| 181 | + | iv := h.interactive | |
| 182 | + | h.mu.Unlock() | |
| 183 | + | iv.SetStatus(extName, key, level, text) | |
| 184 | + | } | |
| 185 | + | func (h *interactiveExtHooks) SetWidget(extName, id, position, title string, lines []string) { | |
| 186 | + | if h == nil { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | h.mu.Lock() | |
| 190 | + | if h.interactive == nil { | |
| 191 | + | if h.pendingWidgets == nil { | |
| 192 | + | h.pendingWidgets = map[string]interactiveWidget{} | |
| 193 | + | } | |
| 194 | + | h.pendingWidgets[pendingChromeKey(extName, id)] = interactiveWidget{ | |
| 195 | + | extName: extName, id: id, position: position, title: title, lines: append([]string(nil), lines...), | |
| 196 | + | } | |
| 197 | + | h.mu.Unlock() | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | iv := h.interactive | |
| 201 | + | h.mu.Unlock() | |
| 202 | + | iv.SetWidget(extName, id, position, title, lines) | |
| 203 | + | } | |
| 204 | + | func (h *interactiveExtHooks) ClearWidget(extName, id string) { | |
| 205 | + | if h == nil { | |
| 206 | + | return | |
| 207 | + | } | |
| 208 | + | h.mu.Lock() | |
| 209 | + | if h.interactive == nil { | |
| 210 | + | if h.pendingWidgets != nil { | |
| 211 | + | delete(h.pendingWidgets, pendingChromeKey(extName, id)) | |
| 212 | + | } | |
| 213 | + | h.mu.Unlock() | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | iv := h.interactive | |
| 217 | + | h.mu.Unlock() | |
| 218 | + | iv.ClearWidget(extName, id) | |
| 219 | + | } | |
| 134 | 220 | ||
| 135 | 221 | // extToolAdapter bridges *extensions.Manager to the | |
| 136 | 222 | // ExtensionToolSource interface declared in build.go (kept narrow to | |
| @@ -141,6 +227,16 @@ type extToolAdapter struct { | |||
| 141 | 227 | mgr *extensions.Manager | |
| 142 | 228 | } | |
| 143 | 229 | ||
| 230 | + | var _ ExtensionToolSource = (*extToolAdapter)(nil) | |
| 231 | + | var _ ExtensionSkillSource = (*extToolAdapter)(nil) | |
| 232 | + | ||
| 233 | + | func (a *extToolAdapter) Skills() []*skills.Skill { | |
| 234 | + | if a == nil || a.mgr == nil { | |
| 235 | + | return nil | |
| 236 | + | } | |
| 237 | + | return a.mgr.Skills() | |
| 238 | + | } | |
| 239 | + | ||
| 144 | 240 | func (a *extToolAdapter) Tools() []ExtensionToolInfo { | |
| 145 | 241 | infos := a.mgr.Tools() | |
| 146 | 242 | out := make([]ExtensionToolInfo, len(infos)) | |
| @@ -436,6 +532,61 @@ func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, ex | |||
| 436 | 532 | // the events that have a clear extension-facing meaning are | |
| 437 | 533 | // forwarded; internal-only ones (text_delta, tool_progress) are | |
| 438 | 534 | // dropped to keep the per-extension stream sane. | |
| 535 | + | func sessionContext(sess *core.Session) *extproto.SessionContext { | |
| 536 | + | if sess == nil { | |
| 537 | + | return nil | |
| 538 | + | } | |
| 539 | + | return &extproto.SessionContext{ | |
| 540 | + | ID: sess.Meta.ID, | |
| 541 | + | ParentID: sess.Meta.Parent, | |
| 542 | + | Path: sess.Path, | |
| 543 | + | CWD: sess.Meta.CWD, | |
| 544 | + | ForkPoint: sess.Meta.ForkPoint, | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | func announceSession(mgr *extensions.Manager, sess *core.Session) { | |
| 549 | + | if mgr == nil { | |
| 550 | + | return | |
| 551 | + | } | |
| 552 | + | var states map[string]json.RawMessage | |
| 553 | + | if sess != nil { | |
| 554 | + | states = copyExtensionStates(sess.ExtensionState) | |
| 555 | + | } | |
| 556 | + | mgr.EmitSessionEvent("session_start", sessionContext(sess), states) | |
| 557 | + | if sess != nil { | |
| 558 | + | mgr.EmitSessionEvent("session_opened", sessionContext(sess), states) | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawMessage { | |
| 563 | + | if len(states) == 0 { | |
| 564 | + | return nil | |
| 565 | + | } | |
| 566 | + | out := make(map[string]json.RawMessage, len(states)) | |
| 567 | + | for name, state := range states { | |
| 568 | + | out[name] = append(json.RawMessage(nil), state...) | |
| 569 | + | } | |
| 570 | + | return out | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) { | |
| 574 | + | if sess == nil { | |
| 575 | + | return | |
| 576 | + | } | |
| 577 | + | details, ok := result.Details.(extensions.ToolResultDetails) | |
| 578 | + | if !ok || details.Extension == "" || len(details.State) == 0 { | |
| 579 | + | return | |
| 580 | + | } | |
| 581 | + | if err := sess.AppendExtensionState(details.Extension, details.State); err != nil { | |
| 582 | + | fmt.Fprintf(os.Stderr, "extension state: %v\n", err) | |
| 583 | + | return | |
| 584 | + | } | |
| 585 | + | if mgr != nil { | |
| 586 | + | mgr.UpdateSessionState(details.Extension, details.State) | |
| 587 | + | } | |
| 588 | + | } | |
| 589 | + | ||
| 439 | 590 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |
| 440 | 591 | if mgr == nil { | |
| 441 | 592 | return | |
| @@ -557,6 +708,9 @@ func (nonInteractiveExtHooks) ClearNotes(string) | |||
| 557 | 708 | func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {} | |
| 558 | 709 | func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {} | |
| 559 | 710 | func (nonInteractiveExtHooks) ClosePanel(string, string) {} | |
| 711 | + | func (nonInteractiveExtHooks) SetStatus(string, string, string, string) {} | |
| 712 | + | func (nonInteractiveExtHooks) SetWidget(string, string, string, string, []string) {} | |
| 713 | + | func (nonInteractiveExtHooks) ClearWidget(string, string) {} | |
| 560 | 714 | ||
| 561 | 715 | // setupNonInteractiveExtensions loads --ext paths and (unless | |
| 562 | 716 | // --no-ext) runs discovery. Returns the manager so the caller can | |
| @@ -574,7 +728,6 @@ func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, | |||
| 574 | 728 | } | |
| 575 | 729 | extMgr.WaitForReady(3 * time.Second) | |
| 576 | 730 | r.MergeExtensionTools(&extToolAdapter{mgr: extMgr}) | |
| 577 | - | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | |
| 578 | 731 | return extMgr, func() { extMgr.Stop(2 * time.Second) } | |
| 579 | 732 | } | |
| 580 | 733 | ||
| @@ -593,9 +746,9 @@ func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr | |||
| 593 | 746 | } | |
| 594 | 747 | return true, "", res.ModifiedArgs | |
| 595 | 748 | } | |
| 596 | - | ag.BeforeTurn = func(step int) (bool, string) { | |
| 597 | - | res := extMgr.InterceptTurnStart(ctx, step) | |
| 598 | - | return !res.Block, res.Reason | |
| 749 | + | ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) { | |
| 750 | + | res := extMgr.InterceptTurnStart(turnCtx, step) | |
| 751 | + | return !res.Block, res.Reason, res.Context | |
| 599 | 752 | } | |
| 600 | 753 | ag.BeforeAssistantMessage = func(text string) (bool, string, string) { | |
| 601 | 754 | res := extMgr.InterceptAssistantMessage(ctx, text) | |
| @@ -677,8 +830,10 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 677 | 830 | return err | |
| 678 | 831 | } | |
| 679 | 832 | r.Provider, r.Model = providerName, model | |
| 833 | + | ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) } | |
| 680 | 834 | defer sess.Close() | |
| 681 | 835 | } | |
| 836 | + | announceSession(extMgr, sess) | |
| 682 | 837 | ||
| 683 | 838 | prompt := args.Prompt | |
| 684 | 839 | if prompt == "" { | |
| @@ -738,8 +893,10 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 738 | 893 | return err | |
| 739 | 894 | } | |
| 740 | 895 | r.Provider, r.Model = providerName, model | |
| 896 | + | ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) } | |
| 741 | 897 | defer sess.Close() | |
| 742 | 898 | } | |
| 899 | + | announceSession(extMgr, sess) | |
| 743 | 900 | ||
| 744 | 901 | prompt := args.Prompt | |
| 745 | 902 | if prompt == "" { | |
| @@ -838,8 +995,10 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 838 | 995 | return err | |
| 839 | 996 | } | |
| 840 | 997 | r.Provider, r.Model = providerName, model | |
| 998 | + | ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) } | |
| 841 | 999 | defer sess.Close() | |
| 842 | 1000 | } | |
| 1001 | + | announceSession(extMgr, sess) | |
| 843 | 1002 | ||
| 844 | 1003 | prompt := args.Prompt | |
| 845 | 1004 | if prompt == "" { | |
| @@ -1170,9 +1329,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1170 | 1329 | } | |
| 1171 | 1330 | return true, "", r.ModifiedArgs | |
| 1172 | 1331 | } | |
| 1173 | - | a.BeforeTurn = func(step int) (bool, string) { | |
| 1174 | - | r := extMgr.InterceptTurnStart(ctx, step) | |
| 1175 | - | return !r.Block, r.Reason | |
| 1332 | + | a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) { | |
| 1333 | + | r := extMgr.InterceptTurnStart(turnCtx, step) | |
| 1334 | + | return !r.Block, r.Reason, r.Context | |
| 1176 | 1335 | } | |
| 1177 | 1336 | a.BeforeAssistantMessage = func(text string) (bool, string, string) { | |
| 1178 | 1337 | r := extMgr.InterceptAssistantMessage(ctx, text) | |
| @@ -1261,9 +1420,6 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1261 | 1420 | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | |
| 1262 | 1421 | }) | |
| 1263 | 1422 | ||
| 1264 | - | // Fire session_start once we know the manager's running. | |
| 1265 | - | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | |
| 1266 | - | ||
| 1267 | 1423 | var sess *core.Session | |
| 1268 | 1424 | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 1269 | 1425 | var sessionTitlePending bool | |
| @@ -1296,6 +1452,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1296 | 1452 | sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint | |
| 1297 | 1453 | } | |
| 1298 | 1454 | } | |
| 1455 | + | announceSession(extMgr, sess) | |
| 1299 | 1456 | defer func() { | |
| 1300 | 1457 | persistMu.Lock() | |
| 1301 | 1458 | defer persistMu.Unlock() | |
| @@ -1334,16 +1491,27 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1334 | 1491 | } | |
| 1335 | 1492 | persistCompaction := func(messages []provider.Message) { | |
| 1336 | 1493 | sessionTransitionMu.RLock() | |
| 1337 | - | defer sessionTransitionMu.RUnlock() | |
| 1494 | + | var session *extproto.SessionContext | |
| 1495 | + | var states map[string]json.RawMessage | |
| 1338 | 1496 | persistMu.Lock() | |
| 1339 | - | defer persistMu.Unlock() | |
| 1340 | - | if sess == nil { | |
| 1341 | - | return | |
| 1497 | + | if sess != nil { | |
| 1498 | + | if err := sess.AppendCompaction(messages); err == nil { | |
| 1499 | + | sessBaselineMsgs = len(messages) | |
| 1500 | + | session = sessionContext(sess) | |
| 1501 | + | states = copyExtensionStates(sess.ExtensionState) | |
| 1502 | + | } | |
| 1342 | 1503 | } | |
| 1343 | - | if err := sess.AppendCompaction(messages); err == nil { | |
| 1344 | - | sessBaselineMsgs = len(messages) | |
| 1504 | + | persistMu.Unlock() | |
| 1505 | + | sessionTransitionMu.RUnlock() | |
| 1506 | + | if session != nil { | |
| 1507 | + | extMgr.EmitSessionEvent("session_compacted", session, states) | |
| 1345 | 1508 | } | |
| 1346 | 1509 | } | |
| 1510 | + | persistToolResult := func(_ string, result core.ToolResult) { | |
| 1511 | + | persistMu.Lock() | |
| 1512 | + | defer persistMu.Unlock() | |
| 1513 | + | persistExtensionToolResult(extMgr, sess, result) | |
| 1514 | + | } | |
| 1347 | 1515 | wireAgentPersist := func(a *core.Agent) *core.Agent { | |
| 1348 | 1516 | if a == nil { | |
| 1349 | 1517 | return a | |
| @@ -1351,6 +1519,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1351 | 1519 | a.OnMessageAppended = persistMessage | |
| 1352 | 1520 | a.OnUsage = persistUsage | |
| 1353 | 1521 | a.OnTranscriptCompacted = persistCompaction | |
| 1522 | + | a.OnToolResult = persistToolResult | |
| 1354 | 1523 | return a | |
| 1355 | 1524 | } | |
| 1356 | 1525 | wireAgentPersist(ag) | |
| @@ -1443,6 +1612,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1443 | 1612 | persistMu.Unlock() | |
| 1444 | 1613 | return fmt.Errorf("session changed while loading; please try again") | |
| 1445 | 1614 | } | |
| 1615 | + | newStates := copyExtensionStates(candidate.session.ExtensionState) | |
| 1446 | 1616 | if oldSess != nil { | |
| 1447 | 1617 | _ = oldSess.Close() | |
| 1448 | 1618 | } | |
| @@ -1477,6 +1647,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1477 | 1647 | } | |
| 1478 | 1648 | committed = true | |
| 1479 | 1649 | persistMu.Unlock() | |
| 1650 | + | if candidate.session.Meta.Parent != "" { | |
| 1651 | + | extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates) | |
| 1652 | + | } | |
| 1653 | + | extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates) | |
| 1480 | 1654 | return nil | |
| 1481 | 1655 | } | |
| 1482 | 1656 | ||
| @@ -1626,12 +1800,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1626 | 1800 | ||
| 1627 | 1801 | // Commit only after every fallible operation has succeeded. Close | |
| 1628 | 1802 | // the old session last so rollback never has to resurrect it. | |
| 1803 | + | var newStates map[string]json.RawMessage | |
| 1629 | 1804 | persistMu.Lock() | |
| 1630 | 1805 | if sess != nil { | |
| 1631 | 1806 | writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs) | |
| 1632 | 1807 | _ = sess.Close() | |
| 1633 | 1808 | } | |
| 1634 | 1809 | sess = newSess | |
| 1810 | + | if newSess != nil { | |
| 1811 | + | newStates = copyExtensionStates(newSess.ExtensionState) | |
| 1812 | + | } | |
| 1635 | 1813 | sessBaselineMsgs = 0 | |
| 1636 | 1814 | sessionTitlePending = false | |
| 1637 | 1815 | activeProvider = newProvider | |
| @@ -1650,6 +1828,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1650 | 1828 | if swarmMgr != nil && newSess != nil { | |
| 1651 | 1829 | swarmMgr.SetActiveSession(newSess.ID) | |
| 1652 | 1830 | } | |
| 1831 | + | if newSess != nil { | |
| 1832 | + | extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates) | |
| 1833 | + | } | |
| 1653 | 1834 | return nil | |
| 1654 | 1835 | } | |
| 1655 | 1836 | ||
| @@ -1906,6 +2087,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1906 | 2087 | // manifest and the skill tool. | |
| 1907 | 2088 | userHome, _ := os.UserHomeDir() | |
| 1908 | 2089 | list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills) | |
| 2090 | + | list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills()) | |
| 1909 | 2091 | return skills.VisibleSkills(list) | |
| 1910 | 2092 | }, | |
| 1911 | 2093 | NoYolo: args.NoYolo, | |
M packages/agent/cli_alert_test.go +23 -0
| @@ -7,6 +7,29 @@ import ( | |||
| 7 | 7 | "github.com/patriceckhart/zot/packages/agent/modes" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestInteractiveExtHooksBufferStartupChrome(t *testing.T) { | |
| 11 | + | hooks := &interactiveExtHooks{} | |
| 12 | + | hooks.SetStatus("tasked-phases", "progress", "success", "2/4") | |
| 13 | + | hooks.SetWidget("tasked-phases", "plan", "above_input", "Plan", []string{"one"}) | |
| 14 | + | hooks.SetWidget("tasked-phases", "cleared", "above_input", "old", []string{"old"}) | |
| 15 | + | hooks.ClearWidget("tasked-phases", "cleared") | |
| 16 | + | hooks.SetStatus("tasked-phases", "removed", "info", "old") | |
| 17 | + | hooks.SetStatus("tasked-phases", "removed", "info", "") | |
| 18 | + | ||
| 19 | + | hooks.mu.Lock() | |
| 20 | + | if len(hooks.pendingStatuses) != 1 || len(hooks.pendingWidgets) != 1 { | |
| 21 | + | t.Fatalf("pending chrome = statuses %d widgets %d, want one each", len(hooks.pendingStatuses), len(hooks.pendingWidgets)) | |
| 22 | + | } | |
| 23 | + | hooks.mu.Unlock() | |
| 24 | + | ||
| 25 | + | hooks.attachInteractive(&modes.Interactive{}) | |
| 26 | + | hooks.mu.Lock() | |
| 27 | + | defer hooks.mu.Unlock() | |
| 28 | + | if len(hooks.pendingStatuses) != 0 || len(hooks.pendingWidgets) != 0 { | |
| 29 | + | t.Fatalf("pending chrome was not flushed: statuses=%v widgets=%v", hooks.pendingStatuses, hooks.pendingWidgets) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 10 | 33 | func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) { | |
| 11 | 34 | hooks := &interactiveExtHooks{} | |
| 12 | 35 | hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"}) | |
M packages/agent/ext/ext.go +56 -5
| @@ -66,7 +66,11 @@ type EventHandler func(ev Event) | |||
| 66 | 66 | // Event is a lifecycle notification from zot. The fields populated | |
| 67 | 67 | // depend on Name (the host's event_name string): | |
| 68 | 68 | // | |
| 69 | - | // session_start : (no extra fields) | |
| 69 | + | // session_start : Session | |
| 70 | + | // session_opened : Session + extension-owned State snapshot | |
| 71 | + | // session_switched : Session + extension-owned State snapshot | |
| 72 | + | // session_forked : Session + extension-owned State snapshot | |
| 73 | + | // session_compacted: Session + extension-owned State snapshot | |
| 70 | 74 | // turn_start : Step | |
| 71 | 75 | // turn_end : Stop, optional Error | |
| 72 | 76 | // tool_call : ToolID, ToolName, ToolArgs | |
| @@ -75,6 +79,9 @@ type EventHandler func(ev Event) | |||
| 75 | 79 | type Event struct { | |
| 76 | 80 | Name string | |
| 77 | 81 | ||
| 82 | + | Session *extproto.SessionContext | |
| 83 | + | State json.RawMessage | |
| 84 | + | ||
| 78 | 85 | Step int | |
| 79 | 86 | Stop string | |
| 80 | 87 | Error string | |
| @@ -122,6 +129,9 @@ type ToolCallHandler func(toolName string, args json.RawMessage) ToolCallDecisio | |||
| 122 | 129 | type TurnStartDecision struct { | |
| 123 | 130 | Block bool | |
| 124 | 131 | Reason string | |
| 132 | + | // Context is hidden, per-turn context sent only in the next provider | |
| 133 | + | // request. It is not recorded as a user or assistant message. | |
| 134 | + | Context string | |
| 125 | 135 | } | |
| 126 | 136 | ||
| 127 | 137 | // TurnStartHandler is called before every turn's model call. Return | |
| @@ -156,6 +166,20 @@ type ToolResult struct { | |||
| 156 | 166 | Content []ToolContent | |
| 157 | 167 | IsError bool | |
| 158 | 168 | ActivateTools []string | |
| 169 | + | // Details is opaque JSON kept out of provider-visible tool results. The | |
| 170 | + | // host may persist it with the active session and return it on the next | |
| 171 | + | // session lifecycle event. | |
| 172 | + | Details json.RawMessage | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // JSONDetails encodes an extension-owned value for ToolResult.Details. Invalid | |
| 176 | + | // values return nil, causing the host to ignore the metadata. | |
| 177 | + | func JSONDetails(value any) json.RawMessage { | |
| 178 | + | data, err := json.Marshal(value) | |
| 179 | + | if err != nil || !json.Valid(data) { | |
| 180 | + | return nil | |
| 181 | + | } | |
| 182 | + | return data | |
| 159 | 183 | } | |
| 160 | 184 | ||
| 161 | 185 | // AlertRequest asks the host to surface a structured alert. The host owns | |
| @@ -301,6 +325,7 @@ type HostInfo struct { | |||
| 301 | 325 | CWD string | |
| 302 | 326 | ExtensionDir string | |
| 303 | 327 | DataDir string | |
| 328 | + | Session *extproto.SessionContext | |
| 304 | 329 | } | |
| 305 | 330 | ||
| 306 | 331 | // New constructs an Extension with the given identifier. name should | |
| @@ -317,7 +342,7 @@ func New(name, version string) *Extension { | |||
| 317 | 342 | eventHandlers: map[string]EventHandler{}, | |
| 318 | 343 | panelKeys: map[string]func(key, text string){}, | |
| 319 | 344 | panelCloses: map[string]func(){}, | |
| 320 | - | caps: []string{"commands", "tools", "events", "alerts", "panels"}, | |
| 345 | + | caps: []string{"commands", "tools", "events", "alerts", "panels", "session_state", "status", "widgets"}, | |
| 321 | 346 | } | |
| 322 | 347 | } | |
| 323 | 348 | ||
| @@ -416,8 +441,9 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag | |||
| 416 | 441 | ||
| 417 | 442 | // On subscribes to a lifecycle event. fn is called for each | |
| 418 | 443 | // notification; the same name can only have one handler (later | |
| 419 | - | // registrations replace earlier ones). Recognised names: | |
| 420 | - | // session_start, turn_start, turn_end, tool_call, | |
| 444 | + | // registrations replace earlier ones). Recognised names include: | |
| 445 | + | // session_start, session_opened, session_switched, session_forked, | |
| 446 | + | // session_compacted, turn_start, turn_end, tool_call, | |
| 421 | 447 | // tool_confirmation_requested, assistant_message. | |
| 422 | 448 | func (e *Extension) On(name string, fn EventHandler) { | |
| 423 | 449 | e.mu.Lock() | |
| @@ -485,6 +511,25 @@ func (e *Extension) Notify(level, message string) { | |||
| 485 | 511 | }) | |
| 486 | 512 | } | |
| 487 | 513 | ||
| 514 | + | // SetStatus replaces one persistent status item owned by this extension. | |
| 515 | + | // Passing an empty text clears the item. | |
| 516 | + | func (e *Extension) SetStatus(key, text string) { | |
| 517 | + | _ = e.send(extproto.StatusFromExt{Type: "status", Key: key, Text: text}) | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | // ClearStatus removes one persistent status item owned by this extension. | |
| 521 | + | func (e *Extension) ClearStatus(key string) { e.SetStatus(key, "") } | |
| 522 | + | ||
| 523 | + | // SetWidget replaces one persistent widget owned by this extension. | |
| 524 | + | func (e *Extension) SetWidget(id, position, title string, lines []string) { | |
| 525 | + | _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines}) | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // ClearWidget removes one persistent widget owned by this extension. | |
| 529 | + | func (e *Extension) ClearWidget(id string) { | |
| 530 | + | _ = e.send(extproto.ClearWidgetFromExt{Type: "widget_clear", ID: id}) | |
| 531 | + | } | |
| 532 | + | ||
| 488 | 533 | // Alert asks the host to surface a structured alert. It is fire-and-forget; | |
| 489 | 534 | // hosts that do not support the requested kind may ignore it. | |
| 490 | 535 | func (e *Extension) Alert(alert AlertRequest) { | |
| @@ -529,6 +574,7 @@ func (e *Extension) Run() error { | |||
| 529 | 574 | CWD: ack.CWD, | |
| 530 | 575 | ExtensionDir: ack.ExtensionDir, | |
| 531 | 576 | DataDir: ack.DataDir, | |
| 577 | + | Session: ack.Session, | |
| 532 | 578 | } | |
| 533 | 579 | e.mu.Lock() | |
| 534 | 580 | onHello := e.onHello | |
| @@ -657,7 +703,8 @@ func (e *Extension) Run() error { | |||
| 657 | 703 | } | |
| 658 | 704 | }() | |
| 659 | 705 | handler(Event{ | |
| 660 | - | Name: ef.Event, Step: ef.Step, Stop: ef.Stop, | |
| 706 | + | Name: ef.Event, Session: ef.Session, State: append(json.RawMessage(nil), ef.State...), | |
| 707 | + | Step: ef.Step, Stop: ef.Stop, | |
| 661 | 708 | Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName, | |
| 662 | 709 | ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text, | |
| 663 | 710 | }) | |
| @@ -739,6 +786,7 @@ func (e *Extension) respondTool(id string, r ToolResult) { | |||
| 739 | 786 | Content: blocks, | |
| 740 | 787 | IsError: r.IsError, | |
| 741 | 788 | ActivateTools: r.ActivateTools, | |
| 789 | + | Details: append(json.RawMessage(nil), r.Details...), | |
| 742 | 790 | }) | |
| 743 | 791 | } | |
| 744 | 792 | ||
| @@ -802,6 +850,9 @@ func (e *Extension) dispatchIntercept(ei extproto.EventInterceptFromHost) { | |||
| 802 | 850 | d := fn(ei.Step) | |
| 803 | 851 | resp.Block = d.Block | |
| 804 | 852 | resp.Reason = d.Reason | |
| 853 | + | if !d.Block { | |
| 854 | + | resp.Context = d.Context | |
| 855 | + | } | |
| 805 | 856 | } | |
| 806 | 857 | case "assistant_message": | |
| 807 | 858 | e.mu.Lock() | |
M packages/agent/ext/ext_test.go +113 -0
| @@ -3,6 +3,7 @@ package ext | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| @@ -64,6 +65,25 @@ func newHarness(name string) *extHarness { | |||
| 64 | 65 | return h | |
| 65 | 66 | } | |
| 66 | 67 | ||
| 68 | + | func (h *extHarness) startRun(t *testing.T) { | |
| 69 | + | t.Helper() | |
| 70 | + | runDone := make(chan error, 1) | |
| 71 | + | go func() { runDone <- h.ext.Run() }() | |
| 72 | + | t.Cleanup(func() { | |
| 73 | + | if err := h.hostW.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) { | |
| 74 | + | t.Errorf("close extension input: %v", err) | |
| 75 | + | } | |
| 76 | + | select { | |
| 77 | + | case err := <-runDone: | |
| 78 | + | if err != nil { | |
| 79 | + | t.Errorf("extension Run: %v", err) | |
| 80 | + | } | |
| 81 | + | case <-time.After(2 * time.Second): | |
| 82 | + | t.Error("extension Run did not exit after closing host input") | |
| 83 | + | } | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | ||
| 67 | 87 | // next returns the next frame, timing out after 2 s. | |
| 68 | 88 | func (h *extHarness) next(t *testing.T) rawFrame { | |
| 69 | 89 | t.Helper() | |
| @@ -220,6 +240,99 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) { | |||
| 220 | 240 | h.hostW.Close() | |
| 221 | 241 | } | |
| 222 | 242 | ||
| 243 | + | func TestPersistentChromeFrames(t *testing.T) { | |
| 244 | + | h := newHarness("chrome-ext") | |
| 245 | + | h.startRun(t) | |
| 246 | + | h.handshake(t) | |
| 247 | + | ||
| 248 | + | h.ext.SetStatus("progress", "2/4 tasks") | |
| 249 | + | status := h.drainUntil(t, "status") | |
| 250 | + | var sf extproto.StatusFromExt | |
| 251 | + | if err := json.Unmarshal(status.raw, &sf); err != nil { | |
| 252 | + | t.Fatal(err) | |
| 253 | + | } | |
| 254 | + | if sf.Key != "progress" || sf.Text != "2/4 tasks" { | |
| 255 | + | t.Fatalf("status = %+v", sf) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | h.ext.SetWidget("plan", "above_input", "Plan", []string{"one"}) | |
| 259 | + | widget := h.drainUntil(t, "widget") | |
| 260 | + | var wf extproto.WidgetFromExt | |
| 261 | + | if err := json.Unmarshal(widget.raw, &wf); err != nil { | |
| 262 | + | t.Fatal(err) | |
| 263 | + | } | |
| 264 | + | if wf.ID != "plan" || wf.Position != "above_input" || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" { | |
| 265 | + | t.Fatalf("widget = %+v", wf) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | h.ext.ClearWidget("plan") | |
| 269 | + | clear := h.drainUntil(t, "widget_clear") | |
| 270 | + | var cf extproto.ClearWidgetFromExt | |
| 271 | + | if err := json.Unmarshal(clear.raw, &cf); err != nil { | |
| 272 | + | t.Fatal(err) | |
| 273 | + | } | |
| 274 | + | if cf.ID != "plan" { | |
| 275 | + | t.Fatalf("widget clear = %+v", cf) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func TestToolResultDetailsEmitsOpaqueMetadata(t *testing.T) { | |
| 280 | + | h := newHarness("details-ext") | |
| 281 | + | h.ext.Tool("details", "returns metadata", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult { | |
| 282 | + | return ToolResult{Content: []ToolContent{Text("ok")}, Details: JSONDetails(map[string]any{"state": 1})} | |
| 283 | + | }) | |
| 284 | + | h.startRun(t) | |
| 285 | + | h.handshake(t) | |
| 286 | + | h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "call-1", Name: "details", Args: json.RawMessage(`{}`)}) | |
| 287 | + | frame := h.drainUntil(t, "tool_result") | |
| 288 | + | var result extproto.ToolResultFromExt | |
| 289 | + | if err := json.Unmarshal(frame.raw, &result); err != nil { | |
| 290 | + | t.Fatal(err) | |
| 291 | + | } | |
| 292 | + | if string(result.Details) != `{"state":1}` { | |
| 293 | + | t.Fatalf("details = %s", result.Details) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestSessionEventCarriesIdentityAndState(t *testing.T) { | |
| 298 | + | h := newHarness("session-ext") | |
| 299 | + | received := make(chan Event, 1) | |
| 300 | + | h.ext.On("session_opened", func(event Event) { received <- event }) | |
| 301 | + | h.startRun(t) | |
| 302 | + | h.handshake(t) | |
| 303 | + | h.sendToExt(t, extproto.EventFromHost{ | |
| 304 | + | Type: "event", Event: "session_opened", | |
| 305 | + | Session: &extproto.SessionContext{ID: "branch-1", ParentID: "root-1", Path: "/tmp/session.jsonl", ForkPoint: 4}, | |
| 306 | + | State: json.RawMessage(`{"version":1}`), | |
| 307 | + | }) | |
| 308 | + | select { | |
| 309 | + | case event := <-received: | |
| 310 | + | if event.Session == nil || event.Session.ID != "branch-1" || event.Session.ParentID != "root-1" || event.Session.Path != "/tmp/session.jsonl" || event.Session.ForkPoint != 4 || string(event.State) != `{"version":1}` { | |
| 311 | + | t.Fatalf("session event = %+v state=%s", event.Session, event.State) | |
| 312 | + | } | |
| 313 | + | case <-time.After(2 * time.Second): | |
| 314 | + | t.Fatal("timeout waiting for session event") | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestTurnStartContextIsReturned(t *testing.T) { | |
| 319 | + | h := newHarness("context-ext") | |
| 320 | + | h.ext.InterceptTurnStart(func(int) TurnStartDecision { | |
| 321 | + | return TurnStartDecision{Context: "current phase: parse"} | |
| 322 | + | }) | |
| 323 | + | h.startRun(t) | |
| 324 | + | h.handshake(t) | |
| 325 | + | h.sendToExt(t, extproto.EventInterceptFromHost{Type: "event_intercept", ID: "intercept-1", Event: "turn_start", Step: 2}) | |
| 326 | + | frame := h.drainUntil(t, "event_intercept_response") | |
| 327 | + | var response extproto.EventInterceptResponseFromExt | |
| 328 | + | if err := json.Unmarshal(frame.raw, &response); err != nil { | |
| 329 | + | t.Fatal(err) | |
| 330 | + | } | |
| 331 | + | if response.ID != "intercept-1" || response.Context != "current phase: parse" || response.Block { | |
| 332 | + | t.Fatalf("intercept response = %+v", response) | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ||
| 223 | 336 | func TestAlertEmitsStructuredFrame(t *testing.T) { | |
| 224 | 337 | h := newHarness("alert-ext") | |
| 225 | 338 | runDone := make(chan error, 1) | |
M packages/agent/extcmd.go +3 -0
| @@ -142,6 +142,9 @@ func (extDoctorHooks) ClearNotes(string) {} | |||
| 142 | 142 | func (extDoctorHooks) OpenPanel(string, extproto.PanelSpec) {} | |
| 143 | 143 | func (extDoctorHooks) UpdatePanel(string, string, string, []string, string) {} | |
| 144 | 144 | func (extDoctorHooks) ClosePanel(string, string) {} | |
| 145 | + | func (extDoctorHooks) SetStatus(string, string, string, string) {} | |
| 146 | + | func (extDoctorHooks) SetWidget(string, string, string, string, []string) {} | |
| 147 | + | func (extDoctorHooks) ClearWidget(string, string) {} | |
| 145 | 148 | ||
| 146 | 149 | type extDoctorStaticRow struct { | |
| 147 | 150 | Scope string | |
M packages/agent/extensions/events.go +131 -9
| @@ -4,7 +4,9 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | + | "strings" | |
| 7 | 8 | "time" | |
| 9 | + | "unicode/utf8" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 10 | 12 | ) | |
| @@ -36,16 +38,111 @@ func (m *Manager) EmitEvent(ev extproto.EventFromHost) { | |||
| 36 | 38 | ||
| 37 | 39 | for _, ext := range subs { | |
| 38 | 40 | go func(ext *Extension) { | |
| 39 | - | defer func() { | |
| 40 | - | // A panicking write to a closed pipe shouldn't kill | |
| 41 | - | // the calling goroutine. | |
| 42 | - | _ = recover() | |
| 43 | - | }() | |
| 44 | - | _, _ = ext.stdin.Write(frame) | |
| 41 | + | defer func() { _ = recover() }() | |
| 42 | + | _, _ = ext.writeFrame(frame) | |
| 45 | 43 | }(ext) | |
| 46 | 44 | } | |
| 47 | 45 | } | |
| 48 | 46 | ||
| 47 | + | // UpdateSessionState updates the cached state used when an extension is | |
| 48 | + | // reloaded during the active session. The session file remains the durable | |
| 49 | + | // source of truth; this only keeps the in-memory lifecycle replay current. | |
| 50 | + | func (m *Manager) UpdateSessionState(extension string, state json.RawMessage) { | |
| 51 | + | if m == nil || strings.TrimSpace(extension) == "" || len(state) > maxSessionStateBytes { | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | m.mu.Lock() | |
| 55 | + | defer m.mu.Unlock() | |
| 56 | + | if m.activeSession == nil { | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | if m.activeSession.states == nil { | |
| 60 | + | m.activeSession.states = map[string]json.RawMessage{} | |
| 61 | + | } | |
| 62 | + | if len(state) == 0 || strings.TrimSpace(string(state)) == "null" { | |
| 63 | + | delete(m.activeSession.states, extension) | |
| 64 | + | return | |
| 65 | + | } | |
| 66 | + | m.activeSession.states[extension] = append(json.RawMessage(nil), state...) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // EmitSessionEvent broadcasts a session lifecycle event with the active | |
| 70 | + | // session identity. Each extension receives only its own opaque state | |
| 71 | + | // snapshot, preventing one extension from reading another extension's data. | |
| 72 | + | // The latest event is retained and replayed to extensions loaded later. | |
| 73 | + | func (m *Manager) EmitSessionEvent(event string, session *extproto.SessionContext, states map[string]json.RawMessage) { | |
| 74 | + | if event == "" { | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | m.sessionEmitMu.Lock() | |
| 78 | + | defer m.sessionEmitMu.Unlock() | |
| 79 | + | base := extproto.EventFromHost{Type: "event", Event: event, Session: cloneSessionContext(session)} | |
| 80 | + | storedStates := cloneExtensionStates(states) | |
| 81 | + | m.mu.Lock() | |
| 82 | + | m.activeSession = &sessionEventState{event: base, states: storedStates} | |
| 83 | + | subs := make([]*Extension, 0, len(m.ext)) | |
| 84 | + | for _, ext := range m.ext { | |
| 85 | + | ext.mu.Lock() | |
| 86 | + | _, subscribed := ext.eventSubs[event] | |
| 87 | + | ext.mu.Unlock() | |
| 88 | + | if subscribed { | |
| 89 | + | subs = append(subs, ext) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | m.mu.Unlock() | |
| 93 | + | for _, ext := range subs { | |
| 94 | + | m.sendSessionEvent(ext, base, storedStates[ext.Manifest.Name]) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (m *Manager) sendSessionEvent(ext *Extension, base extproto.EventFromHost, state json.RawMessage) { | |
| 99 | + | if ext == nil { | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | frame := base | |
| 103 | + | frame.State = append(json.RawMessage(nil), state...) | |
| 104 | + | encoded, err := extproto.Encode(frame) | |
| 105 | + | if err != nil { | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | if !ext.enqueueLifecycleFrame(encoded) { | |
| 109 | + | ext.recordDiagnostic("session lifecycle frame dropped because the extension queue is full or stopped") | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (m *Manager) replaySessionEvent(ext *Extension) { | |
| 114 | + | m.sessionEmitMu.Lock() | |
| 115 | + | defer m.sessionEmitMu.Unlock() | |
| 116 | + | m.mu.RLock() | |
| 117 | + | if m.activeSession == nil { | |
| 118 | + | m.mu.RUnlock() | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | base := m.activeSession.event | |
| 122 | + | state := append(json.RawMessage(nil), m.activeSession.states[ext.Manifest.Name]...) | |
| 123 | + | m.mu.RUnlock() | |
| 124 | + | m.sendSessionEvent(ext, base, state) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func cloneSessionContext(in *extproto.SessionContext) *extproto.SessionContext { | |
| 128 | + | if in == nil { | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | out := *in | |
| 132 | + | return &out | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func cloneExtensionStates(in map[string]json.RawMessage) map[string]json.RawMessage { | |
| 136 | + | if len(in) == 0 { | |
| 137 | + | return nil | |
| 138 | + | } | |
| 139 | + | out := make(map[string]json.RawMessage, len(in)) | |
| 140 | + | for name, state := range in { | |
| 141 | + | out[name] = append(json.RawMessage(nil), state...) | |
| 142 | + | } | |
| 143 | + | return out | |
| 144 | + | } | |
| 145 | + | ||
| 49 | 146 | // InterceptResult aggregates the outcome of walking every subscribed | |
| 50 | 147 | // interceptor for one event. The zero value means "allow, no | |
| 51 | 148 | // modification". Callers check Block first; if allowed, use the | |
| @@ -56,9 +153,29 @@ type InterceptResult struct { | |||
| 56 | 153 | Reason string | |
| 57 | 154 | ModifiedArgs json.RawMessage | |
| 58 | 155 | ReplaceText string | |
| 156 | + | Context string | |
| 59 | 157 | } | |
| 60 | 158 | ||
| 61 | - | const interceptTimeout = 5 * time.Second | |
| 159 | + | const ( | |
| 160 | + | interceptTimeout = 5 * time.Second | |
| 161 | + | maxInterceptContext = 16 * 1024 | |
| 162 | + | maxSessionStateBytes = 256 * 1024 | |
| 163 | + | ) | |
| 164 | + | ||
| 165 | + | const interceptContextTruncatedMarker = "\n[extension context truncated]" | |
| 166 | + | ||
| 167 | + | func boundInterceptContext(text string) string { | |
| 168 | + | text = strings.TrimSpace(text) | |
| 169 | + | if len(text) <= maxInterceptContext { | |
| 170 | + | return text | |
| 171 | + | } | |
| 172 | + | limit := maxInterceptContext - len(interceptContextTruncatedMarker) | |
| 173 | + | cut := limit | |
| 174 | + | for cut > 0 && !utf8.RuneStart(text[cut]) { | |
| 175 | + | cut-- | |
| 176 | + | } | |
| 177 | + | return strings.TrimSpace(text[:cut]) + interceptContextTruncatedMarker | |
| 178 | + | } | |
| 62 | 179 | ||
| 63 | 180 | // InterceptToolCall is the typed entry point for tool_call | |
| 64 | 181 | // interception. Subscribers are invoked serially; the first to return | |
| @@ -100,6 +217,7 @@ func (m *Manager) InterceptTurnStart(ctx context.Context, step int) InterceptRes | |||
| 100 | 217 | if len(subs) == 0 { | |
| 101 | 218 | return InterceptResult{} | |
| 102 | 219 | } | |
| 220 | + | var contexts []string | |
| 103 | 221 | for _, ext := range subs { | |
| 104 | 222 | r := m.askIntercept(ctx, ext, extproto.EventInterceptFromHost{ | |
| 105 | 223 | Event: "turn_start", | |
| @@ -108,8 +226,11 @@ func (m *Manager) InterceptTurnStart(ctx context.Context, step int) InterceptRes | |||
| 108 | 226 | if r.Block { | |
| 109 | 227 | return r | |
| 110 | 228 | } | |
| 229 | + | if text := strings.TrimSpace(r.Context); text != "" { | |
| 230 | + | contexts = append(contexts, "["+ext.Manifest.Name+"]\n"+text) | |
| 231 | + | } | |
| 111 | 232 | } | |
| 112 | - | return InterceptResult{} | |
| 233 | + | return InterceptResult{Context: boundInterceptContext(strings.Join(contexts, "\n\n"))} | |
| 113 | 234 | } | |
| 114 | 235 | ||
| 115 | 236 | // InterceptAssistantMessage asks every subscriber to approve, block, | |
| @@ -179,7 +300,7 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp | |||
| 179 | 300 | ext.mu.Unlock() | |
| 180 | 301 | return InterceptResult{} | |
| 181 | 302 | } | |
| 182 | - | if _, err := ext.stdin.Write(frame); err != nil { | |
| 303 | + | if _, err := ext.writeFrame(frame); err != nil { | |
| 183 | 304 | ext.mu.Lock() | |
| 184 | 305 | delete(ext.pendingIntercept, id) | |
| 185 | 306 | ext.mu.Unlock() | |
| @@ -194,6 +315,7 @@ func (m *Manager) askIntercept(ctx context.Context, ext *Extension, payload extp | |||
| 194 | 315 | Reason: resp.Reason, | |
| 195 | 316 | ModifiedArgs: resp.ModifiedArgs, | |
| 196 | 317 | ReplaceText: resp.ReplaceText, | |
| 318 | + | Context: resp.Context, | |
| 197 | 319 | } | |
| 198 | 320 | case <-time.After(interceptTimeout): | |
| 199 | 321 | ext.mu.Lock() | |
M packages/agent/extensions/intercept_test.go +14 -0
| @@ -8,8 +8,22 @@ import ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | "testing" | |
| 10 | 10 | "time" | |
| 11 | + | "unicode/utf8" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 14 | + | func TestBoundInterceptContextPreservesUTF8AndLimit(t *testing.T) { | |
| 15 | + | got := boundInterceptContext(strings.Repeat("界", maxInterceptContext)) | |
| 16 | + | if len(got) > maxInterceptContext { | |
| 17 | + | t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxInterceptContext) | |
| 18 | + | } | |
| 19 | + | if !utf8.ValidString(got) { | |
| 20 | + | t.Fatal("bounded context is not valid UTF-8") | |
| 21 | + | } | |
| 22 | + | if !strings.HasSuffix(got, interceptContextTruncatedMarker) { | |
| 23 | + | t.Fatalf("bounded context missing truncation marker") | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 13 | 27 | // TestInterceptAllThreeEvents exercises tool_call / turn_start / | |
| 14 | 28 | // assistant_message interception end-to-end via a bash extension that | |
| 15 | 29 | // blocks `rm -rf`, rewrites any other bash command to `echo GUARDED`, | |
M packages/agent/extensions/manager.go +239 -14
| @@ -29,6 +29,7 @@ import ( | |||
| 29 | 29 | "time" | |
| 30 | 30 | ||
| 31 | 31 | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 32 | + | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 32 | 33 | "github.com/patriceckhart/zot/packages/tui" | |
| 33 | 34 | ) | |
| 34 | 35 | ||
| @@ -43,6 +44,7 @@ type Manifest struct { | |||
| 43 | 44 | Language string `json:"language,omitempty"` // informational ("go", "python", "typescript", ...) | |
| 44 | 45 | Enabled *bool `json:"enabled,omitempty"` // nil = enabled | |
| 45 | 46 | Description string `json:"description,omitempty"` | |
| 47 | + | Skills []string `json:"skills,omitempty"` // bundled skill directories | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 48 | 50 | // IsEnabled returns the manifest's effective enabled state. Default | |
| @@ -52,6 +54,48 @@ func (m Manifest) IsEnabled() bool { | |||
| 52 | 54 | return m.Enabled == nil || *m.Enabled | |
| 53 | 55 | } | |
| 54 | 56 | ||
| 57 | + | func loadManifestSkills(extDir string, mf Manifest) ([]*skills.Skill, []error) { | |
| 58 | + | if len(mf.Skills) == 0 { | |
| 59 | + | return nil, nil | |
| 60 | + | } | |
| 61 | + | root, err := filepath.Abs(extDir) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, []error{fmt.Errorf("resolve extension directory: %w", err)} | |
| 64 | + | } | |
| 65 | + | root, err = filepath.EvalSymlinks(root) | |
| 66 | + | if err != nil { | |
| 67 | + | return nil, []error{fmt.Errorf("resolve extension directory: %w", err)} | |
| 68 | + | } | |
| 69 | + | declared := make([]skills.BundledSkillDir, 0, len(mf.Skills)) | |
| 70 | + | var errs []error | |
| 71 | + | for _, raw := range mf.Skills { | |
| 72 | + | rel := strings.TrimSpace(raw) | |
| 73 | + | if rel == "" || filepath.IsAbs(rel) { | |
| 74 | + | errs = append(errs, fmt.Errorf("skill path %q must be a relative directory", raw)) | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | clean := filepath.Clean(rel) | |
| 78 | + | if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { | |
| 79 | + | errs = append(errs, fmt.Errorf("skill path %q escapes the extension directory", raw)) | |
| 80 | + | continue | |
| 81 | + | } | |
| 82 | + | candidate := filepath.Join(root, clean) | |
| 83 | + | resolved, err := filepath.EvalSymlinks(candidate) | |
| 84 | + | if err != nil { | |
| 85 | + | errs = append(errs, fmt.Errorf("skill path %q: %w", raw, err)) | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | relResolved, err := filepath.Rel(root, resolved) | |
| 89 | + | if err != nil || relResolved == ".." || strings.HasPrefix(relResolved, ".."+string(filepath.Separator)) { | |
| 90 | + | errs = append(errs, fmt.Errorf("skill path %q escapes the extension directory through a symlink", raw)) | |
| 91 | + | continue | |
| 92 | + | } | |
| 93 | + | declared = append(declared, skills.BundledSkillDir{Dir: resolved, Root: root, Source: "extension " + mf.Name}) | |
| 94 | + | } | |
| 95 | + | loaded, loadErrs := skills.LoadBundled(declared) | |
| 96 | + | return loaded, append(errs, loadErrs...) | |
| 97 | + | } | |
| 98 | + | ||
| 55 | 99 | // Extension is a running extension subprocess and the metadata zot | |
| 56 | 100 | // tracks about it. | |
| 57 | 101 | type Extension struct { | |
| @@ -66,6 +110,16 @@ type Extension struct { | |||
| 66 | 110 | helloAck bool | |
| 67 | 111 | commands []extproto.RegisterCommandFromExt | |
| 68 | 112 | tools []extproto.RegisterToolFromExt | |
| 113 | + | skills []*skills.Skill | |
| 114 | + | ||
| 115 | + | writeMu sync.Mutex // serializes concurrent host→extension frames | |
| 116 | + | ||
| 117 | + | // lifecycleFrames decouples session-event delivery from the host's | |
| 118 | + | // synchronous control paths. The queue preserves lifecycle ordering while | |
| 119 | + | // bounding backpressure from an extension that stops reading stdin. | |
| 120 | + | lifecycleFrames chan []byte | |
| 121 | + | lifecycleStop chan struct{} | |
| 122 | + | lifecycleStopOnce sync.Once | |
| 69 | 123 | ||
| 70 | 124 | // readyCh is closed when the extension sends a ReadyFromExt | |
| 71 | 125 | // frame, or when the host gives up waiting (registrationGrace). | |
| @@ -130,6 +184,12 @@ type HostHooks interface { | |||
| 130 | 184 | OpenPanel(extName string, spec extproto.PanelSpec) | |
| 131 | 185 | UpdatePanel(extName, panelID, title string, lines []string, footer string) | |
| 132 | 186 | ClosePanel(extName, panelID string) | |
| 187 | + | ||
| 188 | + | // SetStatus and SetWidget maintain persistent extension-owned UI state. | |
| 189 | + | // Hosts without a persistent UI may ignore these calls. | |
| 190 | + | SetStatus(extName, key, level, text string) | |
| 191 | + | SetWidget(extName, id, position, title string, lines []string) | |
| 192 | + | ClearWidget(extName, id string) | |
| 133 | 193 | } | |
| 134 | 194 | ||
| 135 | 195 | // AlertHostHooks is an optional extension to HostHooks. Hosts that support | |
| @@ -173,6 +233,16 @@ type Manager struct { | |||
| 173 | 233 | // by the host so it can rebuild the agent's tool registry with | |
| 174 | 234 | // the freshly-registered extension tools. | |
| 175 | 235 | onReload func() | |
| 236 | + | ||
| 237 | + | // activeSession is replayed to extensions loaded after the current | |
| 238 | + | // session was opened, such as after /reload-ext. | |
| 239 | + | activeSession *sessionEventState | |
| 240 | + | sessionEmitMu sync.Mutex | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | type sessionEventState struct { | |
| 244 | + | event extproto.EventFromHost | |
| 245 | + | states map[string]json.RawMessage | |
| 176 | 246 | } | |
| 177 | 247 | ||
| 178 | 248 | // New constructs an empty Manager. Call Discover to populate it from | |
| @@ -287,6 +357,7 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 287 | 357 | return nil | |
| 288 | 358 | } | |
| 289 | 359 | ||
| 360 | + | bundledSkills, skillErrs := loadManifestSkills(dir, mf) | |
| 290 | 361 | ext := &Extension{ | |
| 291 | 362 | Manifest: mf, | |
| 292 | 363 | Dir: dir, | |
| @@ -296,6 +367,12 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error { | |||
| 296 | 367 | pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{}, | |
| 297 | 368 | eventSubs: map[string]struct{}{}, | |
| 298 | 369 | interceptSubs: map[string]struct{}{}, | |
| 370 | + | skills: bundledSkills, | |
| 371 | + | lifecycleFrames: make(chan []byte, 64), | |
| 372 | + | lifecycleStop: make(chan struct{}), | |
| 373 | + | } | |
| 374 | + | for _, skillErr := range skillErrs { | |
| 375 | + | ext.diagnostics = append(ext.diagnostics, "skill: "+skillErr.Error()) | |
| 299 | 376 | } | |
| 300 | 377 | if mf.Exec != "" { | |
| 301 | 378 | if err := m.spawn(ctx, ext); err != nil { | |
| @@ -627,6 +704,12 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 627 | 704 | // Trust the manifest's name; ignore mismatch from the hello. | |
| 628 | 705 | ext.helloAck = true | |
| 629 | 706 | ||
| 707 | + | m.mu.RLock() | |
| 708 | + | var activeSession *extproto.SessionContext | |
| 709 | + | if m.activeSession != nil { | |
| 710 | + | activeSession = cloneSessionContext(m.activeSession.event.Session) | |
| 711 | + | } | |
| 712 | + | m.mu.RUnlock() | |
| 630 | 713 | ack, _ := extproto.Encode(extproto.HelloAckFromHost{ | |
| 631 | 714 | Type: "hello_ack", | |
| 632 | 715 | ProtocolVersion: extproto.ProtocolVersion, | |
| @@ -636,6 +719,7 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 636 | 719 | CWD: m.cwd, | |
| 637 | 720 | ExtensionDir: ext.Dir, | |
| 638 | 721 | DataDir: ext.Dir, | |
| 722 | + | Session: activeSession, | |
| 639 | 723 | }) | |
| 640 | 724 | if _, err := stdin.Write(ack); err != nil { | |
| 641 | 725 | return fmt.Errorf("send hello_ack (stderr log: %s): %w", logPath, err) | |
| @@ -643,7 +727,8 @@ func (m *Manager) spawn(ctx context.Context, ext *Extension) error { | |||
| 643 | 727 | ||
| 644 | 728 | cleanup = false | |
| 645 | 729 | ||
| 646 | - | // Spin up the read loop now that the handshake is done. | |
| 730 | + | // Spin up the lifecycle writer and read loop now that the handshake is done. | |
| 731 | + | ext.startLifecycleWriter() | |
| 647 | 732 | go m.readLoop(ext, scanner) | |
| 648 | 733 | ||
| 649 | 734 | // Compatibility shim: extensions built against the phase-1 SDK | |
| @@ -737,6 +822,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 737 | 822 | } | |
| 738 | 823 | } | |
| 739 | 824 | m.mu.Unlock() | |
| 825 | + | ext.stopLifecycleWriter() | |
| 740 | 826 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | |
| 741 | 827 | fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339)) | |
| 742 | 828 | }() | |
| @@ -800,6 +886,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 800 | 886 | } | |
| 801 | 887 | case "ready": | |
| 802 | 888 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | |
| 889 | + | m.replaySessionEvent(ext) | |
| 803 | 890 | case "subscribe": | |
| 804 | 891 | var sub extproto.SubscribeFromExt | |
| 805 | 892 | if err := json.Unmarshal(line, &sub); err == nil { | |
| @@ -849,21 +936,38 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 849 | 936 | } | |
| 850 | 937 | case "notify": | |
| 851 | 938 | var n extproto.NotifyFromExt | |
| 852 | - | if err := json.Unmarshal(line, &n); err == nil { | |
| 939 | + | if err := json.Unmarshal(line, &n); err == nil && m.hooks != nil { | |
| 853 | 940 | m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message) | |
| 854 | 941 | } | |
| 942 | + | case "status": | |
| 943 | + | var s extproto.StatusFromExt | |
| 944 | + | if err := json.Unmarshal(line, &s); err == nil && m.hooks != nil { | |
| 945 | + | m.hooks.SetStatus(ext.Manifest.Name, s.Key, s.Level, s.Text) | |
| 946 | + | } | |
| 947 | + | case "widget": | |
| 948 | + | var w extproto.WidgetFromExt | |
| 949 | + | if err := json.Unmarshal(line, &w); err == nil && m.hooks != nil { | |
| 950 | + | m.hooks.SetWidget(ext.Manifest.Name, w.ID, w.Position, w.Title, w.Lines) | |
| 951 | + | } | |
| 952 | + | case "widget_clear": | |
| 953 | + | var w extproto.ClearWidgetFromExt | |
| 954 | + | if err := json.Unmarshal(line, &w); err == nil && m.hooks != nil { | |
| 955 | + | m.hooks.ClearWidget(ext.Manifest.Name, w.ID) | |
| 956 | + | } | |
| 855 | 957 | case "alert": | |
| 856 | 958 | var a extproto.AlertFromExt | |
| 857 | - | if err := json.Unmarshal(line, &a); err == nil { | |
| 959 | + | if err := json.Unmarshal(line, &a); err == nil && m.hooks != nil { | |
| 858 | 960 | if hooks, ok := m.hooks.(AlertHostHooks); ok { | |
| 859 | 961 | hooks.Alert(ext.Manifest.Name, a.AlertRequest) | |
| 860 | 962 | } | |
| 861 | 963 | } | |
| 862 | 964 | case "clear_notes": | |
| 863 | - | m.hooks.ClearNotes(ext.Manifest.Name) | |
| 965 | + | if m.hooks != nil { | |
| 966 | + | m.hooks.ClearNotes(ext.Manifest.Name) | |
| 967 | + | } | |
| 864 | 968 | case "submit": | |
| 865 | 969 | var s extproto.SubmitFromExt | |
| 866 | - | if err := json.Unmarshal(line, &s); err == nil { | |
| 970 | + | if err := json.Unmarshal(line, &s); err == nil && m.hooks != nil { | |
| 867 | 971 | text := strings.TrimSpace(s.Text) | |
| 868 | 972 | if text != "" { | |
| 869 | 973 | m.hooks.Submit(text) | |
| @@ -877,7 +981,7 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 877 | 981 | var s extproto.SubmitSlashFromExt | |
| 878 | 982 | if err := json.Unmarshal(line, &s); err == nil { | |
| 879 | 983 | text := strings.TrimSpace(s.Text) | |
| 880 | - | if strings.HasPrefix(text, "/") { | |
| 984 | + | if strings.HasPrefix(text, "/") && m.hooks != nil { | |
| 881 | 985 | m.hooks.SubmitSlash(text) | |
| 882 | 986 | } else { | |
| 883 | 987 | fmt.Fprintf(ext.logFile, "[zot] submit_slash refused (not a slash command): %q\n", s.Text) | |
| @@ -901,17 +1005,17 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 901 | 1005 | } | |
| 902 | 1006 | case "open_panel": | |
| 903 | 1007 | var op extproto.OpenPanelFromExt | |
| 904 | - | if err := json.Unmarshal(line, &op); err == nil { | |
| 1008 | + | if err := json.Unmarshal(line, &op); err == nil && m.hooks != nil { | |
| 905 | 1009 | m.hooks.OpenPanel(ext.Manifest.Name, op.Panel) | |
| 906 | 1010 | } | |
| 907 | 1011 | case "panel_render": | |
| 908 | 1012 | var pr extproto.PanelRenderFromExt | |
| 909 | - | if err := json.Unmarshal(line, &pr); err == nil { | |
| 1013 | + | if err := json.Unmarshal(line, &pr); err == nil && m.hooks != nil { | |
| 910 | 1014 | m.hooks.UpdatePanel(ext.Manifest.Name, pr.PanelID, pr.Title, pr.Lines, pr.Footer) | |
| 911 | 1015 | } | |
| 912 | 1016 | case "panel_close": | |
| 913 | 1017 | var pc extproto.PanelCloseFromExt | |
| 914 | - | if err := json.Unmarshal(line, &pc); err == nil { | |
| 1018 | + | if err := json.Unmarshal(line, &pc); err == nil && m.hooks != nil { | |
| 915 | 1019 | m.hooks.ClosePanel(ext.Manifest.Name, pc.PanelID) | |
| 916 | 1020 | } | |
| 917 | 1021 | case "shutdown_ack": | |
| @@ -923,6 +1027,109 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 923 | 1027 | } | |
| 924 | 1028 | } | |
| 925 | 1029 | ||
| 1030 | + | const lifecycleWriteGrace = 250 * time.Millisecond | |
| 1031 | + | ||
| 1032 | + | func (ext *Extension) writeFrame(frame []byte) (int, error) { | |
| 1033 | + | if ext == nil || ext.stdin == nil { | |
| 1034 | + | return 0, fmt.Errorf("extension stdin is closed") | |
| 1035 | + | } | |
| 1036 | + | ext.writeMu.Lock() | |
| 1037 | + | defer ext.writeMu.Unlock() | |
| 1038 | + | return ext.stdin.Write(frame) | |
| 1039 | + | } | |
| 1040 | + | ||
| 1041 | + | // tryWriteLifecycleFrame bounds only a lifecycle writer's wait for the shared | |
| 1042 | + | // writer lock. Synchronous host frames retain their existing write behavior; | |
| 1043 | + | // a stalled lifecycle snapshot is dropped rather than delaying them. | |
| 1044 | + | func (ext *Extension) tryWriteLifecycleFrame(frame []byte) error { | |
| 1045 | + | if ext == nil || ext.stdin == nil { | |
| 1046 | + | return fmt.Errorf("extension stdin is closed") | |
| 1047 | + | } | |
| 1048 | + | if !ext.writeMu.TryLock() { | |
| 1049 | + | timer := time.NewTimer(lifecycleWriteGrace) | |
| 1050 | + | defer timer.Stop() | |
| 1051 | + | ticker := time.NewTicker(5 * time.Millisecond) | |
| 1052 | + | defer ticker.Stop() | |
| 1053 | + | for { | |
| 1054 | + | select { | |
| 1055 | + | case <-timer.C: | |
| 1056 | + | return fmt.Errorf("extension lifecycle write is blocked") | |
| 1057 | + | case <-ticker.C: | |
| 1058 | + | if ext.writeMu.TryLock() { | |
| 1059 | + | goto locked | |
| 1060 | + | } | |
| 1061 | + | } | |
| 1062 | + | } | |
| 1063 | + | } | |
| 1064 | + | locked: | |
| 1065 | + | defer ext.writeMu.Unlock() | |
| 1066 | + | _, err := ext.stdin.Write(frame) | |
| 1067 | + | return err | |
| 1068 | + | } | |
| 1069 | + | ||
| 1070 | + | func (ext *Extension) startLifecycleWriter() { | |
| 1071 | + | if ext.lifecycleFrames == nil { | |
| 1072 | + | ext.lifecycleFrames = make(chan []byte, 64) | |
| 1073 | + | } | |
| 1074 | + | if ext.lifecycleStop == nil { | |
| 1075 | + | ext.lifecycleStop = make(chan struct{}) | |
| 1076 | + | } | |
| 1077 | + | go ext.lifecycleWriter() | |
| 1078 | + | } | |
| 1079 | + | ||
| 1080 | + | func (ext *Extension) stopLifecycleWriter() { | |
| 1081 | + | if ext == nil || ext.lifecycleStop == nil { | |
| 1082 | + | return | |
| 1083 | + | } | |
| 1084 | + | ext.lifecycleStopOnce.Do(func() { close(ext.lifecycleStop) }) | |
| 1085 | + | } | |
| 1086 | + | ||
| 1087 | + | func (ext *Extension) enqueueLifecycleFrame(frame []byte) bool { | |
| 1088 | + | if ext == nil { | |
| 1089 | + | return false | |
| 1090 | + | } | |
| 1091 | + | if ext.lifecycleFrames == nil || ext.lifecycleStop == nil { | |
| 1092 | + | _, err := ext.writeFrame(frame) | |
| 1093 | + | return err == nil | |
| 1094 | + | } | |
| 1095 | + | copyFrame := append([]byte(nil), frame...) | |
| 1096 | + | select { | |
| 1097 | + | case <-ext.lifecycleStop: | |
| 1098 | + | return false | |
| 1099 | + | default: | |
| 1100 | + | } | |
| 1101 | + | select { | |
| 1102 | + | case ext.lifecycleFrames <- copyFrame: | |
| 1103 | + | return true | |
| 1104 | + | default: | |
| 1105 | + | // Prefer the newest lifecycle snapshot when a broken extension stops | |
| 1106 | + | // draining its pipe. The latest event still carries the current state. | |
| 1107 | + | select { | |
| 1108 | + | case <-ext.lifecycleFrames: | |
| 1109 | + | default: | |
| 1110 | + | } | |
| 1111 | + | select { | |
| 1112 | + | case ext.lifecycleFrames <- copyFrame: | |
| 1113 | + | return true | |
| 1114 | + | default: | |
| 1115 | + | return false | |
| 1116 | + | } | |
| 1117 | + | } | |
| 1118 | + | } | |
| 1119 | + | ||
| 1120 | + | func (ext *Extension) lifecycleWriter() { | |
| 1121 | + | for { | |
| 1122 | + | select { | |
| 1123 | + | case <-ext.lifecycleStop: | |
| 1124 | + | return | |
| 1125 | + | case frame := <-ext.lifecycleFrames: | |
| 1126 | + | if err := ext.tryWriteLifecycleFrame(frame); err != nil { | |
| 1127 | + | return | |
| 1128 | + | } | |
| 1129 | + | } | |
| 1130 | + | } | |
| 1131 | + | } | |
| 1132 | + | ||
| 926 | 1133 | func (ext *Extension) recordDiagnostic(msg string) { | |
| 927 | 1134 | ext.mu.Lock() | |
| 928 | 1135 | defer ext.mu.Unlock() | |
| @@ -1075,6 +1282,24 @@ func (m *Manager) Tools() []ToolInfo { | |||
| 1075 | 1282 | return out | |
| 1076 | 1283 | } | |
| 1077 | 1284 | ||
| 1285 | + | // Skills returns extension-bundled skills in deterministic extension-name | |
| 1286 | + | // order. The build layer merges them after user skills and before built-ins. | |
| 1287 | + | func (m *Manager) Skills() []*skills.Skill { | |
| 1288 | + | m.mu.RLock() | |
| 1289 | + | names := make([]string, 0, len(m.ext)) | |
| 1290 | + | for name := range m.ext { | |
| 1291 | + | names = append(names, name) | |
| 1292 | + | } | |
| 1293 | + | sort.Strings(names) | |
| 1294 | + | var out []*skills.Skill | |
| 1295 | + | for _, name := range names { | |
| 1296 | + | ext := m.ext[name] | |
| 1297 | + | out = append(out, ext.skills...) | |
| 1298 | + | } | |
| 1299 | + | m.mu.RUnlock() | |
| 1300 | + | return out | |
| 1301 | + | } | |
| 1302 | + | ||
| 1078 | 1303 | // HasTool reports whether name is registered by any extension. | |
| 1079 | 1304 | func (m *Manager) HasTool(name string) bool { | |
| 1080 | 1305 | m.mu.RLock() | |
| @@ -1106,7 +1331,7 @@ func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMess | |||
| 1106 | 1331 | Name: name, | |
| 1107 | 1332 | Args: args, | |
| 1108 | 1333 | }) | |
| 1109 | - | if _, err := ext.stdin.Write(frame); err != nil { | |
| 1334 | + | if _, err := ext.writeFrame(frame); err != nil { | |
| 1110 | 1335 | ext.mu.Lock() | |
| 1111 | 1336 | delete(ext.pendingTool, id) | |
| 1112 | 1337 | ext.mu.Unlock() | |
| @@ -1172,7 +1397,7 @@ func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Du | |||
| 1172 | 1397 | Name: name, | |
| 1173 | 1398 | Args: args, | |
| 1174 | 1399 | }) | |
| 1175 | - | if _, err := ext.stdin.Write(frame); err != nil { | |
| 1400 | + | if _, err := ext.writeFrame(frame); err != nil { | |
| 1176 | 1401 | ext.mu.Lock() | |
| 1177 | 1402 | delete(ext.pending, id) | |
| 1178 | 1403 | ext.mu.Unlock() | |
| @@ -1206,7 +1431,7 @@ func (m *Manager) SendPanelKey(extName, panelID, key, text string) error { | |||
| 1206 | 1431 | return fmt.Errorf("no extension %q", extName) | |
| 1207 | 1432 | } | |
| 1208 | 1433 | frame, _ := extproto.Encode(extproto.PanelKeyFromHost{Type: "panel_key", PanelID: panelID, Key: key, Text: text}) | |
| 1209 | - | _, err := ext.stdin.Write(frame) | |
| 1434 | + | _, err := ext.writeFrame(frame) | |
| 1210 | 1435 | return err | |
| 1211 | 1436 | } | |
| 1212 | 1437 | ||
| @@ -1218,7 +1443,7 @@ func (m *Manager) SendPanelClose(extName, panelID string) error { | |||
| 1218 | 1443 | return fmt.Errorf("no extension %q", extName) | |
| 1219 | 1444 | } | |
| 1220 | 1445 | frame, _ := extproto.Encode(extproto.PanelCloseFromHost{Type: "panel_close", PanelID: panelID}) | |
| 1221 | - | _, err := ext.stdin.Write(frame) | |
| 1446 | + | _, err := ext.writeFrame(frame) | |
| 1222 | 1447 | return err | |
| 1223 | 1448 | } | |
| 1224 | 1449 | ||
| @@ -1238,7 +1463,7 @@ func stopExtensions(exts []*Extension, gracePeriod time.Duration) { | |||
| 1238 | 1463 | continue | |
| 1239 | 1464 | } | |
| 1240 | 1465 | if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil { | |
| 1241 | - | _, _ = ext.stdin.Write(frame) | |
| 1466 | + | _, _ = ext.writeFrame(frame) | |
| 1242 | 1467 | } | |
| 1243 | 1468 | _ = ext.stdin.Close() | |
| 1244 | 1469 | } | |
M packages/agent/extensions/manager_test.go +85 -0
| @@ -3,6 +3,7 @@ package extensions | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "io" | |
| 6 | 7 | "os" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| @@ -14,6 +15,30 @@ import ( | |||
| 14 | 15 | "time" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 18 | + | func TestLifecycleWriteDropsWhenSynchronousWriterOwnsLock(t *testing.T) { | |
| 19 | + | ext := &Extension{stdin: &nopWriteCloser{}} | |
| 20 | + | ext.writeMu.Lock() | |
| 21 | + | defer ext.writeMu.Unlock() | |
| 22 | + | ||
| 23 | + | done := make(chan error, 1) | |
| 24 | + | go func() { done <- ext.tryWriteLifecycleFrame([]byte("lifecycle")) }() | |
| 25 | + | select { | |
| 26 | + | case err := <-done: | |
| 27 | + | if err == nil { | |
| 28 | + | t.Fatal("lifecycle write unexpectedly acquired the synchronous writer lock") | |
| 29 | + | } | |
| 30 | + | case <-time.After(lifecycleWriteGrace + time.Second): | |
| 31 | + | t.Fatal("lifecycle write remained blocked on the synchronous writer lock") | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type nopWriteCloser struct{} | |
| 36 | + | ||
| 37 | + | func (*nopWriteCloser) Write(data []byte) (int, error) { return len(data), nil } | |
| 38 | + | func (*nopWriteCloser) Close() error { return nil } | |
| 39 | + | ||
| 40 | + | var _ io.WriteCloser = (*nopWriteCloser)(nil) | |
| 41 | + | ||
| 17 | 42 | // stubHooks records every callback so the test can assert on them. | |
| 18 | 43 | type stubHooks struct { | |
| 19 | 44 | mu sync.Mutex | |
| @@ -68,6 +93,9 @@ func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) { | |||
| 68 | 93 | } | |
| 69 | 94 | func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {} | |
| 70 | 95 | func (s *stubHooks) ClosePanel(string, string) {} | |
| 96 | + | func (s *stubHooks) SetStatus(string, string, string, string) {} | |
| 97 | + | func (s *stubHooks) SetWidget(string, string, string, string, []string) {} | |
| 98 | + | func (s *stubHooks) ClearWidget(string, string) {} | |
| 71 | 99 | ||
| 72 | 100 | // writeMockExtension creates a minimal extension on disk that uses a | |
| 73 | 101 | // shell script (or batch file on windows) to drive the protocol. The | |
| @@ -259,6 +287,63 @@ func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) { | |||
| 259 | 287 | } | |
| 260 | 288 | } | |
| 261 | 289 | ||
| 290 | + | func TestManifestBundlesSkillsWithSafePrecedence(t *testing.T) { | |
| 291 | + | tmp := t.TempDir() | |
| 292 | + | extRoot := filepath.Join(tmp, "extensions") | |
| 293 | + | extDir := filepath.Join(extRoot, "bundle") | |
| 294 | + | if err := os.MkdirAll(filepath.Join(extDir, "skills", "phases"), 0o755); err != nil { | |
| 295 | + | t.Fatal(err) | |
| 296 | + | } | |
| 297 | + | if err := os.WriteFile(filepath.Join(extDir, "skills", "phases", "SKILL.md"), []byte("---\nname: phases\ndescription: bundled phases\n---\nbody\n"), 0o644); err != nil { | |
| 298 | + | t.Fatal(err) | |
| 299 | + | } | |
| 300 | + | manifest := `{"name":"bundle","skills":["skills"],"description":"bundle"}` | |
| 301 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil { | |
| 302 | + | t.Fatal(err) | |
| 303 | + | } | |
| 304 | + | if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(`{"name":"bundle","colors":{"dark":{"accent":1}}}`), 0o644); err != nil { | |
| 305 | + | t.Fatal(err) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | mgr := New(tmp, "", "0.0.0-test", "", "", &stubHooks{}) | |
| 309 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |
| 310 | + | t.Fatalf("discover errors: %v", errs) | |
| 311 | + | } | |
| 312 | + | defer mgr.Stop(10 * time.Millisecond) | |
| 313 | + | bundled := mgr.Skills() | |
| 314 | + | if len(bundled) != 1 || bundled[0].Name != "phases" || bundled[0].Source != "extension bundle" { | |
| 315 | + | t.Fatalf("bundled skills = %#v", bundled) | |
| 316 | + | } | |
| 317 | + | if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"../outside"}}); len(errs) == 0 { | |
| 318 | + | t.Fatal("path traversal manifest was accepted") | |
| 319 | + | } | |
| 320 | + | for _, path := range []string{"", filepath.Join(string(filepath.Separator), "outside")} { | |
| 321 | + | if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{path}}); len(errs) == 0 { | |
| 322 | + | t.Fatalf("unsafe skill path %q was accepted", path) | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | if runtime.GOOS != "windows" { | |
| 326 | + | outside := filepath.Join(tmp, "outside", "SKILL.md") | |
| 327 | + | if err := os.MkdirAll(filepath.Dir(outside), 0o755); err != nil { | |
| 328 | + | t.Fatal(err) | |
| 329 | + | } | |
| 330 | + | if err := os.WriteFile(outside, []byte("---\nname: escape\ndescription: outside\n---\nbody\n"), 0o644); err != nil { | |
| 331 | + | t.Fatal(err) | |
| 332 | + | } | |
| 333 | + | unsafeDir := filepath.Join(extDir, "unsafe") | |
| 334 | + | if err := os.MkdirAll(unsafeDir, 0o755); err != nil { | |
| 335 | + | t.Fatal(err) | |
| 336 | + | } | |
| 337 | + | if err := os.Symlink(outside, filepath.Join(unsafeDir, "SKILL.md")); err != nil { | |
| 338 | + | t.Fatal(err) | |
| 339 | + | } | |
| 340 | + | loaded, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"unsafe"}}) | |
| 341 | + | if len(errs) == 0 || len(loaded) != 0 { | |
| 342 | + | t.Fatalf("symlinked skill outside extension was accepted: skills=%#v errors=%v", loaded, errs) | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 262 | 347 | func TestManagerSpawnAndInvoke(t *testing.T) { | |
| 263 | 348 | tmp := t.TempDir() | |
| 264 | 349 | extRoot := filepath.Join(tmp, "extensions") | |
M packages/agent/extensions/tool.go +14 -1
| @@ -18,6 +18,15 @@ import ( | |||
| 18 | 18 | // One concrete type instead of a closure-driven anonymous tool | |
| 19 | 19 | // keeps the schema, name, and ownership inspectable for logs and | |
| 20 | 20 | // dialogs. | |
| 21 | + | // ToolResultDetails identifies extension metadata carried out of the | |
| 22 | + | // subprocess. State is persisted with the active session when present and | |
| 23 | + | // is never serialized into a provider request. | |
| 24 | + | type ToolResultDetails struct { | |
| 25 | + | Extension string | |
| 26 | + | Tool string | |
| 27 | + | State json.RawMessage | |
| 28 | + | } | |
| 29 | + | ||
| 21 | 30 | type extensionTool struct { | |
| 22 | 31 | name string | |
| 23 | 32 | description string | |
| @@ -90,7 +99,11 @@ func (t *extensionTool) Execute(ctx context.Context, args json.RawMessage, _ fun | |||
| 90 | 99 | // Defensive: an empty content slice would confuse the model. | |
| 91 | 100 | out.Content = []provider.Content{provider.TextBlock{Text: "(extension returned no content)"}} | |
| 92 | 101 | } | |
| 93 | - | out.Details = map[string]any{"extension": t.extension, "tool": t.name} | |
| 102 | + | out.Details = ToolResultDetails{ | |
| 103 | + | Extension: t.extension, | |
| 104 | + | Tool: t.name, | |
| 105 | + | State: append(json.RawMessage(nil), resp.Details...), | |
| 106 | + | } | |
| 94 | 107 | return out, nil | |
| 95 | 108 | } | |
| 96 | 109 | ||
M packages/agent/extproto/extproto.go +54 -8
| @@ -64,6 +64,9 @@ type EventInterceptResponseFromExt struct { | |||
| 64 | 64 | Reason string `json:"reason,omitempty"` | |
| 65 | 65 | ModifiedArgs json.RawMessage `json:"modified_args,omitempty"` | |
| 66 | 66 | ReplaceText string `json:"replace_text,omitempty"` | |
| 67 | + | // Context is hidden, bounded context for the upcoming model turn. It is | |
| 68 | + | // not appended to the transcript or shown as a user message. | |
| 69 | + | Context string `json:"context,omitempty"` | |
| 67 | 70 | } | |
| 68 | 71 | ||
| 69 | 72 | type ToolResultFromExt struct { | |
| @@ -72,6 +75,9 @@ type ToolResultFromExt struct { | |||
| 72 | 75 | Content []ContentBlock `json:"content"` | |
| 73 | 76 | IsError bool `json:"is_error,omitempty"` | |
| 74 | 77 | ActivateTools []string `json:"activate_tools,omitempty"` | |
| 78 | + | // Details is extension-owned metadata. The host never includes it in | |
| 79 | + | // provider requests, but may persist it with the active session. | |
| 80 | + | Details json.RawMessage `json:"details,omitempty"` | |
| 75 | 81 | } | |
| 76 | 82 | ||
| 77 | 83 | type ContentBlock struct { | |
| @@ -121,6 +127,31 @@ type PanelCloseFromExt struct { | |||
| 121 | 127 | PanelID string `json:"panel_id"` | |
| 122 | 128 | } | |
| 123 | 129 | ||
| 130 | + | // StatusFromExt sets or replaces one persistent status item owned by an | |
| 131 | + | // extension. An empty text clears the item. | |
| 132 | + | type StatusFromExt struct { | |
| 133 | + | Type string `json:"type"` | |
| 134 | + | Key string `json:"key"` | |
| 135 | + | Level string `json:"level,omitempty"` | |
| 136 | + | Text string `json:"text,omitempty"` | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // WidgetFromExt sets or replaces one persistent widget owned by an | |
| 140 | + | // extension. Position is a host-defined placement hint; interactive hosts | |
| 141 | + | // currently support "above_input" and treat unknown values as that default. | |
| 142 | + | type WidgetFromExt struct { | |
| 143 | + | Type string `json:"type"` | |
| 144 | + | ID string `json:"id"` | |
| 145 | + | Position string `json:"position,omitempty"` | |
| 146 | + | Title string `json:"title,omitempty"` | |
| 147 | + | Lines []string `json:"lines,omitempty"` | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | type ClearWidgetFromExt struct { | |
| 151 | + | Type string `json:"type"` | |
| 152 | + | ID string `json:"id"` | |
| 153 | + | } | |
| 154 | + | ||
| 124 | 155 | const AlertKindBell = "bell" | |
| 125 | 156 | ||
| 126 | 157 | // AlertRequest is a host-owned alert request. Extensions provide semantic | |
| @@ -174,14 +205,25 @@ type ShutdownAckFromExt struct { | |||
| 174 | 205 | } | |
| 175 | 206 | ||
| 176 | 207 | type HelloAckFromHost struct { | |
| 177 | - | Type string `json:"type"` | |
| 178 | - | ProtocolVersion int `json:"protocol_version"` | |
| 179 | - | ZotVersion string `json:"zot_version"` | |
| 180 | - | Provider string `json:"provider"` | |
| 181 | - | Model string `json:"model"` | |
| 182 | - | CWD string `json:"cwd"` | |
| 183 | - | ExtensionDir string `json:"extension_dir,omitempty"` | |
| 184 | - | DataDir string `json:"data_dir,omitempty"` | |
| 208 | + | Type string `json:"type"` | |
| 209 | + | ProtocolVersion int `json:"protocol_version"` | |
| 210 | + | ZotVersion string `json:"zot_version"` | |
| 211 | + | Provider string `json:"provider"` | |
| 212 | + | Model string `json:"model"` | |
| 213 | + | CWD string `json:"cwd"` | |
| 214 | + | ExtensionDir string `json:"extension_dir,omitempty"` | |
| 215 | + | DataDir string `json:"data_dir,omitempty"` | |
| 216 | + | Session *SessionContext `json:"session,omitempty"` | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // SessionContext identifies the active transcript branch. ID is the current | |
| 220 | + | // session/branch id; ParentID is empty for a root session. | |
| 221 | + | type SessionContext struct { | |
| 222 | + | ID string `json:"id"` | |
| 223 | + | ParentID string `json:"parent_id,omitempty"` | |
| 224 | + | Path string `json:"path,omitempty"` | |
| 225 | + | CWD string `json:"cwd,omitempty"` | |
| 226 | + | ForkPoint int `json:"fork_point,omitempty"` | |
| 185 | 227 | } | |
| 186 | 228 | ||
| 187 | 229 | type CommandInvokedFromHost struct { | |
| @@ -209,6 +251,10 @@ type EventFromHost struct { | |||
| 209 | 251 | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 210 | 252 | ToolPreview string `json:"tool_preview,omitempty"` | |
| 211 | 253 | Text string `json:"text,omitempty"` | |
| 254 | + | Session *SessionContext `json:"session,omitempty"` | |
| 255 | + | // State is private to the receiving extension. The manager selects the | |
| 256 | + | // matching extension-owned snapshot before sending the event. | |
| 257 | + | State json.RawMessage `json:"state,omitempty"` | |
| 212 | 258 | } | |
| 213 | 259 | ||
| 214 | 260 | type EventInterceptFromHost struct { | |
M packages/agent/modes/ext_notes_test.go +65 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -44,3 +45,67 @@ func TestClearNotesNoMatchKeepsNotes(t *testing.T) { | |||
| 44 | 45 | t.Fatalf("expected note to survive, got %d", len(i.extNotes)) | |
| 45 | 46 | } | |
| 46 | 47 | } | |
| 48 | + | ||
| 49 | + | func TestPersistentExtensionWidgetRowsAreBounded(t *testing.T) { | |
| 50 | + | i := newNotesTestInteractive() | |
| 51 | + | lines := make([]string, 20) | |
| 52 | + | for idx := range lines { | |
| 53 | + | lines[idx] = "line" | |
| 54 | + | } | |
| 55 | + | i.SetWidget("extension", "widget", "above_input", "Title", lines) | |
| 56 | + | ||
| 57 | + | i.mu.Lock() | |
| 58 | + | got := i.extensionChromeLinesLocked(80) | |
| 59 | + | i.mu.Unlock() | |
| 60 | + | if len(got) != maxExtensionWidgetRows { | |
| 61 | + | t.Fatalf("widget rows = %d, want %d: %v", len(got), maxExtensionWidgetRows, got) | |
| 62 | + | } | |
| 63 | + | if !strings.Contains(strings.Join(got, "\n"), "extension widgets truncated") { | |
| 64 | + | t.Fatalf("bounded widget rows omitted truncation marker: %v", got) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) { | |
| 69 | + | i := newNotesTestInteractive() | |
| 70 | + | for n := 0; n < maxExtensionStatusRows+4; n++ { | |
| 71 | + | i.SetStatus("extension", fmt.Sprintf("status-%d", n), "info", "line") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | i.mu.Lock() | |
| 75 | + | got := i.extensionChromeLinesLocked(80) | |
| 76 | + | i.mu.Unlock() | |
| 77 | + | if len(got) != maxExtensionStatusRows { | |
| 78 | + | t.Fatalf("status rows = %d, want %d: %v", len(got), maxExtensionStatusRows, got) | |
| 79 | + | } | |
| 80 | + | if !strings.Contains(strings.Join(got, "\n"), "extension statuses truncated") { | |
| 81 | + | t.Fatalf("bounded status rows omitted truncation marker: %v", got) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestPersistentExtensionStatusAndWidget(t *testing.T) { | |
| 86 | + | i := newNotesTestInteractive() | |
| 87 | + | i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked") | |
| 88 | + | i.SetWidget("tasked-phases", "plan", "above_input", "Tasked phases", []string{"Current phase: parse", "[ ] read files"}) | |
| 89 | + | ||
| 90 | + | i.mu.Lock() | |
| 91 | + | lines := i.extensionChromeLinesLocked(80) | |
| 92 | + | statusKey := i.extensionStatusesKeyLocked() | |
| 93 | + | widgetKey := i.extensionWidgetsKeyLocked() | |
| 94 | + | i.mu.Unlock() | |
| 95 | + | joined := strings.Join(lines, "\n") | |
| 96 | + | if !strings.Contains(joined, "2/4 tasks checked") || !strings.Contains(joined, "Tasked phases") || !strings.Contains(joined, "[ ] read files") { | |
| 97 | + | t.Fatalf("persistent extension chrome = %q", joined) | |
| 98 | + | } | |
| 99 | + | if !strings.Contains(statusKey, "tasked-phases/progress/success/2/4 tasks checked") { | |
| 100 | + | t.Fatalf("status cache key = %q", statusKey) | |
| 101 | + | } | |
| 102 | + | if !strings.Contains(widgetKey, "tasked-phases/plan/above_input/Tasked phases") { | |
| 103 | + | t.Fatalf("widget cache key = %q", widgetKey) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | i.SetStatus("tasked-phases", "progress", "", "") | |
| 107 | + | i.ClearWidget("tasked-phases", "plan") | |
| 108 | + | if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 { | |
| 109 | + | t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets) | |
| 110 | + | } | |
| 111 | + | } | |
M packages/agent/modes/interactive.go +216 -1
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "runtime" | |
| 10 | + | "sort" | |
| 10 | 11 | "strconv" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "sync" | |
| @@ -376,6 +377,8 @@ type chatCacheKey struct { | |||
| 376 | 377 | statusErr string | |
| 377 | 378 | help string | |
| 378 | 379 | extNotes string | |
| 380 | + | extStatuses string | |
| 381 | + | extWidgets string | |
| 379 | 382 | reloadErrors string | |
| 380 | 383 | updateAvailable bool | |
| 381 | 384 | updateCurrent string | |
| @@ -392,6 +395,17 @@ type QuickModelShortcut struct { | |||
| 392 | 395 | Model string | |
| 393 | 396 | } | |
| 394 | 397 | ||
| 398 | + | type extensionStatus struct { | |
| 399 | + | Level string | |
| 400 | + | Text string | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | type extensionWidget struct { | |
| 404 | + | Position string | |
| 405 | + | Title string | |
| 406 | + | Lines []string | |
| 407 | + | } | |
| 408 | + | ||
| 395 | 409 | // SettingsStore persists user-toggleable settings surfaced by /settings. | |
| 396 | 410 | type SettingsStore interface { | |
| 397 | 411 | SetQuickModelShortcut(slot int, providerName, model string) error | |
| @@ -480,6 +494,8 @@ type Interactive struct { | |||
| 480 | 494 | statusErr string | |
| 481 | 495 | statusOK string | |
| 482 | 496 | reloadStatusSeq uint64 | |
| 497 | + | extStatuses map[string]map[string]extensionStatus | |
| 498 | + | extWidgets map[string]map[string]extensionWidget | |
| 483 | 499 | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| 484 | 500 | helpBlock []string // rendered above the chat when /help was typed | |
| 485 | 501 | cumUsage provider.Usage | |
| @@ -748,6 +764,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 748 | 764 | sessionOpsDialog: newSessionOpsDialog(), | |
| 749 | 765 | sessionTreeDialog: newSessionTreeDialog(), | |
| 750 | 766 | extPanel: newExtPanelDialog(), | |
| 767 | + | extStatuses: map[string]map[string]extensionStatus{}, | |
| 768 | + | extWidgets: map[string]map[string]extensionWidget{}, | |
| 751 | 769 | suggest: newSlashSuggester(), | |
| 752 | 770 | fileSuggest: newFileSuggester(), | |
| 753 | 771 | spin: newSpinner(cfg.Theme), | |
| @@ -1105,6 +1123,8 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |||
| 1105 | 1123 | statusErr: i.statusErr, | |
| 1106 | 1124 | help: strings.Join(i.helpBlock, "\n"), | |
| 1107 | 1125 | extNotes: strings.Join(i.extNotes, "\n"), | |
| 1126 | + | extStatuses: i.extensionStatusesKeyLocked(), | |
| 1127 | + | extWidgets: i.extensionWidgetsKeyLocked(), | |
| 1108 | 1128 | reloadErrors: strings.Join(i.reloadErrors, "\n"), | |
| 1109 | 1129 | updateAvailable: i.updateInfo.Available, | |
| 1110 | 1130 | updateCurrent: i.updateInfo.Current, | |
| @@ -1116,6 +1136,138 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) { | |||
| 1116 | 1136 | }, true | |
| 1117 | 1137 | } | |
| 1118 | 1138 | ||
| 1139 | + | func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string { | |
| 1140 | + | keys := make([]string, 0, len(groups)) | |
| 1141 | + | for key := range groups { | |
| 1142 | + | keys = append(keys, key) | |
| 1143 | + | } | |
| 1144 | + | sort.Strings(keys) | |
| 1145 | + | return keys | |
| 1146 | + | } | |
| 1147 | + | ||
| 1148 | + | func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string { | |
| 1149 | + | items := groups[outer] | |
| 1150 | + | keys := make([]string, 0, len(items)) | |
| 1151 | + | for key := range items { | |
| 1152 | + | keys = append(keys, key) | |
| 1153 | + | } | |
| 1154 | + | sort.Strings(keys) | |
| 1155 | + | return keys | |
| 1156 | + | } | |
| 1157 | + | ||
| 1158 | + | func (i *Interactive) extensionStatusesKeyLocked() string { | |
| 1159 | + | var sb strings.Builder | |
| 1160 | + | extNames := sortedNestedOuterKeys(i.extStatuses) | |
| 1161 | + | for _, extName := range extNames { | |
| 1162 | + | keys := sortedNestedInnerKeys(i.extStatuses, extName) | |
| 1163 | + | for _, key := range keys { | |
| 1164 | + | status := i.extStatuses[extName][key] | |
| 1165 | + | fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text) | |
| 1166 | + | } | |
| 1167 | + | } | |
| 1168 | + | return sb.String() | |
| 1169 | + | } | |
| 1170 | + | ||
| 1171 | + | func (i *Interactive) extensionWidgetsKeyLocked() string { | |
| 1172 | + | var sb strings.Builder | |
| 1173 | + | extNames := sortedNestedOuterKeys(i.extWidgets) | |
| 1174 | + | for _, extName := range extNames { | |
| 1175 | + | ids := sortedNestedInnerKeys(i.extWidgets, extName) | |
| 1176 | + | for _, id := range ids { | |
| 1177 | + | widget := i.extWidgets[extName][id] | |
| 1178 | + | fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title) | |
| 1179 | + | for _, line := range widget.Lines { | |
| 1180 | + | sb.WriteString(line) | |
| 1181 | + | sb.WriteByte('\n') | |
| 1182 | + | } | |
| 1183 | + | } | |
| 1184 | + | } | |
| 1185 | + | return sb.String() | |
| 1186 | + | } | |
| 1187 | + | ||
| 1188 | + | const maxExtensionWidgetRows = 12 | |
| 1189 | + | const maxExtensionStatusRows = 6 | |
| 1190 | + | ||
| 1191 | + | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |
| 1192 | + | var out []string | |
| 1193 | + | bodyWidth := cols - 2 | |
| 1194 | + | if bodyWidth < 1 { | |
| 1195 | + | bodyWidth = 1 | |
| 1196 | + | } | |
| 1197 | + | widgetRows := 0 | |
| 1198 | + | widgetTruncated := false | |
| 1199 | + | appendWidgetRow := func(line string) bool { | |
| 1200 | + | if widgetRows >= maxExtensionWidgetRows-1 { | |
| 1201 | + | widgetTruncated = true | |
| 1202 | + | return false | |
| 1203 | + | } | |
| 1204 | + | out = append(out, line) | |
| 1205 | + | widgetRows++ | |
| 1206 | + | return true | |
| 1207 | + | } | |
| 1208 | + | ||
| 1209 | + | stopWidgets := false | |
| 1210 | + | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |
| 1211 | + | if stopWidgets { | |
| 1212 | + | break | |
| 1213 | + | } | |
| 1214 | + | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |
| 1215 | + | widget := i.extWidgets[extName][id] | |
| 1216 | + | label := " [" + extName + "]" | |
| 1217 | + | if widget.Title != "" { | |
| 1218 | + | label += " " + widget.Title | |
| 1219 | + | } | |
| 1220 | + | if !appendWidgetRow(i.cfg.Theme.FG256(i.cfg.Theme.Accent, truncateLine(label, cols))) { | |
| 1221 | + | stopWidgets = true | |
| 1222 | + | break | |
| 1223 | + | } | |
| 1224 | + | for _, line := range widget.Lines { | |
| 1225 | + | if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) { | |
| 1226 | + | stopWidgets = true | |
| 1227 | + | break | |
| 1228 | + | } | |
| 1229 | + | } | |
| 1230 | + | if stopWidgets { | |
| 1231 | + | break | |
| 1232 | + | } | |
| 1233 | + | } | |
| 1234 | + | } | |
| 1235 | + | if widgetTruncated { | |
| 1236 | + | out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols))) | |
| 1237 | + | } | |
| 1238 | + | ||
| 1239 | + | statusRows := 0 | |
| 1240 | + | statusTruncated := false | |
| 1241 | + | for _, extName := range sortedNestedOuterKeys(i.extStatuses) { | |
| 1242 | + | for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) { | |
| 1243 | + | if statusRows >= maxExtensionStatusRows-1 { | |
| 1244 | + | statusTruncated = true | |
| 1245 | + | break | |
| 1246 | + | } | |
| 1247 | + | status := i.extStatuses[extName][key] | |
| 1248 | + | color := i.cfg.Theme.Muted | |
| 1249 | + | switch status.Level { | |
| 1250 | + | case "warn": | |
| 1251 | + | color = i.cfg.Theme.Warning | |
| 1252 | + | case "error": | |
| 1253 | + | color = i.cfg.Theme.Error | |
| 1254 | + | case "success": | |
| 1255 | + | color = i.cfg.Theme.Tool | |
| 1256 | + | } | |
| 1257 | + | label := " [" + extName + "] " + status.Text | |
| 1258 | + | out = append(out, i.cfg.Theme.FG256(color, truncateLine(label, cols))) | |
| 1259 | + | statusRows++ | |
| 1260 | + | } | |
| 1261 | + | if statusTruncated { | |
| 1262 | + | break | |
| 1263 | + | } | |
| 1264 | + | } | |
| 1265 | + | if statusTruncated { | |
| 1266 | + | out = append(out, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols))) | |
| 1267 | + | } | |
| 1268 | + | return out | |
| 1269 | + | } | |
| 1270 | + | ||
| 1119 | 1271 | func (i *Interactive) buildChatLocked(cols int) []string { | |
| 1120 | 1272 | if i.agent != nil { | |
| 1121 | 1273 | i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages()) | |
| @@ -1599,13 +1751,15 @@ func (i *Interactive) redraw() { | |||
| 1599 | 1751 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | |
| 1600 | 1752 | } | |
| 1601 | 1753 | ||
| 1754 | + | extensionLines := i.extensionChromeLinesLocked(cols) | |
| 1755 | + | ||
| 1602 | 1756 | // Bottom-sticky sections (always visible, never scroll). Each | |
| 1603 | 1757 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | |
| 1604 | 1758 | // is preceded by one blank row so it has air above the chat | |
| 1605 | 1759 | // content. The status block and editor get their own dedicated | |
| 1606 | 1760 | // blanks so spacing stays consistent whether or not a dialog or | |
| 1607 | 1761 | // popup is showing. | |
| 1608 | - | bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(statusLines)+len(edLines)+9) | |
| 1762 | + | bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(edLines)+9) | |
| 1609 | 1763 | inputStartRow := -1 | |
| 1610 | 1764 | if len(dialog) > 0 { | |
| 1611 | 1765 | bottom = append(bottom, "") | |
| @@ -1627,6 +1781,7 @@ func (i *Interactive) redraw() { | |||
| 1627 | 1781 | workingBelow := workingPosition == tui.WorkingPositionBelowInput | |
| 1628 | 1782 | ||
| 1629 | 1783 | var aboveInput []string | |
| 1784 | + | aboveInput = append(aboveInput, extensionLines...) | |
| 1630 | 1785 | if !statusBelow { | |
| 1631 | 1786 | aboveInput = append(aboveInput, statusLines...) | |
| 1632 | 1787 | } | |
| @@ -3486,6 +3641,66 @@ func (i *Interactive) Display(extName, text string) { | |||
| 3486 | 3641 | i.invalidate() | |
| 3487 | 3642 | } | |
| 3488 | 3643 | ||
| 3644 | + | // SetStatus replaces one persistent status item owned by an extension. | |
| 3645 | + | func (i *Interactive) SetStatus(extName, key, level, text string) { | |
| 3646 | + | if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" { | |
| 3647 | + | return | |
| 3648 | + | } | |
| 3649 | + | i.mu.Lock() | |
| 3650 | + | if i.extStatuses == nil { | |
| 3651 | + | i.extStatuses = map[string]map[string]extensionStatus{} | |
| 3652 | + | } | |
| 3653 | + | if strings.TrimSpace(text) == "" { | |
| 3654 | + | if items := i.extStatuses[extName]; items != nil { | |
| 3655 | + | delete(items, key) | |
| 3656 | + | if len(items) == 0 { | |
| 3657 | + | delete(i.extStatuses, extName) | |
| 3658 | + | } | |
| 3659 | + | } | |
| 3660 | + | } else { | |
| 3661 | + | if i.extStatuses[extName] == nil { | |
| 3662 | + | i.extStatuses[extName] = map[string]extensionStatus{} | |
| 3663 | + | } | |
| 3664 | + | i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text} | |
| 3665 | + | } | |
| 3666 | + | i.mu.Unlock() | |
| 3667 | + | i.invalidate() | |
| 3668 | + | } | |
| 3669 | + | ||
| 3670 | + | // SetWidget replaces one persistent widget owned by an extension. | |
| 3671 | + | func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) { | |
| 3672 | + | if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" { | |
| 3673 | + | return | |
| 3674 | + | } | |
| 3675 | + | i.mu.Lock() | |
| 3676 | + | if i.extWidgets == nil { | |
| 3677 | + | i.extWidgets = map[string]map[string]extensionWidget{} | |
| 3678 | + | } | |
| 3679 | + | if i.extWidgets[extName] == nil { | |
| 3680 | + | i.extWidgets[extName] = map[string]extensionWidget{} | |
| 3681 | + | } | |
| 3682 | + | i.extWidgets[extName][id] = extensionWidget{ | |
| 3683 | + | Position: position, | |
| 3684 | + | Title: title, | |
| 3685 | + | Lines: append([]string(nil), lines...), | |
| 3686 | + | } | |
| 3687 | + | i.mu.Unlock() | |
| 3688 | + | i.invalidate() | |
| 3689 | + | } | |
| 3690 | + | ||
| 3691 | + | // ClearWidget removes one persistent widget owned by an extension. | |
| 3692 | + | func (i *Interactive) ClearWidget(extName, id string) { | |
| 3693 | + | i.mu.Lock() | |
| 3694 | + | if items := i.extWidgets[extName]; items != nil { | |
| 3695 | + | delete(items, id) | |
| 3696 | + | if len(items) == 0 { | |
| 3697 | + | delete(i.extWidgets, extName) | |
| 3698 | + | } | |
| 3699 | + | } | |
| 3700 | + | i.mu.Unlock() | |
| 3701 | + | i.invalidate() | |
| 3702 | + | } | |
| 3703 | + | ||
| 3489 | 3704 | func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | |
| 3490 | 3705 | i.mu.Lock() | |
| 3491 | 3706 | defer i.mu.Unlock() | |
M packages/agent/rpc.go +28 -4
| @@ -38,6 +38,9 @@ import ( | |||
| 38 | 38 | // | |
| 39 | 39 | // Responses (stdout): {"type":"response","id":"1","command":"prompt","success":true} | |
| 40 | 40 | // Events (stdout): one JSON object per AgentEvent (same schema as --json mode). | |
| 41 | + | // Extensions may additionally emit ext_status with extension/key/level/text, | |
| 42 | + | // ext_widget with extension/id/position/title/lines, and ext_widget_clear | |
| 43 | + | // with extension/id when loaded extensions publish persistent UI state. | |
| 41 | 44 | // | |
| 42 | 45 | // Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be | |
| 43 | 46 | // {"type":"hello","token":"..."} or the connection is closed. | |
| @@ -89,9 +92,9 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 89 | 92 | } | |
| 90 | 93 | return true, "", r.ModifiedArgs | |
| 91 | 94 | } | |
| 92 | - | ag.BeforeTurn = func(step int) (bool, string) { | |
| 93 | - | r := extMgr.InterceptTurnStart(ctx, step) | |
| 94 | - | return !r.Block, r.Reason | |
| 95 | + | ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) { | |
| 96 | + | r := extMgr.InterceptTurnStart(turnCtx, step) | |
| 97 | + | return !r.Block, r.Reason, r.Context | |
| 95 | 98 | } | |
| 96 | 99 | ag.BeforeAssistantMessage = func(text string) (bool, string, string) { | |
| 97 | 100 | r := extMgr.InterceptAssistantMessage(ctx, text) | |
| @@ -120,7 +123,7 @@ func runRPCMode(ctx context.Context, args Args, version string) error { | |||
| 120 | 123 | } | |
| 121 | 124 | }) | |
| 122 | 125 | ||
| 123 | - | extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"}) | |
| 126 | + | extMgr.EmitSessionEvent("session_start", nil, nil) | |
| 124 | 127 | ||
| 125 | 128 | return server.run(os.Stdin) | |
| 126 | 129 | } | |
| @@ -176,6 +179,27 @@ func (h *rpcExtHooks) Insert(string) {} / | |||
| 176 | 179 | func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {} | |
| 177 | 180 | func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {} | |
| 178 | 181 | func (h *rpcExtHooks) ClosePanel(string, string) {} | |
| 182 | + | func (h *rpcExtHooks) SetStatus(extName, key, level, text string) { | |
| 183 | + | if h.server != nil { | |
| 184 | + | h.server.writeExtensionEvent(map[string]any{ | |
| 185 | + | "type": "ext_status", "extension": extName, "key": key, "level": level, "text": text, | |
| 186 | + | }) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | func (h *rpcExtHooks) SetWidget(extName, id, position, title string, lines []string) { | |
| 190 | + | if h.server != nil { | |
| 191 | + | h.server.writeExtensionEvent(map[string]any{ | |
| 192 | + | "type": "ext_widget", "extension": extName, "id": id, "position": position, "title": title, "lines": lines, | |
| 193 | + | }) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | func (h *rpcExtHooks) ClearWidget(extName, id string) { | |
| 197 | + | if h.server != nil { | |
| 198 | + | h.server.writeExtensionEvent(map[string]any{ | |
| 199 | + | "type": "ext_widget_clear", "extension": extName, "id": id, | |
| 200 | + | }) | |
| 201 | + | } | |
| 202 | + | } | |
| 179 | 203 | ||
| 180 | 204 | const maxPendingRPCExtEvents = 64 | |
| 181 | 205 | ||
A packages/agent/skills/bundled_test.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package skills | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "runtime" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestLoadBundledLoadsDeclaredDirectoriesInOrder(t *testing.T) { | |
| 11 | + | tmp := t.TempDir() | |
| 12 | + | collectionA := filepath.Join(tmp, "extension-a", "skills") | |
| 13 | + | collectionB := filepath.Join(tmp, "extension-b", "skills") | |
| 14 | + | direct := filepath.Join(tmp, "extension-c", "one-skill") | |
| 15 | + | ||
| 16 | + | writeSkill := func(dir, name, description string) { | |
| 17 | + | t.Helper() | |
| 18 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | body := "---\nname: " + name + "\ndescription: " + description + "\n---\nbody\n" | |
| 22 | + | if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | writeSkill(filepath.Join(collectionA, "shared"), "shared", "first declaration") | |
| 28 | + | writeSkill(filepath.Join(collectionA, "alpha"), "alpha", "from a") | |
| 29 | + | writeSkill(filepath.Join(collectionB, "shared"), "shared", "later declaration") | |
| 30 | + | writeSkill(filepath.Join(collectionB, "beta"), "beta", "from b") | |
| 31 | + | writeSkill(direct, "direct", "from a direct skill directory") | |
| 32 | + | ||
| 33 | + | got, errs := LoadBundled([]BundledSkillDir{ | |
| 34 | + | {Dir: collectionA, Source: "extension alpha"}, | |
| 35 | + | {Dir: collectionB, Source: "extension beta"}, | |
| 36 | + | {Dir: direct, Source: "extension direct"}, | |
| 37 | + | }) | |
| 38 | + | if len(errs) != 0 { | |
| 39 | + | t.Fatalf("LoadBundled() errors = %v", errs) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | if len(got) != 4 { | |
| 43 | + | t.Fatalf("LoadBundled() returned %d skills, want 4: %#v", len(got), got) | |
| 44 | + | } | |
| 45 | + | if shared := FindByName(got, "shared"); shared == nil { | |
| 46 | + | t.Fatal("shared skill was not loaded") | |
| 47 | + | } else { | |
| 48 | + | if shared.Description != "first declaration" || shared.Source != "extension alpha" { | |
| 49 | + | t.Fatalf("shared skill = %#v, want first declaration and source label", shared) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if beta := FindByName(got, "beta"); beta == nil || beta.Source != "extension beta" { | |
| 53 | + | t.Fatalf("beta skill = %#v, want extension beta source", beta) | |
| 54 | + | } | |
| 55 | + | if directSkill := FindByName(got, "direct"); directSkill == nil { | |
| 56 | + | t.Fatal("direct skill directory was not loaded") | |
| 57 | + | } else if directSkill.Path != filepath.Join(direct, "SKILL.md") { | |
| 58 | + | t.Fatalf("direct skill path = %q, want %q", directSkill.Path, filepath.Join(direct, "SKILL.md")) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestLoadBundledRejectsSkillFilesOutsideRoot(t *testing.T) { | |
| 63 | + | tmp := t.TempDir() | |
| 64 | + | root := filepath.Join(tmp, "extension") | |
| 65 | + | bundle := filepath.Join(root, "skills") | |
| 66 | + | outside := filepath.Join(tmp, "outside", "SKILL.md") | |
| 67 | + | if err := os.MkdirAll(bundle, 0o755); err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | if err := os.MkdirAll(filepath.Dir(outside), 0o755); err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | if err := os.WriteFile(outside, []byte("---\nname: escape\ndescription: outside\n---\nbody\n"), 0o644); err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | got, errs := LoadBundled([]BundledSkillDir{{Dir: filepath.Dir(outside), Root: root, Source: "extension test"}}) | |
| 77 | + | if len(errs) == 0 || len(got) != 0 { | |
| 78 | + | t.Fatalf("outside bundle was accepted: skills=%#v errors=%v", got, errs) | |
| 79 | + | } | |
| 80 | + | if runtime.GOOS == "windows" { | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | if err := os.Symlink(outside, filepath.Join(bundle, "SKILL.md")); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | got, errs = LoadBundled([]BundledSkillDir{{Dir: bundle, Root: root, Source: "extension test"}}) | |
| 88 | + | if len(errs) == 0 || len(got) != 0 { | |
| 89 | + | t.Fatalf("outside skill was accepted: skills=%#v errors=%v", got, errs) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestLoadBundledDoesNotIncludeUserOrBuiltinSkills(t *testing.T) { | |
| 94 | + | tmp := t.TempDir() | |
| 95 | + | dir := filepath.Join(tmp, "skills", "declared") | |
| 96 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | content := "---\nname: declared\ndescription: declared only\n---\nbody\n" | |
| 100 | + | if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | got, errs := LoadBundled([]BundledSkillDir{{Dir: filepath.Dir(dir), Source: "extension test"}}) | |
| 105 | + | if len(errs) != 0 { | |
| 106 | + | t.Fatalf("LoadBundled() errors = %v", errs) | |
| 107 | + | } | |
| 108 | + | if FindByName(got, "declared") == nil { | |
| 109 | + | t.Fatalf("declared skill missing from %#v", got) | |
| 110 | + | } | |
| 111 | + | for _, skill := range got { | |
| 112 | + | if skill.Builtin { | |
| 113 | + | t.Fatalf("LoadBundled() returned builtin skill %q", skill.Name) | |
| 114 | + | } | |
| 115 | + | if skill.Name != "declared" { | |
| 116 | + | t.Fatalf("LoadBundled() returned unexpected skill %q", skill.Name) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
M packages/agent/skills/skills.go +127 -2
| @@ -73,6 +73,17 @@ type Skill struct { | |||
| 73 | 73 | Permissions map[string][]string | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | // BundledSkillDir identifies a skill directory shipped by an extension. | |
| 77 | + | // The directory contains one subdirectory per skill, each with SKILL.md. | |
| 78 | + | // Root is optional; when set, every loaded SKILL.md must resolve beneath it. | |
| 79 | + | // Extension managers set Root to keep symlinked skill files inside the | |
| 80 | + | // extension directory. | |
| 81 | + | type BundledSkillDir struct { | |
| 82 | + | Dir string | |
| 83 | + | Source string | |
| 84 | + | Root string | |
| 85 | + | } | |
| 86 | + | ||
| 76 | 87 | // VisibleSkills returns the subset of skills users should see in | |
| 77 | 88 | // pickers, /skills, and other interactive surfaces. Built-ins are | |
| 78 | 89 | // hidden because they're implementation detail; the model still | |
| @@ -102,13 +113,22 @@ func VisibleSkills(in []*Skill) []*Skill { | |||
| 102 | 113 | // Errors per skill are returned alongside the partial result so a | |
| 103 | 114 | // single broken file doesn't suppress the rest. | |
| 104 | 115 | func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []error) { | |
| 116 | + | return DiscoverWithBundled(zotHome, cwd, userHome, includeUser, nil) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // DiscoverWithBundled merges normal user skills with extension-bundled skills | |
| 120 | + | // and built-ins. Precedence is user/project locations, then bundled skills in | |
| 121 | + | // declaration order, then built-ins. The first matching name wins. | |
| 122 | + | func DiscoverWithBundled(zotHome, cwd, userHome string, includeUser bool, bundled []BundledSkillDir) ([]*Skill, []error) { | |
| 105 | 123 | var errs []error | |
| 106 | 124 | seen := map[string]*Skill{} | |
| 107 | 125 | if includeUser { | |
| 108 | 126 | errs = append(errs, scanUserSkills(zotHome, cwd, userHome, seen)...) | |
| 109 | 127 | } | |
| 110 | - | // Built-ins fill in any name the user didn't already provide | |
| 111 | - | // (or every name, when includeUser is false). | |
| 128 | + | for _, dir := range bundled { | |
| 129 | + | errs = append(errs, scanBundledSkills(dir, seen)...) | |
| 130 | + | } | |
| 131 | + | // Built-ins fill in any name the user or an extension didn't provide. | |
| 112 | 132 | for _, s := range loadBuiltins() { | |
| 113 | 133 | if _, dup := seen[s.Name]; dup { | |
| 114 | 134 | continue | |
| @@ -123,6 +143,111 @@ func Discover(zotHome, cwd, userHome string, includeUser bool) ([]*Skill, []erro | |||
| 123 | 143 | return out, errs | |
| 124 | 144 | } | |
| 125 | 145 | ||
| 146 | + | // LoadBundled loads only the skills declared by extension manifests. The | |
| 147 | + | // first declaration with a given name wins; built-in and user locations are | |
| 148 | + | // intentionally not considered here. | |
| 149 | + | func LoadBundled(bundled []BundledSkillDir) ([]*Skill, []error) { | |
| 150 | + | seen := map[string]*Skill{} | |
| 151 | + | var errs []error | |
| 152 | + | for _, dir := range bundled { | |
| 153 | + | errs = append(errs, scanBundledSkills(dir, seen)...) | |
| 154 | + | } | |
| 155 | + | out := make([]*Skill, 0, len(seen)) | |
| 156 | + | for _, s := range seen { | |
| 157 | + | out = append(out, s) | |
| 158 | + | } | |
| 159 | + | sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 160 | + | return out, errs | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func pathWithinRoot(root, path string) (bool, error) { | |
| 164 | + | rootResolved, err := filepath.EvalSymlinks(root) | |
| 165 | + | if err != nil { | |
| 166 | + | return false, err | |
| 167 | + | } | |
| 168 | + | rootAbs, err := filepath.Abs(rootResolved) | |
| 169 | + | if err != nil { | |
| 170 | + | return false, err | |
| 171 | + | } | |
| 172 | + | resolved, err := filepath.EvalSymlinks(path) | |
| 173 | + | if err != nil { | |
| 174 | + | return false, err | |
| 175 | + | } | |
| 176 | + | resolvedAbs, err := filepath.Abs(resolved) | |
| 177 | + | if err != nil { | |
| 178 | + | return false, err | |
| 179 | + | } | |
| 180 | + | rel, err := filepath.Rel(rootAbs, resolvedAbs) | |
| 181 | + | if err != nil { | |
| 182 | + | return false, err | |
| 183 | + | } | |
| 184 | + | return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)), nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // scanBundledSkills walks one caller-resolved bundled-skill directory and | |
| 188 | + | // populates `seen` with first-match-wins per name. | |
| 189 | + | func scanBundledSkills(dir BundledSkillDir, seen map[string]*Skill) []error { | |
| 190 | + | if strings.TrimSpace(dir.Dir) == "" { | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | label := strings.TrimSpace(dir.Source) | |
| 194 | + | if label == "" { | |
| 195 | + | label = "extension (unnamed)" | |
| 196 | + | } | |
| 197 | + | var errs []error | |
| 198 | + | loadOne := func(path, fallbackName string) { | |
| 199 | + | if dir.Root != "" { | |
| 200 | + | safe, err := pathWithinRoot(dir.Root, path) | |
| 201 | + | if err != nil { | |
| 202 | + | if !os.IsNotExist(err) { | |
| 203 | + | errs = append(errs, fmt.Errorf("%s: validate path: %w", path, err)) | |
| 204 | + | } | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | if !safe { | |
| 208 | + | errs = append(errs, fmt.Errorf("%s: resolved path escapes bundled extension directory", path)) | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | s, err := load(path, label) | |
| 213 | + | if err != nil { | |
| 214 | + | if !os.IsNotExist(err) { | |
| 215 | + | errs = append(errs, fmt.Errorf("%s: %w", path, err)) | |
| 216 | + | } | |
| 217 | + | return | |
| 218 | + | } | |
| 219 | + | if s.Name == "" { | |
| 220 | + | s.Name = fallbackName | |
| 221 | + | } | |
| 222 | + | if _, dup := seen[s.Name]; !dup { | |
| 223 | + | seen[s.Name] = s | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | skillPath := filepath.Join(dir.Dir, "SKILL.md") | |
| 227 | + | if info, err := os.Stat(skillPath); err == nil { | |
| 228 | + | if !info.IsDir() { | |
| 229 | + | loadOne(skillPath, filepath.Base(dir.Dir)) | |
| 230 | + | return errs | |
| 231 | + | } | |
| 232 | + | } else if !os.IsNotExist(err) { | |
| 233 | + | errs = append(errs, fmt.Errorf("%s: %w", skillPath, err)) | |
| 234 | + | } | |
| 235 | + | entries, err := os.ReadDir(dir.Dir) | |
| 236 | + | if err != nil { | |
| 237 | + | if !os.IsNotExist(err) { | |
| 238 | + | errs = append(errs, fmt.Errorf("%s: %w", dir.Dir, err)) | |
| 239 | + | } | |
| 240 | + | return errs | |
| 241 | + | } | |
| 242 | + | for _, entry := range entries { | |
| 243 | + | if !entry.IsDir() { | |
| 244 | + | continue | |
| 245 | + | } | |
| 246 | + | loadOne(filepath.Join(dir.Dir, entry.Name(), "SKILL.md"), entry.Name()) | |
| 247 | + | } | |
| 248 | + | return errs | |
| 249 | + | } | |
| 250 | + | ||
| 126 | 251 | // scanUserSkills walks the user-skill search dirs and populates | |
| 127 | 252 | // `seen` with first-match-wins per name. Split out so Discover's | |
| 128 | 253 | // includeUser=false path doesn't have to skip over a giant block. | |
M packages/agent/skills/skills_test.go +53 -0
| @@ -94,6 +94,59 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |||
| 94 | 94 | } | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | + | func TestDiscoverWithBundledSkillsPrecedence(t *testing.T) { | |
| 98 | + | t.Setenv("ZOT_AGENT_SKILLS", "") | |
| 99 | + | tmp := t.TempDir() | |
| 100 | + | cwd := filepath.Join(tmp, "project") | |
| 101 | + | userDir := filepath.Join(cwd, ".zot", "skills") | |
| 102 | + | bundleA := filepath.Join(tmp, "ext-a", "skills") | |
| 103 | + | bundleB := filepath.Join(tmp, "ext-b", "skills") | |
| 104 | + | write := func(root, name, description string) { | |
| 105 | + | dir := filepath.Join(root, name) | |
| 106 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | body := "---\nname: " + name + "\ndescription: " + description + "\n---\nbody\n" | |
| 110 | + | if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil { | |
| 111 | + | t.Fatal(err) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | write(userDir, "shared", "user") | |
| 115 | + | write(bundleA, "shared", "bundle-a") | |
| 116 | + | write(bundleA, "only-a", "only a") | |
| 117 | + | write(bundleB, "only-a", "bundle-b") | |
| 118 | + | write(bundleB, "only-b", "only b") | |
| 119 | + | ||
| 120 | + | list, errs := DiscoverWithBundled("", cwd, "", true, []BundledSkillDir{ | |
| 121 | + | {Dir: bundleA, Source: "extension a"}, | |
| 122 | + | {Dir: bundleB, Source: "extension b"}, | |
| 123 | + | }) | |
| 124 | + | if len(errs) > 0 { | |
| 125 | + | t.Fatalf("errs: %v", errs) | |
| 126 | + | } | |
| 127 | + | if got := FindByName(list, "shared"); got == nil || got.Description != "user" { | |
| 128 | + | t.Fatalf("user precedence = %#v", got) | |
| 129 | + | } | |
| 130 | + | if got := FindByName(list, "only-a"); got == nil || got.Description != "only a" || got.Source != "extension a" { | |
| 131 | + | t.Fatalf("bundle declaration precedence = %#v", got) | |
| 132 | + | } | |
| 133 | + | if got := FindByName(list, "only-b"); got == nil || got.Source != "extension b" { | |
| 134 | + | t.Fatalf("second bundle skill = %#v", got) | |
| 135 | + | } | |
| 136 | + | builtins := loadBuiltins() | |
| 137 | + | if len(builtins) == 0 { | |
| 138 | + | t.Fatal("expected at least one builtin skill") | |
| 139 | + | } | |
| 140 | + | write(bundleA, builtins[0].Name, "bundled override") | |
| 141 | + | list, errs = DiscoverWithBundled("", cwd, "", true, []BundledSkillDir{{Dir: bundleA, Source: "extension a"}}) | |
| 142 | + | if len(errs) > 0 { | |
| 143 | + | t.Fatalf("builtin override errors: %v", errs) | |
| 144 | + | } | |
| 145 | + | if got := FindByName(list, builtins[0].Name); got == nil || got.Description != "bundled override" || got.Builtin { | |
| 146 | + | t.Fatalf("bundled builtin override = %#v", got) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 97 | 150 | func TestVisibleSkillsHidesBuiltins(t *testing.T) { | |
| 98 | 151 | in := []*Skill{ | |
| 99 | 152 | {Name: "user-one"}, | |
M packages/agent/swarm_agent.go +2 -0
| @@ -78,8 +78,10 @@ func runSwarmAgentMode(ctx context.Context, args Args, version string) error { | |||
| 78 | 78 | return err | |
| 79 | 79 | } | |
| 80 | 80 | r.Provider, r.Model = providerName, model | |
| 81 | + | ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) } | |
| 81 | 82 | defer sess.Close() | |
| 82 | 83 | } | |
| 84 | + | announceSession(extMgr, sess) | |
| 83 | 85 | ||
| 84 | 86 | // Open the inbox listener BEFORE emitting agent_ready so the | |
| 85 | 87 | // supervisor can dial through on the very first send. The | |
M packages/core/agent.go +60 -4
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | "sync" | |
| 10 | 10 | "time" | |
| 11 | + | "unicode/utf8" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | 14 | ) | |
| @@ -49,6 +50,20 @@ type Agent struct { | |||
| 49 | 50 | // limiting, business-hour gates, and deny-by-default setups. | |
| 50 | 51 | BeforeTurn func(step int) (allowed bool, reason string) | |
| 51 | 52 | ||
| 53 | + | // BeforeTurnContext is the richer turn-start hook. It receives the | |
| 54 | + | // current turn context so blocking interception can honor cancellation. | |
| 55 | + | // It may return a bounded, non-transcript context string that is appended | |
| 56 | + | // to the provider request's system prompt for this turn only. When set, it | |
| 57 | + | // replaces BeforeTurn so a host can combine approval and context | |
| 58 | + | // collection into one lifecycle round trip. | |
| 59 | + | BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string) | |
| 60 | + | ||
| 61 | + | // OnToolResult, if set, receives extension/tool metadata immediately | |
| 62 | + | // after each tool call completes. Details are host-owned metadata and | |
| 63 | + | // are never sent to the provider; hosts may persist selected details | |
| 64 | + | // with the active session. | |
| 65 | + | OnToolResult func(id string, result ToolResult) | |
| 66 | + | ||
| 52 | 67 | // BeforeAssistantMessage, if set, is called after the model's | |
| 53 | 68 | // final assistant message is assembled but before it's appended | |
| 54 | 69 | // to the transcript. Returning (allowed=false) suppresses both | |
| @@ -384,7 +399,20 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |||
| 384 | 399 | } | |
| 385 | 400 | ||
| 386 | 401 | sink(EvTurnStart{Step: step}) | |
| 387 | - | if a.BeforeTurn != nil { | |
| 402 | + | turnContext := "" | |
| 403 | + | if a.BeforeTurnContext != nil { | |
| 404 | + | var allowed bool | |
| 405 | + | var reason string | |
| 406 | + | allowed, reason, turnContext = a.BeforeTurnContext(ctx, step) | |
| 407 | + | if !allowed { | |
| 408 | + | if reason == "" { | |
| 409 | + | reason = "turn blocked by extension guard" | |
| 410 | + | } | |
| 411 | + | sink(EvTurnEnd{Stop: provider.StopError, Err: fmt.Errorf("%s", reason)}) | |
| 412 | + | sink(EvDone{}) | |
| 413 | + | return nil | |
| 414 | + | } | |
| 415 | + | } else if a.BeforeTurn != nil { | |
| 388 | 416 | if allowed, reason := a.BeforeTurn(step); !allowed { | |
| 389 | 417 | if reason == "" { | |
| 390 | 418 | reason = "turn blocked by extension guard" | |
| @@ -401,7 +429,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |||
| 401 | 429 | err error | |
| 402 | 430 | ) | |
| 403 | 431 | for attempt := 0; ; attempt++ { | |
| 404 | - | stop, assistantMsg, err = a.oneTurn(ctx, sink) | |
| 432 | + | stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext) | |
| 405 | 433 | sink(EvTurnEnd{Stop: stop, Err: err}) | |
| 406 | 434 | if err == nil || !a.canRetryError(err, attempt) { | |
| 407 | 435 | break | |
| @@ -561,14 +589,21 @@ func (a *Agent) dropLastAssistantMessage() { | |||
| 561 | 589 | ||
| 562 | 590 | // oneTurn calls the LLM once, forwards events, returns the stop reason | |
| 563 | 591 | // and the assembled assistant message (already appended to the transcript). | |
| 564 | - | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | |
| 592 | + | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string) (provider.StopReason, provider.Message, error) { | |
| 565 | 593 | fastMode := a.FastModeEnabled() | |
| 566 | 594 | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | |
| 567 | 595 | return provider.StopError, provider.Message{}, err | |
| 568 | 596 | } | |
| 597 | + | system := a.System | |
| 598 | + | if contextText := boundedTurnContext(turnContext); contextText != "" { | |
| 599 | + | if system != "" { | |
| 600 | + | system += "\n\n" | |
| 601 | + | } | |
| 602 | + | system += "[Extension context for this turn]\n" + contextText | |
| 603 | + | } | |
| 569 | 604 | req := provider.Request{ | |
| 570 | 605 | Model: a.Model, | |
| 571 | - | System: a.System, | |
| 606 | + | System: system, | |
| 572 | 607 | // Repair any dangling tool_use blocks before sending. A turn | |
| 573 | 608 | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | |
| 574 | 609 | // dev server, etc.) can leave an assistant tool_use with no | |
| @@ -708,6 +743,9 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||
| 708 | 743 | addedTools = append(addedTools, name) | |
| 709 | 744 | } | |
| 710 | 745 | } | |
| 746 | + | if a.OnToolResult != nil { | |
| 747 | + | a.OnToolResult(tc.ID, res) | |
| 748 | + | } | |
| 711 | 749 | sink(EvToolResult{ID: tc.ID, Result: res}) | |
| 712 | 750 | } | |
| 713 | 751 | ||
| @@ -821,6 +859,24 @@ func mirrorToolImagesAsUser(msg provider.Message) provider.Message { | |||
| 821 | 859 | return provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()} | |
| 822 | 860 | } | |
| 823 | 861 | ||
| 862 | + | const ( | |
| 863 | + | maxTurnContextBytes = 16 * 1024 | |
| 864 | + | turnContextTruncatedMarker = "\n[extension context truncated]" | |
| 865 | + | ) | |
| 866 | + | ||
| 867 | + | func boundedTurnContext(text string) string { | |
| 868 | + | text = strings.TrimSpace(text) | |
| 869 | + | if len(text) <= maxTurnContextBytes { | |
| 870 | + | return text | |
| 871 | + | } | |
| 872 | + | limit := maxTurnContextBytes - len(turnContextTruncatedMarker) | |
| 873 | + | cut := limit | |
| 874 | + | for cut > 0 && !utf8.RuneStart(text[cut]) { | |
| 875 | + | cut-- | |
| 876 | + | } | |
| 877 | + | return strings.TrimSpace(text[:cut]) + turnContextTruncatedMarker | |
| 878 | + | } | |
| 879 | + | ||
| 824 | 880 | func containsString(values []string, want string) bool { | |
| 825 | 881 | for _, value := range values { | |
| 826 | 882 | if value == want { | |
M packages/core/agent_retry_test.go +41 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "sync/atomic" | |
| 9 | 9 | "testing" | |
| 10 | 10 | "time" | |
| 11 | + | "unicode/utf8" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | 14 | ) | |
| @@ -221,6 +222,46 @@ func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-cha | |||
| 221 | 222 | return out, nil | |
| 222 | 223 | } | |
| 223 | 224 | ||
| 225 | + | func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) { | |
| 226 | + | client := &captureClient{} | |
| 227 | + | a := NewAgent(client, "fake-model", "system", Registry{}) | |
| 228 | + | a.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | |
| 229 | + | return true, "", "current phase: parse files" | |
| 230 | + | } | |
| 231 | + | if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil { | |
| 232 | + | t.Fatalf("Prompt returned %v", err) | |
| 233 | + | } | |
| 234 | + | if !strings.Contains(client.lastReq.System, "current phase: parse files") { | |
| 235 | + | t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System) | |
| 236 | + | } | |
| 237 | + | if len(a.Messages()) != 2 { | |
| 238 | + | t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages())) | |
| 239 | + | } | |
| 240 | + | for _, msg := range a.Messages() { | |
| 241 | + | if strings.Contains(extractText(msg), "current phase: parse files") { | |
| 242 | + | t.Fatal("hidden turn context leaked into transcript") | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func TestBoundedTurnContextPreservesUTF8AndLimit(t *testing.T) { | |
| 248 | + | contextText := strings.Repeat("界", maxTurnContextBytes) | |
| 249 | + | got := boundedTurnContext(contextText) | |
| 250 | + | if len(got) > maxTurnContextBytes { | |
| 251 | + | t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxTurnContextBytes) | |
| 252 | + | } | |
| 253 | + | if !utf8.ValidString(got) { | |
| 254 | + | t.Fatal("bounded context is not valid UTF-8") | |
| 255 | + | } | |
| 256 | + | if !strings.HasSuffix(got, turnContextTruncatedMarker) { | |
| 257 | + | tail := got | |
| 258 | + | if len(tail) > 64 { | |
| 259 | + | tail = tail[len(tail)-64:] | |
| 260 | + | } | |
| 261 | + | t.Fatalf("bounded context missing truncation marker: %q", tail) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 224 | 265 | func TestAgentPropagatesMaxTokens(t *testing.T) { | |
| 225 | 266 | client := &captureClient{} | |
| 226 | 267 | a := NewAgent(client, "fake-model", "system", Registry{}) | |
M packages/core/core_test.go +73 -0
| @@ -2,6 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "os" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| @@ -163,6 +164,78 @@ func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) { | |||
| 163 | 164 | } | |
| 164 | 165 | } | |
| 165 | 166 | ||
| 167 | + | func TestSessionExtensionStateRoundTrip(t *testing.T) { | |
| 168 | + | dir := t.TempDir() | |
| 169 | + | sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude", "test") | |
| 170 | + | if err != nil { | |
| 171 | + | t.Fatal(err) | |
| 172 | + | } | |
| 173 | + | if err := sess.AppendMessage(provider.Message{ | |
| 174 | + | Role: provider.RoleUser, | |
| 175 | + | Content: []provider.Content{provider.TextBlock{Text: "prompt"}}, | |
| 176 | + | }); err != nil { | |
| 177 | + | t.Fatal(err) | |
| 178 | + | } | |
| 179 | + | state := json.RawMessage(`{"phases":[{"id":"phase-1"}]}`) | |
| 180 | + | if err := sess.AppendExtensionState("tasked-phases", state); err != nil { | |
| 181 | + | t.Fatal(err) | |
| 182 | + | } | |
| 183 | + | if err := sess.Close(); err != nil { | |
| 184 | + | t.Fatal(err) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | reopened, _, err := OpenSession(sess.Path) | |
| 188 | + | if err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | firstReopened := reopened | |
| 192 | + | t.Cleanup(func() { _ = firstReopened.Close() }) | |
| 193 | + | if got := string(reopened.ExtensionState["tasked-phases"]); got != string(state) { | |
| 194 | + | t.Fatalf("extension state = %q, want %q", got, state) | |
| 195 | + | } | |
| 196 | + | if err := reopened.AppendExtensionState("tasked-phases", nil); err != nil { | |
| 197 | + | t.Fatal(err) | |
| 198 | + | } | |
| 199 | + | if _, ok := reopened.ExtensionState["tasked-phases"]; ok { | |
| 200 | + | t.Fatal("nil extension state did not clear the snapshot") | |
| 201 | + | } | |
| 202 | + | if err := reopened.Close(); err != nil { | |
| 203 | + | t.Fatal(err) | |
| 204 | + | } | |
| 205 | + | reopened, _, err = OpenSession(sess.Path) | |
| 206 | + | if err != nil { | |
| 207 | + | t.Fatal(err) | |
| 208 | + | } | |
| 209 | + | t.Cleanup(func() { _ = reopened.Close() }) | |
| 210 | + | if _, ok := reopened.ExtensionState["tasked-phases"]; ok { | |
| 211 | + | t.Fatal("cleared extension state was restored after reopening") | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestSessionRetainsExtensionStateWithoutMessages(t *testing.T) { | |
| 216 | + | dir := t.TempDir() | |
| 217 | + | sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude", "test") | |
| 218 | + | if err != nil { | |
| 219 | + | t.Fatal(err) | |
| 220 | + | } | |
| 221 | + | state := json.RawMessage(`{"version":1}`) | |
| 222 | + | if err := sess.AppendExtensionState("tasked-phases", state); err != nil { | |
| 223 | + | t.Fatal(err) | |
| 224 | + | } | |
| 225 | + | path := sess.Path | |
| 226 | + | if err := sess.Close(); err != nil { | |
| 227 | + | t.Fatal(err) | |
| 228 | + | } | |
| 229 | + | reopened, _, err := OpenSession(path) | |
| 230 | + | if err != nil { | |
| 231 | + | t.Fatal(err) | |
| 232 | + | } | |
| 233 | + | defer reopened.Close() | |
| 234 | + | if got := string(reopened.ExtensionState["tasked-phases"]); got != string(state) { | |
| 235 | + | t.Fatalf("extension state = %q, want %q", got, state) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 166 | 239 | func TestCostAdd(t *testing.T) { | |
| 167 | 240 | var c CostTracker | |
| 168 | 241 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01}) | |
M packages/core/session.go +73 -16
| @@ -26,9 +26,12 @@ type Session struct { | |||
| 26 | 26 | Meta SessionMeta | |
| 27 | 27 | // Title is the effective title after applying append-only rename rows. | |
| 28 | 28 | // Meta.Title remains the original meta-line value for compatibility. | |
| 29 | - | Title string | |
| 30 | - | writer *os.File | |
| 31 | - | buf *bufio.Writer | |
| 29 | + | Title string | |
| 30 | + | // ExtensionState contains the latest opaque state snapshot recorded by | |
| 31 | + | // each extension for this session/branch. It is never sent to providers. | |
| 32 | + | ExtensionState map[string]json.RawMessage | |
| 33 | + | writer *os.File | |
| 34 | + | buf *bufio.Writer | |
| 32 | 35 | ||
| 33 | 36 | // freshFile is true when the file was created by NewSession (this | |
| 34 | 37 | // process owns it) and false when OpenSession reopened an existing | |
| @@ -81,6 +84,8 @@ type sessionLine struct { | |||
| 81 | 84 | Messages *[]provider.Message `json:"messages,omitempty"` | |
| 82 | 85 | Usage *provider.Usage `json:"usage,omitempty"` | |
| 83 | 86 | Cumulative *provider.Usage `json:"cumulative,omitempty"` | |
| 87 | + | Extension string `json:"extension,omitempty"` | |
| 88 | + | State json.RawMessage `json:"state,omitempty"` | |
| 84 | 89 | } | |
| 85 | 90 | ||
| 86 | 91 | type sessionLineHead struct { | |
| @@ -104,6 +109,7 @@ type SessionSnapshot struct { | |||
| 104 | 109 | Meta SessionMeta | |
| 105 | 110 | Title string | |
| 106 | 111 | Messages []provider.Message | |
| 112 | + | ExtensionState map[string]json.RawMessage | |
| 107 | 113 | UsageCheckpoints []SessionUsageCheckpoint | |
| 108 | 114 | CompactionGeneration int | |
| 109 | 115 | } | |
| @@ -168,12 +174,13 @@ func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) | |||
| 168 | 174 | return nil, err | |
| 169 | 175 | } | |
| 170 | 176 | s := &Session{ | |
| 171 | - | ID: id, | |
| 172 | - | Path: p, | |
| 173 | - | Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version}, | |
| 174 | - | writer: f, | |
| 175 | - | buf: bufio.NewWriter(f), | |
| 176 | - | freshFile: true, | |
| 177 | + | ID: id, | |
| 178 | + | Path: p, | |
| 179 | + | Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version}, | |
| 180 | + | writer: f, | |
| 181 | + | buf: bufio.NewWriter(f), | |
| 182 | + | freshFile: true, | |
| 183 | + | ExtensionState: map[string]json.RawMessage{}, | |
| 177 | 184 | } | |
| 178 | 185 | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |
| 179 | 186 | f.Close() | |
| @@ -277,6 +284,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 277 | 284 | } | |
| 278 | 285 | var rawCheckpoints []rawCheckpoint | |
| 279 | 286 | titleFromRename := false | |
| 287 | + | extensionState := map[string]json.RawMessage{} | |
| 280 | 288 | ||
| 281 | 289 | err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error { | |
| 282 | 290 | var head sessionLineHead | |
| @@ -350,6 +358,19 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 350 | 358 | titleFromRename = true | |
| 351 | 359 | snapshot.Title = *row.Title | |
| 352 | 360 | ||
| 361 | + | case "extension_state": | |
| 362 | + | var row sessionLine | |
| 363 | + | if err := json.Unmarshal(line, &row); err != nil { | |
| 364 | + | return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err) | |
| 365 | + | } | |
| 366 | + | if row.Extension != "" && len(row.State) <= maxExtensionStateBytes { | |
| 367 | + | if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" { | |
| 368 | + | delete(extensionState, row.Extension) | |
| 369 | + | } else if json.Valid(row.State) { | |
| 370 | + | extensionState[row.Extension] = append(json.RawMessage(nil), row.State...) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 353 | 374 | default: | |
| 354 | 375 | return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type) | |
| 355 | 376 | } | |
| @@ -363,6 +384,7 @@ func ReadSessionSnapshot(path string) (SessionSnapshot, error) { | |||
| 363 | 384 | } | |
| 364 | 385 | ||
| 365 | 386 | snapshot.CompactionGeneration = generation | |
| 387 | + | snapshot.ExtensionState = extensionState | |
| 366 | 388 | ||
| 367 | 389 | // Repair only after compaction replacement. This makes synthetic tool | |
| 368 | 390 | // results and the removal of orphaned results part of the same indexed | |
| @@ -703,12 +725,13 @@ func OpenSession(path string) (*Session, []provider.Message, error) { | |||
| 703 | 725 | return nil, nil, err | |
| 704 | 726 | } | |
| 705 | 727 | s := &Session{ | |
| 706 | - | ID: snapshot.Meta.ID, | |
| 707 | - | Path: path, | |
| 708 | - | Meta: snapshot.Meta, | |
| 709 | - | Title: NormalizeSessionTitle(snapshot.Title), | |
| 710 | - | writer: out, | |
| 711 | - | buf: bufio.NewWriter(out), | |
| 728 | + | ID: snapshot.Meta.ID, | |
| 729 | + | Path: path, | |
| 730 | + | Meta: snapshot.Meta, | |
| 731 | + | Title: NormalizeSessionTitle(snapshot.Title), | |
| 732 | + | ExtensionState: snapshot.ExtensionState, | |
| 733 | + | writer: out, | |
| 734 | + | buf: bufio.NewWriter(out), | |
| 712 | 735 | } | |
| 713 | 736 | return s, snapshot.Messages, nil | |
| 714 | 737 | } | |
| @@ -1110,6 +1133,40 @@ func (s *Session) AppendUsage(u, cum provider.Usage) error { | |||
| 1110 | 1133 | return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum}) | |
| 1111 | 1134 | } | |
| 1112 | 1135 | ||
| 1136 | + | const maxExtensionStateBytes = 256 * 1024 | |
| 1137 | + | ||
| 1138 | + | // AppendExtensionState records the latest opaque extension snapshot for the | |
| 1139 | + | // active session/branch. State is persisted in the session file but is never | |
| 1140 | + | // included in provider requests. A nil or JSON null state clears the key. | |
| 1141 | + | func (s *Session) AppendExtensionState(extension string, state json.RawMessage) error { | |
| 1142 | + | if s == nil { | |
| 1143 | + | return nil | |
| 1144 | + | } | |
| 1145 | + | extension = strings.TrimSpace(extension) | |
| 1146 | + | if extension == "" { | |
| 1147 | + | return fmt.Errorf("extension state: extension name is empty") | |
| 1148 | + | } | |
| 1149 | + | if len(state) > maxExtensionStateBytes { | |
| 1150 | + | return fmt.Errorf("extension state %q exceeds %d bytes", extension, maxExtensionStateBytes) | |
| 1151 | + | } | |
| 1152 | + | if len(state) > 0 && !json.Valid(state) { | |
| 1153 | + | return fmt.Errorf("extension state %q is not valid JSON", extension) | |
| 1154 | + | } | |
| 1155 | + | copyState := append(json.RawMessage(nil), state...) | |
| 1156 | + | if err := s.writeLine(sessionLine{Type: "extension_state", Extension: extension, State: copyState}); err != nil { | |
| 1157 | + | return fmt.Errorf("append extension state: %w", err) | |
| 1158 | + | } | |
| 1159 | + | if s.ExtensionState == nil { | |
| 1160 | + | s.ExtensionState = map[string]json.RawMessage{} | |
| 1161 | + | } | |
| 1162 | + | if len(copyState) == 0 || strings.TrimSpace(string(copyState)) == "null" { | |
| 1163 | + | delete(s.ExtensionState, extension) | |
| 1164 | + | } else { | |
| 1165 | + | s.ExtensionState[extension] = copyState | |
| 1166 | + | } | |
| 1167 | + | return nil | |
| 1168 | + | } | |
| 1169 | + | ||
| 1113 | 1170 | // Close flushes and closes the session file. If the session was | |
| 1114 | 1171 | // freshly created in this process and never had any messages | |
| 1115 | 1172 | // appended (the user opened zot, looked around, and exited without | |
| @@ -1121,7 +1178,7 @@ func (s *Session) Close() error { | |||
| 1121 | 1178 | } | |
| 1122 | 1179 | flushErr := s.buf.Flush() | |
| 1123 | 1180 | closeErr := s.writer.Close() | |
| 1124 | - | if s.freshFile && s.messagesAppended == 0 { | |
| 1181 | + | if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 { | |
| 1125 | 1182 | // Best-effort cleanup. We deliberately don't propagate the | |
| 1126 | 1183 | // remove error: if it fails (file already gone, perms changed) | |
| 1127 | 1184 | // the worst case is one stale empty file in the listing. | |
M packages/core/session_portable.go +109 -3
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "io" | |
| 10 | 10 | "os" | |
| 11 | 11 | "path/filepath" | |
| 12 | + | "sort" | |
| 12 | 13 | "strings" | |
| 13 | 14 | "time" | |
| 14 | 15 | ||
| @@ -308,7 +309,11 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||
| 308 | 309 | limit++ | |
| 309 | 310 | } | |
| 310 | 311 | ||
| 311 | - | return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions) | |
| 312 | + | extensionState, err := readExtensionStateAtFork(parentPath, limit) | |
| 313 | + | if err != nil { | |
| 314 | + | return "", err | |
| 315 | + | } | |
| 316 | + | return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState) | |
| 312 | 317 | } | |
| 313 | 318 | ||
| 314 | 319 | // BranchSessionHiddenFromHistory creates a hidden tree branch from a | |
| @@ -334,10 +339,91 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme | |||
| 334 | 339 | if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool { | |
| 335 | 340 | limit++ | |
| 336 | 341 | } | |
| 337 | - | return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true) | |
| 342 | + | extensionState, err := readExtensionStateAtFork(parentPath, limit) | |
| 343 | + | if err != nil { | |
| 344 | + | return "", err | |
| 345 | + | } | |
| 346 | + | return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState) | |
| 338 | 347 | } | |
| 339 | 348 | ||
| 340 | - | func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool) (string, error) { | |
| 349 | + | // readExtensionStateAtFork reconstructs the latest extension snapshot that was | |
| 350 | + | // persisted at or before a branch boundary. Extension state is independent of | |
| 351 | + | // provider messages, but its timeline still follows the effective message | |
| 352 | + | // stream across compaction rows. | |
| 353 | + | func readExtensionStateAtFork(path string, limit int) (map[string]json.RawMessage, error) { | |
| 354 | + | f, err := os.Open(path) | |
| 355 | + | if err != nil { | |
| 356 | + | return nil, fmt.Errorf("branch: open parent for extension state: %w", err) | |
| 357 | + | } | |
| 358 | + | defer f.Close() | |
| 359 | + | ||
| 360 | + | state := make(map[string]json.RawMessage) | |
| 361 | + | forkState := make(map[string]json.RawMessage) | |
| 362 | + | cloneState := func(source map[string]json.RawMessage) map[string]json.RawMessage { | |
| 363 | + | clone := make(map[string]json.RawMessage, len(source)) | |
| 364 | + | for name, raw := range source { | |
| 365 | + | clone[name] = append(json.RawMessage(nil), raw...) | |
| 366 | + | } | |
| 367 | + | return clone | |
| 368 | + | } | |
| 369 | + | applyState := func(target map[string]json.RawMessage, row sessionLine) { | |
| 370 | + | if len(row.State) == 0 || strings.TrimSpace(string(row.State)) == "null" { | |
| 371 | + | delete(target, row.Extension) | |
| 372 | + | return | |
| 373 | + | } | |
| 374 | + | if json.Valid(row.State) { | |
| 375 | + | target[row.Extension] = append(json.RawMessage(nil), row.State...) | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | effectiveCount := 0 | |
| 379 | + | err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error { | |
| 380 | + | var head sessionLineHead | |
| 381 | + | if err := json.Unmarshal(line, &head); err != nil { | |
| 382 | + | return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err) | |
| 383 | + | } | |
| 384 | + | switch head.Type { | |
| 385 | + | case "meta", "usage", "rename": | |
| 386 | + | return nil | |
| 387 | + | case "message": | |
| 388 | + | if _, err := hydrateMessage(line); err != nil { | |
| 389 | + | return fmt.Errorf("line %d: invalid message row: %w", lineNo, err) | |
| 390 | + | } | |
| 391 | + | effectiveCount++ | |
| 392 | + | case "compaction": | |
| 393 | + | messages, err := hydrateCompaction(line) | |
| 394 | + | if err != nil { | |
| 395 | + | return fmt.Errorf("line %d: invalid compaction row: %w", lineNo, err) | |
| 396 | + | } | |
| 397 | + | effectiveCount = len(messages) | |
| 398 | + | // A compaction replaces the effective transcript. State that was | |
| 399 | + | // recorded anywhere before this boundary belongs to every fork | |
| 400 | + | // inside the replacement transcript, even when its old message | |
| 401 | + | // index was after the requested fork point. | |
| 402 | + | forkState = cloneState(state) | |
| 403 | + | case "extension_state": | |
| 404 | + | var row sessionLine | |
| 405 | + | if err := json.Unmarshal(line, &row); err != nil { | |
| 406 | + | return fmt.Errorf("line %d: invalid extension state row: %w", lineNo, err) | |
| 407 | + | } | |
| 408 | + | if row.Extension == "" || len(row.State) > maxExtensionStateBytes { | |
| 409 | + | return nil | |
| 410 | + | } | |
| 411 | + | applyState(state, row) | |
| 412 | + | if effectiveCount <= limit { | |
| 413 | + | applyState(forkState, row) | |
| 414 | + | } | |
| 415 | + | default: | |
| 416 | + | return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type) | |
| 417 | + | } | |
| 418 | + | return nil | |
| 419 | + | }) | |
| 420 | + | if err != nil { | |
| 421 | + | return nil, fmt.Errorf("branch: read extension state: %w", err) | |
| 422 | + | } | |
| 423 | + | return forkState, nil | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage) (string, error) { | |
| 341 | 427 | dir := SessionsDir(root, cwd) | |
| 342 | 428 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 343 | 429 | return "", err | |
| @@ -404,6 +490,26 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||
| 404 | 490 | } | |
| 405 | 491 | } | |
| 406 | 492 | ||
| 493 | + | // Extension snapshots are branch state, not provider-visible transcript | |
| 494 | + | // content. Write the latest snapshot that existed at the fork point so a | |
| 495 | + | // child branch starts from the same extension state as its copied prefix. | |
| 496 | + | extensionNames := make([]string, 0, len(extensionState)) | |
| 497 | + | for name := range extensionState { | |
| 498 | + | extensionNames = append(extensionNames, name) | |
| 499 | + | } | |
| 500 | + | sort.Strings(extensionNames) | |
| 501 | + | for _, name := range extensionNames { | |
| 502 | + | line, err := json.Marshal(sessionLine{Type: "extension_state", Extension: name, State: extensionState[name]}) | |
| 503 | + | if err != nil { | |
| 504 | + | return "", fmt.Errorf("branch: marshal extension state: %w", err) | |
| 505 | + | } | |
| 506 | + | if _, err := bw.Write(line); err != nil { | |
| 507 | + | return "", fmt.Errorf("branch: write extension state: %w", err) | |
| 508 | + | } | |
| 509 | + | if err := bw.WriteByte('\n'); err != nil { | |
| 510 | + | return "", fmt.Errorf("branch: terminate extension state row: %w", err) | |
| 511 | + | } | |
| 512 | + | } | |
| 407 | 513 | // Preserve every cumulative checkpoint that belongs to this prefix. The | |
| 408 | 514 | // final two rows are needed to reconstruct LastTurnUsage as a delta. | |
| 409 | 515 | for _, checkpoint := range checkpoints { | |
M packages/core/session_portable_test.go +76 -0
| @@ -491,6 +491,82 @@ func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) { | |||
| 491 | 491 | } | |
| 492 | 492 | } | |
| 493 | 493 | ||
| 494 | + | func TestBranchSessionCopiesExtensionStateAtFork(t *testing.T) { | |
| 495 | + | root := t.TempDir() | |
| 496 | + | cwd := "/project" | |
| 497 | + | parent, err := NewSession(root, cwd, "anthropic", "claude", "test") | |
| 498 | + | if err != nil { | |
| 499 | + | t.Fatal(err) | |
| 500 | + | } | |
| 501 | + | if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}); err != nil { | |
| 502 | + | t.Fatal(err) | |
| 503 | + | } | |
| 504 | + | if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil { | |
| 505 | + | t.Fatal(err) | |
| 506 | + | } | |
| 507 | + | if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}); err != nil { | |
| 508 | + | t.Fatal(err) | |
| 509 | + | } | |
| 510 | + | if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":2}`)); err != nil { | |
| 511 | + | t.Fatal(err) | |
| 512 | + | } | |
| 513 | + | if err := parent.Close(); err != nil { | |
| 514 | + | t.Fatal(err) | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1) | |
| 518 | + | if err != nil { | |
| 519 | + | t.Fatal(err) | |
| 520 | + | } | |
| 521 | + | branch, _, err := OpenSession(branchPath) | |
| 522 | + | if err != nil { | |
| 523 | + | t.Fatal(err) | |
| 524 | + | } | |
| 525 | + | t.Cleanup(func() { _ = branch.Close() }) | |
| 526 | + | if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` { | |
| 527 | + | t.Fatalf("branch extension state = %q, want version 1", got) | |
| 528 | + | } | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | func TestBranchSessionCarriesStateAcrossCompactionBoundary(t *testing.T) { | |
| 532 | + | root := t.TempDir() | |
| 533 | + | cwd := "/project" | |
| 534 | + | parent, err := NewSession(root, cwd, "anthropic", "claude", "test") | |
| 535 | + | if err != nil { | |
| 536 | + | t.Fatal(err) | |
| 537 | + | } | |
| 538 | + | for _, text := range []string{"before-a", "before-b"} { | |
| 539 | + | if err := parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: text}}}); err != nil { | |
| 540 | + | t.Fatal(err) | |
| 541 | + | } | |
| 542 | + | } | |
| 543 | + | if err := parent.AppendExtensionState("tasked-phases", json.RawMessage(`{"version":1}`)); err != nil { | |
| 544 | + | t.Fatal(err) | |
| 545 | + | } | |
| 546 | + | if err := parent.AppendCompaction([]provider.Message{ | |
| 547 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "summary"}}}, | |
| 548 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "tail"}}}, | |
| 549 | + | }); err != nil { | |
| 550 | + | t.Fatal(err) | |
| 551 | + | } | |
| 552 | + | if err := parent.Close(); err != nil { | |
| 553 | + | t.Fatal(err) | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | branchPath, err := BranchSession(parent.Path, root, cwd, "test", 1) | |
| 557 | + | if err != nil { | |
| 558 | + | t.Fatal(err) | |
| 559 | + | } | |
| 560 | + | branch, _, err := OpenSession(branchPath) | |
| 561 | + | if err != nil { | |
| 562 | + | t.Fatal(err) | |
| 563 | + | } | |
| 564 | + | t.Cleanup(func() { _ = branch.Close() }) | |
| 565 | + | if got := string(branch.ExtensionState["tasked-phases"]); got != `{"version":1}` { | |
| 566 | + | t.Fatalf("compacted branch extension state = %q, want version 1", got) | |
| 567 | + | } | |
| 568 | + | } | |
| 569 | + | ||
| 494 | 570 | // TestBuildSessionTree verifies parent/child edges are rebuilt | |
| 495 | 571 | // from meta + sibling-scan. | |
| 496 | 572 | func TestBuildSessionTree(t *testing.T) { | |