🛟 Updated.
1d19377 parent: 67f78b5 modified
.claude/skills/drawio-fancy-diagram/generator/__pycache__/flowgen.cpython-314.pyc +0 -0 | Binary files a/.claude/skills/drawio-fancy-diagram/generator/__pycache__/flowgen.cpython-314.pyc and b/.claude/skills/drawio-fancy-diagram/generator/__pycache__/flowgen.cpython-314.pyc differ |
| Binary files a/.claude/skills/drawio-fancy-diagram/generator/__pycache__/flowgen.cpython-314.pyc and b/.claude/skills/drawio-fancy-diagram/generator/__pycache__/flowgen.cpython-314.pyc differ | Binary files a/.claude/skills/drawio-fancy-diagram/generator/__pycache__/flowgen.cpython-314.pyc and b/.claude/skills/drawio-fancy-diagram/generator/__pycache__/flowgen.cpython-314.pyc differ |
modified
.claude/skills/drawio-fancy-diagram/generator/examples/sidekicks_packages.py +9 -9 | @@ -1,4 +1,4 @@ | ||
| 1 | -"""The description of docs/diagrams/packages.{drawio,svg} (Sidekicks, ADR 0003 and 0004). | |
| 1 | +"""The description of docs/diagrams/packages.{drawio,svg} (Sidekicks, ADR 0003, 0004 and 0006). | |
| 2 | 2 | |
| 3 | 3 | python3 .claude/skills/drawio-fancy-diagram/generator/examples/sidekicks_packages.py docs/diagrams |
| 4 | 4 | rsvg-convert -w 1440 -h 800 -o docs/diagrams/packages.png docs/diagrams/packages.svg |
| @@ -16,17 +16,17 @@ W, COLUMNS = 230, (30, 310, 590, 870, 1150) | ||
| 16 | 16 | CENTERS = [x + W // 2 for x in COLUMNS] |
| 17 | 17 | |
| 18 | 18 | d = Diagram("packages", "Sidekicks — packages and dependencies", |
| 19 | - "arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails", width=1410, height=800) | |
| 19 | + "arrows are imports · blue: exists (M3) · grey: planned · only main.go imports Wails", width=1410, height=800) | |
| 20 | 20 | |
| 21 | 21 | # Row 1: the entry points. |
| 22 | -d.box(COLUMNS[0], 100, W, 115, "user", "💻", "frontend/", ["Svelte 5 · panels · chat", "editor · generated bindings"]) | |
| 23 | -d.box(COLUMNS[2], 100, W, 115, "decider", "🔀", "internal/app", ["Agent · Chat · Config", "services · Core"], badge="★ only API of the UI") | |
| 22 | +d.box(COLUMNS[0], 100, W, 115, "user", "💻", "frontend/", ["Svelte 5 · panels · chat · @ /", "editor · generated bindings"]) | |
| 23 | +d.box(COLUMNS[2], 100, W, 115, "decider", "🔀", "internal/app", ["Agent · Channel · Config", "services · Core"], badge="★ only API of the UI") | |
| 24 | 24 | d.box(COLUMNS[4], 100, W, 115, "hook", "⚙️", "main.go (root)", ["flags · Wails app · window", "events · file dialog"]) |
| 25 | 25 | d.edge([(COLUMNS[0] + W, 157), (COLUMNS[2], 157)], "bindings · events", at=(450, 157), lw=130, both=True) |
| 26 | 26 | d.edge([(COLUMNS[4], 157), (COLUMNS[2] + W, 157)], "creates", at=(1010, 157), lw=70) |
| 27 | 27 | |
| 28 | 28 | # Row 2: what internal/app uses. |
| 29 | -d.box(COLUMNS[0], 300, W, 110, "agent", "💬", "internal/conversations", ["one per agent · in memory", "queue · permissions (M3: channels)"], title_size=14) | |
| 29 | +d.box(COLUMNS[0], 300, W, 110, "agent", "💬", "internal/channels", ["channels · direct · routing @", "unseen context · queue · perms"]) | |
| 30 | 30 | d.box(COLUMNS[1], 300, W, 110, "agent", "🤖", "internal/agents", ["lazy start · LRU limit", "idle stop · states · logs"]) |
| 31 | 31 | d.box(COLUMNS[2], 300, W, 110, "agent", "📋", "internal/config", ["load · validate · locate", "default · save · watch"]) |
| 32 | 32 | d.box(COLUMNS[3], 300, W, 110, "agent", "🧰", "internal/prefs", ["last opened file", "recent files (JSON)"]) |
| @@ -37,11 +37,11 @@ d.edge([(COLUMNS[0] + W, 355), (COLUMNS[1], 355)]) | ||
| 37 | 37 | d.edge([(COLUMNS[1] + W, 355), (COLUMNS[2], 355)]) |
| 38 | 38 | |
| 39 | 39 | # Row 3: what they build on. |
| 40 | -d.box(COLUMNS[0], 490, W, 105, "data", "📥", "internal/store", ["planned · M3", "Pebble: channels, messages"]) | |
| 40 | +d.box(COLUMNS[0], 490, W, 105, "agent", "📥", "internal/store", ["Pebble · one DB per config", "channels · members · messages"]) | |
| 41 | 41 | d.box(COLUMNS[1], 490, W, 105, "agent", "🔧", "internal/acp", ["ACP v1 client · sessions", "stdin → TERM → KILL"]) |
| 42 | 42 | d.box(COLUMNS[2], 490, W, 105, "agent", "🛡️", "schema", ["config JSON Schema", "ValidateConfig()"]) |
| 43 | 43 | d.box(COLUMNS[3], 490, W, 105, "agent", "📋", "internal/events", ["event names + payloads", "Emitter interface"]) |
| 44 | -d.edge([(CENTERS[0], 410), (CENTERS[0], 490)], dashed=True) | |
| 44 | +d.edge([(CENTERS[0], 410), (CENTERS[0], 490)]) | |
| 45 | 45 | d.edge([(CENTERS[1], 410), (CENTERS[1], 490)]) |
| 46 | 46 | d.edge([(CENTERS[2], 410), (CENTERS[2], 490)]) |
| 47 | 47 | d.edge([(COLUMNS[1] + W - 20, 410), (COLUMNS[1] + W - 20, 450), (CENTERS[3], 450), (CENTERS[3], 490)], "emits", at=(800, 450), lw=60) |
| @@ -51,8 +51,8 @@ d.chip(COLUMNS[0], 670, W, 50, "cockroachdb/pebble/v2", size=12) | ||
| 51 | 51 | d.chip(COLUMNS[1], 670, W, 50, "acp-go-sdk → mm -acp", size=12) |
| 52 | 52 | d.chip(COLUMNS[2], 670, W, 50, "jsonschema · yaml · fsnotify", size=12) |
| 53 | 53 | d.chip(COLUMNS[3], 670, W, 50, "Wails v3 (main.go only)", size=12) |
| 54 | -d.edge([(CENTERS[0], 595), (CENTERS[0], 670)], dashed=True) | |
| 54 | +d.edge([(CENTERS[0], 595), (CENTERS[0], 670)]) | |
| 55 | 55 | d.edge([(CENTERS[1], 595), (CENTERS[1], 670)], "stdio", at=(CENTERS[1] + 45, 632), lw=50) |
| 56 | 56 | d.edge([(CENTERS[2], 595), (CENTERS[2], 670)]) |
| 57 | -d.text(705, 765, "conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema.", 12.5, italic=True) | |
| 57 | +d.text(705, 765, "channels and app also emit through internal/events; channels uses acp types; app uses prefs, config, schema and store.", 12.5, italic=True) | |
| 58 | 58 | d.write(OUT) |
| @@ -1,4 +1,4 @@ | |||
| 1 | -"""The description of docs/diagrams/packages.{drawio,svg} (Sidekicks, ADR 0003 and 0004). | 1 | +"""The description of docs/diagrams/packages.{drawio,svg} (Sidekicks, ADR 0003, 0004 and 0006). |
| 2 | 2 | ||
| 3 | python3 .claude/skills/drawio-fancy-diagram/generator/examples/sidekicks_packages.py docs/diagrams | 3 | python3 .claude/skills/drawio-fancy-diagram/generator/examples/sidekicks_packages.py docs/diagrams |
| 4 | rsvg-convert -w 1440 -h 800 -o docs/diagrams/packages.png docs/diagrams/packages.svg | 4 | rsvg-convert -w 1440 -h 800 -o docs/diagrams/packages.png docs/diagrams/packages.svg |
| @@ -16,17 +16,17 @@ W, COLUMNS = 230, (30, 310, 590, 870, 1150) | |||
| 16 | CENTERS = [x + W // 2 for x in COLUMNS] | 16 | CENTERS = [x + W // 2 for x in COLUMNS] |
| 17 | 17 | ||
| 18 | d = Diagram("packages", "Sidekicks — packages and dependencies", | 18 | d = Diagram("packages", "Sidekicks — packages and dependencies", |
| 19 | - "arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails", width=1410, height=800) | 19 | + "arrows are imports · blue: exists (M3) · grey: planned · only main.go imports Wails", width=1410, height=800) |
| 20 | 20 | ||
| 21 | # Row 1: the entry points. | 21 | # Row 1: the entry points. |
| 22 | -d.box(COLUMNS[0], 100, W, 115, "user", "💻", "frontend/", ["Svelte 5 · panels · chat", "editor · generated bindings"]) | 22 | +d.box(COLUMNS[0], 100, W, 115, "user", "💻", "frontend/", ["Svelte 5 · panels · chat · @ /", "editor · generated bindings"]) |
| 23 | -d.box(COLUMNS[2], 100, W, 115, "decider", "🔀", "internal/app", ["Agent · Chat · Config", "services · Core"], badge="★ only API of the UI") | 23 | +d.box(COLUMNS[2], 100, W, 115, "decider", "🔀", "internal/app", ["Agent · Channel · Config", "services · Core"], badge="★ only API of the UI") |
| 24 | d.box(COLUMNS[4], 100, W, 115, "hook", "⚙️", "main.go (root)", ["flags · Wails app · window", "events · file dialog"]) | 24 | d.box(COLUMNS[4], 100, W, 115, "hook", "⚙️", "main.go (root)", ["flags · Wails app · window", "events · file dialog"]) |
| 25 | d.edge([(COLUMNS[0] + W, 157), (COLUMNS[2], 157)], "bindings · events", at=(450, 157), lw=130, both=True) | 25 | d.edge([(COLUMNS[0] + W, 157), (COLUMNS[2], 157)], "bindings · events", at=(450, 157), lw=130, both=True) |
| 26 | d.edge([(COLUMNS[4], 157), (COLUMNS[2] + W, 157)], "creates", at=(1010, 157), lw=70) | 26 | d.edge([(COLUMNS[4], 157), (COLUMNS[2] + W, 157)], "creates", at=(1010, 157), lw=70) |
| 27 | 27 | ||
| 28 | # Row 2: what internal/app uses. | 28 | # Row 2: what internal/app uses. |
| 29 | -d.box(COLUMNS[0], 300, W, 110, "agent", "💬", "internal/conversations", ["one per agent · in memory", "queue · permissions (M3: channels)"], title_size=14) | 29 | +d.box(COLUMNS[0], 300, W, 110, "agent", "💬", "internal/channels", ["channels · direct · routing @", "unseen context · queue · perms"]) |
| 30 | d.box(COLUMNS[1], 300, W, 110, "agent", "🤖", "internal/agents", ["lazy start · LRU limit", "idle stop · states · logs"]) | 30 | d.box(COLUMNS[1], 300, W, 110, "agent", "🤖", "internal/agents", ["lazy start · LRU limit", "idle stop · states · logs"]) |
| 31 | d.box(COLUMNS[2], 300, W, 110, "agent", "📋", "internal/config", ["load · validate · locate", "default · save · watch"]) | 31 | d.box(COLUMNS[2], 300, W, 110, "agent", "📋", "internal/config", ["load · validate · locate", "default · save · watch"]) |
| 32 | d.box(COLUMNS[3], 300, W, 110, "agent", "🧰", "internal/prefs", ["last opened file", "recent files (JSON)"]) | 32 | d.box(COLUMNS[3], 300, W, 110, "agent", "🧰", "internal/prefs", ["last opened file", "recent files (JSON)"]) |
| @@ -37,11 +37,11 @@ d.edge([(COLUMNS[0] + W, 355), (COLUMNS[1], 355)]) | |||
| 37 | d.edge([(COLUMNS[1] + W, 355), (COLUMNS[2], 355)]) | 37 | d.edge([(COLUMNS[1] + W, 355), (COLUMNS[2], 355)]) |
| 38 | 38 | ||
| 39 | # Row 3: what they build on. | 39 | # Row 3: what they build on. |
| 40 | -d.box(COLUMNS[0], 490, W, 105, "data", "📥", "internal/store", ["planned · M3", "Pebble: channels, messages"]) | 40 | +d.box(COLUMNS[0], 490, W, 105, "agent", "📥", "internal/store", ["Pebble · one DB per config", "channels · members · messages"]) |
| 41 | d.box(COLUMNS[1], 490, W, 105, "agent", "🔧", "internal/acp", ["ACP v1 client · sessions", "stdin → TERM → KILL"]) | 41 | d.box(COLUMNS[1], 490, W, 105, "agent", "🔧", "internal/acp", ["ACP v1 client · sessions", "stdin → TERM → KILL"]) |
| 42 | d.box(COLUMNS[2], 490, W, 105, "agent", "🛡️", "schema", ["config JSON Schema", "ValidateConfig()"]) | 42 | d.box(COLUMNS[2], 490, W, 105, "agent", "🛡️", "schema", ["config JSON Schema", "ValidateConfig()"]) |
| 43 | d.box(COLUMNS[3], 490, W, 105, "agent", "📋", "internal/events", ["event names + payloads", "Emitter interface"]) | 43 | d.box(COLUMNS[3], 490, W, 105, "agent", "📋", "internal/events", ["event names + payloads", "Emitter interface"]) |
| 44 | -d.edge([(CENTERS[0], 410), (CENTERS[0], 490)], dashed=True) | 44 | +d.edge([(CENTERS[0], 410), (CENTERS[0], 490)]) |
| 45 | d.edge([(CENTERS[1], 410), (CENTERS[1], 490)]) | 45 | d.edge([(CENTERS[1], 410), (CENTERS[1], 490)]) |
| 46 | d.edge([(CENTERS[2], 410), (CENTERS[2], 490)]) | 46 | d.edge([(CENTERS[2], 410), (CENTERS[2], 490)]) |
| 47 | d.edge([(COLUMNS[1] + W - 20, 410), (COLUMNS[1] + W - 20, 450), (CENTERS[3], 450), (CENTERS[3], 490)], "emits", at=(800, 450), lw=60) | 47 | d.edge([(COLUMNS[1] + W - 20, 410), (COLUMNS[1] + W - 20, 450), (CENTERS[3], 450), (CENTERS[3], 490)], "emits", at=(800, 450), lw=60) |
| @@ -51,8 +51,8 @@ d.chip(COLUMNS[0], 670, W, 50, "cockroachdb/pebble/v2", size=12) | |||
| 51 | d.chip(COLUMNS[1], 670, W, 50, "acp-go-sdk → mm -acp", size=12) | 51 | d.chip(COLUMNS[1], 670, W, 50, "acp-go-sdk → mm -acp", size=12) |
| 52 | d.chip(COLUMNS[2], 670, W, 50, "jsonschema · yaml · fsnotify", size=12) | 52 | d.chip(COLUMNS[2], 670, W, 50, "jsonschema · yaml · fsnotify", size=12) |
| 53 | d.chip(COLUMNS[3], 670, W, 50, "Wails v3 (main.go only)", size=12) | 53 | d.chip(COLUMNS[3], 670, W, 50, "Wails v3 (main.go only)", size=12) |
| 54 | -d.edge([(CENTERS[0], 595), (CENTERS[0], 670)], dashed=True) | 54 | +d.edge([(CENTERS[0], 595), (CENTERS[0], 670)]) |
| 55 | d.edge([(CENTERS[1], 595), (CENTERS[1], 670)], "stdio", at=(CENTERS[1] + 45, 632), lw=50) | 55 | d.edge([(CENTERS[1], 595), (CENTERS[1], 670)], "stdio", at=(CENTERS[1] + 45, 632), lw=50) |
| 56 | d.edge([(CENTERS[2], 595), (CENTERS[2], 670)]) | 56 | d.edge([(CENTERS[2], 595), (CENTERS[2], 670)]) |
| 57 | -d.text(705, 765, "conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema.", 12.5, italic=True) | 57 | +d.text(705, 765, "channels and app also emit through internal/events; channels uses acp types; app uses prefs, config, schema and store.", 12.5, italic=True) |
| 58 | d.write(OUT) | 58 | d.write(OUT) |
added
.memory/handoffs/2026-10-29-milestone-m3.md +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +# Handoff — 2026-10-29 — milestone M3 · Channels & multi-agents | |
| 2 | + | |
| 3 | +## State | |
| 4 | + | |
| 5 | +M3 implemented on `feature/m3-channels-multi-agents` (not committed by the agent). `make check`, `go test -race ./...` and `make test-real` pass in the sandbox; the app was exercised under Xvfb (channels, @ popup, Alice + Bob, Garfield /idioms, create/rename/delete, direct conversation, unread badge, history after restart). Issues #20, #22–#25 closed; #21 open with one task: the owner's macOS check (`docs/fr/how-to/tester-la-milestone-m3-sous-macos.md`). Milestone M3 open. | |
| 6 | + | |
| 7 | +## In flight | |
| 8 | + | |
| 9 | +- Done: owner validated M3 on macOS; #21 and milestone "M3 · Channels & multi-agents" closed. | |
| 10 | + | |
| 11 | +## Next steps | |
| 12 | + | |
| 13 | +1. M4 (workspace: files and terminal panels, `internal/workspace`, sbx/container runtimes; #26 spike on agent-to-agent invocation). | |
| 14 | +2. #38 qlty kit. | |
| 15 | +3. Possible follow-ups seen during M3: offer to start an agent to learn its commands; persist unread counts; a "/" hint listing members without commands. | |
| 16 | + | |
| 17 | +## Open questions / blockers | |
| 18 | + | |
| 19 | +- None blocking. On the Mac, check that no model stays loaded after quitting with two agents running (fixed in agents.sh; the cause was found in the sandbox). | |
| 20 | + | |
| 21 | +## Watch out for | |
| 22 | + | |
| 23 | +- A Svelte `$derived` must only read state: `conversationOf()` creates conversations, call it from actions (selectChannel), not from derived values — the UI silently froze otherwise. | |
| 24 | +- Under Xvfb, JS errors are invisible: a temporary `window.onerror` overlay in `frontend/src/main.ts` showed them (removed afterwards). | |
| 25 | +- `sed -i` drops the executable bit of scripts on the shared folder; use Python or `chmod +x` after. | |
| 26 | +- The Linux build writes `build/linux/sidekicks.desktop` when run; it is not part of the repository. | |
| 27 | +- xdotool cannot type accented characters (the message is cut): use ASCII in UI tests. | |
| 28 | +- Realagents throwaway tests must `StopAll` their agents between runs, or the two-agent limit refuses the next start. | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +# Handoff — 2026-10-29 — milestone M3 · Channels & multi-agents | ||
| 2 | + | ||
| 3 | +## State | ||
| 4 | + | ||
| 5 | +M3 implemented on `feature/m3-channels-multi-agents` (not committed by the agent). `make check`, `go test -race ./...` and `make test-real` pass in the sandbox; the app was exercised under Xvfb (channels, @ popup, Alice + Bob, Garfield /idioms, create/rename/delete, direct conversation, unread badge, history after restart). Issues #20, #22–#25 closed; #21 open with one task: the owner's macOS check (`docs/fr/how-to/tester-la-milestone-m3-sous-macos.md`). Milestone M3 open. | ||
| 6 | + | ||
| 7 | +## In flight | ||
| 8 | + | ||
| 9 | +- Done: owner validated M3 on macOS; #21 and milestone "M3 · Channels & multi-agents" closed. | ||
| 10 | + | ||
| 11 | +## Next steps | ||
| 12 | + | ||
| 13 | +1. M4 (workspace: files and terminal panels, `internal/workspace`, sbx/container runtimes; #26 spike on agent-to-agent invocation). | ||
| 14 | +2. #38 qlty kit. | ||
| 15 | +3. Possible follow-ups seen during M3: offer to start an agent to learn its commands; persist unread counts; a "/" hint listing members without commands. | ||
| 16 | + | ||
| 17 | +## Open questions / blockers | ||
| 18 | + | ||
| 19 | +- None blocking. On the Mac, check that no model stays loaded after quitting with two agents running (fixed in agents.sh; the cause was found in the sandbox). | ||
| 20 | + | ||
| 21 | +## Watch out for | ||
| 22 | + | ||
| 23 | +- A Svelte `$derived` must only read state: `conversationOf()` creates conversations, call it from actions (selectChannel), not from derived values — the UI silently froze otherwise. | ||
| 24 | +- Under Xvfb, JS errors are invisible: a temporary `window.onerror` overlay in `frontend/src/main.ts` showed them (removed afterwards). | ||
| 25 | +- `sed -i` drops the executable bit of scripts on the shared folder; use Python or `chmod +x` after. | ||
| 26 | +- The Linux build writes `build/linux/sidekicks.desktop` when run; it is not part of the repository. | ||
| 27 | +- xdotool cannot type accented characters (the message is cut): use ASCII in UI tests. | ||
| 28 | +- Realagents throwaway tests must `StopAll` their agents between runs, or the two-agent limit refuses the next start. | ||
modified
.memory/history.md +25 -0 | @@ -87,3 +87,28 @@ | ||
| 87 | 87 | ## 2026-10-29 — M2 closed |
| 88 | 88 | |
| 89 | 89 | - Owner validated M2 on macOS ("tout fonctionne très bien"). Closed #15 (task 6 + comment) and milestone "M2 · Conversation avec un agent". The `/` command popup is #24 (M3), not a regression. |
| 90 | + | |
| 91 | +## 2026-10-29 — Ticket #12 YAML fixed | |
| 92 | + | |
| 93 | +- Owner noticed #12 was malformed: tasks 1–4 had `updatedAt`/`closedAt` twice (the scratch helper `close_tasks` re-added dates to already-closed tasks). Duplicates removed (the earlier, real dates kept); helper fixed to skip closed tasks. Every other ticket checked with a Go validator (schema rules + duplicate keys): clean. | |
| 94 | + | |
| 95 | +## 2026-10-29 — Milestone M3 · Channels & multi-agents (#20–#25) | |
| 96 | + | |
| 97 | +- **Goal**: M3 in one go; owner's choices: no mention → last agent who spoke; direct conversations = private channels with one member; no Sidekicks-local slash commands in M3. | |
| 98 | +- **Changes**: `internal/store` (new, Pebble v2.1.7), `internal/channels` (new, replaces `internal/conversations`: channels.go, turns.go, route.go), `acp.Conn.LoadSession` + `agents.Conn` interface, `internal/app` (ChannelService, history per config file, in-memory fallback, ConfigView.historyProblem instead of channels), `internal/events` (`channels:changed`, `commands:changed`), main.go (11 events, emitter guard before the app exists — the first build panicked at startup because Declare emitted during New); frontend: state keyed by channel, `suggestions.ts` (+ tests), Composer popup (@ and /), ChannelsPanel rewrite (create/rename/delete with confirm, direct messages, unread badges), ConversationPanel (members, invite/remove, older messages, answering agent), MessageView (author per message, highlighted mentions), i18n keys; `scripts/agents.sh` lock fix (+ shell test); ADR 0006, ADR 0003 Amendment 3, package diagram; docs EN/FR: tutorial first-channel/premier-channel, how-to manage-channels/gerer-les-channels, how-to test M3 on macOS, explanation channels-and-context/channels-et-contexte, reference agents-and-events (channels, routing, ChannelService, history) and configuration (declared channels), run-the-tests, indexes, README layout. | |
| 99 | +- **Decisions**: ADR 0006. Notable: sequential answers (parallel option rejected for local models); commands sent without context; fresh sessions re-told the last 20 messages, so the M2 "agent restarted" notice is gone; prompt ends with "answer directly, without your name in front" (Bob answered "Bob: 4217" before; 3/3 clean after). | |
| 100 | +- **Bugs found while testing**: `state_unsafe_mutation` (conversationOf in a $derived) froze the UI after switching channels — found by a temporary on-screen error overlay under Xvfb, then removed; models left loaded when two agents stop together — traced with bash xtrace to the non-atomic mkdir lock + `set -e` rmdir; fixed in agents.sh (first hypothesis about llmman was wrong and reverted). | |
| 101 | +- **Tests**: Go `-race` all packages (store: CRUD, paging, reopen, ErrClosed, DirFor, benchmark; channels: 25 tests with fake agents incl. restart/load-session/paging; app: history per file, locked history); `make test-real` (Riker permission, Bob reads Alice, Alice remembers after restart); vitest 56; shell 15; app under Xvfb with Alice, Bob, Garfield. | |
| 102 | +- **Quality**: golangci-lint 0 issues (prompt split for gocyclo, a store test split, `ListPayload` instead of a stuttering name), svelte-check 0/0, licence headers ok. qlty still unavailable (#38). | |
| 103 | + | |
| 104 | +## 2026-10-29 — Commands at the first "/", spinners | |
| 105 | + | |
| 106 | +- **Goal**: owner: "on devrait voir les skills de Garfield du 1er coup" and "un spinner pendant que l'agent réfléchit". | |
| 107 | +- **Finding**: starting mm and opening a session returns its commands in ~0.1 s **without loading the model** (realagents probe on Garfield: 7 commands, `/api/ps` empty). | |
| 108 | +- **Changes**: `channels.Manager.Prepare` + `ChannelService.Prepare` (start members with unknown commands, open their channel session, keep it for the first answer with a `fresh` flag so the past is still re-sent; skip an agent answering elsewhere via `tryTurn`); frontend `prepareCommands` called from the composer's new `onTrigger` (an event handler — never from a `$derived`), Composer suggestions now `$derived` so commands arriving later fill the open popup, hints "Asking X for their commands…" / error; `Spinner.svelte`, `activityOf()` (thinking / using a tool / writing) next to the agent's name, "X is getting ready…" line before the first part, spinner in the header. Docs EN/FR (explanation, reference `Prepare`, tutorial, M3 guide), ADR 0006 consequences. | |
| 109 | +- **Decision**: prepare on the first "/" only, not on opening a channel (a started agent takes a slot and may stop an idle one). | |
| 110 | +- **Tests**: Go 3 new channel tests (once, prepared session serves the first answer and is still told the past, busy agent skipped), vitest `activityOf`; checked under Xvfb (commands listed within 0.15 s on the first "/", spinner while Garfield and Bob think). | |
| 111 | + | |
| 112 | +## 2026-10-29 — M3 closed | |
| 113 | + | |
| 114 | +- Owner validated M3 on macOS ("tout semble être ok"). Closed #21 (tasks + comment) and milestone "M3 · Channels & multi-agents". | |
| @@ -87,3 +87,28 @@ | |||
| 87 | ## 2026-10-29 — M2 closed | 87 | ## 2026-10-29 — M2 closed |
| 88 | 88 | ||
| 89 | - Owner validated M2 on macOS ("tout fonctionne très bien"). Closed #15 (task 6 + comment) and milestone "M2 · Conversation avec un agent". The `/` command popup is #24 (M3), not a regression. | 89 | - Owner validated M2 on macOS ("tout fonctionne très bien"). Closed #15 (task 6 + comment) and milestone "M2 · Conversation avec un agent". The `/` command popup is #24 (M3), not a regression. |
| 90 | + | ||
| 91 | +## 2026-10-29 — Ticket #12 YAML fixed | ||
| 92 | + | ||
| 93 | +- Owner noticed #12 was malformed: tasks 1–4 had `updatedAt`/`closedAt` twice (the scratch helper `close_tasks` re-added dates to already-closed tasks). Duplicates removed (the earlier, real dates kept); helper fixed to skip closed tasks. Every other ticket checked with a Go validator (schema rules + duplicate keys): clean. | ||
| 94 | + | ||
| 95 | +## 2026-10-29 — Milestone M3 · Channels & multi-agents (#20–#25) | ||
| 96 | + | ||
| 97 | +- **Goal**: M3 in one go; owner's choices: no mention → last agent who spoke; direct conversations = private channels with one member; no Sidekicks-local slash commands in M3. | ||
| 98 | +- **Changes**: `internal/store` (new, Pebble v2.1.7), `internal/channels` (new, replaces `internal/conversations`: channels.go, turns.go, route.go), `acp.Conn.LoadSession` + `agents.Conn` interface, `internal/app` (ChannelService, history per config file, in-memory fallback, ConfigView.historyProblem instead of channels), `internal/events` (`channels:changed`, `commands:changed`), main.go (11 events, emitter guard before the app exists — the first build panicked at startup because Declare emitted during New); frontend: state keyed by channel, `suggestions.ts` (+ tests), Composer popup (@ and /), ChannelsPanel rewrite (create/rename/delete with confirm, direct messages, unread badges), ConversationPanel (members, invite/remove, older messages, answering agent), MessageView (author per message, highlighted mentions), i18n keys; `scripts/agents.sh` lock fix (+ shell test); ADR 0006, ADR 0003 Amendment 3, package diagram; docs EN/FR: tutorial first-channel/premier-channel, how-to manage-channels/gerer-les-channels, how-to test M3 on macOS, explanation channels-and-context/channels-et-contexte, reference agents-and-events (channels, routing, ChannelService, history) and configuration (declared channels), run-the-tests, indexes, README layout. | ||
| 99 | +- **Decisions**: ADR 0006. Notable: sequential answers (parallel option rejected for local models); commands sent without context; fresh sessions re-told the last 20 messages, so the M2 "agent restarted" notice is gone; prompt ends with "answer directly, without your name in front" (Bob answered "Bob: 4217" before; 3/3 clean after). | ||
| 100 | +- **Bugs found while testing**: `state_unsafe_mutation` (conversationOf in a $derived) froze the UI after switching channels — found by a temporary on-screen error overlay under Xvfb, then removed; models left loaded when two agents stop together — traced with bash xtrace to the non-atomic mkdir lock + `set -e` rmdir; fixed in agents.sh (first hypothesis about llmman was wrong and reverted). | ||
| 101 | +- **Tests**: Go `-race` all packages (store: CRUD, paging, reopen, ErrClosed, DirFor, benchmark; channels: 25 tests with fake agents incl. restart/load-session/paging; app: history per file, locked history); `make test-real` (Riker permission, Bob reads Alice, Alice remembers after restart); vitest 56; shell 15; app under Xvfb with Alice, Bob, Garfield. | ||
| 102 | +- **Quality**: golangci-lint 0 issues (prompt split for gocyclo, a store test split, `ListPayload` instead of a stuttering name), svelte-check 0/0, licence headers ok. qlty still unavailable (#38). | ||
| 103 | + | ||
| 104 | +## 2026-10-29 — Commands at the first "/", spinners | ||
| 105 | + | ||
| 106 | +- **Goal**: owner: "on devrait voir les skills de Garfield du 1er coup" and "un spinner pendant que l'agent réfléchit". | ||
| 107 | +- **Finding**: starting mm and opening a session returns its commands in ~0.1 s **without loading the model** (realagents probe on Garfield: 7 commands, `/api/ps` empty). | ||
| 108 | +- **Changes**: `channels.Manager.Prepare` + `ChannelService.Prepare` (start members with unknown commands, open their channel session, keep it for the first answer with a `fresh` flag so the past is still re-sent; skip an agent answering elsewhere via `tryTurn`); frontend `prepareCommands` called from the composer's new `onTrigger` (an event handler — never from a `$derived`), Composer suggestions now `$derived` so commands arriving later fill the open popup, hints "Asking X for their commands…" / error; `Spinner.svelte`, `activityOf()` (thinking / using a tool / writing) next to the agent's name, "X is getting ready…" line before the first part, spinner in the header. Docs EN/FR (explanation, reference `Prepare`, tutorial, M3 guide), ADR 0006 consequences. | ||
| 109 | +- **Decision**: prepare on the first "/" only, not on opening a channel (a started agent takes a slot and may stop an idle one). | ||
| 110 | +- **Tests**: Go 3 new channel tests (once, prepared session serves the first answer and is still told the past, busy agent skipped), vitest `activityOf`; checked under Xvfb (commands listed within 0.15 s on the first "/", spinner while Garfield and Bob think). | ||
| 111 | + | ||
| 112 | +## 2026-10-29 — M3 closed | ||
| 113 | + | ||
| 114 | +- Owner validated M3 on macOS ("tout semble être ok"). Closed #21 (tasks + comment) and milestone "M3 · Channels & multi-agents". | ||
modified
.memory/summary.md +7 -5 | @@ -4,14 +4,14 @@ | ||
| 4 | 4 | |
| 5 | 5 | A desktop application (Go + Wails) to talk with AI agents in channels, for non-technical ("citizen") users. Agent-agnostic: every agent is reached over the Agent Client Protocol (ACP, JSON-RPC on stdio). Full brief: `.tickets/issues/0001-draft.yaml`; backlog of 36 issues, 9 epics, 6 milestones in `.tickets/` (IssueSpec format, see `.claude/skills/issuespec`). |
| 6 | 6 | |
| 7 | -## State (2026-10-29, M2 closed) | |
| 7 | +## State (2026-10-29, M3 closed) | |
| 8 | 8 | |
| 9 | -Milestones **Initialize**, **M1** and **M2 · Conversation avec un agent** are closed (M2 validated by the owner on macOS, branch `feature/m2-chat`). One in-memory conversation per agent: streaming Markdown (Shiki, DOMPurify), tool calls with diffs, permission cards in plain words, stuck-agent banner, Stop, message queue, context gauge; Settings editor (CodeMirror, live validation by Go, schema completion, save/revert); open another config file + recent list + last-opened at startup. The interface is in **English by default**, French with `ui: language: fr` (ADR 0005). Next: M3 (#20–#25), #26 spike, #38 qlty kit. | |
| 9 | +Milestones **Initialize**, **M1**, **M2** and **M3 · Channels & multi-agents** are closed (M3 validated by the owner on macOS, branch `feature/m3-channels-multi-agents`). Channels persisted in Pebble (one DB per config file), direct conversations = channels with one member, `@` mentions with popup + invitation, sequential multi-agent answers with unseen-context injection, `/` and `/agent:command` popup (commands cached in the store), resume after restart by context re-injection (mm has no session/load), channel CRUD in the Channels panel, unread badges. Since M2: streaming Markdown (Shiki, DOMPurify), tool calls with diffs, permission cards in plain words, stuck-agent banner, Stop, message queue, context gauge; Settings editor (CodeMirror, live validation by Go, schema completion, save/revert); open another config file + recent list + last-opened at startup. The interface is in **English by default**, French with `ui: language: fr` (ADR 0005). Next: M4 (workspace/files/terminal, #26 spike), #38 qlty kit. | |
| 10 | 10 | |
| 11 | 11 | What exists: |
| 12 | 12 | |
| 13 | -- `main.go` (root, only Wails importer; registers 9 typed events; injects the native file dialog) · `internal/app` (Core, AgentService, ChatService, ConfigService) · `internal/conversations` · `internal/agents` · `internal/acp` (sessions, readable AgentError, stdin→TERM→KILL) · `internal/config` (+ `WatchWithReady`) · `internal/prefs` · `internal/events` · `schema`. | |
| 14 | -- `frontend/`: shell + panel registry, `state.svelte.ts` (`appState`, one ordered frame batcher for message events), `lib/chat/*` (Markdown, MessageView, ToolCallCard, PermissionCard, Composer), `lib/settings/ConfigEditor.svelte`, `lib/i18n` (en/fr catalogue, `t()`), pure modules with vitest (`markdown`, `conversation`, `composer`, `tools`, `yamlSchema`, `events`, `theme`, `panels/registry`). | |
| 13 | +- `main.go` (root, only Wails importer; registers 11 typed events; injects the native file dialog; its emitter ignores events sent before the Wails app exists) · `internal/app` (Core, AgentService, ChannelService, ConfigService; opens the history per config file) · `internal/channels` (routing, sessions, context, queue, permissions) · `internal/store` (Pebble) · `internal/agents` · `internal/acp` (sessions, readable AgentError, stdin→TERM→KILL) · `internal/config` (+ `WatchWithReady`) · `internal/prefs` · `internal/events` · `schema`. | |
| 14 | +- `frontend/`: shell + panel registry, `state.svelte.ts` (`appState`, one ordered frame batcher for message events), `lib/chat/*` (Markdown, MessageView, ToolCallCard, PermissionCard, Composer), `lib/settings/ConfigEditor.svelte`, `lib/i18n` (en/fr catalogue, `t()`), pure modules with vitest (`markdown`, `conversation`, `composer`, `suggestions`, `tools`, `yamlSchema`, `events`, `theme`, `panels/registry`). State is keyed by channel id; a `$derived` must never call `conversationOf` (it writes state → `state_unsafe_mutation` freezes the UI). | |
| 15 | 15 | - Tooling: `Makefile`, golangci-lint (`tools/go.mod`), CI, `scripts/agents.sh`, `scripts/install-test-config.sh`, `scripts/check-license-headers.sh`. |
| 16 | 16 | |
| 17 | 17 | ## Decisions in force |
| @@ -19,6 +19,7 @@ What exists: | ||
| 19 | 19 | - ACP **v1**, via `github.com/coder/acp-go-sdk` v0.13.5, wrapped in `internal/acp` (ADR 0001). One ACP session per (channel, agent). |
| 20 | 20 | - **Wails v3.0.0**, Svelte 5 + TS, paneforge, markdown-it + Shiki (JS engine) + DOMPurify, CodeMirror 6, xterm.js + creack/pty (ADR 0002). Markdown rendered at most once per animation frame. macOS hidden-inset title bar: content near the top-left is padded by `--titlebar-inset` (38px under `:root.macos`, = `InvisibleTitleBarHeight`). |
| 21 | 21 | - Conversations, permissions, settings: ADR 0004 (direct conversation per agent, in memory; new session after an agent restart + system message; queue; events `message:updated|delta`, `turn:changed`, `permission:requested|resolved`, `usage:changed`; never-before-reject ordering; stuck banner after 3 failed tool calls; Go validates, the editor only displays; prefs.json). |
| 22 | +- Channels: ADR 0006 — routing (`/agent:cmd` > mentions in order > last speaker > nobody), one session per (channel, agent), unseen messages re-sent (20 max, 1500 chars each, prompt ends with "answer directly, without your name in front"), one channel at a time per agent, commands sent alone, fresh session after restart gets the last messages, no Sidekicks-local slash commands (owner's choice), deleting a channel deletes its history after confirmation. Store: keys of ADR 0003 + `direct/<agent>` + `value/<key>`, `ErrClosed` after Close, in-memory fallback when the DB is locked (`historyProblem`). | |
| 22 | 23 | - Interface language: ADR 0005 — `ui.language` defaults to `en`; every UI text is a key of `frontend/src/lib/i18n/messages.ts` (`fr` typed `Record<MessageKey,string>`, test checks keys and variables); config errors use the Go catalogue in the same language; agent titles and channel names are user data, never translated. Default config file and `examples/test-agents.yaml` are in English. |
| 23 | 24 | - Packages and data model: ADR 0003 + Amendments 1 and 2 (main at the root, only main.go imports Wails, `internal/events` with an `Emitter` interface, LRU eviction of idle agents at the limit). |
| 24 | 25 | - Frontend dependencies with **pnpm 12.11.2** and `frontend/pnpm-workspace.yaml` `supportedArchitectures` (darwin+linux): the repository folder is shared between the Mac and the Linux sandbox. Never use npm in `frontend/`. |
| @@ -44,6 +45,7 @@ What exists: | ||
| 44 | 45 | |
| 45 | 46 | - Alice (gemma E2B), Bob and Riker (same Qwen3.5 2B), Garfield (gemma E4B, skills). Never more than 2 at once (MacBook Air 32 GB). |
| 46 | 47 | - mm: protocolVersion 1, no `loadSession`, commands = `compact`/`new`/`unload` + skills, sent right after `session/new`; `/skill args` as prompt text runs a skill; ignores `mcpServers`; reads AGENTS.md/skills from the session cwd. |
| 48 | +- `scripts/agents.sh` `with_lock`: `rmdir` of the lock is never fatal. On the Mac⇄Linux shared folder `mkdir` is not atomic; two agents stopping together could both take the lock and the second `rmdir` failed under `set -e`, killing its cleanup → its model stayed loaded (cause of the M2 "model left loaded" observation in the sandbox; reproduced 2/3, fixed 4/4). | |
| 47 | 49 | - Riker has reasoning **off** (`enable_thinking: false`, Qwen card non-thinking sampling 0.7/0.8/20): with it on, Qwen3.5 2B wrote its final answer in the reasoning after a tool call and returned an empty answer, which mm reports as an agent text chunk `*⚠ mm: empty answer*` (2/2 on, 7/7 fine off). |
| 48 | 50 | - Riker loops ~40 times after `reject_once`, then the turn ends with JSON-RPC error -32603. |
| 49 | 51 | |
| @@ -52,5 +54,5 @@ What exists: | ||
| 52 | 54 | - How to exec a shell / read files inside an `sbx` sandbox (issue #28). |
| 53 | 55 | - Quality gate: `qlty` is not in this sandbox → no qlty measurement yet (issue #38: build a sandbox kit with qlty). golangci-lint (go tool) is the stopgap. |
| 54 | 56 | - Whether the CI workflow runs as-is on rickub. |
| 55 | -- A model was once left loaded after quitting during M2 testing (after a Riker loop + Stop); not reproduced, mitigated by SIGTERM before SIGKILL. Watch for it. | |
| 57 | +- On the shared Mac⇄Linux folder, the `mkdir` lock of `scripts/agents.sh` is not exclusive: two agents starting at the same instant could exceed the slot limit in the sandbox. Not seen; on the Mac's own disk mkdir is atomic. | |
| 56 | 58 | - Whether `.claude/skills/*` (kit files) should be committed with the project is the owner's call. |
| @@ -4,14 +4,14 @@ | |||
| 4 | 4 | ||
| 5 | A desktop application (Go + Wails) to talk with AI agents in channels, for non-technical ("citizen") users. Agent-agnostic: every agent is reached over the Agent Client Protocol (ACP, JSON-RPC on stdio). Full brief: `.tickets/issues/0001-draft.yaml`; backlog of 36 issues, 9 epics, 6 milestones in `.tickets/` (IssueSpec format, see `.claude/skills/issuespec`). | 5 | A desktop application (Go + Wails) to talk with AI agents in channels, for non-technical ("citizen") users. Agent-agnostic: every agent is reached over the Agent Client Protocol (ACP, JSON-RPC on stdio). Full brief: `.tickets/issues/0001-draft.yaml`; backlog of 36 issues, 9 epics, 6 milestones in `.tickets/` (IssueSpec format, see `.claude/skills/issuespec`). |
| 6 | 6 | ||
| 7 | -## State (2026-10-29, M2 closed) | 7 | +## State (2026-10-29, M3 closed) |
| 8 | 8 | ||
| 9 | -Milestones **Initialize**, **M1** and **M2 · Conversation avec un agent** are closed (M2 validated by the owner on macOS, branch `feature/m2-chat`). One in-memory conversation per agent: streaming Markdown (Shiki, DOMPurify), tool calls with diffs, permission cards in plain words, stuck-agent banner, Stop, message queue, context gauge; Settings editor (CodeMirror, live validation by Go, schema completion, save/revert); open another config file + recent list + last-opened at startup. The interface is in **English by default**, French with `ui: language: fr` (ADR 0005). Next: M3 (#20–#25), #26 spike, #38 qlty kit. | 9 | +Milestones **Initialize**, **M1**, **M2** and **M3 · Channels & multi-agents** are closed (M3 validated by the owner on macOS, branch `feature/m3-channels-multi-agents`). Channels persisted in Pebble (one DB per config file), direct conversations = channels with one member, `@` mentions with popup + invitation, sequential multi-agent answers with unseen-context injection, `/` and `/agent:command` popup (commands cached in the store), resume after restart by context re-injection (mm has no session/load), channel CRUD in the Channels panel, unread badges. Since M2: streaming Markdown (Shiki, DOMPurify), tool calls with diffs, permission cards in plain words, stuck-agent banner, Stop, message queue, context gauge; Settings editor (CodeMirror, live validation by Go, schema completion, save/revert); open another config file + recent list + last-opened at startup. The interface is in **English by default**, French with `ui: language: fr` (ADR 0005). Next: M4 (workspace/files/terminal, #26 spike), #38 qlty kit. |
| 10 | 10 | ||
| 11 | What exists: | 11 | What exists: |
| 12 | 12 | ||
| 13 | -- `main.go` (root, only Wails importer; registers 9 typed events; injects the native file dialog) · `internal/app` (Core, AgentService, ChatService, ConfigService) · `internal/conversations` · `internal/agents` · `internal/acp` (sessions, readable AgentError, stdin→TERM→KILL) · `internal/config` (+ `WatchWithReady`) · `internal/prefs` · `internal/events` · `schema`. | 13 | +- `main.go` (root, only Wails importer; registers 11 typed events; injects the native file dialog; its emitter ignores events sent before the Wails app exists) · `internal/app` (Core, AgentService, ChannelService, ConfigService; opens the history per config file) · `internal/channels` (routing, sessions, context, queue, permissions) · `internal/store` (Pebble) · `internal/agents` · `internal/acp` (sessions, readable AgentError, stdin→TERM→KILL) · `internal/config` (+ `WatchWithReady`) · `internal/prefs` · `internal/events` · `schema`. |
| 14 | -- `frontend/`: shell + panel registry, `state.svelte.ts` (`appState`, one ordered frame batcher for message events), `lib/chat/*` (Markdown, MessageView, ToolCallCard, PermissionCard, Composer), `lib/settings/ConfigEditor.svelte`, `lib/i18n` (en/fr catalogue, `t()`), pure modules with vitest (`markdown`, `conversation`, `composer`, `tools`, `yamlSchema`, `events`, `theme`, `panels/registry`). | 14 | +- `frontend/`: shell + panel registry, `state.svelte.ts` (`appState`, one ordered frame batcher for message events), `lib/chat/*` (Markdown, MessageView, ToolCallCard, PermissionCard, Composer), `lib/settings/ConfigEditor.svelte`, `lib/i18n` (en/fr catalogue, `t()`), pure modules with vitest (`markdown`, `conversation`, `composer`, `suggestions`, `tools`, `yamlSchema`, `events`, `theme`, `panels/registry`). State is keyed by channel id; a `$derived` must never call `conversationOf` (it writes state → `state_unsafe_mutation` freezes the UI). |
| 15 | - Tooling: `Makefile`, golangci-lint (`tools/go.mod`), CI, `scripts/agents.sh`, `scripts/install-test-config.sh`, `scripts/check-license-headers.sh`. | 15 | - Tooling: `Makefile`, golangci-lint (`tools/go.mod`), CI, `scripts/agents.sh`, `scripts/install-test-config.sh`, `scripts/check-license-headers.sh`. |
| 16 | 16 | ||
| 17 | ## Decisions in force | 17 | ## Decisions in force |
| @@ -19,6 +19,7 @@ What exists: | |||
| 19 | - ACP **v1**, via `github.com/coder/acp-go-sdk` v0.13.5, wrapped in `internal/acp` (ADR 0001). One ACP session per (channel, agent). | 19 | - ACP **v1**, via `github.com/coder/acp-go-sdk` v0.13.5, wrapped in `internal/acp` (ADR 0001). One ACP session per (channel, agent). |
| 20 | - **Wails v3.0.0**, Svelte 5 + TS, paneforge, markdown-it + Shiki (JS engine) + DOMPurify, CodeMirror 6, xterm.js + creack/pty (ADR 0002). Markdown rendered at most once per animation frame. macOS hidden-inset title bar: content near the top-left is padded by `--titlebar-inset` (38px under `:root.macos`, = `InvisibleTitleBarHeight`). | 20 | - **Wails v3.0.0**, Svelte 5 + TS, paneforge, markdown-it + Shiki (JS engine) + DOMPurify, CodeMirror 6, xterm.js + creack/pty (ADR 0002). Markdown rendered at most once per animation frame. macOS hidden-inset title bar: content near the top-left is padded by `--titlebar-inset` (38px under `:root.macos`, = `InvisibleTitleBarHeight`). |
| 21 | - Conversations, permissions, settings: ADR 0004 (direct conversation per agent, in memory; new session after an agent restart + system message; queue; events `message:updated|delta`, `turn:changed`, `permission:requested|resolved`, `usage:changed`; never-before-reject ordering; stuck banner after 3 failed tool calls; Go validates, the editor only displays; prefs.json). | 21 | - Conversations, permissions, settings: ADR 0004 (direct conversation per agent, in memory; new session after an agent restart + system message; queue; events `message:updated|delta`, `turn:changed`, `permission:requested|resolved`, `usage:changed`; never-before-reject ordering; stuck banner after 3 failed tool calls; Go validates, the editor only displays; prefs.json). |
| 22 | +- Channels: ADR 0006 — routing (`/agent:cmd` > mentions in order > last speaker > nobody), one session per (channel, agent), unseen messages re-sent (20 max, 1500 chars each, prompt ends with "answer directly, without your name in front"), one channel at a time per agent, commands sent alone, fresh session after restart gets the last messages, no Sidekicks-local slash commands (owner's choice), deleting a channel deletes its history after confirmation. Store: keys of ADR 0003 + `direct/<agent>` + `value/<key>`, `ErrClosed` after Close, in-memory fallback when the DB is locked (`historyProblem`). | ||
| 22 | - Interface language: ADR 0005 — `ui.language` defaults to `en`; every UI text is a key of `frontend/src/lib/i18n/messages.ts` (`fr` typed `Record<MessageKey,string>`, test checks keys and variables); config errors use the Go catalogue in the same language; agent titles and channel names are user data, never translated. Default config file and `examples/test-agents.yaml` are in English. | 23 | - Interface language: ADR 0005 — `ui.language` defaults to `en`; every UI text is a key of `frontend/src/lib/i18n/messages.ts` (`fr` typed `Record<MessageKey,string>`, test checks keys and variables); config errors use the Go catalogue in the same language; agent titles and channel names are user data, never translated. Default config file and `examples/test-agents.yaml` are in English. |
| 23 | - Packages and data model: ADR 0003 + Amendments 1 and 2 (main at the root, only main.go imports Wails, `internal/events` with an `Emitter` interface, LRU eviction of idle agents at the limit). | 24 | - Packages and data model: ADR 0003 + Amendments 1 and 2 (main at the root, only main.go imports Wails, `internal/events` with an `Emitter` interface, LRU eviction of idle agents at the limit). |
| 24 | - Frontend dependencies with **pnpm 12.11.2** and `frontend/pnpm-workspace.yaml` `supportedArchitectures` (darwin+linux): the repository folder is shared between the Mac and the Linux sandbox. Never use npm in `frontend/`. | 25 | - Frontend dependencies with **pnpm 12.11.2** and `frontend/pnpm-workspace.yaml` `supportedArchitectures` (darwin+linux): the repository folder is shared between the Mac and the Linux sandbox. Never use npm in `frontend/`. |
| @@ -44,6 +45,7 @@ What exists: | |||
| 44 | 45 | ||
| 45 | - Alice (gemma E2B), Bob and Riker (same Qwen3.5 2B), Garfield (gemma E4B, skills). Never more than 2 at once (MacBook Air 32 GB). | 46 | - Alice (gemma E2B), Bob and Riker (same Qwen3.5 2B), Garfield (gemma E4B, skills). Never more than 2 at once (MacBook Air 32 GB). |
| 46 | - mm: protocolVersion 1, no `loadSession`, commands = `compact`/`new`/`unload` + skills, sent right after `session/new`; `/skill args` as prompt text runs a skill; ignores `mcpServers`; reads AGENTS.md/skills from the session cwd. | 47 | - mm: protocolVersion 1, no `loadSession`, commands = `compact`/`new`/`unload` + skills, sent right after `session/new`; `/skill args` as prompt text runs a skill; ignores `mcpServers`; reads AGENTS.md/skills from the session cwd. |
| 48 | +- `scripts/agents.sh` `with_lock`: `rmdir` of the lock is never fatal. On the Mac⇄Linux shared folder `mkdir` is not atomic; two agents stopping together could both take the lock and the second `rmdir` failed under `set -e`, killing its cleanup → its model stayed loaded (cause of the M2 "model left loaded" observation in the sandbox; reproduced 2/3, fixed 4/4). | ||
| 47 | - Riker has reasoning **off** (`enable_thinking: false`, Qwen card non-thinking sampling 0.7/0.8/20): with it on, Qwen3.5 2B wrote its final answer in the reasoning after a tool call and returned an empty answer, which mm reports as an agent text chunk `*⚠ mm: empty answer*` (2/2 on, 7/7 fine off). | 49 | - Riker has reasoning **off** (`enable_thinking: false`, Qwen card non-thinking sampling 0.7/0.8/20): with it on, Qwen3.5 2B wrote its final answer in the reasoning after a tool call and returned an empty answer, which mm reports as an agent text chunk `*⚠ mm: empty answer*` (2/2 on, 7/7 fine off). |
| 48 | - Riker loops ~40 times after `reject_once`, then the turn ends with JSON-RPC error -32603. | 50 | - Riker loops ~40 times after `reject_once`, then the turn ends with JSON-RPC error -32603. |
| 49 | 51 | ||
| @@ -52,5 +54,5 @@ What exists: | |||
| 52 | - How to exec a shell / read files inside an `sbx` sandbox (issue #28). | 54 | - How to exec a shell / read files inside an `sbx` sandbox (issue #28). |
| 53 | - Quality gate: `qlty` is not in this sandbox → no qlty measurement yet (issue #38: build a sandbox kit with qlty). golangci-lint (go tool) is the stopgap. | 55 | - Quality gate: `qlty` is not in this sandbox → no qlty measurement yet (issue #38: build a sandbox kit with qlty). golangci-lint (go tool) is the stopgap. |
| 54 | - Whether the CI workflow runs as-is on rickub. | 56 | - Whether the CI workflow runs as-is on rickub. |
| 55 | -- A model was once left loaded after quitting during M2 testing (after a Riker loop + Stop); not reproduced, mitigated by SIGTERM before SIGKILL. Watch for it. | 57 | +- On the shared Mac⇄Linux folder, the `mkdir` lock of `scripts/agents.sh` is not exclusive: two agents starting at the same instant could exceed the slot limit in the sandbox. Not seen; on the Mac's own disk mkdir is atomic. |
| 56 | - Whether `.claude/skills/*` (kit files) should be committed with the project is the owner's call. | 58 | - Whether `.claude/skills/*` (kit files) should be committed with the project is the owner's call. |
modified
.tickets/issues/0012-shell-ui-a-panels-inspire-de-zed.yaml +0 -10 | @@ -43,8 +43,6 @@ tasks: | ||
| 43 | 43 | name: k33g |
| 44 | 44 | email: ph.charriere@gmail.com |
| 45 | 45 | createdAt: 2026-10-29T07:20:10.000Z |
| 46 | - updatedAt: 2026-10-29T12:09:30.000Z | |
| 47 | - closedAt: 2026-10-29T12:09:30.000Z | |
| 48 | 46 | updatedAt: 2026-10-29T10:19:30.000Z |
| 49 | 47 | closedAt: 2026-10-29T10:19:30.000Z |
| 50 | 48 | - id: 2 |
| @@ -55,8 +53,6 @@ tasks: | ||
| 55 | 53 | name: k33g |
| 56 | 54 | email: ph.charriere@gmail.com |
| 57 | 55 | createdAt: 2026-10-29T07:20:10.000Z |
| 58 | - updatedAt: 2026-10-29T12:09:30.000Z | |
| 59 | - closedAt: 2026-10-29T12:09:30.000Z | |
| 60 | 56 | updatedAt: 2026-10-29T10:19:30.000Z |
| 61 | 57 | closedAt: 2026-10-29T10:19:30.000Z |
| 62 | 58 | - id: 3 |
| @@ -67,8 +63,6 @@ tasks: | ||
| 67 | 63 | name: k33g |
| 68 | 64 | email: ph.charriere@gmail.com |
| 69 | 65 | createdAt: 2026-10-29T07:20:10.000Z |
| 70 | - updatedAt: 2026-10-29T12:09:30.000Z | |
| 71 | - closedAt: 2026-10-29T12:09:30.000Z | |
| 72 | 66 | updatedAt: 2026-10-29T10:19:30.000Z |
| 73 | 67 | closedAt: 2026-10-29T10:19:30.000Z |
| 74 | 68 | - id: 4 |
| @@ -79,8 +73,6 @@ tasks: | ||
| 79 | 73 | name: k33g |
| 80 | 74 | email: ph.charriere@gmail.com |
| 81 | 75 | createdAt: 2026-10-29T07:20:10.000Z |
| 82 | - updatedAt: 2026-10-29T12:09:30.000Z | |
| 83 | - closedAt: 2026-10-29T12:09:30.000Z | |
| 84 | 76 | updatedAt: 2026-10-29T10:19:30.000Z |
| 85 | 77 | closedAt: 2026-10-29T10:19:30.000Z |
| 86 | 78 | - id: 5 |
| @@ -91,8 +83,6 @@ tasks: | ||
| 91 | 83 | name: k33g |
| 92 | 84 | email: ph.charriere@gmail.com |
| 93 | 85 | createdAt: 2026-10-29T07:20:10.000Z |
| 94 | - updatedAt: 2026-10-29T12:09:30.000Z | |
| 95 | - closedAt: 2026-10-29T12:09:30.000Z | |
| 96 | 86 | updatedAt: 2026-10-29T10:19:30.000Z |
| 97 | 87 | closedAt: 2026-10-29T10:19:30.000Z |
| 98 | 88 | - id: 6 |
| @@ -43,8 +43,6 @@ tasks: | |||
| 43 | name: k33g | 43 | name: k33g |
| 44 | email: ph.charriere@gmail.com | 44 | email: ph.charriere@gmail.com |
| 45 | createdAt: 2026-10-29T07:20:10.000Z | 45 | createdAt: 2026-10-29T07:20:10.000Z |
| 46 | - updatedAt: 2026-10-29T12:09:30.000Z | ||
| 47 | - closedAt: 2026-10-29T12:09:30.000Z | ||
| 48 | updatedAt: 2026-10-29T10:19:30.000Z | 46 | updatedAt: 2026-10-29T10:19:30.000Z |
| 49 | closedAt: 2026-10-29T10:19:30.000Z | 47 | closedAt: 2026-10-29T10:19:30.000Z |
| 50 | - id: 2 | 48 | - id: 2 |
| @@ -55,8 +53,6 @@ tasks: | |||
| 55 | name: k33g | 53 | name: k33g |
| 56 | email: ph.charriere@gmail.com | 54 | email: ph.charriere@gmail.com |
| 57 | createdAt: 2026-10-29T07:20:10.000Z | 55 | createdAt: 2026-10-29T07:20:10.000Z |
| 58 | - updatedAt: 2026-10-29T12:09:30.000Z | ||
| 59 | - closedAt: 2026-10-29T12:09:30.000Z | ||
| 60 | updatedAt: 2026-10-29T10:19:30.000Z | 56 | updatedAt: 2026-10-29T10:19:30.000Z |
| 61 | closedAt: 2026-10-29T10:19:30.000Z | 57 | closedAt: 2026-10-29T10:19:30.000Z |
| 62 | - id: 3 | 58 | - id: 3 |
| @@ -67,8 +63,6 @@ tasks: | |||
| 67 | name: k33g | 63 | name: k33g |
| 68 | email: ph.charriere@gmail.com | 64 | email: ph.charriere@gmail.com |
| 69 | createdAt: 2026-10-29T07:20:10.000Z | 65 | createdAt: 2026-10-29T07:20:10.000Z |
| 70 | - updatedAt: 2026-10-29T12:09:30.000Z | ||
| 71 | - closedAt: 2026-10-29T12:09:30.000Z | ||
| 72 | updatedAt: 2026-10-29T10:19:30.000Z | 66 | updatedAt: 2026-10-29T10:19:30.000Z |
| 73 | closedAt: 2026-10-29T10:19:30.000Z | 67 | closedAt: 2026-10-29T10:19:30.000Z |
| 74 | - id: 4 | 68 | - id: 4 |
| @@ -79,8 +73,6 @@ tasks: | |||
| 79 | name: k33g | 73 | name: k33g |
| 80 | email: ph.charriere@gmail.com | 74 | email: ph.charriere@gmail.com |
| 81 | createdAt: 2026-10-29T07:20:10.000Z | 75 | createdAt: 2026-10-29T07:20:10.000Z |
| 82 | - updatedAt: 2026-10-29T12:09:30.000Z | ||
| 83 | - closedAt: 2026-10-29T12:09:30.000Z | ||
| 84 | updatedAt: 2026-10-29T10:19:30.000Z | 76 | updatedAt: 2026-10-29T10:19:30.000Z |
| 85 | closedAt: 2026-10-29T10:19:30.000Z | 77 | closedAt: 2026-10-29T10:19:30.000Z |
| 86 | - id: 5 | 78 | - id: 5 |
| @@ -91,8 +83,6 @@ tasks: | |||
| 91 | name: k33g | 83 | name: k33g |
| 92 | email: ph.charriere@gmail.com | 84 | email: ph.charriere@gmail.com |
| 93 | createdAt: 2026-10-29T07:20:10.000Z | 85 | createdAt: 2026-10-29T07:20:10.000Z |
| 94 | - updatedAt: 2026-10-29T12:09:30.000Z | ||
| 95 | - closedAt: 2026-10-29T12:09:30.000Z | ||
| 96 | updatedAt: 2026-10-29T10:19:30.000Z | 86 | updatedAt: 2026-10-29T10:19:30.000Z |
| 97 | closedAt: 2026-10-29T10:19:30.000Z | 87 | closedAt: 2026-10-29T10:19:30.000Z |
| 98 | - id: 6 | 88 | - id: 6 |
modified
.tickets/issues/0020-persister-channels-et-messages-avec-pebble.yaml +32 -9 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 20 |
| 3 | 3 | title: "Persister channels et messages avec Pebble" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:18.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:18.000Z | |
| 9 | +updatedAt: 2026-10-29T16:35:01.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::persistence" |
| @@ -35,50 +35,73 @@ body: | | ||
| 35 | 35 | tasks: |
| 36 | 36 | - id: 1 |
| 37 | 37 | title: "Schéma de clés et encodage" |
| 38 | - state: open | |
| 38 | + state: closed | |
| 39 | 39 | priority: high |
| 40 | 40 | author: |
| 41 | 41 | name: k33g |
| 42 | 42 | email: ph.charriere@gmail.com |
| 43 | 43 | createdAt: 2026-10-29T07:20:18.000Z |
| 44 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 45 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 44 | 46 | - id: 2 |
| 45 | 47 | title: "API store : channels" |
| 46 | - state: open | |
| 48 | + state: closed | |
| 47 | 49 | priority: high |
| 48 | 50 | author: |
| 49 | 51 | name: k33g |
| 50 | 52 | email: ph.charriere@gmail.com |
| 51 | 53 | createdAt: 2026-10-29T07:20:18.000Z |
| 54 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 55 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 52 | 56 | - id: 3 |
| 53 | 57 | title: "API store : messages (append, scan paginé)" |
| 54 | - state: open | |
| 58 | + state: closed | |
| 55 | 59 | priority: high |
| 56 | 60 | author: |
| 57 | 61 | name: k33g |
| 58 | 62 | email: ph.charriere@gmail.com |
| 59 | 63 | createdAt: 2026-10-29T07:20:18.000Z |
| 64 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 65 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 60 | 66 | - id: 4 |
| 61 | 67 | title: "Emplacement de la base lié à la config" |
| 62 | - state: open | |
| 68 | + state: closed | |
| 63 | 69 | priority: medium |
| 64 | 70 | author: |
| 65 | 71 | name: k33g |
| 66 | 72 | email: ph.charriere@gmail.com |
| 67 | 73 | createdAt: 2026-10-29T07:20:18.000Z |
| 74 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 75 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 68 | 76 | - id: 5 |
| 69 | 77 | title: "Version de schéma" |
| 70 | - state: open | |
| 78 | + state: closed | |
| 71 | 79 | priority: medium |
| 72 | 80 | author: |
| 73 | 81 | name: k33g |
| 74 | 82 | email: ph.charriere@gmail.com |
| 75 | 83 | createdAt: 2026-10-29T07:20:18.000Z |
| 84 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 85 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 76 | 86 | - id: 6 |
| 77 | 87 | title: "Tests unitaires" |
| 78 | - state: open | |
| 88 | + state: closed | |
| 79 | 89 | priority: medium |
| 80 | 90 | author: |
| 81 | 91 | name: k33g |
| 82 | 92 | email: ph.charriere@gmail.com |
| 83 | 93 | createdAt: 2026-10-29T07:20:18.000Z |
| 84 | -comments: [] | |
| 94 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 95 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 96 | +comments: | |
| 97 | + - id: 1 | |
| 98 | + author: | |
| 99 | + name: k33g | |
| 100 | + email: ph.charriere@gmail.com | |
| 101 | + createdAt: 2026-10-29T16:35:01.000Z | |
| 102 | + body: | | |
| 103 | + ## Résultat | |
| 104 | + | |
| 105 | + `internal/store` : Pebble v2 (v2.1.7), une base par fichier de configuration sous `<dossier de config utilisateur>/sidekicks/data/<empreinte du chemin>/`. Clés de l'ADR 0003 plus `direct/<agent>` (conversations directes) et `value/<clé>` (dernières commandes annoncées par chaque agent). Version de schéma (`meta/version`, une base plus récente est refusée), ids triables par date, pagination arrière des messages. Après `Close`, les opérations renvoient `ErrClosed` au lieu de laisser Pebble paniquer (une réponse peut se terminer pendant qu'on quitte). Une base déjà ouverte par un autre Sidekicks est remplacée par une base en mémoire, et l'interface le signale. | |
| 106 | + | |
| 107 | + Mesure (`go test -bench . ./internal/store`, base en mémoire, messages de 2000 octets) : les 100 derniers messages d'un channel de 5000 messages en ~0,02 ms (critère : < 50 ms) ; sur disque, Pebble lit depuis son cache de blocs, du même ordre de grandeur. Décisions : [ADR 0006](docs/adr/0006-channels.md). | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 20 | 2 | id: 20 |
| 3 | title: "Persister channels et messages avec Pebble" | 3 | title: "Persister channels et messages avec Pebble" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:18.000Z | 8 | createdAt: 2026-10-29T07:20:18.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:18.000Z | 9 | +updatedAt: 2026-10-29T16:35:01.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - "kind::feature" | 11 | - "kind::feature" |
| 12 | - "area::persistence" | 12 | - "area::persistence" |
| @@ -35,50 +35,73 @@ body: | | |||
| 35 | tasks: | 35 | tasks: |
| 36 | - id: 1 | 36 | - id: 1 |
| 37 | title: "Schéma de clés et encodage" | 37 | title: "Schéma de clés et encodage" |
| 38 | - state: open | 38 | + state: closed |
| 39 | priority: high | 39 | priority: high |
| 40 | author: | 40 | author: |
| 41 | name: k33g | 41 | name: k33g |
| 42 | email: ph.charriere@gmail.com | 42 | email: ph.charriere@gmail.com |
| 43 | createdAt: 2026-10-29T07:20:18.000Z | 43 | createdAt: 2026-10-29T07:20:18.000Z |
| 44 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 45 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 44 | - id: 2 | 46 | - id: 2 |
| 45 | title: "API store : channels" | 47 | title: "API store : channels" |
| 46 | - state: open | 48 | + state: closed |
| 47 | priority: high | 49 | priority: high |
| 48 | author: | 50 | author: |
| 49 | name: k33g | 51 | name: k33g |
| 50 | email: ph.charriere@gmail.com | 52 | email: ph.charriere@gmail.com |
| 51 | createdAt: 2026-10-29T07:20:18.000Z | 53 | createdAt: 2026-10-29T07:20:18.000Z |
| 54 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 55 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 52 | - id: 3 | 56 | - id: 3 |
| 53 | title: "API store : messages (append, scan paginé)" | 57 | title: "API store : messages (append, scan paginé)" |
| 54 | - state: open | 58 | + state: closed |
| 55 | priority: high | 59 | priority: high |
| 56 | author: | 60 | author: |
| 57 | name: k33g | 61 | name: k33g |
| 58 | email: ph.charriere@gmail.com | 62 | email: ph.charriere@gmail.com |
| 59 | createdAt: 2026-10-29T07:20:18.000Z | 63 | createdAt: 2026-10-29T07:20:18.000Z |
| 64 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 65 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 60 | - id: 4 | 66 | - id: 4 |
| 61 | title: "Emplacement de la base lié à la config" | 67 | title: "Emplacement de la base lié à la config" |
| 62 | - state: open | 68 | + state: closed |
| 63 | priority: medium | 69 | priority: medium |
| 64 | author: | 70 | author: |
| 65 | name: k33g | 71 | name: k33g |
| 66 | email: ph.charriere@gmail.com | 72 | email: ph.charriere@gmail.com |
| 67 | createdAt: 2026-10-29T07:20:18.000Z | 73 | createdAt: 2026-10-29T07:20:18.000Z |
| 74 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 75 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 68 | - id: 5 | 76 | - id: 5 |
| 69 | title: "Version de schéma" | 77 | title: "Version de schéma" |
| 70 | - state: open | 78 | + state: closed |
| 71 | priority: medium | 79 | priority: medium |
| 72 | author: | 80 | author: |
| 73 | name: k33g | 81 | name: k33g |
| 74 | email: ph.charriere@gmail.com | 82 | email: ph.charriere@gmail.com |
| 75 | createdAt: 2026-10-29T07:20:18.000Z | 83 | createdAt: 2026-10-29T07:20:18.000Z |
| 84 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 85 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 76 | - id: 6 | 86 | - id: 6 |
| 77 | title: "Tests unitaires" | 87 | title: "Tests unitaires" |
| 78 | - state: open | 88 | + state: closed |
| 79 | priority: medium | 89 | priority: medium |
| 80 | author: | 90 | author: |
| 81 | name: k33g | 91 | name: k33g |
| 82 | email: ph.charriere@gmail.com | 92 | email: ph.charriere@gmail.com |
| 83 | createdAt: 2026-10-29T07:20:18.000Z | 93 | createdAt: 2026-10-29T07:20:18.000Z |
| 84 | -comments: [] | 94 | + updatedAt: 2026-10-29T16:35:01.000Z |
| 95 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 96 | +comments: | ||
| 97 | + - id: 1 | ||
| 98 | + author: | ||
| 99 | + name: k33g | ||
| 100 | + email: ph.charriere@gmail.com | ||
| 101 | + createdAt: 2026-10-29T16:35:01.000Z | ||
| 102 | + body: | | ||
| 103 | + ## Résultat | ||
| 104 | + | ||
| 105 | + `internal/store` : Pebble v2 (v2.1.7), une base par fichier de configuration sous `<dossier de config utilisateur>/sidekicks/data/<empreinte du chemin>/`. Clés de l'ADR 0003 plus `direct/<agent>` (conversations directes) et `value/<clé>` (dernières commandes annoncées par chaque agent). Version de schéma (`meta/version`, une base plus récente est refusée), ids triables par date, pagination arrière des messages. Après `Close`, les opérations renvoient `ErrClosed` au lieu de laisser Pebble paniquer (une réponse peut se terminer pendant qu'on quitte). Une base déjà ouverte par un autre Sidekicks est remplacée par une base en mémoire, et l'interface le signale. | ||
| 106 | + | ||
| 107 | + Mesure (`go test -bench . ./internal/store`, base en mémoire, messages de 2000 octets) : les 100 derniers messages d'un channel de 5000 messages en ~0,02 ms (critère : < 50 ms) ; sur disque, Pebble lit depuis son cache de blocs, du même ordre de grandeur. Décisions : [ADR 0006](docs/adr/0006-channels.md). | ||
modified
.tickets/issues/0021-panel-channels-creer-renommer-supprimer.yaml +48 -8 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 21 |
| 3 | 3 | title: "Panel Channels : créer, renommer, supprimer" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:19.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:19.000Z | |
| 9 | +updatedAt: 2026-10-29T17:30:42.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::ui" |
| @@ -33,42 +33,82 @@ body: | | ||
| 33 | 33 | tasks: |
| 34 | 34 | - id: 1 |
| 35 | 35 | title: "Liste des channels dans le dock" |
| 36 | - state: open | |
| 36 | + state: closed | |
| 37 | 37 | priority: high |
| 38 | 38 | author: |
| 39 | 39 | name: k33g |
| 40 | 40 | email: ph.charriere@gmail.com |
| 41 | 41 | createdAt: 2026-10-29T07:20:19.000Z |
| 42 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 43 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 42 | 44 | - id: 2 |
| 43 | 45 | title: "Créer et renommer" |
| 44 | - state: open | |
| 46 | + state: closed | |
| 45 | 47 | priority: high |
| 46 | 48 | author: |
| 47 | 49 | name: k33g |
| 48 | 50 | email: ph.charriere@gmail.com |
| 49 | 51 | createdAt: 2026-10-29T07:20:19.000Z |
| 52 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 53 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 50 | 54 | - id: 3 |
| 51 | 55 | title: "Supprimer avec confirmation" |
| 52 | - state: open | |
| 56 | + state: closed | |
| 53 | 57 | priority: medium |
| 54 | 58 | author: |
| 55 | 59 | name: k33g |
| 56 | 60 | email: ph.charriere@gmail.com |
| 57 | 61 | createdAt: 2026-10-29T07:20:19.000Z |
| 62 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 63 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 58 | 64 | - id: 4 |
| 59 | 65 | title: "Afficher les agents membres" |
| 60 | - state: open | |
| 66 | + state: closed | |
| 61 | 67 | priority: medium |
| 62 | 68 | author: |
| 63 | 69 | name: k33g |
| 64 | 70 | email: ph.charriere@gmail.com |
| 65 | 71 | createdAt: 2026-10-29T07:20:19.000Z |
| 72 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 73 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 66 | 74 | - id: 5 |
| 67 | 75 | title: "Badge non lus" |
| 68 | - state: open | |
| 76 | + state: closed | |
| 69 | 77 | priority: medium |
| 70 | 78 | author: |
| 71 | 79 | name: k33g |
| 72 | 80 | email: ph.charriere@gmail.com |
| 73 | 81 | createdAt: 2026-10-29T07:20:19.000Z |
| 74 | -comments: [] | |
| 82 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 83 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 84 | + - id: 6 | |
| 85 | + title: "Vérifier les channels sur macOS (guide de test M3)" | |
| 86 | + state: closed | |
| 87 | + priority: high | |
| 88 | + author: | |
| 89 | + name: k33g | |
| 90 | + email: ph.charriere@gmail.com | |
| 91 | + createdAt: 2026-10-29T16:35:01.000Z | |
| 92 | + updatedAt: 2026-10-29T17:30:42.000Z | |
| 93 | + closedAt: 2026-10-29T17:30:42.000Z | |
| 94 | +comments: | |
| 95 | + - id: 1 | |
| 96 | + author: | |
| 97 | + name: k33g | |
| 98 | + email: ph.charriere@gmail.com | |
| 99 | + createdAt: 2026-10-29T16:35:01.000Z | |
| 100 | + body: | | |
| 101 | + ## Résultat — à vérifier sur macOS | |
| 102 | + | |
| 103 | + Panneau Channels : la liste des channels (badge des réponses non lues), **+ New channel** dessous, puis **Direct messages** ; survol → ✎ renommer (Entrée / Échap), 🗑 supprimer avec confirmation « Delete #nom and its whole history? ». Les membres sont dans l'en-tête du channel (× pour retirer, **+ Invite** pour inviter). Les channels déclarés dans le YAML sont créés au démarrage et au rechargement s'ils n'existent pas, puis vivent dans l'historique. | |
| 104 | + | |
| 105 | + Vérifié sous Linux (Xvfb) avec Alice, Bob et Garfield. Reste la vérification sur Mac : [guide de test M3](docs/fr/how-to/tester-la-milestone-m3-sous-macos.md). | |
| 106 | + - id: 2 | |
| 107 | + author: | |
| 108 | + name: k33g | |
| 109 | + email: ph.charriere@gmail.com | |
| 110 | + createdAt: 2026-10-29T17:30:42.000Z | |
| 111 | + body: | | |
| 112 | + ## Vérifié sur macOS — issue fermée | |
| 113 | + | |
| 114 | + Le propriétaire a validé M3 sur son Mac (« tout semble être ok »), y compris les commandes proposées dès le premier `/` et le spinner pendant les réponses. | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 21 | 2 | id: 21 |
| 3 | title: "Panel Channels : créer, renommer, supprimer" | 3 | title: "Panel Channels : créer, renommer, supprimer" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:19.000Z | 8 | createdAt: 2026-10-29T07:20:19.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:19.000Z | 9 | +updatedAt: 2026-10-29T17:30:42.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - "kind::feature" | 11 | - "kind::feature" |
| 12 | - "area::ui" | 12 | - "area::ui" |
| @@ -33,42 +33,82 @@ body: | | |||
| 33 | tasks: | 33 | tasks: |
| 34 | - id: 1 | 34 | - id: 1 |
| 35 | title: "Liste des channels dans le dock" | 35 | title: "Liste des channels dans le dock" |
| 36 | - state: open | 36 | + state: closed |
| 37 | priority: high | 37 | priority: high |
| 38 | author: | 38 | author: |
| 39 | name: k33g | 39 | name: k33g |
| 40 | email: ph.charriere@gmail.com | 40 | email: ph.charriere@gmail.com |
| 41 | createdAt: 2026-10-29T07:20:19.000Z | 41 | createdAt: 2026-10-29T07:20:19.000Z |
| 42 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 43 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 42 | - id: 2 | 44 | - id: 2 |
| 43 | title: "Créer et renommer" | 45 | title: "Créer et renommer" |
| 44 | - state: open | 46 | + state: closed |
| 45 | priority: high | 47 | priority: high |
| 46 | author: | 48 | author: |
| 47 | name: k33g | 49 | name: k33g |
| 48 | email: ph.charriere@gmail.com | 50 | email: ph.charriere@gmail.com |
| 49 | createdAt: 2026-10-29T07:20:19.000Z | 51 | createdAt: 2026-10-29T07:20:19.000Z |
| 52 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 53 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 50 | - id: 3 | 54 | - id: 3 |
| 51 | title: "Supprimer avec confirmation" | 55 | title: "Supprimer avec confirmation" |
| 52 | - state: open | 56 | + state: closed |
| 53 | priority: medium | 57 | priority: medium |
| 54 | author: | 58 | author: |
| 55 | name: k33g | 59 | name: k33g |
| 56 | email: ph.charriere@gmail.com | 60 | email: ph.charriere@gmail.com |
| 57 | createdAt: 2026-10-29T07:20:19.000Z | 61 | createdAt: 2026-10-29T07:20:19.000Z |
| 62 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 63 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 58 | - id: 4 | 64 | - id: 4 |
| 59 | title: "Afficher les agents membres" | 65 | title: "Afficher les agents membres" |
| 60 | - state: open | 66 | + state: closed |
| 61 | priority: medium | 67 | priority: medium |
| 62 | author: | 68 | author: |
| 63 | name: k33g | 69 | name: k33g |
| 64 | email: ph.charriere@gmail.com | 70 | email: ph.charriere@gmail.com |
| 65 | createdAt: 2026-10-29T07:20:19.000Z | 71 | createdAt: 2026-10-29T07:20:19.000Z |
| 72 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 73 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 66 | - id: 5 | 74 | - id: 5 |
| 67 | title: "Badge non lus" | 75 | title: "Badge non lus" |
| 68 | - state: open | 76 | + state: closed |
| 69 | priority: medium | 77 | priority: medium |
| 70 | author: | 78 | author: |
| 71 | name: k33g | 79 | name: k33g |
| 72 | email: ph.charriere@gmail.com | 80 | email: ph.charriere@gmail.com |
| 73 | createdAt: 2026-10-29T07:20:19.000Z | 81 | createdAt: 2026-10-29T07:20:19.000Z |
| 74 | -comments: [] | 82 | + updatedAt: 2026-10-29T16:35:01.000Z |
| 83 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 84 | + - id: 6 | ||
| 85 | + title: "Vérifier les channels sur macOS (guide de test M3)" | ||
| 86 | + state: closed | ||
| 87 | + priority: high | ||
| 88 | + author: | ||
| 89 | + name: k33g | ||
| 90 | + email: ph.charriere@gmail.com | ||
| 91 | + createdAt: 2026-10-29T16:35:01.000Z | ||
| 92 | + updatedAt: 2026-10-29T17:30:42.000Z | ||
| 93 | + closedAt: 2026-10-29T17:30:42.000Z | ||
| 94 | +comments: | ||
| 95 | + - id: 1 | ||
| 96 | + author: | ||
| 97 | + name: k33g | ||
| 98 | + email: ph.charriere@gmail.com | ||
| 99 | + createdAt: 2026-10-29T16:35:01.000Z | ||
| 100 | + body: | | ||
| 101 | + ## Résultat — à vérifier sur macOS | ||
| 102 | + | ||
| 103 | + Panneau Channels : la liste des channels (badge des réponses non lues), **+ New channel** dessous, puis **Direct messages** ; survol → ✎ renommer (Entrée / Échap), 🗑 supprimer avec confirmation « Delete #nom and its whole history? ». Les membres sont dans l'en-tête du channel (× pour retirer, **+ Invite** pour inviter). Les channels déclarés dans le YAML sont créés au démarrage et au rechargement s'ils n'existent pas, puis vivent dans l'historique. | ||
| 104 | + | ||
| 105 | + Vérifié sous Linux (Xvfb) avec Alice, Bob et Garfield. Reste la vérification sur Mac : [guide de test M3](docs/fr/how-to/tester-la-milestone-m3-sous-macos.md). | ||
| 106 | + - id: 2 | ||
| 107 | + author: | ||
| 108 | + name: k33g | ||
| 109 | + email: ph.charriere@gmail.com | ||
| 110 | + createdAt: 2026-10-29T17:30:42.000Z | ||
| 111 | + body: | | ||
| 112 | + ## Vérifié sur macOS — issue fermée | ||
| 113 | + | ||
| 114 | + Le propriétaire a validé M3 sur son Mac (« tout semble être ok »), y compris les commandes proposées dès le premier `/` et le spinner pendant les réponses. | ||
modified
.tickets/issues/0022-invoquer-un-agent-avec-et-autocompletion.yaml +27 -8 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 22 |
| 3 | 3 | title: "Invoquer un agent avec @ et autocomplétion" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:20.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:20.000Z | |
| 9 | +updatedAt: 2026-10-29T16:35:01.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::ui" |
| @@ -34,42 +34,61 @@ body: | | ||
| 34 | 34 | tasks: |
| 35 | 35 | - id: 1 |
| 36 | 36 | title: "Popup de mentions avec filtrage" |
| 37 | - state: open | |
| 37 | + state: closed | |
| 38 | 38 | priority: high |
| 39 | 39 | author: |
| 40 | 40 | name: k33g |
| 41 | 41 | email: ph.charriere@gmail.com |
| 42 | 42 | createdAt: 2026-10-29T07:20:20.000Z |
| 43 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 44 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 43 | 45 | - id: 2 |
| 44 | 46 | title: "Tab, ↑, ↓, Échap" |
| 45 | - state: open | |
| 47 | + state: closed | |
| 46 | 48 | priority: medium |
| 47 | 49 | author: |
| 48 | 50 | name: k33g |
| 49 | 51 | email: ph.charriere@gmail.com |
| 50 | 52 | createdAt: 2026-10-29T07:20:20.000Z |
| 53 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 54 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 51 | 55 | - id: 3 |
| 52 | 56 | title: "Invitation au premier appel" |
| 53 | - state: open | |
| 57 | + state: closed | |
| 54 | 58 | priority: high |
| 55 | 59 | author: |
| 56 | 60 | name: k33g |
| 57 | 61 | email: ph.charriere@gmail.com |
| 58 | 62 | createdAt: 2026-10-29T07:20:20.000Z |
| 63 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 64 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 59 | 65 | - id: 4 |
| 60 | 66 | title: "Message système d'arrivée" |
| 61 | - state: open | |
| 67 | + state: closed | |
| 62 | 68 | priority: medium |
| 63 | 69 | author: |
| 64 | 70 | name: k33g |
| 65 | 71 | email: ph.charriere@gmail.com |
| 66 | 72 | createdAt: 2026-10-29T07:20:20.000Z |
| 73 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 74 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 67 | 75 | - id: 5 |
| 68 | 76 | title: "Rendu de la mention dans les messages" |
| 69 | - state: open | |
| 77 | + state: closed | |
| 70 | 78 | priority: medium |
| 71 | 79 | author: |
| 72 | 80 | name: k33g |
| 73 | 81 | email: ph.charriere@gmail.com |
| 74 | 82 | createdAt: 2026-10-29T07:20:20.000Z |
| 75 | -comments: [] | |
| 83 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 84 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 85 | +comments: | |
| 86 | + - id: 1 | |
| 87 | + author: | |
| 88 | + name: k33g | |
| 89 | + email: ph.charriere@gmail.com | |
| 90 | + createdAt: 2026-10-29T16:35:01.000Z | |
| 91 | + body: | | |
| 92 | + ## Résultat | |
| 93 | + | |
| 94 | + Popup après `@` dans le composer : agents filtrés sur nom et titre (préfixe, début de mot, puis lettres dans l'ordre : « gfd » → Garfield), ↑ ↓ pour naviguer, Tab ou Entrée pour choisir, Échap pour fermer. Une mention d'un agent qui n'est pas membre l'invite avant sa réponse, avec le message système « X joined the channel. » Les mentions d'agents connus sont mises en évidence dans les messages. Dans une conversation directe, `@` n'ouvre pas de popup (les mentions y sont du texte). Vérifié sous Linux (Xvfb). | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 22 | 2 | id: 22 |
| 3 | title: "Invoquer un agent avec @ et autocomplétion" | 3 | title: "Invoquer un agent avec @ et autocomplétion" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:20.000Z | 8 | createdAt: 2026-10-29T07:20:20.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:20.000Z | 9 | +updatedAt: 2026-10-29T16:35:01.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - "kind::feature" | 11 | - "kind::feature" |
| 12 | - "area::ui" | 12 | - "area::ui" |
| @@ -34,42 +34,61 @@ body: | | |||
| 34 | tasks: | 34 | tasks: |
| 35 | - id: 1 | 35 | - id: 1 |
| 36 | title: "Popup de mentions avec filtrage" | 36 | title: "Popup de mentions avec filtrage" |
| 37 | - state: open | 37 | + state: closed |
| 38 | priority: high | 38 | priority: high |
| 39 | author: | 39 | author: |
| 40 | name: k33g | 40 | name: k33g |
| 41 | email: ph.charriere@gmail.com | 41 | email: ph.charriere@gmail.com |
| 42 | createdAt: 2026-10-29T07:20:20.000Z | 42 | createdAt: 2026-10-29T07:20:20.000Z |
| 43 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 44 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 43 | - id: 2 | 45 | - id: 2 |
| 44 | title: "Tab, ↑, ↓, Échap" | 46 | title: "Tab, ↑, ↓, Échap" |
| 45 | - state: open | 47 | + state: closed |
| 46 | priority: medium | 48 | priority: medium |
| 47 | author: | 49 | author: |
| 48 | name: k33g | 50 | name: k33g |
| 49 | email: ph.charriere@gmail.com | 51 | email: ph.charriere@gmail.com |
| 50 | createdAt: 2026-10-29T07:20:20.000Z | 52 | createdAt: 2026-10-29T07:20:20.000Z |
| 53 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 54 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 51 | - id: 3 | 55 | - id: 3 |
| 52 | title: "Invitation au premier appel" | 56 | title: "Invitation au premier appel" |
| 53 | - state: open | 57 | + state: closed |
| 54 | priority: high | 58 | priority: high |
| 55 | author: | 59 | author: |
| 56 | name: k33g | 60 | name: k33g |
| 57 | email: ph.charriere@gmail.com | 61 | email: ph.charriere@gmail.com |
| 58 | createdAt: 2026-10-29T07:20:20.000Z | 62 | createdAt: 2026-10-29T07:20:20.000Z |
| 63 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 64 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 59 | - id: 4 | 65 | - id: 4 |
| 60 | title: "Message système d'arrivée" | 66 | title: "Message système d'arrivée" |
| 61 | - state: open | 67 | + state: closed |
| 62 | priority: medium | 68 | priority: medium |
| 63 | author: | 69 | author: |
| 64 | name: k33g | 70 | name: k33g |
| 65 | email: ph.charriere@gmail.com | 71 | email: ph.charriere@gmail.com |
| 66 | createdAt: 2026-10-29T07:20:20.000Z | 72 | createdAt: 2026-10-29T07:20:20.000Z |
| 73 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 74 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 67 | - id: 5 | 75 | - id: 5 |
| 68 | title: "Rendu de la mention dans les messages" | 76 | title: "Rendu de la mention dans les messages" |
| 69 | - state: open | 77 | + state: closed |
| 70 | priority: medium | 78 | priority: medium |
| 71 | author: | 79 | author: |
| 72 | name: k33g | 80 | name: k33g |
| 73 | email: ph.charriere@gmail.com | 81 | email: ph.charriere@gmail.com |
| 74 | createdAt: 2026-10-29T07:20:20.000Z | 82 | createdAt: 2026-10-29T07:20:20.000Z |
| 75 | -comments: [] | 83 | + updatedAt: 2026-10-29T16:35:01.000Z |
| 84 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 85 | +comments: | ||
| 86 | + - id: 1 | ||
| 87 | + author: | ||
| 88 | + name: k33g | ||
| 89 | + email: ph.charriere@gmail.com | ||
| 90 | + createdAt: 2026-10-29T16:35:01.000Z | ||
| 91 | + body: | | ||
| 92 | + ## Résultat | ||
| 93 | + | ||
| 94 | + Popup après `@` dans le composer : agents filtrés sur nom et titre (préfixe, début de mot, puis lettres dans l'ordre : « gfd » → Garfield), ↑ ↓ pour naviguer, Tab ou Entrée pour choisir, Échap pour fermer. Une mention d'un agent qui n'est pas membre l'invite avant sa réponse, avec le message système « X joined the channel. » Les mentions d'agents connus sont mises en évidence dans les messages. Dans une conversation directe, `@` n'ouvre pas de popup (les mentions y sont du texte). Vérifié sous Linux (Xvfb). | ||
modified
.tickets/issues/0023-router-les-messages-entre-plusieurs-agents-d-un.yaml +31 -8 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 23 |
| 3 | 3 | title: "Router les messages entre plusieurs agents d'un channel" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:21.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:21.000Z | |
| 9 | +updatedAt: 2026-10-29T16:35:01.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::acp" |
| @@ -35,42 +35,65 @@ body: | | ||
| 35 | 35 | tasks: |
| 36 | 36 | - id: 1 |
| 37 | 37 | title: "Règles de destinataire (mentions, dernier agent)" |
| 38 | - state: open | |
| 38 | + state: closed | |
| 39 | 39 | priority: high |
| 40 | 40 | author: |
| 41 | 41 | name: k33g |
| 42 | 42 | email: ph.charriere@gmail.com |
| 43 | 43 | createdAt: 2026-10-29T07:20:21.000Z |
| 44 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 45 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 44 | 46 | - id: 2 |
| 45 | 47 | title: "Injection du contexte non vu dans le prompt" |
| 46 | - state: open | |
| 48 | + state: closed | |
| 47 | 49 | priority: high |
| 48 | 50 | author: |
| 49 | 51 | name: k33g |
| 50 | 52 | email: ph.charriere@gmail.com |
| 51 | 53 | createdAt: 2026-10-29T07:20:21.000Z |
| 54 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 55 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 52 | 56 | - id: 3 |
| 53 | 57 | title: "Exécution séquentielle / parallèle" |
| 54 | - state: open | |
| 58 | + state: closed | |
| 55 | 59 | priority: medium |
| 56 | 60 | author: |
| 57 | 61 | name: k33g |
| 58 | 62 | email: ph.charriere@gmail.com |
| 59 | 63 | createdAt: 2026-10-29T07:20:21.000Z |
| 64 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 65 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 60 | 66 | - id: 4 |
| 61 | 67 | title: "Tests unitaires avec faux agents" |
| 62 | - state: open | |
| 68 | + state: closed | |
| 63 | 69 | priority: medium |
| 64 | 70 | author: |
| 65 | 71 | name: k33g |
| 66 | 72 | email: ph.charriere@gmail.com |
| 67 | 73 | createdAt: 2026-10-29T07:20:21.000Z |
| 74 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 75 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 68 | 76 | - id: 5 |
| 69 | 77 | title: "Test realagents Alice + Bob" |
| 70 | - state: open | |
| 78 | + state: closed | |
| 71 | 79 | priority: medium |
| 72 | 80 | author: |
| 73 | 81 | name: k33g |
| 74 | 82 | email: ph.charriere@gmail.com |
| 75 | 83 | createdAt: 2026-10-29T07:20:21.000Z |
| 76 | -comments: [] | |
| 84 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 85 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 86 | +comments: | |
| 87 | + - id: 1 | |
| 88 | + author: | |
| 89 | + name: k33g | |
| 90 | + email: ph.charriere@gmail.com | |
| 91 | + createdAt: 2026-10-29T16:35:01.000Z | |
| 92 | + body: | | |
| 93 | + ## Résultat | |
| 94 | + | |
| 95 | + Règles (ADR 0006) : `/agent:commande` → cet agent ; sinon les agents mentionnés, l'un après l'autre dans l'ordre des mentions ; sinon le dernier agent qui a parlé (ou l'unique membre) ; sinon message système demandant de mentionner un agent. Une session ACP par (channel, agent) ; avant son tour, l'agent reçoit les messages du channel qu'il n'a pas vus (20 au plus, 1500 caractères chacun). Un agent ne répond qu'à un channel à la fois. | |
| 96 | + | |
| 97 | + **Exécution parallèle en option : non retenue.** Deux modèles locaux qui génèrent en même temps sont chacun plus lents, et le second ne pourrait pas lire la réponse du premier. À rouvrir si des agents distants (non locaux) arrivent. | |
| 98 | + | |
| 99 | + Tests : faux agents (routage, invitation, contexte non vu, une seule réponse à la fois par agent, Stop qui arrête aussi les agents suivants) et `make test-real` : Bob retrouve le code donné par Alice (3 essais sur 3, sans « Bob: » devant après l'ajout d'une consigne en fin de prompt). | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 23 | 2 | id: 23 |
| 3 | title: "Router les messages entre plusieurs agents d'un channel" | 3 | title: "Router les messages entre plusieurs agents d'un channel" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:21.000Z | 8 | createdAt: 2026-10-29T07:20:21.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:21.000Z | 9 | +updatedAt: 2026-10-29T16:35:01.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - "kind::feature" | 11 | - "kind::feature" |
| 12 | - "area::acp" | 12 | - "area::acp" |
| @@ -35,42 +35,65 @@ body: | | |||
| 35 | tasks: | 35 | tasks: |
| 36 | - id: 1 | 36 | - id: 1 |
| 37 | title: "Règles de destinataire (mentions, dernier agent)" | 37 | title: "Règles de destinataire (mentions, dernier agent)" |
| 38 | - state: open | 38 | + state: closed |
| 39 | priority: high | 39 | priority: high |
| 40 | author: | 40 | author: |
| 41 | name: k33g | 41 | name: k33g |
| 42 | email: ph.charriere@gmail.com | 42 | email: ph.charriere@gmail.com |
| 43 | createdAt: 2026-10-29T07:20:21.000Z | 43 | createdAt: 2026-10-29T07:20:21.000Z |
| 44 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 45 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 44 | - id: 2 | 46 | - id: 2 |
| 45 | title: "Injection du contexte non vu dans le prompt" | 47 | title: "Injection du contexte non vu dans le prompt" |
| 46 | - state: open | 48 | + state: closed |
| 47 | priority: high | 49 | priority: high |
| 48 | author: | 50 | author: |
| 49 | name: k33g | 51 | name: k33g |
| 50 | email: ph.charriere@gmail.com | 52 | email: ph.charriere@gmail.com |
| 51 | createdAt: 2026-10-29T07:20:21.000Z | 53 | createdAt: 2026-10-29T07:20:21.000Z |
| 54 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 55 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 52 | - id: 3 | 56 | - id: 3 |
| 53 | title: "Exécution séquentielle / parallèle" | 57 | title: "Exécution séquentielle / parallèle" |
| 54 | - state: open | 58 | + state: closed |
| 55 | priority: medium | 59 | priority: medium |
| 56 | author: | 60 | author: |
| 57 | name: k33g | 61 | name: k33g |
| 58 | email: ph.charriere@gmail.com | 62 | email: ph.charriere@gmail.com |
| 59 | createdAt: 2026-10-29T07:20:21.000Z | 63 | createdAt: 2026-10-29T07:20:21.000Z |
| 64 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 65 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 60 | - id: 4 | 66 | - id: 4 |
| 61 | title: "Tests unitaires avec faux agents" | 67 | title: "Tests unitaires avec faux agents" |
| 62 | - state: open | 68 | + state: closed |
| 63 | priority: medium | 69 | priority: medium |
| 64 | author: | 70 | author: |
| 65 | name: k33g | 71 | name: k33g |
| 66 | email: ph.charriere@gmail.com | 72 | email: ph.charriere@gmail.com |
| 67 | createdAt: 2026-10-29T07:20:21.000Z | 73 | createdAt: 2026-10-29T07:20:21.000Z |
| 74 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 75 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 68 | - id: 5 | 76 | - id: 5 |
| 69 | title: "Test realagents Alice + Bob" | 77 | title: "Test realagents Alice + Bob" |
| 70 | - state: open | 78 | + state: closed |
| 71 | priority: medium | 79 | priority: medium |
| 72 | author: | 80 | author: |
| 73 | name: k33g | 81 | name: k33g |
| 74 | email: ph.charriere@gmail.com | 82 | email: ph.charriere@gmail.com |
| 75 | createdAt: 2026-10-29T07:20:21.000Z | 83 | createdAt: 2026-10-29T07:20:21.000Z |
| 76 | -comments: [] | 84 | + updatedAt: 2026-10-29T16:35:01.000Z |
| 85 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 86 | +comments: | ||
| 87 | + - id: 1 | ||
| 88 | + author: | ||
| 89 | + name: k33g | ||
| 90 | + email: ph.charriere@gmail.com | ||
| 91 | + createdAt: 2026-10-29T16:35:01.000Z | ||
| 92 | + body: | | ||
| 93 | + ## Résultat | ||
| 94 | + | ||
| 95 | + Règles (ADR 0006) : `/agent:commande` → cet agent ; sinon les agents mentionnés, l'un après l'autre dans l'ordre des mentions ; sinon le dernier agent qui a parlé (ou l'unique membre) ; sinon message système demandant de mentionner un agent. Une session ACP par (channel, agent) ; avant son tour, l'agent reçoit les messages du channel qu'il n'a pas vus (20 au plus, 1500 caractères chacun). Un agent ne répond qu'à un channel à la fois. | ||
| 96 | + | ||
| 97 | + **Exécution parallèle en option : non retenue.** Deux modèles locaux qui génèrent en même temps sont chacun plus lents, et le second ne pourrait pas lire la réponse du premier. À rouvrir si des agents distants (non locaux) arrivent. | ||
| 98 | + | ||
| 99 | + Tests : faux agents (routage, invitation, contexte non vu, une seule réponse à la fois par agent, Stop qui arrête aussi les agents suivants) et `make test-real` : Bob retrouve le code donné par Alice (3 essais sur 3, sans « Bob: » devant après l'ajout d'une consigne en fin de prompt). | ||
modified
.tickets/issues/0024-commandes-slash-exposees-par-les-agents.yaml +35 -7 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 24 |
| 3 | 3 | title: "Commandes slash exposées par les agents" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:22.000Z |
| 9 | -updatedAt: 2026-10-29T08:51:00.000Z | |
| 9 | +updatedAt: 2026-10-29T17:05:41.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::acp" |
| @@ -35,44 +35,54 @@ body: | | ||
| 35 | 35 | tasks: |
| 36 | 36 | - id: 1 |
| 37 | 37 | title: "Réception et cache de available_commands_update" |
| 38 | - state: open | |
| 38 | + state: closed | |
| 39 | 39 | priority: high |
| 40 | 40 | author: |
| 41 | 41 | name: k33g |
| 42 | 42 | email: ph.charriere@gmail.com |
| 43 | 43 | createdAt: 2026-10-29T07:20:22.000Z |
| 44 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 45 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 44 | 46 | - id: 2 |
| 45 | 47 | title: "Popup / avec filtrage" |
| 46 | - state: open | |
| 48 | + state: closed | |
| 47 | 49 | priority: high |
| 48 | 50 | author: |
| 49 | 51 | name: k33g |
| 50 | 52 | email: ph.charriere@gmail.com |
| 51 | 53 | createdAt: 2026-10-29T07:20:22.000Z |
| 54 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 55 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 52 | 56 | - id: 3 |
| 53 | 57 | title: "Syntaxe /agent:commande en multi-agents" |
| 54 | - state: open | |
| 58 | + state: closed | |
| 55 | 59 | priority: medium |
| 56 | 60 | author: |
| 57 | 61 | name: k33g |
| 58 | 62 | email: ph.charriere@gmail.com |
| 59 | 63 | createdAt: 2026-10-29T07:20:22.000Z |
| 64 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 65 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 60 | 66 | - id: 4 |
| 61 | 67 | title: "Décider des commandes locales et de leur préfixe" |
| 62 | - state: open | |
| 68 | + state: closed | |
| 63 | 69 | priority: medium |
| 64 | 70 | author: |
| 65 | 71 | name: k33g |
| 66 | 72 | email: ph.charriere@gmail.com |
| 67 | 73 | createdAt: 2026-10-29T07:20:22.000Z |
| 74 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 75 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 68 | 76 | - id: 5 |
| 69 | 77 | title: "Tests avec Garfield" |
| 70 | - state: open | |
| 78 | + state: closed | |
| 71 | 79 | priority: medium |
| 72 | 80 | author: |
| 73 | 81 | name: k33g |
| 74 | 82 | email: ph.charriere@gmail.com |
| 75 | 83 | createdAt: 2026-10-29T07:20:22.000Z |
| 84 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 85 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 76 | 86 | comments: |
| 77 | 87 | - id: 1 |
| 78 | 88 | author: |
| @@ -81,3 +91,21 @@ comments: | ||
| 81 | 91 | createdAt: 2026-10-29T08:51:00.000Z |
| 82 | 92 | body: | |
| 83 | 93 | Validé par le spike ACP (#2) : mm annonce `compact`, `new`, `unload` + les skills (Garfield : `email-letter`, `idioms`, `styles`, `technical-text`) dans `available_commands_update`, juste après `session/new`. Envoyer `/idioms Il pleut des cordes.` comme texte du prompt exécute le skill. Donc `/garfield:idioms …` → envoyer `/idioms …` à Garfield seul. Collision à prévoir : toutes les instances mm exposent `compact`/`new`/`unload`. |
| 94 | + - id: 2 | |
| 95 | + author: | |
| 96 | + name: k33g | |
| 97 | + email: ph.charriere@gmail.com | |
| 98 | + createdAt: 2026-10-29T16:35:01.000Z | |
| 99 | + body: | | |
| 100 | + ## Résultat | |
| 101 | + | |
| 102 | + `available_commands_update` est reçu par session, gardé par agent et en base (les commandes sont proposées avant que l'agent ne redémarre). Popup après `/` en début de message (éventuellement après des mentions) : `/commande` avec un seul membre, `/agent:commande` avec plusieurs ; `/gar` filtre sur l'agent, `/garfield:id` sur ses commandes ; la description est affichée. Une commande est envoyée seule, sans contexte du channel, pour que l'agent la reconnaisse. | |
| 103 | + | |
| 104 | + **Commandes locales : aucune en M3** (choix du propriétaire) — inviter, retirer, renommer, supprimer passent par l'interface ; `/` ne désigne que les commandes des agents. Testé avec Garfield sous Linux (`/idioms`, puis la popup propose `/idioms`, `/email-letter`, `/technical-text`). | |
| 105 | + - id: 3 | |
| 106 | + author: | |
| 107 | + name: k33g | |
| 108 | + email: ph.charriere@gmail.com | |
| 109 | + createdAt: 2026-10-29T17:05:41.000Z | |
| 110 | + body: | | |
| 111 | + Retour du propriétaire : les commandes doivent apparaître dès le premier `/`, sans avoir fait tourner l'agent avant. Mesuré : démarrer mm et ouvrir une session renvoie ses commandes en ~0,1 s **sans charger le modèle**. Désormais, le premier `/` d'une conversation démarre les membres dont les commandes sont inconnues et ouvre leur session (`ChannelService.Prepare`) ; la popup affiche « Asking … for their commands… » puis se remplit. La session est gardée pour la première réponse, qui reçoit quand même le passé du channel. Ajout aussi d'un spinner (thinking / using a tool / writing) pendant les réponses. Voir [ADR 0006](docs/adr/0006-channels.md). | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 24 | 2 | id: 24 |
| 3 | title: "Commandes slash exposées par les agents" | 3 | title: "Commandes slash exposées par les agents" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:22.000Z | 8 | createdAt: 2026-10-29T07:20:22.000Z |
| 9 | -updatedAt: 2026-10-29T08:51:00.000Z | 9 | +updatedAt: 2026-10-29T17:05:41.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - "kind::feature" | 11 | - "kind::feature" |
| 12 | - "area::acp" | 12 | - "area::acp" |
| @@ -35,44 +35,54 @@ body: | | |||
| 35 | tasks: | 35 | tasks: |
| 36 | - id: 1 | 36 | - id: 1 |
| 37 | title: "Réception et cache de available_commands_update" | 37 | title: "Réception et cache de available_commands_update" |
| 38 | - state: open | 38 | + state: closed |
| 39 | priority: high | 39 | priority: high |
| 40 | author: | 40 | author: |
| 41 | name: k33g | 41 | name: k33g |
| 42 | email: ph.charriere@gmail.com | 42 | email: ph.charriere@gmail.com |
| 43 | createdAt: 2026-10-29T07:20:22.000Z | 43 | createdAt: 2026-10-29T07:20:22.000Z |
| 44 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 45 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 44 | - id: 2 | 46 | - id: 2 |
| 45 | title: "Popup / avec filtrage" | 47 | title: "Popup / avec filtrage" |
| 46 | - state: open | 48 | + state: closed |
| 47 | priority: high | 49 | priority: high |
| 48 | author: | 50 | author: |
| 49 | name: k33g | 51 | name: k33g |
| 50 | email: ph.charriere@gmail.com | 52 | email: ph.charriere@gmail.com |
| 51 | createdAt: 2026-10-29T07:20:22.000Z | 53 | createdAt: 2026-10-29T07:20:22.000Z |
| 54 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 55 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 52 | - id: 3 | 56 | - id: 3 |
| 53 | title: "Syntaxe /agent:commande en multi-agents" | 57 | title: "Syntaxe /agent:commande en multi-agents" |
| 54 | - state: open | 58 | + state: closed |
| 55 | priority: medium | 59 | priority: medium |
| 56 | author: | 60 | author: |
| 57 | name: k33g | 61 | name: k33g |
| 58 | email: ph.charriere@gmail.com | 62 | email: ph.charriere@gmail.com |
| 59 | createdAt: 2026-10-29T07:20:22.000Z | 63 | createdAt: 2026-10-29T07:20:22.000Z |
| 64 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 65 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 60 | - id: 4 | 66 | - id: 4 |
| 61 | title: "Décider des commandes locales et de leur préfixe" | 67 | title: "Décider des commandes locales et de leur préfixe" |
| 62 | - state: open | 68 | + state: closed |
| 63 | priority: medium | 69 | priority: medium |
| 64 | author: | 70 | author: |
| 65 | name: k33g | 71 | name: k33g |
| 66 | email: ph.charriere@gmail.com | 72 | email: ph.charriere@gmail.com |
| 67 | createdAt: 2026-10-29T07:20:22.000Z | 73 | createdAt: 2026-10-29T07:20:22.000Z |
| 74 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 75 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 68 | - id: 5 | 76 | - id: 5 |
| 69 | title: "Tests avec Garfield" | 77 | title: "Tests avec Garfield" |
| 70 | - state: open | 78 | + state: closed |
| 71 | priority: medium | 79 | priority: medium |
| 72 | author: | 80 | author: |
| 73 | name: k33g | 81 | name: k33g |
| 74 | email: ph.charriere@gmail.com | 82 | email: ph.charriere@gmail.com |
| 75 | createdAt: 2026-10-29T07:20:22.000Z | 83 | createdAt: 2026-10-29T07:20:22.000Z |
| 84 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 85 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 76 | comments: | 86 | comments: |
| 77 | - id: 1 | 87 | - id: 1 |
| 78 | author: | 88 | author: |
| @@ -81,3 +91,21 @@ comments: | |||
| 81 | createdAt: 2026-10-29T08:51:00.000Z | 91 | createdAt: 2026-10-29T08:51:00.000Z |
| 82 | body: | | 92 | body: | |
| 83 | Validé par le spike ACP (#2) : mm annonce `compact`, `new`, `unload` + les skills (Garfield : `email-letter`, `idioms`, `styles`, `technical-text`) dans `available_commands_update`, juste après `session/new`. Envoyer `/idioms Il pleut des cordes.` comme texte du prompt exécute le skill. Donc `/garfield:idioms …` → envoyer `/idioms …` à Garfield seul. Collision à prévoir : toutes les instances mm exposent `compact`/`new`/`unload`. | 93 | Validé par le spike ACP (#2) : mm annonce `compact`, `new`, `unload` + les skills (Garfield : `email-letter`, `idioms`, `styles`, `technical-text`) dans `available_commands_update`, juste après `session/new`. Envoyer `/idioms Il pleut des cordes.` comme texte du prompt exécute le skill. Donc `/garfield:idioms …` → envoyer `/idioms …` à Garfield seul. Collision à prévoir : toutes les instances mm exposent `compact`/`new`/`unload`. |
| 94 | + - id: 2 | ||
| 95 | + author: | ||
| 96 | + name: k33g | ||
| 97 | + email: ph.charriere@gmail.com | ||
| 98 | + createdAt: 2026-10-29T16:35:01.000Z | ||
| 99 | + body: | | ||
| 100 | + ## Résultat | ||
| 101 | + | ||
| 102 | + `available_commands_update` est reçu par session, gardé par agent et en base (les commandes sont proposées avant que l'agent ne redémarre). Popup après `/` en début de message (éventuellement après des mentions) : `/commande` avec un seul membre, `/agent:commande` avec plusieurs ; `/gar` filtre sur l'agent, `/garfield:id` sur ses commandes ; la description est affichée. Une commande est envoyée seule, sans contexte du channel, pour que l'agent la reconnaisse. | ||
| 103 | + | ||
| 104 | + **Commandes locales : aucune en M3** (choix du propriétaire) — inviter, retirer, renommer, supprimer passent par l'interface ; `/` ne désigne que les commandes des agents. Testé avec Garfield sous Linux (`/idioms`, puis la popup propose `/idioms`, `/email-letter`, `/technical-text`). | ||
| 105 | + - id: 3 | ||
| 106 | + author: | ||
| 107 | + name: k33g | ||
| 108 | + email: ph.charriere@gmail.com | ||
| 109 | + createdAt: 2026-10-29T17:05:41.000Z | ||
| 110 | + body: | | ||
| 111 | + Retour du propriétaire : les commandes doivent apparaître dès le premier `/`, sans avoir fait tourner l'agent avant. Mesuré : démarrer mm et ouvrir une session renvoie ses commandes en ~0,1 s **sans charger le modèle**. Désormais, le premier `/` d'une conversation démarre les membres dont les commandes sont inconnues et ouvre leur session (`ChannelService.Prepare`) ; la popup affiche « Asking … for their commands… » puis se remplit. La session est gardée pour la première réponse, qui reçoit quand même le passé du channel. Ajout aussi d'un spinner (thinking / using a tool / writing) pendant les réponses. Voir [ADR 0006](docs/adr/0006-channels.md). | ||
modified
.tickets/issues/0025-reprendre-les-conversations-apres-redemarrage.yaml +25 -6 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 25 |
| 3 | 3 | title: "Reprendre les conversations après redémarrage" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:23.000Z |
| 9 | -updatedAt: 2026-10-29T08:51:00.000Z | |
| 9 | +updatedAt: 2026-10-29T16:35:01.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::persistence" |
| @@ -31,36 +31,44 @@ body: | | ||
| 31 | 31 | tasks: |
| 32 | 32 | - id: 1 |
| 33 | 33 | title: "Stocker l'id de session ACP par membership" |
| 34 | - state: open | |
| 34 | + state: closed | |
| 35 | 35 | priority: medium |
| 36 | 36 | author: |
| 37 | 37 | name: k33g |
| 38 | 38 | email: ph.charriere@gmail.com |
| 39 | 39 | createdAt: 2026-10-29T07:20:23.000Z |
| 40 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 41 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 40 | 42 | - id: 2 |
| 41 | 43 | title: "session/load si supporté" |
| 42 | - state: open | |
| 44 | + state: closed | |
| 43 | 45 | priority: medium |
| 44 | 46 | author: |
| 45 | 47 | name: k33g |
| 46 | 48 | email: ph.charriere@gmail.com |
| 47 | 49 | createdAt: 2026-10-29T07:20:23.000Z |
| 50 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 51 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 48 | 52 | - id: 3 |
| 49 | 53 | title: "Fallback : réinjection de contexte" |
| 50 | - state: open | |
| 54 | + state: closed | |
| 51 | 55 | priority: medium |
| 52 | 56 | author: |
| 53 | 57 | name: k33g |
| 54 | 58 | email: ph.charriere@gmail.com |
| 55 | 59 | createdAt: 2026-10-29T07:20:23.000Z |
| 60 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 61 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 56 | 62 | - id: 4 |
| 57 | 63 | title: "Test de bout en bout" |
| 58 | - state: open | |
| 64 | + state: closed | |
| 59 | 65 | priority: medium |
| 60 | 66 | author: |
| 61 | 67 | name: k33g |
| 62 | 68 | email: ph.charriere@gmail.com |
| 63 | 69 | createdAt: 2026-10-29T07:20:23.000Z |
| 70 | + updatedAt: 2026-10-29T16:35:01.000Z | |
| 71 | + closedAt: 2026-10-29T16:35:01.000Z | |
| 64 | 72 | comments: |
| 65 | 73 | - id: 1 |
| 66 | 74 | author: |
| @@ -69,3 +77,14 @@ comments: | ||
| 69 | 77 | createdAt: 2026-10-29T08:51:00.000Z |
| 70 | 78 | body: | |
| 71 | 79 | Confirmé par le spike ACP (#2) : mm v1.0.3 n'annonce pas `loadSession` (`agentCapabilities` vides). Le fallback « nouvelle session + réinjection du contexte » est donc le cas nominal avec mm, pas un cas dégradé. `acpSessionId` reste stocké pour les agents qui supportent `session/load` ([ADR 0003](docs/adr/0003-architecture.md)). |
| 80 | + - id: 2 | |
| 81 | + author: | |
| 82 | + name: k33g | |
| 83 | + email: ph.charriere@gmail.com | |
| 84 | + createdAt: 2026-10-29T16:35:01.000Z | |
| 85 | + body: | | |
| 86 | + ## Résultat | |
| 87 | + | |
| 88 | + L'id de session ACP est gardé avec la membership. Un agent qui annonce `loadSession` reçoit `session/load` (son historique rejoué est ignoré, l'interface l'a déjà) ; sinon — c'est le cas de mm — une nouvelle session reçoit dans son premier prompt les derniers messages du channel, les siens compris. Le message « X a redémarré et ne se souvient plus du début » de M2 a disparu : l'utilisateur ne voit pas la différence. Même mécanisme quand un agent a été arrêté pour faire de la place. | |
| 89 | + | |
| 90 | + Test de bout en bout (`make test-real`) : Alice, relancée avec une nouvelle base ouverte sur le même dossier, retrouve « Philippe ». | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 25 | 2 | id: 25 |
| 3 | title: "Reprendre les conversations après redémarrage" | 3 | title: "Reprendre les conversations après redémarrage" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:23.000Z | 8 | createdAt: 2026-10-29T07:20:23.000Z |
| 9 | -updatedAt: 2026-10-29T08:51:00.000Z | 9 | +updatedAt: 2026-10-29T16:35:01.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - "kind::feature" | 11 | - "kind::feature" |
| 12 | - "area::persistence" | 12 | - "area::persistence" |
| @@ -31,36 +31,44 @@ body: | | |||
| 31 | tasks: | 31 | tasks: |
| 32 | - id: 1 | 32 | - id: 1 |
| 33 | title: "Stocker l'id de session ACP par membership" | 33 | title: "Stocker l'id de session ACP par membership" |
| 34 | - state: open | 34 | + state: closed |
| 35 | priority: medium | 35 | priority: medium |
| 36 | author: | 36 | author: |
| 37 | name: k33g | 37 | name: k33g |
| 38 | email: ph.charriere@gmail.com | 38 | email: ph.charriere@gmail.com |
| 39 | createdAt: 2026-10-29T07:20:23.000Z | 39 | createdAt: 2026-10-29T07:20:23.000Z |
| 40 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 41 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 40 | - id: 2 | 42 | - id: 2 |
| 41 | title: "session/load si supporté" | 43 | title: "session/load si supporté" |
| 42 | - state: open | 44 | + state: closed |
| 43 | priority: medium | 45 | priority: medium |
| 44 | author: | 46 | author: |
| 45 | name: k33g | 47 | name: k33g |
| 46 | email: ph.charriere@gmail.com | 48 | email: ph.charriere@gmail.com |
| 47 | createdAt: 2026-10-29T07:20:23.000Z | 49 | createdAt: 2026-10-29T07:20:23.000Z |
| 50 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 51 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 48 | - id: 3 | 52 | - id: 3 |
| 49 | title: "Fallback : réinjection de contexte" | 53 | title: "Fallback : réinjection de contexte" |
| 50 | - state: open | 54 | + state: closed |
| 51 | priority: medium | 55 | priority: medium |
| 52 | author: | 56 | author: |
| 53 | name: k33g | 57 | name: k33g |
| 54 | email: ph.charriere@gmail.com | 58 | email: ph.charriere@gmail.com |
| 55 | createdAt: 2026-10-29T07:20:23.000Z | 59 | createdAt: 2026-10-29T07:20:23.000Z |
| 60 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 61 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 56 | - id: 4 | 62 | - id: 4 |
| 57 | title: "Test de bout en bout" | 63 | title: "Test de bout en bout" |
| 58 | - state: open | 64 | + state: closed |
| 59 | priority: medium | 65 | priority: medium |
| 60 | author: | 66 | author: |
| 61 | name: k33g | 67 | name: k33g |
| 62 | email: ph.charriere@gmail.com | 68 | email: ph.charriere@gmail.com |
| 63 | createdAt: 2026-10-29T07:20:23.000Z | 69 | createdAt: 2026-10-29T07:20:23.000Z |
| 70 | + updatedAt: 2026-10-29T16:35:01.000Z | ||
| 71 | + closedAt: 2026-10-29T16:35:01.000Z | ||
| 64 | comments: | 72 | comments: |
| 65 | - id: 1 | 73 | - id: 1 |
| 66 | author: | 74 | author: |
| @@ -69,3 +77,14 @@ comments: | |||
| 69 | createdAt: 2026-10-29T08:51:00.000Z | 77 | createdAt: 2026-10-29T08:51:00.000Z |
| 70 | body: | | 78 | body: | |
| 71 | Confirmé par le spike ACP (#2) : mm v1.0.3 n'annonce pas `loadSession` (`agentCapabilities` vides). Le fallback « nouvelle session + réinjection du contexte » est donc le cas nominal avec mm, pas un cas dégradé. `acpSessionId` reste stocké pour les agents qui supportent `session/load` ([ADR 0003](docs/adr/0003-architecture.md)). | 79 | Confirmé par le spike ACP (#2) : mm v1.0.3 n'annonce pas `loadSession` (`agentCapabilities` vides). Le fallback « nouvelle session + réinjection du contexte » est donc le cas nominal avec mm, pas un cas dégradé. `acpSessionId` reste stocké pour les agents qui supportent `session/load` ([ADR 0003](docs/adr/0003-architecture.md)). |
| 80 | + - id: 2 | ||
| 81 | + author: | ||
| 82 | + name: k33g | ||
| 83 | + email: ph.charriere@gmail.com | ||
| 84 | + createdAt: 2026-10-29T16:35:01.000Z | ||
| 85 | + body: | | ||
| 86 | + ## Résultat | ||
| 87 | + | ||
| 88 | + L'id de session ACP est gardé avec la membership. Un agent qui annonce `loadSession` reçoit `session/load` (son historique rejoué est ignoré, l'interface l'a déjà) ; sinon — c'est le cas de mm — une nouvelle session reçoit dans son premier prompt les derniers messages du channel, les siens compris. Le message « X a redémarré et ne se souvient plus du début » de M2 a disparu : l'utilisateur ne voit pas la différence. Même mécanisme quand un agent a été arrêté pour faire de la place. | ||
| 89 | + | ||
| 90 | + Test de bout en bout (`make test-real`) : Alice, relancée avec une nouvelle base ouverte sur le même dossier, retrouve « Philippe ». | ||
modified
.tickets/milestones.yaml +1 -1 | @@ -13,7 +13,7 @@ milestones: | ||
| 13 | 13 | description: "Discuter avec UN agent de bout en bout : streaming, Markdown, tool calls et permissions, édition de la config dans le panel Settings." |
| 14 | 14 | dueOn: 2026-11-27 |
| 15 | 15 | - title: "M3 · Channels & multi-agents" |
| 16 | - state: open | |
| 16 | + state: closed | |
| 17 | 17 | description: "Channels persistés, invocation @agent, routage multi-agents, commandes slash /agent:commande." |
| 18 | 18 | dueOn: 2026-12-18 |
| 19 | 19 | - title: "M4 · Workspace des agents" |
| @@ -13,7 +13,7 @@ milestones: | |||
| 13 | description: "Discuter avec UN agent de bout en bout : streaming, Markdown, tool calls et permissions, édition de la config dans le panel Settings." | 13 | description: "Discuter avec UN agent de bout en bout : streaming, Markdown, tool calls et permissions, édition de la config dans le panel Settings." |
| 14 | dueOn: 2026-11-27 | 14 | dueOn: 2026-11-27 |
| 15 | - title: "M3 · Channels & multi-agents" | 15 | - title: "M3 · Channels & multi-agents" |
| 16 | - state: open | 16 | + state: closed |
| 17 | description: "Channels persistés, invocation @agent, routage multi-agents, commandes slash /agent:commande." | 17 | description: "Channels persistés, invocation @agent, routage multi-agents, commandes slash /agent:commande." |
| 18 | dueOn: 2026-12-18 | 18 | dueOn: 2026-12-18 |
| 19 | - title: "M4 · Workspace des agents" | 19 | - title: "M4 · Workspace des agents" |
modified
README.md +4 -3 | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | A desktop application to talk with AI agents in channels, for people who are not developers. Agents are reached through the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), so any ACP agent can join. Built with Go and [Wails v3](https://wails.io), interface in Svelte 5. |
| 4 | 4 | |
| 5 | -> **Status — milestone M2 (Conversation with an agent)**: talk with one agent at a time in streaming, Markdown answers with coloured code, tool calls and permission requests in plain words, a configuration editor with live validation, and opening another configuration file. Channels and multi-agent conversations arrive with milestone M3. Backlog and roadmap: `.tickets/` (IssueSpec). | |
| 5 | +> **Status — milestone M3 (Channels & multi-agents)**: channels kept from one launch to the next, several agents per channel with `@` mentions, agent commands with `/`, direct conversations; answers stream as Markdown with coloured code, tool calls ask permission in plain words, and the configuration is edited from the window. Files and terminal panels arrive with milestone M4. Backlog and roadmap: `.tickets/` (IssueSpec). | |
| 6 | 6 | |
| 7 | 7 | Documentation: [English](docs/en/README.md) · [Français](docs/fr/README.md) · [decisions (ADR)](docs/adr/) · [wireframes](docs/design/README.md) |
| 8 | 8 | |
| @@ -31,8 +31,9 @@ Details: [How to develop the application](docs/en/how-to/develop-the-application | ||
| 31 | 31 | | Path | What it is | |
| 32 | 32 | | --- | --- | |
| 33 | 33 | | `main.go` | Entry point: flags, Wails application, services, window | |
| 34 | -| `internal/app` | The services bound to the interface (`AgentService`, `ChatService`, `ConfigService`) | | |
| 35 | -| `internal/conversations` | One conversation per agent: sessions, streaming, queue, permissions | | |
| 34 | +| `internal/app` | The services bound to the interface (`AgentService`, `ChannelService`, `ConfigService`) | | |
| 35 | +| `internal/channels` | Channels and direct conversations: routing (`@agent`, `/agent:command`), unseen context, sessions, streaming, queue, permissions | | |
| 36 | +| `internal/store` | The history: channels, members and messages in Pebble, one database per configuration file | | |
| 36 | 37 | | `internal/prefs` | Last opened and recent configuration files | |
| 37 | 38 | | `internal/agents` | The agent processes: lazy start, limit, idle stop, states, logs | |
| 38 | 39 | | `internal/acp` | ACP v1 client over stdio (wraps `coder/acp-go-sdk`) | |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | A desktop application to talk with AI agents in channels, for people who are not developers. Agents are reached through the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), so any ACP agent can join. Built with Go and [Wails v3](https://wails.io), interface in Svelte 5. | 3 | A desktop application to talk with AI agents in channels, for people who are not developers. Agents are reached through the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), so any ACP agent can join. Built with Go and [Wails v3](https://wails.io), interface in Svelte 5. |
| 4 | 4 | ||
| 5 | -> **Status — milestone M2 (Conversation with an agent)**: talk with one agent at a time in streaming, Markdown answers with coloured code, tool calls and permission requests in plain words, a configuration editor with live validation, and opening another configuration file. Channels and multi-agent conversations arrive with milestone M3. Backlog and roadmap: `.tickets/` (IssueSpec). | 5 | +> **Status — milestone M3 (Channels & multi-agents)**: channels kept from one launch to the next, several agents per channel with `@` mentions, agent commands with `/`, direct conversations; answers stream as Markdown with coloured code, tool calls ask permission in plain words, and the configuration is edited from the window. Files and terminal panels arrive with milestone M4. Backlog and roadmap: `.tickets/` (IssueSpec). |
| 6 | 6 | ||
| 7 | Documentation: [English](docs/en/README.md) · [Français](docs/fr/README.md) · [decisions (ADR)](docs/adr/) · [wireframes](docs/design/README.md) | 7 | Documentation: [English](docs/en/README.md) · [Français](docs/fr/README.md) · [decisions (ADR)](docs/adr/) · [wireframes](docs/design/README.md) |
| 8 | 8 | ||
| @@ -31,8 +31,9 @@ Details: [How to develop the application](docs/en/how-to/develop-the-application | |||
| 31 | | Path | What it is | | 31 | | Path | What it is | |
| 32 | | --- | --- | | 32 | | --- | --- | |
| 33 | | `main.go` | Entry point: flags, Wails application, services, window | | 33 | | `main.go` | Entry point: flags, Wails application, services, window | |
| 34 | -| `internal/app` | The services bound to the interface (`AgentService`, `ChatService`, `ConfigService`) | | 34 | +| `internal/app` | The services bound to the interface (`AgentService`, `ChannelService`, `ConfigService`) | |
| 35 | -| `internal/conversations` | One conversation per agent: sessions, streaming, queue, permissions | | 35 | +| `internal/channels` | Channels and direct conversations: routing (`@agent`, `/agent:command`), unseen context, sessions, streaming, queue, permissions | |
| 36 | +| `internal/store` | The history: channels, members and messages in Pebble, one database per configuration file | | ||
| 36 | | `internal/prefs` | Last opened and recent configuration files | | 37 | | `internal/prefs` | Last opened and recent configuration files | |
| 37 | | `internal/agents` | The agent processes: lazy start, limit, idle stop, states, logs | | 38 | | `internal/agents` | The agent processes: lazy start, limit, idle stop, states, logs | |
| 38 | | `internal/acp` | ACP v1 client over stdio (wraps `coder/acp-go-sdk`) | | 39 | | `internal/acp` | ACP v1 client over stdio (wraps `coder/acp-go-sdk`) | |
modified
build/darwin/icons.icns +0 -0 | Binary files a/build/darwin/icons.icns and b/build/darwin/icons.icns differ |
| Binary files a/build/darwin/icons.icns and b/build/darwin/icons.icns differ | Binary files a/build/darwin/icons.icns and b/build/darwin/icons.icns differ |
modified
docs/adr/0003-architecture.md +5 -1 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # ADR 0003 — Architecture, data model and events |
| 2 | 2 | |
| 3 | -- **Status**: accepted — amended by milestone M1 (Amendment 1) and milestone M2 (Amendment 2) | |
| 3 | +- **Status**: accepted — amended by milestone M1 (Amendment 1), milestone M2 (Amendment 2) and milestone M3 (Amendment 3) | |
| 4 | 4 | - **Date**: 2026-10-29 |
| 5 | 5 | - **Issue**: [#5 — Définir l'architecture et le modèle de données](../../.tickets/issues/0005-definir-l-architecture-et-le-modele-de-donnees.yaml) |
| 6 | 6 | - **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [configuration spec](../en/reference/configuration.md) |
| @@ -120,6 +120,10 @@ UI ─ ChannelService.Send(channel, "@garfield /idioms Il pleut") ─▶ interna | ||
| 120 | 120 | |
| 121 | 121 | Milestone M2 adds direct conversations before channels: `internal/conversations` (one in-memory conversation per agent), `internal/prefs` (last opened and recent configuration files), the `ChatService`, the Settings methods of `ConfigService`, and six conversation events keyed by agent (`message:updated`, `message:delta`, `turn:changed`, `permission:requested`, `permission:resolved`, `usage:changed`). Details and reasons: [ADR 0004](0004-conversations.md). The channel events of the catalogue above remain the M3 target. |
| 122 | 122 | |
| 123 | +## Amendment 3 — what M3 changed (2026-10-29) | |
| 124 | + | |
| 125 | +`internal/channels` (as planned in the package table) replaces `internal/conversations`: a direct conversation is a channel with one member. `internal/store` holds channels, members, messages and a small key-value space (`value/<key>`: the commands each agent announced); a direct conversation is indexed by `direct/<agent>` instead of a name. The database lives under `<user config dir>/sidekicks/data/<hash of the configuration path>/`. Events keep the M2 names, keyed by channel, plus `channels:changed` (the whole list) and `commands:changed`; `message:started` of the catalogue above is `message:updated`, as in M2. `ChannelService` replaces `ChatService`. `channels` uses `agents` through the `AgentSource` interface (Acquire, Definition), never the opposite. Details and reasons: [ADR 0006](0006-channels.md). | |
| 126 | + | |
| 123 | 127 | ## Consequences |
| 124 | 128 | |
| 125 | 129 | - M1 creates `main.go`, `internal/app`, `internal/events`, `internal/config`, `internal/acp`, `internal/agents` (issues #8–#13; see Amendment 1). |
| @@ -1,6 +1,6 @@ | |||
| 1 | # ADR 0003 — Architecture, data model and events | 1 | # ADR 0003 — Architecture, data model and events |
| 2 | 2 | ||
| 3 | -- **Status**: accepted — amended by milestone M1 (Amendment 1) and milestone M2 (Amendment 2) | 3 | +- **Status**: accepted — amended by milestone M1 (Amendment 1), milestone M2 (Amendment 2) and milestone M3 (Amendment 3) |
| 4 | - **Date**: 2026-10-29 | 4 | - **Date**: 2026-10-29 |
| 5 | - **Issue**: [#5 — Définir l'architecture et le modèle de données](../../.tickets/issues/0005-definir-l-architecture-et-le-modele-de-donnees.yaml) | 5 | - **Issue**: [#5 — Définir l'architecture et le modèle de données](../../.tickets/issues/0005-definir-l-architecture-et-le-modele-de-donnees.yaml) |
| 6 | - **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [configuration spec](../en/reference/configuration.md) | 6 | - **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [configuration spec](../en/reference/configuration.md) |
| @@ -120,6 +120,10 @@ UI ─ ChannelService.Send(channel, "@garfield /idioms Il pleut") ─▶ interna | |||
| 120 | 120 | ||
| 121 | Milestone M2 adds direct conversations before channels: `internal/conversations` (one in-memory conversation per agent), `internal/prefs` (last opened and recent configuration files), the `ChatService`, the Settings methods of `ConfigService`, and six conversation events keyed by agent (`message:updated`, `message:delta`, `turn:changed`, `permission:requested`, `permission:resolved`, `usage:changed`). Details and reasons: [ADR 0004](0004-conversations.md). The channel events of the catalogue above remain the M3 target. | 121 | Milestone M2 adds direct conversations before channels: `internal/conversations` (one in-memory conversation per agent), `internal/prefs` (last opened and recent configuration files), the `ChatService`, the Settings methods of `ConfigService`, and six conversation events keyed by agent (`message:updated`, `message:delta`, `turn:changed`, `permission:requested`, `permission:resolved`, `usage:changed`). Details and reasons: [ADR 0004](0004-conversations.md). The channel events of the catalogue above remain the M3 target. |
| 122 | 122 | ||
| 123 | +## Amendment 3 — what M3 changed (2026-10-29) | ||
| 124 | + | ||
| 125 | +`internal/channels` (as planned in the package table) replaces `internal/conversations`: a direct conversation is a channel with one member. `internal/store` holds channels, members, messages and a small key-value space (`value/<key>`: the commands each agent announced); a direct conversation is indexed by `direct/<agent>` instead of a name. The database lives under `<user config dir>/sidekicks/data/<hash of the configuration path>/`. Events keep the M2 names, keyed by channel, plus `channels:changed` (the whole list) and `commands:changed`; `message:started` of the catalogue above is `message:updated`, as in M2. `ChannelService` replaces `ChatService`. `channels` uses `agents` through the `AgentSource` interface (Acquire, Definition), never the opposite. Details and reasons: [ADR 0006](0006-channels.md). | ||
| 126 | + | ||
| 123 | ## Consequences | 127 | ## Consequences |
| 124 | 128 | ||
| 125 | - M1 creates `main.go`, `internal/app`, `internal/events`, `internal/config`, `internal/acp`, `internal/agents` (issues #8–#13; see Amendment 1). | 129 | - M1 creates `main.go`, `internal/app`, `internal/events`, `internal/config`, `internal/acp`, `internal/agents` (issues #8–#13; see Amendment 1). |
added
docs/adr/0006-channels.md +62 -0 | new file mode 100644 | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +# ADR 0006 — Channels, routing between agents, history | |
| 2 | + | |
| 3 | +- **Status**: accepted | |
| 4 | +- **Date**: 2026-10-29 | |
| 5 | +- **Issues**: [#20](../../.tickets/issues/0020-persister-channels-et-messages-avec-pebble.yaml), [#21](../../.tickets/issues/0021-panel-channels-creer-renommer-supprimer.yaml), [#22](../../.tickets/issues/0022-invoquer-un-agent-avec-et-autocompletion.yaml), [#23](../../.tickets/issues/0023-router-les-messages-entre-plusieurs-agents-d-un.yaml), [#24](../../.tickets/issues/0024-commandes-slash-exposees-par-les-agents.yaml), [#25](../../.tickets/issues/0025-reprendre-les-conversations-apres-redemarrage.yaml) | |
| 6 | +- **Builds on**: [ADR 0003](0003-architecture.md) (packages, keys, events) and [ADR 0004](0004-conversations.md) (the conversation model of M2) | |
| 7 | + | |
| 8 | +## Context | |
| 9 | + | |
| 10 | +Milestone M2 kept one conversation per agent, in memory. Milestone M3 adds what the brief asks for: channels the user creates, several agents in one channel, `@agent` and `/agent:command`, and a history that survives a restart. ACP is one client talking with one agent: everything multi-agent is Sidekicks' job. The agents are small local models, two at most in memory on the reference machine. | |
| 11 | + | |
| 12 | +## Decisions | |
| 13 | + | |
| 14 | +### 1. Channels and direct conversations are one thing | |
| 15 | + | |
| 16 | +`internal/channels` replaces `internal/conversations`. A direct conversation (clicking an agent) is a channel with one member and no name, flagged `direct` with the agent's name; the Channels panel lists it under "Direct messages". The same code streams, queues, asks permissions and stores both. Mentions are plain text in a direct conversation: its agent always answers. | |
| 17 | + | |
| 18 | +### 2. Who answers | |
| 19 | + | |
| 20 | +- `/agent:command arguments` goes to that agent, sent as `/command arguments`. | |
| 21 | +- Otherwise the agents mentioned with `@name` answer, **one after the other**, in the order of the mentions. A mentioned agent that is not a member is invited first, and the channel says so. | |
| 22 | +- Otherwise the **last agent who spoke** in the channel answers; with a single member, that member. | |
| 23 | +- Otherwise nobody: the channel says to mention an agent. | |
| 24 | + | |
| 25 | +Answering in parallel was rejected: two local models generating at once on a laptop are both slower, and the second answer could not take the first into account. One agent answers **one channel at a time**: a message for an agent busy elsewhere waits (the channel shows whose turn it is). | |
| 26 | + | |
| 27 | +### 3. What each agent reads | |
| 28 | + | |
| 29 | +Each member has its own ACP session for the channel and remembers the id of the last channel message it has seen. Before its turn, an agent receives the finished user and agent messages it has not seen — what the user said to others, what other agents answered — then the user's text: | |
| 30 | + | |
| 31 | +```text | |
| 32 | +[Messages in #general you have not seen yet] | |
| 33 | +user: @alice explain goroutines | |
| 34 | +alice: Goroutines are light threads… | |
| 35 | +[End of the messages] | |
| 36 | + | |
| 37 | +The user now writes to you (answer directly, without your name in front): | |
| 38 | +@bob what do you think? | |
| 39 | +``` | |
| 40 | + | |
| 41 | +With nothing unseen, the text is sent alone. At most the last 20 messages are re-sent, each cut at 1500 characters: small context windows. A slash command is sent alone, without this context, or the agent would not recognise it. The last sentence of the prompt was added after testing: without it, Bob imitated the transcript and answered "Bob: 4217". | |
| 42 | + | |
| 43 | +### 4. After a restart | |
| 44 | + | |
| 45 | +The ACP session id is stored with the membership. An agent that announces `loadSession` gets `session/load`; its replayed history is ignored (the interface already shows it from the store). Otherwise — mm has no `session/load` — a new session is opened and is "fresh": its first prompt carries the last messages of the channel, its own answers included. The user sees no difference and no "agent restarted" notice any more (ADR 0004's notice is gone). The same happens when an agent was stopped to make room for another. | |
| 46 | + | |
| 47 | +### 5. The store | |
| 48 | + | |
| 49 | +Pebble v2 in `internal/store`, one database per configuration file under `<user config dir>/sidekicks/data/<hash of the path>/`, with the keys of ADR 0003 plus `direct/<agent>` and `value/<key>` (the last commands each agent announced, so that `/` can suggest them before the agent starts). Ids sort by time (milliseconds, counter, random). Messages are written when they are finished; a streaming answer is not. After `Close`, operations return `ErrClosed` instead of letting Pebble panic: an answer can end while Sidekicks quits. A database that cannot be opened (another Sidekicks uses it) is replaced by one in memory, and the interface says the history is unavailable. | |
| 50 | + | |
| 51 | +### 6. The interface | |
| 52 | + | |
| 53 | +`ChannelService` replaces `ChatService`. Events are keyed by channel; `channels:changed` carries the whole list and `commands:changed` an agent's commands. The composer opens a popup after `@` (agents, filtered on name and title, letters in order) and after `/` at the start of a message (the members' commands; `/agent:command` with several members). A streaming answer shows a spinner with what it is doing — thinking, using a tool, writing — and a line "… is getting ready" covers the time before its first part (agent starting, waiting for its turn). A channel is loaded from Go when first shown, then kept up to date by events only. Unread answers are counted in memory. | |
| 54 | + | |
| 55 | +There are no Sidekicks commands typed in the composer in M3: inviting, removing, renaming and deleting are buttons, so `/` only ever means an agent's command. | |
| 56 | + | |
| 57 | +## Consequences | |
| 58 | + | |
| 59 | +- Deleting a channel deletes its history, after an explicit confirmation. | |
| 60 | +- The first `/` in a conversation starts the members that never announced their commands and opens their session (`ChannelService.Prepare`): mm announces them right after `session/new` without loading its model (measured: about 0.1 s). The session is kept for the agent's first answer and marked fresh, so that answer is still told the channel's past. Doing it when a channel is merely opened was rejected: a started agent takes one of the two slots and could stop an idle one. | |
| 61 | +- Messages written by a model are re-sent to other models: a long channel costs context, which the 20-message limit bounds. | |
| 62 | +- An answer interrupted by quitting is lost (it was never finished, so never stored). | |
| new file mode 100644 | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | +# ADR 0006 — Channels, routing between agents, history | ||
| 2 | + | ||
| 3 | +- **Status**: accepted | ||
| 4 | +- **Date**: 2026-10-29 | ||
| 5 | +- **Issues**: [#20](../../.tickets/issues/0020-persister-channels-et-messages-avec-pebble.yaml), [#21](../../.tickets/issues/0021-panel-channels-creer-renommer-supprimer.yaml), [#22](../../.tickets/issues/0022-invoquer-un-agent-avec-et-autocompletion.yaml), [#23](../../.tickets/issues/0023-router-les-messages-entre-plusieurs-agents-d-un.yaml), [#24](../../.tickets/issues/0024-commandes-slash-exposees-par-les-agents.yaml), [#25](../../.tickets/issues/0025-reprendre-les-conversations-apres-redemarrage.yaml) | ||
| 6 | +- **Builds on**: [ADR 0003](0003-architecture.md) (packages, keys, events) and [ADR 0004](0004-conversations.md) (the conversation model of M2) | ||
| 7 | + | ||
| 8 | +## Context | ||
| 9 | + | ||
| 10 | +Milestone M2 kept one conversation per agent, in memory. Milestone M3 adds what the brief asks for: channels the user creates, several agents in one channel, `@agent` and `/agent:command`, and a history that survives a restart. ACP is one client talking with one agent: everything multi-agent is Sidekicks' job. The agents are small local models, two at most in memory on the reference machine. | ||
| 11 | + | ||
| 12 | +## Decisions | ||
| 13 | + | ||
| 14 | +### 1. Channels and direct conversations are one thing | ||
| 15 | + | ||
| 16 | +`internal/channels` replaces `internal/conversations`. A direct conversation (clicking an agent) is a channel with one member and no name, flagged `direct` with the agent's name; the Channels panel lists it under "Direct messages". The same code streams, queues, asks permissions and stores both. Mentions are plain text in a direct conversation: its agent always answers. | ||
| 17 | + | ||
| 18 | +### 2. Who answers | ||
| 19 | + | ||
| 20 | +- `/agent:command arguments` goes to that agent, sent as `/command arguments`. | ||
| 21 | +- Otherwise the agents mentioned with `@name` answer, **one after the other**, in the order of the mentions. A mentioned agent that is not a member is invited first, and the channel says so. | ||
| 22 | +- Otherwise the **last agent who spoke** in the channel answers; with a single member, that member. | ||
| 23 | +- Otherwise nobody: the channel says to mention an agent. | ||
| 24 | + | ||
| 25 | +Answering in parallel was rejected: two local models generating at once on a laptop are both slower, and the second answer could not take the first into account. One agent answers **one channel at a time**: a message for an agent busy elsewhere waits (the channel shows whose turn it is). | ||
| 26 | + | ||
| 27 | +### 3. What each agent reads | ||
| 28 | + | ||
| 29 | +Each member has its own ACP session for the channel and remembers the id of the last channel message it has seen. Before its turn, an agent receives the finished user and agent messages it has not seen — what the user said to others, what other agents answered — then the user's text: | ||
| 30 | + | ||
| 31 | +```text | ||
| 32 | +[Messages in #general you have not seen yet] | ||
| 33 | +user: @alice explain goroutines | ||
| 34 | +alice: Goroutines are light threads… | ||
| 35 | +[End of the messages] | ||
| 36 | + | ||
| 37 | +The user now writes to you (answer directly, without your name in front): | ||
| 38 | +@bob what do you think? | ||
| 39 | +``` | ||
| 40 | + | ||
| 41 | +With nothing unseen, the text is sent alone. At most the last 20 messages are re-sent, each cut at 1500 characters: small context windows. A slash command is sent alone, without this context, or the agent would not recognise it. The last sentence of the prompt was added after testing: without it, Bob imitated the transcript and answered "Bob: 4217". | ||
| 42 | + | ||
| 43 | +### 4. After a restart | ||
| 44 | + | ||
| 45 | +The ACP session id is stored with the membership. An agent that announces `loadSession` gets `session/load`; its replayed history is ignored (the interface already shows it from the store). Otherwise — mm has no `session/load` — a new session is opened and is "fresh": its first prompt carries the last messages of the channel, its own answers included. The user sees no difference and no "agent restarted" notice any more (ADR 0004's notice is gone). The same happens when an agent was stopped to make room for another. | ||
| 46 | + | ||
| 47 | +### 5. The store | ||
| 48 | + | ||
| 49 | +Pebble v2 in `internal/store`, one database per configuration file under `<user config dir>/sidekicks/data/<hash of the path>/`, with the keys of ADR 0003 plus `direct/<agent>` and `value/<key>` (the last commands each agent announced, so that `/` can suggest them before the agent starts). Ids sort by time (milliseconds, counter, random). Messages are written when they are finished; a streaming answer is not. After `Close`, operations return `ErrClosed` instead of letting Pebble panic: an answer can end while Sidekicks quits. A database that cannot be opened (another Sidekicks uses it) is replaced by one in memory, and the interface says the history is unavailable. | ||
| 50 | + | ||
| 51 | +### 6. The interface | ||
| 52 | + | ||
| 53 | +`ChannelService` replaces `ChatService`. Events are keyed by channel; `channels:changed` carries the whole list and `commands:changed` an agent's commands. The composer opens a popup after `@` (agents, filtered on name and title, letters in order) and after `/` at the start of a message (the members' commands; `/agent:command` with several members). A streaming answer shows a spinner with what it is doing — thinking, using a tool, writing — and a line "… is getting ready" covers the time before its first part (agent starting, waiting for its turn). A channel is loaded from Go when first shown, then kept up to date by events only. Unread answers are counted in memory. | ||
| 54 | + | ||
| 55 | +There are no Sidekicks commands typed in the composer in M3: inviting, removing, renaming and deleting are buttons, so `/` only ever means an agent's command. | ||
| 56 | + | ||
| 57 | +## Consequences | ||
| 58 | + | ||
| 59 | +- Deleting a channel deletes its history, after an explicit confirmation. | ||
| 60 | +- The first `/` in a conversation starts the members that never announced their commands and opens their session (`ChannelService.Prepare`): mm announces them right after `session/new` without loading its model (measured: about 0.1 s). The session is kept for the agent's first answer and marked fresh, so that answer is still told the channel's past. Doing it when a channel is merely opened was rejected: a started agent takes one of the two slots and could stop an idle one. | ||
| 61 | +- Messages written by a model are re-sent to other models: a long channel costs context, which the 20-message limit bounds. | ||
| 62 | +- An answer interrupted by quitting is lost (it was never finished, so never stored). | ||
modified
docs/diagrams/packages.drawio +8 -8 | @@ -4,7 +4,7 @@ | ||
| 4 | 4 | <root> |
| 5 | 5 | <mxCell id="0"/> |
| 6 | 6 | <mxCell id="1" parent="0"/> |
| 7 | - <mxCell id="title" value="<b style="font-size:25px;color:#1A237E">Sidekicks — packages and dependencies</b><br><i style="font-size:14px;color:#546E7A">arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails</i>" style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;" vertex="1" parent="1"><mxGeometry x="255.0" y="12" width="900" height="56" as="geometry"/></mxCell> | |
| 7 | + <mxCell id="title" value="<b style="font-size:25px;color:#1A237E">Sidekicks — packages and dependencies</b><br><i style="font-size:14px;color:#546E7A">arrows are imports · blue: exists (M3) · grey: planned · only main.go imports Wails</i>" style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;" vertex="1" parent="1"><mxGeometry x="255.0" y="12" width="900" height="56" as="geometry"/></mxCell> | |
| 8 | 8 | <mxCell id="c5" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=block;startFill=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="157" as="sourcePoint"/><mxPoint x="590" y="157" as="targetPoint"/></mxGeometry></mxCell> |
| 9 | 9 | <mxCell id="c7" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="1150" y="157" as="sourcePoint"/><mxPoint x="820" y="157" as="targetPoint"/></mxGeometry></mxCell> |
| 10 | 10 | <mxCell id="c14" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="145" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="145" y="255"/></Array></mxGeometry></mxCell> |
| @@ -14,25 +14,25 @@ | ||
| 14 | 14 | <mxCell id="c18" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="1265" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="1265" y="255"/></Array></mxGeometry></mxCell> |
| 15 | 15 | <mxCell id="c19" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="355" as="sourcePoint"/><mxPoint x="310" y="355" as="targetPoint"/></mxGeometry></mxCell> |
| 16 | 16 | <mxCell id="c20" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="540" y="355" as="sourcePoint"/><mxPoint x="590" y="355" as="targetPoint"/></mxGeometry></mxCell> |
| 17 | - <mxCell id="c25" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="410" as="sourcePoint"/><mxPoint x="145" y="490" as="targetPoint"/></mxGeometry></mxCell> | |
| 17 | + <mxCell id="c25" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="410" as="sourcePoint"/><mxPoint x="145" y="490" as="targetPoint"/></mxGeometry></mxCell> | |
| 18 | 18 | <mxCell id="c26" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="410" as="sourcePoint"/><mxPoint x="425" y="490" as="targetPoint"/></mxGeometry></mxCell> |
| 19 | 19 | <mxCell id="c27" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="410" as="sourcePoint"/><mxPoint x="705" y="490" as="targetPoint"/></mxGeometry></mxCell> |
| 20 | 20 | <mxCell id="c28" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="520" y="410" as="sourcePoint"/><mxPoint x="985" y="490" as="targetPoint"/><Array as="points"><mxPoint x="520" y="450"/><mxPoint x="985" y="450"/></Array></mxGeometry></mxCell> |
| 21 | - <mxCell id="c34" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="595" as="sourcePoint"/><mxPoint x="145" y="670" as="targetPoint"/></mxGeometry></mxCell> | |
| 21 | + <mxCell id="c34" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="595" as="sourcePoint"/><mxPoint x="145" y="670" as="targetPoint"/></mxGeometry></mxCell> | |
| 22 | 22 | <mxCell id="c35" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="595" as="sourcePoint"/><mxPoint x="425" y="670" as="targetPoint"/></mxGeometry></mxCell> |
| 23 | 23 | <mxCell id="c37" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="595" as="sourcePoint"/><mxPoint x="705" y="670" as="targetPoint"/></mxGeometry></mxCell> |
| 24 | - <mxCell id="c1" value="💻<br><b style="font-size:16px">frontend/</b><br><span style="font-size:11.5px;font-weight:normal">Svelte 5 · panels · chat<br>editor · generated bindings</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E3F2FD;gradientColor=#BBDEFB;strokeColor=#1565C0;strokeWidth=2;shadow=1;fontSize=20;fontColor=#0D47A1;" vertex="1" parent="1"><mxGeometry x="30" y="100" width="230" height="115" as="geometry"/></mxCell> | |
| 25 | - <mxCell id="c2" value="🔀<br><b style="font-size:16px">internal/app</b><br><span style="font-size:11.5px;font-weight:normal">Agent · Chat · Config<br>services · Core</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#F3E5F5;gradientColor=#E1BEE7;strokeColor=#6A1B9A;strokeWidth=3;shadow=1;fontSize=20;fontColor=#4A148C;" vertex="1" parent="1"><mxGeometry x="590" y="100" width="230" height="115" as="geometry"/></mxCell> | |
| 24 | + <mxCell id="c1" value="💻<br><b style="font-size:16px">frontend/</b><br><span style="font-size:11.5px;font-weight:normal">Svelte 5 · panels · chat · @ /<br>editor · generated bindings</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E3F2FD;gradientColor=#BBDEFB;strokeColor=#1565C0;strokeWidth=2;shadow=1;fontSize=20;fontColor=#0D47A1;" vertex="1" parent="1"><mxGeometry x="30" y="100" width="230" height="115" as="geometry"/></mxCell> | |
| 25 | + <mxCell id="c2" value="🔀<br><b style="font-size:16px">internal/app</b><br><span style="font-size:11.5px;font-weight:normal">Agent · Channel · Config<br>services · Core</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#F3E5F5;gradientColor=#E1BEE7;strokeColor=#6A1B9A;strokeWidth=3;shadow=1;fontSize=20;fontColor=#4A148C;" vertex="1" parent="1"><mxGeometry x="590" y="100" width="230" height="115" as="geometry"/></mxCell> | |
| 26 | 26 | <mxCell id="c3" value="<b>★ only API of the UI</b>" style="rounded=1;arcSize=50;html=1;fillColor=#6A1B9A;strokeColor=none;fontColor=#FFFFFF;fontSize=11;" vertex="1" parent="1"><mxGeometry x="672.0" y="88" width="158.0" height="24" as="geometry"/></mxCell> |
| 27 | 27 | <mxCell id="c4" value="⚙️<br><b style="font-size:16px">main.go (root)</b><br><span style="font-size:11.5px;font-weight:normal">flags · Wails app · window<br>events · file dialog</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#FFFDE7;gradientColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;shadow=1;fontSize=20;fontColor=#E65100;" vertex="1" parent="1"><mxGeometry x="1150" y="100" width="230" height="115" as="geometry"/></mxCell> |
| 28 | 28 | <mxCell id="c6" value="<b style="font-size:12.5px;color:#263238">bindings · events</b>" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="385.0" y="145.0" width="130" height="24" as="geometry"/></mxCell> |
| 29 | 29 | <mxCell id="c8" value="<b style="font-size:12.5px;color:#263238">creates</b>" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="975.0" y="145.0" width="70" height="24" as="geometry"/></mxCell> |
| 30 | - <mxCell id="c9" value="💬<br><b style="font-size:14px">internal/conversations</b><br><span style="font-size:11.5px;font-weight:normal">one per agent · in memory<br>queue · permissions (M3: channels)</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="30" y="300" width="230" height="110" as="geometry"/></mxCell> | |
| 30 | + <mxCell id="c9" value="💬<br><b style="font-size:16px">internal/channels</b><br><span style="font-size:11.5px;font-weight:normal">channels · direct · routing @<br>unseen context · queue · perms</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="30" y="300" width="230" height="110" as="geometry"/></mxCell> | |
| 31 | 31 | <mxCell id="c10" value="🤖<br><b style="font-size:16px">internal/agents</b><br><span style="font-size:11.5px;font-weight:normal">lazy start · LRU limit<br>idle stop · states · logs</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="300" width="230" height="110" as="geometry"/></mxCell> |
| 32 | 32 | <mxCell id="c11" value="📋<br><b style="font-size:16px">internal/config</b><br><span style="font-size:11.5px;font-weight:normal">load · validate · locate<br>default · save · watch</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="300" width="230" height="110" as="geometry"/></mxCell> |
| 33 | 33 | <mxCell id="c12" value="🧰<br><b style="font-size:16px">internal/prefs</b><br><span style="font-size:11.5px;font-weight:normal">last opened file<br>recent files (JSON)</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="300" width="230" height="110" as="geometry"/></mxCell> |
| 34 | 34 | <mxCell id="c13" value="🧰<br><b style="font-size:16px">internal/workspace</b><br><span style="font-size:11.5px;font-weight:normal">planned · M4<br>files + exec: local, sbx…</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="1150" y="300" width="230" height="110" as="geometry"/></mxCell> |
| 35 | - <mxCell id="c21" value="📥<br><b style="font-size:16px">internal/store</b><br><span style="font-size:11.5px;font-weight:normal">planned · M3<br>Pebble: channels, messages</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="30" y="490" width="230" height="105" as="geometry"/></mxCell> | |
| 35 | + <mxCell id="c21" value="📥<br><b style="font-size:16px">internal/store</b><br><span style="font-size:11.5px;font-weight:normal">Pebble · one DB per config<br>channels · members · messages</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="30" y="490" width="230" height="105" as="geometry"/></mxCell> | |
| 36 | 36 | <mxCell id="c22" value="🔧<br><b style="font-size:16px">internal/acp</b><br><span style="font-size:11.5px;font-weight:normal">ACP v1 client · sessions<br>stdin → TERM → KILL</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="490" width="230" height="105" as="geometry"/></mxCell> |
| 37 | 37 | <mxCell id="c23" value="🛡️<br><b style="font-size:16px">schema</b><br><span style="font-size:11.5px;font-weight:normal">config JSON Schema<br>ValidateConfig()</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="490" width="230" height="105" as="geometry"/></mxCell> |
| 38 | 38 | <mxCell id="c24" value="📋<br><b style="font-size:16px">internal/events</b><br><span style="font-size:11.5px;font-weight:normal">event names + payloads<br>Emitter interface</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="490" width="230" height="105" as="geometry"/></mxCell> |
| @@ -42,7 +42,7 @@ | ||
| 42 | 42 | <mxCell id="c32" value="<b>jsonschema · yaml · fsnotify</b>" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="590" y="670" width="230" height="50" as="geometry"/></mxCell> |
| 43 | 43 | <mxCell id="c33" value="<b>Wails v3 (main.go only)</b>" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="870" y="670" width="230" height="50" as="geometry"/></mxCell> |
| 44 | 44 | <mxCell id="c36" value="<b style="font-size:12.5px;color:#263238">stdio</b>" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="445.0" y="620.0" width="50" height="24" as="geometry"/></mxCell> |
| 45 | - <mxCell id="c38" value="conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema." style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;fontSize=12.5;fontColor=#455A64;fontStyle=2;" vertex="1" parent="1"><mxGeometry x="236.125" y="752.5" width="937.75" height="18.5" as="geometry"/></mxCell> | |
| 45 | + <mxCell id="c38" value="channels and app also emit through internal/events; channels uses acp types; app uses prefs, config, schema and store." style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;fontSize=12.5;fontColor=#455A64;fontStyle=2;" vertex="1" parent="1"><mxGeometry x="247.75" y="752.5" width="914.5" height="18.5" as="geometry"/></mxCell> | |
| 46 | 46 | </root> |
| 47 | 47 | </mxGraphModel> |
| 48 | 48 | </diagram> |
| @@ -4,7 +4,7 @@ | |||
| 4 | <root> | 4 | <root> |
| 5 | <mxCell id="0"/> | 5 | <mxCell id="0"/> |
| 6 | <mxCell id="1" parent="0"/> | 6 | <mxCell id="1" parent="0"/> |
| 7 | - <mxCell id="title" value="<b style="font-size:25px;color:#1A237E">Sidekicks — packages and dependencies</b><br><i style="font-size:14px;color:#546E7A">arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails</i>" style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;" vertex="1" parent="1"><mxGeometry x="255.0" y="12" width="900" height="56" as="geometry"/></mxCell> | 7 | + <mxCell id="title" value="<b style="font-size:25px;color:#1A237E">Sidekicks — packages and dependencies</b><br><i style="font-size:14px;color:#546E7A">arrows are imports · blue: exists (M3) · grey: planned · only main.go imports Wails</i>" style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;" vertex="1" parent="1"><mxGeometry x="255.0" y="12" width="900" height="56" as="geometry"/></mxCell> |
| 8 | <mxCell id="c5" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=block;startFill=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="157" as="sourcePoint"/><mxPoint x="590" y="157" as="targetPoint"/></mxGeometry></mxCell> | 8 | <mxCell id="c5" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=block;startFill=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="157" as="sourcePoint"/><mxPoint x="590" y="157" as="targetPoint"/></mxGeometry></mxCell> |
| 9 | <mxCell id="c7" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="1150" y="157" as="sourcePoint"/><mxPoint x="820" y="157" as="targetPoint"/></mxGeometry></mxCell> | 9 | <mxCell id="c7" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="1150" y="157" as="sourcePoint"/><mxPoint x="820" y="157" as="targetPoint"/></mxGeometry></mxCell> |
| 10 | <mxCell id="c14" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="145" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="145" y="255"/></Array></mxGeometry></mxCell> | 10 | <mxCell id="c14" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="145" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="145" y="255"/></Array></mxGeometry></mxCell> |
| @@ -14,25 +14,25 @@ | |||
| 14 | <mxCell id="c18" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="1265" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="1265" y="255"/></Array></mxGeometry></mxCell> | 14 | <mxCell id="c18" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="1265" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="1265" y="255"/></Array></mxGeometry></mxCell> |
| 15 | <mxCell id="c19" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="355" as="sourcePoint"/><mxPoint x="310" y="355" as="targetPoint"/></mxGeometry></mxCell> | 15 | <mxCell id="c19" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="355" as="sourcePoint"/><mxPoint x="310" y="355" as="targetPoint"/></mxGeometry></mxCell> |
| 16 | <mxCell id="c20" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="540" y="355" as="sourcePoint"/><mxPoint x="590" y="355" as="targetPoint"/></mxGeometry></mxCell> | 16 | <mxCell id="c20" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="540" y="355" as="sourcePoint"/><mxPoint x="590" y="355" as="targetPoint"/></mxGeometry></mxCell> |
| 17 | - <mxCell id="c25" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="410" as="sourcePoint"/><mxPoint x="145" y="490" as="targetPoint"/></mxGeometry></mxCell> | 17 | + <mxCell id="c25" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="410" as="sourcePoint"/><mxPoint x="145" y="490" as="targetPoint"/></mxGeometry></mxCell> |
| 18 | <mxCell id="c26" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="410" as="sourcePoint"/><mxPoint x="425" y="490" as="targetPoint"/></mxGeometry></mxCell> | 18 | <mxCell id="c26" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="410" as="sourcePoint"/><mxPoint x="425" y="490" as="targetPoint"/></mxGeometry></mxCell> |
| 19 | <mxCell id="c27" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="410" as="sourcePoint"/><mxPoint x="705" y="490" as="targetPoint"/></mxGeometry></mxCell> | 19 | <mxCell id="c27" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="410" as="sourcePoint"/><mxPoint x="705" y="490" as="targetPoint"/></mxGeometry></mxCell> |
| 20 | <mxCell id="c28" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="520" y="410" as="sourcePoint"/><mxPoint x="985" y="490" as="targetPoint"/><Array as="points"><mxPoint x="520" y="450"/><mxPoint x="985" y="450"/></Array></mxGeometry></mxCell> | 20 | <mxCell id="c28" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="520" y="410" as="sourcePoint"/><mxPoint x="985" y="490" as="targetPoint"/><Array as="points"><mxPoint x="520" y="450"/><mxPoint x="985" y="450"/></Array></mxGeometry></mxCell> |
| 21 | - <mxCell id="c34" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="595" as="sourcePoint"/><mxPoint x="145" y="670" as="targetPoint"/></mxGeometry></mxCell> | 21 | + <mxCell id="c34" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="595" as="sourcePoint"/><mxPoint x="145" y="670" as="targetPoint"/></mxGeometry></mxCell> |
| 22 | <mxCell id="c35" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="595" as="sourcePoint"/><mxPoint x="425" y="670" as="targetPoint"/></mxGeometry></mxCell> | 22 | <mxCell id="c35" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="595" as="sourcePoint"/><mxPoint x="425" y="670" as="targetPoint"/></mxGeometry></mxCell> |
| 23 | <mxCell id="c37" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="595" as="sourcePoint"/><mxPoint x="705" y="670" as="targetPoint"/></mxGeometry></mxCell> | 23 | <mxCell id="c37" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="595" as="sourcePoint"/><mxPoint x="705" y="670" as="targetPoint"/></mxGeometry></mxCell> |
| 24 | - <mxCell id="c1" value="💻<br><b style="font-size:16px">frontend/</b><br><span style="font-size:11.5px;font-weight:normal">Svelte 5 · panels · chat<br>editor · generated bindings</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E3F2FD;gradientColor=#BBDEFB;strokeColor=#1565C0;strokeWidth=2;shadow=1;fontSize=20;fontColor=#0D47A1;" vertex="1" parent="1"><mxGeometry x="30" y="100" width="230" height="115" as="geometry"/></mxCell> | 24 | + <mxCell id="c1" value="💻<br><b style="font-size:16px">frontend/</b><br><span style="font-size:11.5px;font-weight:normal">Svelte 5 · panels · chat · @ /<br>editor · generated bindings</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E3F2FD;gradientColor=#BBDEFB;strokeColor=#1565C0;strokeWidth=2;shadow=1;fontSize=20;fontColor=#0D47A1;" vertex="1" parent="1"><mxGeometry x="30" y="100" width="230" height="115" as="geometry"/></mxCell> |
| 25 | - <mxCell id="c2" value="🔀<br><b style="font-size:16px">internal/app</b><br><span style="font-size:11.5px;font-weight:normal">Agent · Chat · Config<br>services · Core</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#F3E5F5;gradientColor=#E1BEE7;strokeColor=#6A1B9A;strokeWidth=3;shadow=1;fontSize=20;fontColor=#4A148C;" vertex="1" parent="1"><mxGeometry x="590" y="100" width="230" height="115" as="geometry"/></mxCell> | 25 | + <mxCell id="c2" value="🔀<br><b style="font-size:16px">internal/app</b><br><span style="font-size:11.5px;font-weight:normal">Agent · Channel · Config<br>services · Core</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#F3E5F5;gradientColor=#E1BEE7;strokeColor=#6A1B9A;strokeWidth=3;shadow=1;fontSize=20;fontColor=#4A148C;" vertex="1" parent="1"><mxGeometry x="590" y="100" width="230" height="115" as="geometry"/></mxCell> |
| 26 | <mxCell id="c3" value="<b>★ only API of the UI</b>" style="rounded=1;arcSize=50;html=1;fillColor=#6A1B9A;strokeColor=none;fontColor=#FFFFFF;fontSize=11;" vertex="1" parent="1"><mxGeometry x="672.0" y="88" width="158.0" height="24" as="geometry"/></mxCell> | 26 | <mxCell id="c3" value="<b>★ only API of the UI</b>" style="rounded=1;arcSize=50;html=1;fillColor=#6A1B9A;strokeColor=none;fontColor=#FFFFFF;fontSize=11;" vertex="1" parent="1"><mxGeometry x="672.0" y="88" width="158.0" height="24" as="geometry"/></mxCell> |
| 27 | <mxCell id="c4" value="⚙️<br><b style="font-size:16px">main.go (root)</b><br><span style="font-size:11.5px;font-weight:normal">flags · Wails app · window<br>events · file dialog</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#FFFDE7;gradientColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;shadow=1;fontSize=20;fontColor=#E65100;" vertex="1" parent="1"><mxGeometry x="1150" y="100" width="230" height="115" as="geometry"/></mxCell> | 27 | <mxCell id="c4" value="⚙️<br><b style="font-size:16px">main.go (root)</b><br><span style="font-size:11.5px;font-weight:normal">flags · Wails app · window<br>events · file dialog</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#FFFDE7;gradientColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;shadow=1;fontSize=20;fontColor=#E65100;" vertex="1" parent="1"><mxGeometry x="1150" y="100" width="230" height="115" as="geometry"/></mxCell> |
| 28 | <mxCell id="c6" value="<b style="font-size:12.5px;color:#263238">bindings · events</b>" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="385.0" y="145.0" width="130" height="24" as="geometry"/></mxCell> | 28 | <mxCell id="c6" value="<b style="font-size:12.5px;color:#263238">bindings · events</b>" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="385.0" y="145.0" width="130" height="24" as="geometry"/></mxCell> |
| 29 | <mxCell id="c8" value="<b style="font-size:12.5px;color:#263238">creates</b>" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="975.0" y="145.0" width="70" height="24" as="geometry"/></mxCell> | 29 | <mxCell id="c8" value="<b style="font-size:12.5px;color:#263238">creates</b>" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="975.0" y="145.0" width="70" height="24" as="geometry"/></mxCell> |
| 30 | - <mxCell id="c9" value="💬<br><b style="font-size:14px">internal/conversations</b><br><span style="font-size:11.5px;font-weight:normal">one per agent · in memory<br>queue · permissions (M3: channels)</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="30" y="300" width="230" height="110" as="geometry"/></mxCell> | 30 | + <mxCell id="c9" value="💬<br><b style="font-size:16px">internal/channels</b><br><span style="font-size:11.5px;font-weight:normal">channels · direct · routing @<br>unseen context · queue · perms</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="30" y="300" width="230" height="110" as="geometry"/></mxCell> |
| 31 | <mxCell id="c10" value="🤖<br><b style="font-size:16px">internal/agents</b><br><span style="font-size:11.5px;font-weight:normal">lazy start · LRU limit<br>idle stop · states · logs</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="300" width="230" height="110" as="geometry"/></mxCell> | 31 | <mxCell id="c10" value="🤖<br><b style="font-size:16px">internal/agents</b><br><span style="font-size:11.5px;font-weight:normal">lazy start · LRU limit<br>idle stop · states · logs</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="300" width="230" height="110" as="geometry"/></mxCell> |
| 32 | <mxCell id="c11" value="📋<br><b style="font-size:16px">internal/config</b><br><span style="font-size:11.5px;font-weight:normal">load · validate · locate<br>default · save · watch</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="300" width="230" height="110" as="geometry"/></mxCell> | 32 | <mxCell id="c11" value="📋<br><b style="font-size:16px">internal/config</b><br><span style="font-size:11.5px;font-weight:normal">load · validate · locate<br>default · save · watch</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="300" width="230" height="110" as="geometry"/></mxCell> |
| 33 | <mxCell id="c12" value="🧰<br><b style="font-size:16px">internal/prefs</b><br><span style="font-size:11.5px;font-weight:normal">last opened file<br>recent files (JSON)</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="300" width="230" height="110" as="geometry"/></mxCell> | 33 | <mxCell id="c12" value="🧰<br><b style="font-size:16px">internal/prefs</b><br><span style="font-size:11.5px;font-weight:normal">last opened file<br>recent files (JSON)</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="300" width="230" height="110" as="geometry"/></mxCell> |
| 34 | <mxCell id="c13" value="🧰<br><b style="font-size:16px">internal/workspace</b><br><span style="font-size:11.5px;font-weight:normal">planned · M4<br>files + exec: local, sbx…</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="1150" y="300" width="230" height="110" as="geometry"/></mxCell> | 34 | <mxCell id="c13" value="🧰<br><b style="font-size:16px">internal/workspace</b><br><span style="font-size:11.5px;font-weight:normal">planned · M4<br>files + exec: local, sbx…</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="1150" y="300" width="230" height="110" as="geometry"/></mxCell> |
| 35 | - <mxCell id="c21" value="📥<br><b style="font-size:16px">internal/store</b><br><span style="font-size:11.5px;font-weight:normal">planned · M3<br>Pebble: channels, messages</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="30" y="490" width="230" height="105" as="geometry"/></mxCell> | 35 | + <mxCell id="c21" value="📥<br><b style="font-size:16px">internal/store</b><br><span style="font-size:11.5px;font-weight:normal">Pebble · one DB per config<br>channels · members · messages</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="30" y="490" width="230" height="105" as="geometry"/></mxCell> |
| 36 | <mxCell id="c22" value="🔧<br><b style="font-size:16px">internal/acp</b><br><span style="font-size:11.5px;font-weight:normal">ACP v1 client · sessions<br>stdin → TERM → KILL</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="490" width="230" height="105" as="geometry"/></mxCell> | 36 | <mxCell id="c22" value="🔧<br><b style="font-size:16px">internal/acp</b><br><span style="font-size:11.5px;font-weight:normal">ACP v1 client · sessions<br>stdin → TERM → KILL</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="490" width="230" height="105" as="geometry"/></mxCell> |
| 37 | <mxCell id="c23" value="🛡️<br><b style="font-size:16px">schema</b><br><span style="font-size:11.5px;font-weight:normal">config JSON Schema<br>ValidateConfig()</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="490" width="230" height="105" as="geometry"/></mxCell> | 37 | <mxCell id="c23" value="🛡️<br><b style="font-size:16px">schema</b><br><span style="font-size:11.5px;font-weight:normal">config JSON Schema<br>ValidateConfig()</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="490" width="230" height="105" as="geometry"/></mxCell> |
| 38 | <mxCell id="c24" value="📋<br><b style="font-size:16px">internal/events</b><br><span style="font-size:11.5px;font-weight:normal">event names + payloads<br>Emitter interface</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="490" width="230" height="105" as="geometry"/></mxCell> | 38 | <mxCell id="c24" value="📋<br><b style="font-size:16px">internal/events</b><br><span style="font-size:11.5px;font-weight:normal">event names + payloads<br>Emitter interface</span>" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="490" width="230" height="105" as="geometry"/></mxCell> |
| @@ -42,7 +42,7 @@ | |||
| 42 | <mxCell id="c32" value="<b>jsonschema · yaml · fsnotify</b>" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="590" y="670" width="230" height="50" as="geometry"/></mxCell> | 42 | <mxCell id="c32" value="<b>jsonschema · yaml · fsnotify</b>" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="590" y="670" width="230" height="50" as="geometry"/></mxCell> |
| 43 | <mxCell id="c33" value="<b>Wails v3 (main.go only)</b>" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="870" y="670" width="230" height="50" as="geometry"/></mxCell> | 43 | <mxCell id="c33" value="<b>Wails v3 (main.go only)</b>" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="870" y="670" width="230" height="50" as="geometry"/></mxCell> |
| 44 | <mxCell id="c36" value="<b style="font-size:12.5px;color:#263238">stdio</b>" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="445.0" y="620.0" width="50" height="24" as="geometry"/></mxCell> | 44 | <mxCell id="c36" value="<b style="font-size:12.5px;color:#263238">stdio</b>" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="445.0" y="620.0" width="50" height="24" as="geometry"/></mxCell> |
| 45 | - <mxCell id="c38" value="conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema." style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;fontSize=12.5;fontColor=#455A64;fontStyle=2;" vertex="1" parent="1"><mxGeometry x="236.125" y="752.5" width="937.75" height="18.5" as="geometry"/></mxCell> | 45 | + <mxCell id="c38" value="channels and app also emit through internal/events; channels uses acp types; app uses prefs, config, schema and store." style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;fontSize=12.5;fontColor=#455A64;fontStyle=2;" vertex="1" parent="1"><mxGeometry x="247.75" y="752.5" width="914.5" height="18.5" as="geometry"/></mxCell> |
| 46 | </root> | 46 | </root> |
| 47 | </mxGraphModel> | 47 | </mxGraphModel> |
| 48 | </diagram> | 48 | </diagram> |
modified
docs/diagrams/packages.png +0 -0 | Binary files a/docs/diagrams/packages.png and b/docs/diagrams/packages.png differ |
| Binary files a/docs/diagrams/packages.png and b/docs/diagrams/packages.png differ | Binary files a/docs/diagrams/packages.png and b/docs/diagrams/packages.png differ |
modified
docs/diagrams/packages.svg +13 -13 | @@ -11,7 +11,7 @@ | ||
| 11 | 11 | </defs> |
| 12 | 12 | <rect x="0" y="0" width="1410" height="800" fill="url(#gBg)"/> |
| 13 | 13 | <text x="705.0" y="38" text-anchor="middle" font-size="25" font-weight="700" fill="#1A237E">Sidekicks — packages and dependencies</text> |
| 14 | - <text x="705.0" y="61" text-anchor="middle" font-size="14" font-style="italic" fill="#546E7A">arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails</text> | |
| 14 | + <text x="705.0" y="61" text-anchor="middle" font-size="14" font-style="italic" fill="#546E7A">arrows are imports · blue: exists (M3) · grey: planned · only main.go imports Wails</text> | |
| 15 | 15 | <path d="M260,157 L590,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-start="url(#arrow)" marker-end="url(#arrow)"/> |
| 16 | 16 | <path d="M1150,157 L820,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 17 | 17 | <path d="M705,215 L705,255 L145,255 L145,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| @@ -21,22 +21,22 @@ | ||
| 21 | 21 | <path d="M705,215 L705,255 L1265,255 L1265,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 22 | 22 | <path d="M260,355 L310,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 23 | 23 | <path d="M540,355 L590,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 24 | - <path d="M145,410 L145,490" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/> | |
| 24 | + <path d="M145,410 L145,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | |
| 25 | 25 | <path d="M425,410 L425,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 26 | 26 | <path d="M705,410 L705,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 27 | 27 | <path d="M520,410 L520,450 L985,450 L985,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 28 | - <path d="M145,595 L145,670" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/> | |
| 28 | + <path d="M145,595 L145,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | |
| 29 | 29 | <path d="M425,595 L425,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 30 | 30 | <path d="M705,595 L705,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 31 | 31 | <g filter="url(#shadow)"><rect x="30" y="100" width="230" height="115" rx="16" fill="url(#gBlue)" stroke="#1565C0" stroke-width="2"/></g> |
| 32 | 32 | <text x="145.0" y="137.5" text-anchor="middle" font-size="22">💻</text> |
| 33 | 33 | <text x="145.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#0D47A1">frontend/</text> |
| 34 | - <text x="145.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">Svelte 5 · panels · chat</text> | |
| 34 | + <text x="145.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">Svelte 5 · panels · chat · @ /</text> | |
| 35 | 35 | <text x="145.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">editor · generated bindings</text> |
| 36 | 36 | <g filter="url(#shadow)"><rect x="590" y="100" width="230" height="115" rx="16" fill="url(#gPurple)" stroke="#6A1B9A" stroke-width="3"/></g> |
| 37 | 37 | <text x="705.0" y="137.5" text-anchor="middle" font-size="22">🔀</text> |
| 38 | 38 | <text x="705.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#4A148C">internal/app</text> |
| 39 | - <text x="705.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#4A148C">Agent · Chat · Config</text> | |
| 39 | + <text x="705.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#4A148C">Agent · Channel · Config</text> | |
| 40 | 40 | <text x="705.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#4A148C">services · Core</text> |
| 41 | 41 | <rect x="672.0" y="88" width="158.0" height="24" rx="12" fill="#6A1B9A"/> |
| 42 | 42 | <text x="751.0" y="104" text-anchor="middle" font-size="11.5" font-weight="700" fill="#FFFFFF">★ only API of the UI</text> |
| @@ -51,9 +51,9 @@ | ||
| 51 | 51 | <text x="1010" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">creates</text> |
| 52 | 52 | <g filter="url(#shadow)"><rect x="30" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> |
| 53 | 53 | <text x="145.0" y="335.0" text-anchor="middle" font-size="22">💬</text> |
| 54 | - <text x="145.0" y="358.0" text-anchor="middle" font-size="14" font-weight="700" fill="#01579B">internal/conversations</text> | |
| 55 | - <text x="145.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">one per agent · in memory</text> | |
| 56 | - <text x="145.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">queue · permissions (M3: channels)</text> | |
| 54 | + <text x="145.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/channels</text> | |
| 55 | + <text x="145.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">channels · direct · routing @</text> | |
| 56 | + <text x="145.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">unseen context · queue · perms</text> | |
| 57 | 57 | <g filter="url(#shadow)"><rect x="310" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> |
| 58 | 58 | <text x="425.0" y="335.0" text-anchor="middle" font-size="22">🤖</text> |
| 59 | 59 | <text x="425.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/agents</text> |
| @@ -74,11 +74,11 @@ | ||
| 74 | 74 | <text x="1265.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/workspace</text> |
| 75 | 75 | <text x="1265.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M4</text> |
| 76 | 76 | <text x="1265.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#263238">files + exec: local, sbx…</text> |
| 77 | - <g filter="url(#shadow)"><rect x="30" y="490" width="230" height="105" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g> | |
| 77 | + <g filter="url(#shadow)"><rect x="30" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> | |
| 78 | 78 | <text x="145.0" y="522.5" text-anchor="middle" font-size="22">📥</text> |
| 79 | - <text x="145.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/store</text> | |
| 80 | - <text x="145.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#263238">planned · M3</text> | |
| 81 | - <text x="145.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#263238">Pebble: channels, messages</text> | |
| 79 | + <text x="145.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/store</text> | |
| 80 | + <text x="145.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">Pebble · one DB per config</text> | |
| 81 | + <text x="145.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">channels · members · messages</text> | |
| 82 | 82 | <g filter="url(#shadow)"><rect x="310" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> |
| 83 | 83 | <text x="425.0" y="522.5" text-anchor="middle" font-size="22">🔧</text> |
| 84 | 84 | <text x="425.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/acp</text> |
| @@ -106,5 +106,5 @@ | ||
| 106 | 106 | <text x="985.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">Wails v3 (main.go only)</text> |
| 107 | 107 | <rect x="445.0" y="620.0" width="50" height="24" rx="5" fill="#FFFFFF"/> |
| 108 | 108 | <text x="470" y="636.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">stdio</text> |
| 109 | - <text x="705" y="765" text-anchor="middle" font-size="12.5" fill="#455A64" font-style="italic">conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema.</text> | |
| 109 | + <text x="705" y="765" text-anchor="middle" font-size="12.5" fill="#455A64" font-style="italic">channels and app also emit through internal/events; channels uses acp types; app uses prefs, config, schema and store.</text> | |
| 110 | 110 | </svg> |
| @@ -11,7 +11,7 @@ | |||
| 11 | </defs> | 11 | </defs> |
| 12 | <rect x="0" y="0" width="1410" height="800" fill="url(#gBg)"/> | 12 | <rect x="0" y="0" width="1410" height="800" fill="url(#gBg)"/> |
| 13 | <text x="705.0" y="38" text-anchor="middle" font-size="25" font-weight="700" fill="#1A237E">Sidekicks — packages and dependencies</text> | 13 | <text x="705.0" y="38" text-anchor="middle" font-size="25" font-weight="700" fill="#1A237E">Sidekicks — packages and dependencies</text> |
| 14 | - <text x="705.0" y="61" text-anchor="middle" font-size="14" font-style="italic" fill="#546E7A">arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails</text> | 14 | + <text x="705.0" y="61" text-anchor="middle" font-size="14" font-style="italic" fill="#546E7A">arrows are imports · blue: exists (M3) · grey: planned · only main.go imports Wails</text> |
| 15 | <path d="M260,157 L590,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-start="url(#arrow)" marker-end="url(#arrow)"/> | 15 | <path d="M260,157 L590,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-start="url(#arrow)" marker-end="url(#arrow)"/> |
| 16 | <path d="M1150,157 L820,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 16 | <path d="M1150,157 L820,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 17 | <path d="M705,215 L705,255 L145,255 L145,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 17 | <path d="M705,215 L705,255 L145,255 L145,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| @@ -21,22 +21,22 @@ | |||
| 21 | <path d="M705,215 L705,255 L1265,255 L1265,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 21 | <path d="M705,215 L705,255 L1265,255 L1265,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 22 | <path d="M260,355 L310,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 22 | <path d="M260,355 L310,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 23 | <path d="M540,355 L590,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 23 | <path d="M540,355 L590,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 24 | - <path d="M145,410 L145,490" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/> | 24 | + <path d="M145,410 L145,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 25 | <path d="M425,410 L425,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 25 | <path d="M425,410 L425,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 26 | <path d="M705,410 L705,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 26 | <path d="M705,410 L705,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 27 | <path d="M520,410 L520,450 L985,450 L985,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 27 | <path d="M520,410 L520,450 L985,450 L985,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 28 | - <path d="M145,595 L145,670" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/> | 28 | + <path d="M145,595 L145,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 29 | <path d="M425,595 L425,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 29 | <path d="M425,595 L425,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 30 | <path d="M705,595 L705,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> | 30 | <path d="M705,595 L705,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/> |
| 31 | <g filter="url(#shadow)"><rect x="30" y="100" width="230" height="115" rx="16" fill="url(#gBlue)" stroke="#1565C0" stroke-width="2"/></g> | 31 | <g filter="url(#shadow)"><rect x="30" y="100" width="230" height="115" rx="16" fill="url(#gBlue)" stroke="#1565C0" stroke-width="2"/></g> |
| 32 | <text x="145.0" y="137.5" text-anchor="middle" font-size="22">💻</text> | 32 | <text x="145.0" y="137.5" text-anchor="middle" font-size="22">💻</text> |
| 33 | <text x="145.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#0D47A1">frontend/</text> | 33 | <text x="145.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#0D47A1">frontend/</text> |
| 34 | - <text x="145.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">Svelte 5 · panels · chat</text> | 34 | + <text x="145.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">Svelte 5 · panels · chat · @ /</text> |
| 35 | <text x="145.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">editor · generated bindings</text> | 35 | <text x="145.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">editor · generated bindings</text> |
| 36 | <g filter="url(#shadow)"><rect x="590" y="100" width="230" height="115" rx="16" fill="url(#gPurple)" stroke="#6A1B9A" stroke-width="3"/></g> | 36 | <g filter="url(#shadow)"><rect x="590" y="100" width="230" height="115" rx="16" fill="url(#gPurple)" stroke="#6A1B9A" stroke-width="3"/></g> |
| 37 | <text x="705.0" y="137.5" text-anchor="middle" font-size="22">🔀</text> | 37 | <text x="705.0" y="137.5" text-anchor="middle" font-size="22">🔀</text> |
| 38 | <text x="705.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#4A148C">internal/app</text> | 38 | <text x="705.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#4A148C">internal/app</text> |
| 39 | - <text x="705.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#4A148C">Agent · Chat · Config</text> | 39 | + <text x="705.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#4A148C">Agent · Channel · Config</text> |
| 40 | <text x="705.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#4A148C">services · Core</text> | 40 | <text x="705.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#4A148C">services · Core</text> |
| 41 | <rect x="672.0" y="88" width="158.0" height="24" rx="12" fill="#6A1B9A"/> | 41 | <rect x="672.0" y="88" width="158.0" height="24" rx="12" fill="#6A1B9A"/> |
| 42 | <text x="751.0" y="104" text-anchor="middle" font-size="11.5" font-weight="700" fill="#FFFFFF">★ only API of the UI</text> | 42 | <text x="751.0" y="104" text-anchor="middle" font-size="11.5" font-weight="700" fill="#FFFFFF">★ only API of the UI</text> |
| @@ -51,9 +51,9 @@ | |||
| 51 | <text x="1010" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">creates</text> | 51 | <text x="1010" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">creates</text> |
| 52 | <g filter="url(#shadow)"><rect x="30" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> | 52 | <g filter="url(#shadow)"><rect x="30" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> |
| 53 | <text x="145.0" y="335.0" text-anchor="middle" font-size="22">💬</text> | 53 | <text x="145.0" y="335.0" text-anchor="middle" font-size="22">💬</text> |
| 54 | - <text x="145.0" y="358.0" text-anchor="middle" font-size="14" font-weight="700" fill="#01579B">internal/conversations</text> | 54 | + <text x="145.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/channels</text> |
| 55 | - <text x="145.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">one per agent · in memory</text> | 55 | + <text x="145.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">channels · direct · routing @</text> |
| 56 | - <text x="145.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">queue · permissions (M3: channels)</text> | 56 | + <text x="145.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">unseen context · queue · perms</text> |
| 57 | <g filter="url(#shadow)"><rect x="310" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> | 57 | <g filter="url(#shadow)"><rect x="310" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> |
| 58 | <text x="425.0" y="335.0" text-anchor="middle" font-size="22">🤖</text> | 58 | <text x="425.0" y="335.0" text-anchor="middle" font-size="22">🤖</text> |
| 59 | <text x="425.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/agents</text> | 59 | <text x="425.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/agents</text> |
| @@ -74,11 +74,11 @@ | |||
| 74 | <text x="1265.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/workspace</text> | 74 | <text x="1265.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/workspace</text> |
| 75 | <text x="1265.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M4</text> | 75 | <text x="1265.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M4</text> |
| 76 | <text x="1265.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#263238">files + exec: local, sbx…</text> | 76 | <text x="1265.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#263238">files + exec: local, sbx…</text> |
| 77 | - <g filter="url(#shadow)"><rect x="30" y="490" width="230" height="105" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g> | 77 | + <g filter="url(#shadow)"><rect x="30" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> |
| 78 | <text x="145.0" y="522.5" text-anchor="middle" font-size="22">📥</text> | 78 | <text x="145.0" y="522.5" text-anchor="middle" font-size="22">📥</text> |
| 79 | - <text x="145.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/store</text> | 79 | + <text x="145.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/store</text> |
| 80 | - <text x="145.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#263238">planned · M3</text> | 80 | + <text x="145.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">Pebble · one DB per config</text> |
| 81 | - <text x="145.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#263238">Pebble: channels, messages</text> | 81 | + <text x="145.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">channels · members · messages</text> |
| 82 | <g filter="url(#shadow)"><rect x="310" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> | 82 | <g filter="url(#shadow)"><rect x="310" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g> |
| 83 | <text x="425.0" y="522.5" text-anchor="middle" font-size="22">🔧</text> | 83 | <text x="425.0" y="522.5" text-anchor="middle" font-size="22">🔧</text> |
| 84 | <text x="425.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/acp</text> | 84 | <text x="425.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/acp</text> |
| @@ -106,5 +106,5 @@ | |||
| 106 | <text x="985.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">Wails v3 (main.go only)</text> | 106 | <text x="985.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">Wails v3 (main.go only)</text> |
| 107 | <rect x="445.0" y="620.0" width="50" height="24" rx="5" fill="#FFFFFF"/> | 107 | <rect x="445.0" y="620.0" width="50" height="24" rx="5" fill="#FFFFFF"/> |
| 108 | <text x="470" y="636.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">stdio</text> | 108 | <text x="470" y="636.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">stdio</text> |
| 109 | - <text x="705" y="765" text-anchor="middle" font-size="12.5" fill="#455A64" font-style="italic">conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema.</text> | 109 | + <text x="705" y="765" text-anchor="middle" font-size="12.5" fill="#455A64" font-style="italic">channels and app also emit through internal/events; channels uses acp types; app uses prefs, config, schema and store.</text> |
| 110 | </svg> | 110 | </svg> |
modified
docs/en/README.md +6 -6 | @@ -1,21 +1,21 @@ | ||
| 1 | 1 | # Sidekicks documentation (English) |
| 2 | 2 | |
| 3 | -Sidekicks is a desktop application to talk with AI agents in channels, through the Agent Client Protocol, designed for people who are not developers. Milestone M2 (Conversation with an agent) is done: you talk with one agent at a time, read formatted answers, approve its tools, and edit or switch the configuration from the window. Channels and several agents per conversation arrive with milestone M3. | |
| 3 | +Sidekicks is a desktop application to talk with AI agents in channels, through the Agent Client Protocol, designed for people who are not developers. Milestone M3 (Channels & multi-agents) is done: you create channels, bring several agents into them with `@`, run their commands with `/`, talk with an agent directly, and find every conversation again after a restart. Answers are formatted, tools need your approval, and the configuration can be edited from the window. | |
| 4 | 4 | |
| 5 | 5 | This documentation follows [Diátaxis](https://diataxis.fr): pick the section that matches what you need right now. |
| 6 | 6 | |
| 7 | 7 | | You want to… | Section | Pages | |
| 8 | 8 | | --- | --- | --- | |
| 9 | -| **learn** by doing | Tutorials | [Your first conversation with an agent](tutorials/first-conversation.md) · [Your first launch of Sidekicks](tutorials/first-launch.md) · [Your first ACP exchange with a test agent](tutorials/first-acp-exchange.md) | | |
| 10 | -| **do** a specific task | How-to guides | [Edit or switch the configuration](how-to/edit-or-switch-the-configuration.md) · [Develop the application](how-to/develop-the-application.md) · [Run the tests](how-to/run-the-tests.md) · [Run a test agent](how-to/run-a-test-agent.md) · [Test milestone M2 on macOS](how-to/test-milestone-m2-on-macos.md) · [Test milestone M1 on macOS](how-to/test-milestone-m1-on-macos.md) · [Test milestone Initialize on macOS](how-to/test-milestone-initialize-on-macos.md) | | |
| 11 | -| **look up** an exact detail | Reference | [The configuration file](reference/configuration.md) · [Agent states and events](reference/agents-and-events.md) · [Commands](reference/commands.md) · [scripts/agents.sh](reference/agents-script.md) | | |
| 12 | -| **understand** why | Explanation | [Permissions and trust](explanation/permissions.md) · [Why ACP](explanation/why-acp.md) · [The interface stack](explanation/ui-stack.md) · [Architecture](explanation/architecture.md) · [Memory and local models](explanation/memory-and-local-models.md) | | |
| 9 | +| **learn** by doing | Tutorials | [A channel with two agents](tutorials/first-channel.md) · [Your first conversation with an agent](tutorials/first-conversation.md) · [Your first launch of Sidekicks](tutorials/first-launch.md) · [Your first ACP exchange with a test agent](tutorials/first-acp-exchange.md) | | |
| 10 | +| **do** a specific task | How-to guides | [Manage channels and their agents](how-to/manage-channels.md) · [Edit or switch the configuration](how-to/edit-or-switch-the-configuration.md) · [Develop the application](how-to/develop-the-application.md) · [Run the tests](how-to/run-the-tests.md) · [Run a test agent](how-to/run-a-test-agent.md) · [Test milestone M3 on macOS](how-to/test-milestone-m3-on-macos.md) · [Test milestone M2 on macOS](how-to/test-milestone-m2-on-macos.md) · [Test milestone M1 on macOS](how-to/test-milestone-m1-on-macos.md) · [Test milestone Initialize on macOS](how-to/test-milestone-initialize-on-macos.md) | | |
| 11 | +| **look up** an exact detail | Reference | [The configuration file](reference/configuration.md) · [Agents, channels and events](reference/agents-and-events.md) · [Commands](reference/commands.md) · [scripts/agents.sh](reference/agents-script.md) | | |
| 12 | +| **understand** why | Explanation | [Channels and what each agent knows](explanation/channels-and-context.md) · [Permissions and trust](explanation/permissions.md) · [Why ACP](explanation/why-acp.md) · [The interface stack](explanation/ui-stack.md) · [Architecture](explanation/architecture.md) · [Memory and local models](explanation/memory-and-local-models.md) | | |
| 13 | 13 | |
| 14 | 14 | ## Design records |
| 15 | 15 | |
| 16 | 16 | Shared by both languages and written in English: |
| 17 | 17 | |
| 18 | -- [Architecture decision records](../adr/) — ACP, interface stack, architecture, conversations, interface language | |
| 18 | +- [Architecture decision records](../adr/) — ACP, interface stack, architecture, conversations, interface language, channels | |
| 19 | 19 | - [Wireframes](../design/README.md) — the six main screens |
| 20 | 20 | - [Package diagram](../diagrams/packages.drawio) |
| 21 | 21 | - [ACP traces](../spikes/acp/README.md) recorded with the real agents |
| @@ -1,21 +1,21 @@ | |||
| 1 | # Sidekicks documentation (English) | 1 | # Sidekicks documentation (English) |
| 2 | 2 | ||
| 3 | -Sidekicks is a desktop application to talk with AI agents in channels, through the Agent Client Protocol, designed for people who are not developers. Milestone M2 (Conversation with an agent) is done: you talk with one agent at a time, read formatted answers, approve its tools, and edit or switch the configuration from the window. Channels and several agents per conversation arrive with milestone M3. | 3 | +Sidekicks is a desktop application to talk with AI agents in channels, through the Agent Client Protocol, designed for people who are not developers. Milestone M3 (Channels & multi-agents) is done: you create channels, bring several agents into them with `@`, run their commands with `/`, talk with an agent directly, and find every conversation again after a restart. Answers are formatted, tools need your approval, and the configuration can be edited from the window. |
| 4 | 4 | ||
| 5 | This documentation follows [Diátaxis](https://diataxis.fr): pick the section that matches what you need right now. | 5 | This documentation follows [Diátaxis](https://diataxis.fr): pick the section that matches what you need right now. |
| 6 | 6 | ||
| 7 | | You want to… | Section | Pages | | 7 | | You want to… | Section | Pages | |
| 8 | | --- | --- | --- | | 8 | | --- | --- | --- | |
| 9 | -| **learn** by doing | Tutorials | [Your first conversation with an agent](tutorials/first-conversation.md) · [Your first launch of Sidekicks](tutorials/first-launch.md) · [Your first ACP exchange with a test agent](tutorials/first-acp-exchange.md) | | 9 | +| **learn** by doing | Tutorials | [A channel with two agents](tutorials/first-channel.md) · [Your first conversation with an agent](tutorials/first-conversation.md) · [Your first launch of Sidekicks](tutorials/first-launch.md) · [Your first ACP exchange with a test agent](tutorials/first-acp-exchange.md) | |
| 10 | -| **do** a specific task | How-to guides | [Edit or switch the configuration](how-to/edit-or-switch-the-configuration.md) · [Develop the application](how-to/develop-the-application.md) · [Run the tests](how-to/run-the-tests.md) · [Run a test agent](how-to/run-a-test-agent.md) · [Test milestone M2 on macOS](how-to/test-milestone-m2-on-macos.md) · [Test milestone M1 on macOS](how-to/test-milestone-m1-on-macos.md) · [Test milestone Initialize on macOS](how-to/test-milestone-initialize-on-macos.md) | | 10 | +| **do** a specific task | How-to guides | [Manage channels and their agents](how-to/manage-channels.md) · [Edit or switch the configuration](how-to/edit-or-switch-the-configuration.md) · [Develop the application](how-to/develop-the-application.md) · [Run the tests](how-to/run-the-tests.md) · [Run a test agent](how-to/run-a-test-agent.md) · [Test milestone M3 on macOS](how-to/test-milestone-m3-on-macos.md) · [Test milestone M2 on macOS](how-to/test-milestone-m2-on-macos.md) · [Test milestone M1 on macOS](how-to/test-milestone-m1-on-macos.md) · [Test milestone Initialize on macOS](how-to/test-milestone-initialize-on-macos.md) | |
| 11 | -| **look up** an exact detail | Reference | [The configuration file](reference/configuration.md) · [Agent states and events](reference/agents-and-events.md) · [Commands](reference/commands.md) · [scripts/agents.sh](reference/agents-script.md) | | 11 | +| **look up** an exact detail | Reference | [The configuration file](reference/configuration.md) · [Agents, channels and events](reference/agents-and-events.md) · [Commands](reference/commands.md) · [scripts/agents.sh](reference/agents-script.md) | |
| 12 | -| **understand** why | Explanation | [Permissions and trust](explanation/permissions.md) · [Why ACP](explanation/why-acp.md) · [The interface stack](explanation/ui-stack.md) · [Architecture](explanation/architecture.md) · [Memory and local models](explanation/memory-and-local-models.md) | | 12 | +| **understand** why | Explanation | [Channels and what each agent knows](explanation/channels-and-context.md) · [Permissions and trust](explanation/permissions.md) · [Why ACP](explanation/why-acp.md) · [The interface stack](explanation/ui-stack.md) · [Architecture](explanation/architecture.md) · [Memory and local models](explanation/memory-and-local-models.md) | |
| 13 | 13 | ||
| 14 | ## Design records | 14 | ## Design records |
| 15 | 15 | ||
| 16 | Shared by both languages and written in English: | 16 | Shared by both languages and written in English: |
| 17 | 17 | ||
| 18 | -- [Architecture decision records](../adr/) — ACP, interface stack, architecture, conversations, interface language | 18 | +- [Architecture decision records](../adr/) — ACP, interface stack, architecture, conversations, interface language, channels |
| 19 | - [Wireframes](../design/README.md) — the six main screens | 19 | - [Wireframes](../design/README.md) — the six main screens |
| 20 | - [Package diagram](../diagrams/packages.drawio) | 20 | - [Package diagram](../diagrams/packages.drawio) |
| 21 | - [ACP traces](../spikes/acp/README.md) recorded with the real agents | 21 | - [ACP traces](../spikes/acp/README.md) recorded with the real agents |
added
docs/en/explanation/channels-and-context.md +35 -0 | new file mode 100644 | ||
| @@ -0,0 +1,35 @@ | ||
| 1 | +# Channels and what each agent knows | |
| 2 | + | |
| 3 | +## What is this about? | |
| 4 | + | |
| 5 | +A channel is a conversation the user has with several agents at once: ask Alice, then ask Bob for an opinion on Alice's answer. The Agent Client Protocol does not know this situation. It links one client to one agent, in sessions where only the two of them talk. Everything that makes a channel — who answers, who knows what was said — is decided by Sidekicks. This page explains those choices; the exact rules are in the [reference](../reference/agents-and-events.md#channels-milestones-m2-and-m3), the decisions in [ADR 0006](../../adr/0006-channels.md). | |
| 6 | + | |
| 7 | +## One session per agent and per channel | |
| 8 | + | |
| 9 | +Each agent of a channel has its own ACP session for that channel. Alice in #general and Alice in #translations are two sessions of the same process: what Alice hears in one channel never leaks into the other. | |
| 10 | + | |
| 11 | +A session only contains what was said in it. When Bob is asked something after Alice answered, Bob's session knows nothing of that answer. So before Bob's turn, Sidekicks adds to Bob's prompt what Bob has not seen: the user's messages to others and the other agents' answers, since Bob's last turn. The agent reads a short transcript, then the user's message. It keeps its own session — and its own memory of its own answers — while following the channel. | |
| 12 | + | |
| 13 | +The alternative, one shared session that every agent reads, does not exist in ACP: a session belongs to one agent. Re-sending the whole channel at every turn would fill the small context windows of local models within a few exchanges. Sending only what is new is the cheapest way to stay up to date; the transcript is limited to the last 20 messages, each shortened, so that a long channel does not crowd out the question. | |
| 14 | + | |
| 15 | +## Who answers, and why one at a time | |
| 16 | + | |
| 17 | +A message goes to the agents it mentions, in order; without a mention, to the last agent who spoke. That is how people talk in a group: you name someone, or you carry on with the person you were talking to. Nobody answers a message that names nobody in a channel where several agents have not spoken yet — guessing would surprise the user more than asking them to choose. | |
| 18 | + | |
| 19 | +Agents answer one after the other, never at the same time. On a laptop, two local models generating together are each slower than one after the other, and memory is the real limit (two small models on the reference machine). Answering in turn also lets the second agent read the first one's answer: "@alice @bob compare" gives Bob Alice's answer to compare with. For the same reason an agent answers one channel at a time: a second channel waits, and shows whose turn it is. | |
| 20 | + | |
| 21 | +## Commands are sent alone | |
| 22 | + | |
| 23 | +`/idioms Il pleut des cordes` asks Garfield to run a skill. The agent recognises a command by its first character. If the transcript of the channel came before it, the command would no longer be a command. So a message starting with `/` is sent as it is, without context, and `/garfield:idioms …` picks the agent when several are in the channel. An agent announces its commands as soon as a session opens, before any question — and mm does so without loading its model. So the first `/` typed in a conversation starts the members that never announced theirs and opens their session (the one their first answer will use); the list fills in a fraction of a second. Sidekicks then keeps the commands, and offers them at once the next times. | |
| 24 | + | |
| 25 | +## After a restart | |
| 26 | + | |
| 27 | +The history is in a database next to the configuration (one per configuration file), so the channels are there again after a restart. The agents, though, are new processes: their sessions are gone. ACP lets an agent resume a session (`session/load`); Sidekicks uses it when the agent offers it. mm does not, so Sidekicks opens a new session and gives the agent, in its first prompt, the last messages of the channel — its own included this time, since the new session has none of them. The user sees no difference: Alice still knows their name. | |
| 28 | + | |
| 29 | +The same mechanism covers an agent stopped to make room for another (two agents at most): its next turn starts a new session that is told what it missed. In milestone M2, the conversation said "Alice restarted and no longer remembers the beginning"; that message is gone. | |
| 30 | + | |
| 31 | +## How it relates to the rest | |
| 32 | + | |
| 33 | +- [Permissions and trust](permissions.md): permission cards work the same way in a channel; the card names the agent that asks. | |
| 34 | +- [Memory and local models](memory-and-local-models.md): why two agents at most, and why answers are given in turn. | |
| 35 | +- [Architecture](architecture.md): `internal/channels` decides, `internal/store` remembers, `internal/agents` lends the processes. | |
| new file mode 100644 | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | +# Channels and what each agent knows | ||
| 2 | + | ||
| 3 | +## What is this about? | ||
| 4 | + | ||
| 5 | +A channel is a conversation the user has with several agents at once: ask Alice, then ask Bob for an opinion on Alice's answer. The Agent Client Protocol does not know this situation. It links one client to one agent, in sessions where only the two of them talk. Everything that makes a channel — who answers, who knows what was said — is decided by Sidekicks. This page explains those choices; the exact rules are in the [reference](../reference/agents-and-events.md#channels-milestones-m2-and-m3), the decisions in [ADR 0006](../../adr/0006-channels.md). | ||
| 6 | + | ||
| 7 | +## One session per agent and per channel | ||
| 8 | + | ||
| 9 | +Each agent of a channel has its own ACP session for that channel. Alice in #general and Alice in #translations are two sessions of the same process: what Alice hears in one channel never leaks into the other. | ||
| 10 | + | ||
| 11 | +A session only contains what was said in it. When Bob is asked something after Alice answered, Bob's session knows nothing of that answer. So before Bob's turn, Sidekicks adds to Bob's prompt what Bob has not seen: the user's messages to others and the other agents' answers, since Bob's last turn. The agent reads a short transcript, then the user's message. It keeps its own session — and its own memory of its own answers — while following the channel. | ||
| 12 | + | ||
| 13 | +The alternative, one shared session that every agent reads, does not exist in ACP: a session belongs to one agent. Re-sending the whole channel at every turn would fill the small context windows of local models within a few exchanges. Sending only what is new is the cheapest way to stay up to date; the transcript is limited to the last 20 messages, each shortened, so that a long channel does not crowd out the question. | ||
| 14 | + | ||
| 15 | +## Who answers, and why one at a time | ||
| 16 | + | ||
| 17 | +A message goes to the agents it mentions, in order; without a mention, to the last agent who spoke. That is how people talk in a group: you name someone, or you carry on with the person you were talking to. Nobody answers a message that names nobody in a channel where several agents have not spoken yet — guessing would surprise the user more than asking them to choose. | ||
| 18 | + | ||
| 19 | +Agents answer one after the other, never at the same time. On a laptop, two local models generating together are each slower than one after the other, and memory is the real limit (two small models on the reference machine). Answering in turn also lets the second agent read the first one's answer: "@alice @bob compare" gives Bob Alice's answer to compare with. For the same reason an agent answers one channel at a time: a second channel waits, and shows whose turn it is. | ||
| 20 | + | ||
| 21 | +## Commands are sent alone | ||
| 22 | + | ||
| 23 | +`/idioms Il pleut des cordes` asks Garfield to run a skill. The agent recognises a command by its first character. If the transcript of the channel came before it, the command would no longer be a command. So a message starting with `/` is sent as it is, without context, and `/garfield:idioms …` picks the agent when several are in the channel. An agent announces its commands as soon as a session opens, before any question — and mm does so without loading its model. So the first `/` typed in a conversation starts the members that never announced theirs and opens their session (the one their first answer will use); the list fills in a fraction of a second. Sidekicks then keeps the commands, and offers them at once the next times. | ||
| 24 | + | ||
| 25 | +## After a restart | ||
| 26 | + | ||
| 27 | +The history is in a database next to the configuration (one per configuration file), so the channels are there again after a restart. The agents, though, are new processes: their sessions are gone. ACP lets an agent resume a session (`session/load`); Sidekicks uses it when the agent offers it. mm does not, so Sidekicks opens a new session and gives the agent, in its first prompt, the last messages of the channel — its own included this time, since the new session has none of them. The user sees no difference: Alice still knows their name. | ||
| 28 | + | ||
| 29 | +The same mechanism covers an agent stopped to make room for another (two agents at most): its next turn starts a new session that is told what it missed. In milestone M2, the conversation said "Alice restarted and no longer remembers the beginning"; that message is gone. | ||
| 30 | + | ||
| 31 | +## How it relates to the rest | ||
| 32 | + | ||
| 33 | +- [Permissions and trust](permissions.md): permission cards work the same way in a channel; the card names the agent that asks. | ||
| 34 | +- [Memory and local models](memory-and-local-models.md): why two agents at most, and why answers are given in turn. | ||
| 35 | +- [Architecture](architecture.md): `internal/channels` decides, `internal/store` remembers, `internal/agents` lends the processes. | ||
modified
docs/en/how-to/edit-or-switch-the-configuration.md +1 -1 | @@ -13,7 +13,7 @@ This guide shows how to change the configuration file from the **Settings** pane | ||
| 13 | 13 | ## Open another configuration file |
| 14 | 14 | |
| 15 | 15 | 1. In **Settings**, click **Open…** and choose a `.yaml` file. |
| 16 | -2. Sidekicks stops every agent, forgets the conversations, and loads the chosen file — even if it has problems, which are then shown. | |
| 16 | +2. Sidekicks stops every agent and loads the chosen file with its own channels and history — even if it has problems, which are then shown. Opening the first file again brings its channels back. | |
| 17 | 17 | 3. The file is remembered: **Recent…** lists the last 8, and the next launch opens the last one (unless `--config` or `SIDEKICKS_CONFIG` says otherwise). |
| 18 | 18 | |
| 19 | 19 | ## Variants |
| @@ -13,7 +13,7 @@ This guide shows how to change the configuration file from the **Settings** pane | |||
| 13 | ## Open another configuration file | 13 | ## Open another configuration file |
| 14 | 14 | ||
| 15 | 1. In **Settings**, click **Open…** and choose a `.yaml` file. | 15 | 1. In **Settings**, click **Open…** and choose a `.yaml` file. |
| 16 | -2. Sidekicks stops every agent, forgets the conversations, and loads the chosen file — even if it has problems, which are then shown. | 16 | +2. Sidekicks stops every agent and loads the chosen file with its own channels and history — even if it has problems, which are then shown. Opening the first file again brings its channels back. |
| 17 | 3. The file is remembered: **Recent…** lists the last 8, and the next launch opens the last one (unless `--config` or `SIDEKICKS_CONFIG` says otherwise). | 17 | 3. The file is remembered: **Recent…** lists the last 8, and the next launch opens the last one (unless `--config` or `SIDEKICKS_CONFIG` says otherwise). |
| 18 | 18 | ||
| 19 | ## Variants | 19 | ## Variants |
added
docs/en/how-to/manage-channels.md +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +# How to manage channels and their agents | |
| 2 | + | |
| 3 | +This guide shows how to create, rename and delete channels, choose the agents in them, and talk with an agent directly. It assumes Sidekicks runs with a configuration that has agents. | |
| 4 | + | |
| 5 | +## Create a channel | |
| 6 | + | |
| 7 | +1. In the **Channels** panel, click **+ New channel** under the list. | |
| 8 | +2. Type a name (1 to 64 characters, spaces and accents allowed) and press Enter. The channel opens in the centre, with no member. | |
| 9 | +3. Invite agents (next section), or simply mention one: `@alice …` invites Alice before the answer — the channel says "Alice (assistant) joined the channel." | |
| 10 | + | |
| 11 | +A name already used by another channel, whatever its case, is refused: the error appears under the name. | |
| 12 | + | |
| 13 | +## Choose the agents of a channel | |
| 14 | + | |
| 15 | +- **Invite**: in the channel's header, open **+ Invite** and pick an agent. | |
| 16 | +- **Remove**: click **×** next to an agent's name in the header. The agent's past messages stay; it no longer answers in this channel unless mentioned again. | |
| 17 | + | |
| 18 | +## Rename or delete a channel | |
| 19 | + | |
| 20 | +Hover the channel in the list: | |
| 21 | + | |
| 22 | +- **✎** turns its name into a text box: type the new name, Enter to keep it, Escape to cancel. | |
| 23 | +- **🗑** asks "Delete #name and its whole history?": **Delete** removes the channel, its members and all its messages, for good. | |
| 24 | + | |
| 25 | +## Talk with one agent directly | |
| 26 | + | |
| 27 | +Click an agent in the **Agents** panel. Its direct conversation opens, and appears under **Direct messages** in the Channels panel. Every message goes to that agent; `@` mentions are plain text there. | |
| 28 | + | |
| 29 | +## Address the right agent | |
| 30 | + | |
| 31 | +| You want | Type | | |
| 32 | +| --- | --- | | |
| 33 | +| a specific agent to answer | `@name your message` (the list after `@` filters as you type; Tab or Enter picks) | | |
| 34 | +| several agents to answer in turn | `@alice @bob compare your answers` | | |
| 35 | +| the agent you were talking with | your message, without mention | | |
| 36 | +| an agent's command | `/` then pick it in the list; with several agents, `/agent:command` | | |
| 37 | + | |
| 38 | +## Variants | |
| 39 | + | |
| 40 | +- **Channels from the configuration file**: channels declared under `channels:` are created at startup if they do not exist yet, with their agents; afterwards they are ordinary channels. Removing them from the file deletes nothing ([configuration reference](../reference/configuration.md)). | |
| 41 | +- **Start again from an empty history**: quit Sidekicks and delete the folder `sidekicks/data/<…>` of your configuration (its location is in the [reference](../reference/agents-and-events.md#history)). The channels declared in the file come back at the next launch, empty. | |
| 42 | +- **"History unavailable" banner**: another Sidekicks has the same configuration file open. This one keeps working, but forgets everything when it quits; close the other one and relaunch. | |
| 43 | + | |
| 44 | +## See also | |
| 45 | + | |
| 46 | +- [Channels and what each agent knows](../explanation/channels-and-context.md) | |
| 47 | +- [Reference: agents, channels and events](../reference/agents-and-events.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +# How to manage channels and their agents | ||
| 2 | + | ||
| 3 | +This guide shows how to create, rename and delete channels, choose the agents in them, and talk with an agent directly. It assumes Sidekicks runs with a configuration that has agents. | ||
| 4 | + | ||
| 5 | +## Create a channel | ||
| 6 | + | ||
| 7 | +1. In the **Channels** panel, click **+ New channel** under the list. | ||
| 8 | +2. Type a name (1 to 64 characters, spaces and accents allowed) and press Enter. The channel opens in the centre, with no member. | ||
| 9 | +3. Invite agents (next section), or simply mention one: `@alice …` invites Alice before the answer — the channel says "Alice (assistant) joined the channel." | ||
| 10 | + | ||
| 11 | +A name already used by another channel, whatever its case, is refused: the error appears under the name. | ||
| 12 | + | ||
| 13 | +## Choose the agents of a channel | ||
| 14 | + | ||
| 15 | +- **Invite**: in the channel's header, open **+ Invite** and pick an agent. | ||
| 16 | +- **Remove**: click **×** next to an agent's name in the header. The agent's past messages stay; it no longer answers in this channel unless mentioned again. | ||
| 17 | + | ||
| 18 | +## Rename or delete a channel | ||
| 19 | + | ||
| 20 | +Hover the channel in the list: | ||
| 21 | + | ||
| 22 | +- **✎** turns its name into a text box: type the new name, Enter to keep it, Escape to cancel. | ||
| 23 | +- **🗑** asks "Delete #name and its whole history?": **Delete** removes the channel, its members and all its messages, for good. | ||
| 24 | + | ||
| 25 | +## Talk with one agent directly | ||
| 26 | + | ||
| 27 | +Click an agent in the **Agents** panel. Its direct conversation opens, and appears under **Direct messages** in the Channels panel. Every message goes to that agent; `@` mentions are plain text there. | ||
| 28 | + | ||
| 29 | +## Address the right agent | ||
| 30 | + | ||
| 31 | +| You want | Type | | ||
| 32 | +| --- | --- | | ||
| 33 | +| a specific agent to answer | `@name your message` (the list after `@` filters as you type; Tab or Enter picks) | | ||
| 34 | +| several agents to answer in turn | `@alice @bob compare your answers` | | ||
| 35 | +| the agent you were talking with | your message, without mention | | ||
| 36 | +| an agent's command | `/` then pick it in the list; with several agents, `/agent:command` | | ||
| 37 | + | ||
| 38 | +## Variants | ||
| 39 | + | ||
| 40 | +- **Channels from the configuration file**: channels declared under `channels:` are created at startup if they do not exist yet, with their agents; afterwards they are ordinary channels. Removing them from the file deletes nothing ([configuration reference](../reference/configuration.md)). | ||
| 41 | +- **Start again from an empty history**: quit Sidekicks and delete the folder `sidekicks/data/<…>` of your configuration (its location is in the [reference](../reference/agents-and-events.md#history)). The channels declared in the file come back at the next launch, empty. | ||
| 42 | +- **"History unavailable" banner**: another Sidekicks has the same configuration file open. This one keeps working, but forgets everything when it quits; close the other one and relaunch. | ||
| 43 | + | ||
| 44 | +## See also | ||
| 45 | + | ||
| 46 | +- [Channels and what each agent knows](../explanation/channels-and-context.md) | ||
| 47 | +- [Reference: agents, channels and events](../reference/agents-and-events.md) | ||
modified
docs/en/how-to/run-the-tests.md +1 -1 | @@ -35,7 +35,7 @@ The CI (`.github/workflows/ci.yml`) runs the same command, plus `make bindings-c | ||
| 35 | 35 | make test-real |
| 36 | 36 | ``` |
| 37 | 37 | |
| 38 | -It runs the tests built with the `realagents` tag: today, the ACP handshake with Alice. They need `mm` (or `MM_BIN`) and llmman, and start agents only through `scripts/agents.sh`, so the two-agents limit and the unloading apply. | |
| 38 | +It runs the tests built with the `realagents` tag: the ACP handshake with Alice, Riker asking permission before running a command, Bob reading Alice's answer in a channel, and Alice remembering a conversation after a restart (`internal/acp`, `internal/channels`). Allow about a minute. They need `mm` (or `MM_BIN`) and llmman, and start agents only through `scripts/agents.sh`, so the two-agents limit and the unloading apply. | |
| 39 | 39 | |
| 40 | 40 | ## Variants |
| 41 | 41 | |
| @@ -35,7 +35,7 @@ The CI (`.github/workflows/ci.yml`) runs the same command, plus `make bindings-c | |||
| 35 | make test-real | 35 | make test-real |
| 36 | ``` | 36 | ``` |
| 37 | 37 | ||
| 38 | -It runs the tests built with the `realagents` tag: today, the ACP handshake with Alice. They need `mm` (or `MM_BIN`) and llmman, and start agents only through `scripts/agents.sh`, so the two-agents limit and the unloading apply. | 38 | +It runs the tests built with the `realagents` tag: the ACP handshake with Alice, Riker asking permission before running a command, Bob reading Alice's answer in a channel, and Alice remembering a conversation after a restart (`internal/acp`, `internal/channels`). Allow about a minute. They need `mm` (or `MM_BIN`) and llmman, and start agents only through `scripts/agents.sh`, so the two-agents limit and the unloading apply. |
| 39 | 39 | ||
| 40 | ## Variants | 40 | ## Variants |
| 41 | 41 | ||
modified
docs/en/how-to/test-milestone-m1-on-macos.md +1 -1 | @@ -109,4 +109,4 @@ If everything is as described, M1 is confirmed: close issue #12 and set mileston | ||
| 109 | 109 | |
| 110 | 110 | ## See also |
| 111 | 111 | |
| 112 | -- [How to develop the application](develop-the-application.md) · [Reference: agent states and events](../reference/agents-and-events.md) | |
| 112 | +- [How to develop the application](develop-the-application.md) · [Reference: agents, channels and events](../reference/agents-and-events.md) | |
| @@ -109,4 +109,4 @@ If everything is as described, M1 is confirmed: close issue #12 and set mileston | |||
| 109 | 109 | ||
| 110 | ## See also | 110 | ## See also |
| 111 | 111 | ||
| 112 | -- [How to develop the application](develop-the-application.md) · [Reference: agent states and events](../reference/agents-and-events.md) | 112 | +- [How to develop the application](develop-the-application.md) · [Reference: agents, channels and events](../reference/agents-and-events.md) |
modified
docs/en/how-to/test-milestone-m2-on-macos.md +7 -7 | @@ -9,7 +9,7 @@ Open **Activity Monitor** (Memory tab) and keep a terminal for `scripts/agents.s | ||
| 9 | 9 | ```bash |
| 10 | 10 | make install # new frontend dependencies (CodeMirror, Shiki, markdown-it…) |
| 11 | 11 | make check # expected: 0 issues, Tests 43 passed, 14 passed, licence headers ok |
| 12 | -make test-real # expected: ok … internal/acp and ok … internal/conversations (Alice then Riker) | |
| 12 | +make test-real # expected: ok … internal/acp and ok … internal/channels (Riker, Alice and Bob, Alice after a restart; since M3) | |
| 13 | 13 | make install-test-config |
| 14 | 14 | make dev |
| 15 | 15 | ``` |
| @@ -18,9 +18,9 @@ make dev | ||
| 18 | 18 | |
| 19 | 19 | ## 1. Talk with Alice (#14, #15, #16) |
| 20 | 20 | |
| 21 | -1. Click **Alice**: her conversation opens in the centre; nothing is hidden under the window buttons. | |
| 21 | +1. Click **Alice**: the conversation opens in the centre; nothing is hidden under the window buttons. | |
| 22 | 22 | 2. Send: `Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.` — the answer arrives word by word; meanwhile "writing…", an orange dot and a **■ Stop** button. |
| 23 | -3. At the end: a table, a coloured Go block with its language and **Copier**; click **Copier**, then paste in TextEdit (⌘V). | |
| 23 | +3. At the end: a table, a coloured Go block with its language and **Copy**; click **Copy**, then paste in TextEdit (⌘V). | |
| 24 | 24 | 4. Theme: System Settings → Appearance → Dark: the code changes colours with the rest. |
| 25 | 25 | 5. Send: `Mets un lien vers https://wails.io` and click the link: it opens in your browser, not in Sidekicks. |
| 26 | 26 | 6. Send `Je m'appelle <your first name>.` then `Comment je m'appelle ?` — Alice remembers (same session). |
| @@ -40,7 +40,7 @@ make dev | ||
| 40 | 40 | |
| 41 | 41 | - [ ] Points 1 to 6 are as described. |
| 42 | 42 | |
| 43 | -## 3. Garfield and his skills | |
| 43 | +## 3. Garfield and the skills | |
| 44 | 44 | |
| 45 | 45 | Click **Garfield**, send `/idioms Il pleut des cordes.` — a line "🛠 read_skill: idioms ✓ done" then a translation such as "It's raining cats and dogs". |
| 46 | 46 | |
| @@ -48,9 +48,9 @@ Click **Garfield**, send `/idioms Il pleut des cordes.` — a line "🛠 read_sk | ||
| 48 | 48 | |
| 49 | 49 | ## 4. The memory limit with conversations |
| 50 | 50 | |
| 51 | -With Alice and Riker started, write to **Garfield**: the idle agent used least recently is stopped to make room. Then go back to that agent and write to it: a grey message says it restarted and no longer remembers the beginning. | |
| 51 | +With Alice and Riker started, write to **Garfield**: the idle agent used least recently is stopped to make room. Then go back to that agent and write to it. Since M3, it is told the last messages and no grey message appears (in M2, a grey message said it restarted and no longer remembered the beginning). | |
| 52 | 52 | |
| 53 | -- [ ] Two agents at most (`scripts/agents.sh ps`), and the restart message appears. | |
| 53 | +- [ ] Two agents at most (`scripts/agents.sh ps`). | |
| 54 | 54 | |
| 55 | 55 | ## 5. The configuration editor (#18) |
| 56 | 56 | |
| @@ -96,4 +96,4 @@ If everything is as described, M2 is confirmed: close issue #15 and milestone ** | ||
| 96 | 96 | ## See also |
| 97 | 97 | |
| 98 | 98 | - [Your first conversation](../tutorials/first-conversation.md) · [Edit or switch the configuration](edit-or-switch-the-configuration.md) |
| 99 | -- [Reference: agent states and events](../reference/agents-and-events.md) · [Permissions and trust](../explanation/permissions.md) | |
| 99 | +- [Reference: agents, channels and events](../reference/agents-and-events.md) · [Permissions and trust](../explanation/permissions.md) | |
| @@ -9,7 +9,7 @@ Open **Activity Monitor** (Memory tab) and keep a terminal for `scripts/agents.s | |||
| 9 | ```bash | 9 | ```bash |
| 10 | make install # new frontend dependencies (CodeMirror, Shiki, markdown-it…) | 10 | make install # new frontend dependencies (CodeMirror, Shiki, markdown-it…) |
| 11 | make check # expected: 0 issues, Tests 43 passed, 14 passed, licence headers ok | 11 | make check # expected: 0 issues, Tests 43 passed, 14 passed, licence headers ok |
| 12 | -make test-real # expected: ok … internal/acp and ok … internal/conversations (Alice then Riker) | 12 | +make test-real # expected: ok … internal/acp and ok … internal/channels (Riker, Alice and Bob, Alice after a restart; since M3) |
| 13 | make install-test-config | 13 | make install-test-config |
| 14 | make dev | 14 | make dev |
| 15 | ``` | 15 | ``` |
| @@ -18,9 +18,9 @@ make dev | |||
| 18 | 18 | ||
| 19 | ## 1. Talk with Alice (#14, #15, #16) | 19 | ## 1. Talk with Alice (#14, #15, #16) |
| 20 | 20 | ||
| 21 | -1. Click **Alice**: her conversation opens in the centre; nothing is hidden under the window buttons. | 21 | +1. Click **Alice**: the conversation opens in the centre; nothing is hidden under the window buttons. |
| 22 | 2. Send: `Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.` — the answer arrives word by word; meanwhile "writing…", an orange dot and a **■ Stop** button. | 22 | 2. Send: `Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.` — the answer arrives word by word; meanwhile "writing…", an orange dot and a **■ Stop** button. |
| 23 | -3. At the end: a table, a coloured Go block with its language and **Copier**; click **Copier**, then paste in TextEdit (⌘V). | 23 | +3. At the end: a table, a coloured Go block with its language and **Copy**; click **Copy**, then paste in TextEdit (⌘V). |
| 24 | 4. Theme: System Settings → Appearance → Dark: the code changes colours with the rest. | 24 | 4. Theme: System Settings → Appearance → Dark: the code changes colours with the rest. |
| 25 | 5. Send: `Mets un lien vers https://wails.io` and click the link: it opens in your browser, not in Sidekicks. | 25 | 5. Send: `Mets un lien vers https://wails.io` and click the link: it opens in your browser, not in Sidekicks. |
| 26 | 6. Send `Je m'appelle <your first name>.` then `Comment je m'appelle ?` — Alice remembers (same session). | 26 | 6. Send `Je m'appelle <your first name>.` then `Comment je m'appelle ?` — Alice remembers (same session). |
| @@ -40,7 +40,7 @@ make dev | |||
| 40 | 40 | ||
| 41 | - [ ] Points 1 to 6 are as described. | 41 | - [ ] Points 1 to 6 are as described. |
| 42 | 42 | ||
| 43 | -## 3. Garfield and his skills | 43 | +## 3. Garfield and the skills |
| 44 | 44 | ||
| 45 | Click **Garfield**, send `/idioms Il pleut des cordes.` — a line "🛠 read_skill: idioms ✓ done" then a translation such as "It's raining cats and dogs". | 45 | Click **Garfield**, send `/idioms Il pleut des cordes.` — a line "🛠 read_skill: idioms ✓ done" then a translation such as "It's raining cats and dogs". |
| 46 | 46 | ||
| @@ -48,9 +48,9 @@ Click **Garfield**, send `/idioms Il pleut des cordes.` — a line "🛠 read_sk | |||
| 48 | 48 | ||
| 49 | ## 4. The memory limit with conversations | 49 | ## 4. The memory limit with conversations |
| 50 | 50 | ||
| 51 | -With Alice and Riker started, write to **Garfield**: the idle agent used least recently is stopped to make room. Then go back to that agent and write to it: a grey message says it restarted and no longer remembers the beginning. | 51 | +With Alice and Riker started, write to **Garfield**: the idle agent used least recently is stopped to make room. Then go back to that agent and write to it. Since M3, it is told the last messages and no grey message appears (in M2, a grey message said it restarted and no longer remembered the beginning). |
| 52 | 52 | ||
| 53 | -- [ ] Two agents at most (`scripts/agents.sh ps`), and the restart message appears. | 53 | +- [ ] Two agents at most (`scripts/agents.sh ps`). |
| 54 | 54 | ||
| 55 | ## 5. The configuration editor (#18) | 55 | ## 5. The configuration editor (#18) |
| 56 | 56 | ||
| @@ -96,4 +96,4 @@ If everything is as described, M2 is confirmed: close issue #15 and milestone ** | |||
| 96 | ## See also | 96 | ## See also |
| 97 | 97 | ||
| 98 | - [Your first conversation](../tutorials/first-conversation.md) · [Edit or switch the configuration](edit-or-switch-the-configuration.md) | 98 | - [Your first conversation](../tutorials/first-conversation.md) · [Edit or switch the configuration](edit-or-switch-the-configuration.md) |
| 99 | -- [Reference: agent states and events](../reference/agents-and-events.md) · [Permissions and trust](../explanation/permissions.md) | 99 | +- [Reference: agents, channels and events](../reference/agents-and-events.md) · [Permissions and trust](../explanation/permissions.md) |
added
docs/en/how-to/test-milestone-m3-on-macos.md +93 -0 | new file mode 100644 | ||
| @@ -0,0 +1,93 @@ | ||
| 1 | +# How to test milestone M3 on macOS | |
| 2 | + | |
| 3 | +This guide checks on a Mac what milestone M3 (Channels & multi-agents) produced: channels kept in a history, `@` mentions, several agents in one channel, slash commands, and conversations that survive a restart. It assumes the Mac was prepared for [milestone M2](test-milestone-m2-on-macos.md) and the repository is on the M3 branch. Allow 30 minutes. | |
| 4 | + | |
| 5 | +Keep a terminal for `scripts/agents.sh ps`. | |
| 6 | + | |
| 7 | +## 0. Prepare | |
| 8 | + | |
| 9 | +```bash | |
| 10 | +make install # Pebble is a new Go dependency; nothing new in the frontend | |
| 11 | +make check # expected: 0 issues, Tests 56 passed, 14 passed, licence headers ok | |
| 12 | +make test-real # expected: ok … internal/channels (Riker, Alice + Bob, Alice after a restart) | |
| 13 | +make install-test-config | |
| 14 | +make dev | |
| 15 | +``` | |
| 16 | + | |
| 17 | +`make install-test-config` matters: the test channels are now called `general` (Alice, Bob) and `translations` (Garfield). | |
| 18 | + | |
| 19 | +- [ ] `make check` and `make test-real` pass; `scripts/agents.sh ps` lists no model afterwards. | |
| 20 | + | |
| 21 | +## 1. Channels from the configuration (#20, #21) | |
| 22 | + | |
| 23 | +1. The **Channels** panel lists `# general` and `# translations`, then **+ New channel**, then **Direct messages** ("Click an agent to talk with it directly."). | |
| 24 | +2. Click **# general**: the header shows `# general` and two members, `Alice (assistant) ×` and `Bob (assistant) ×`, and **+ Invite**. | |
| 25 | + | |
| 26 | +- [ ] Points 1 and 2 are as described. | |
| 27 | + | |
| 28 | +## 2. Mentions and two agents (#22, #23) | |
| 29 | + | |
| 30 | +1. In #general, type `@` : a list of the four agents opens above the text box. Type `b`: only Bob remains. Press **Escape**: the list closes. Clear the text box. | |
| 31 | +2. Type `@al`, press **Tab**: `@alice ` is inserted. Complete: `@alice Reply only with this code: 4217` and send. The header says "Alice (assistant) is answering…"; Alice answers `4217`. In your message, `@alice` is highlighted. | |
| 32 | +3. Send `@bob What code did Alice give? Answer with the code only.` — Bob starts and answers `4217` (without "Bob:" in front). | |
| 33 | +4. Send `and you, Bob, which number?` without mention: Bob answers (the last agent who spoke). | |
| 34 | +5. Send `@alice @bob Say hello in three words.` — Alice answers, then Bob; never both at once. `scripts/agents.sh ps` shows two agents at most. | |
| 35 | +6. Click **■ Stop** while Alice answers a long request (`@alice @bob Write a poem of 40 lines about the sea.`): Alice's answer ends "Answer stopped." and Bob does not answer. | |
| 36 | + | |
| 37 | +- [ ] Points 1 to 6 are as described. | |
| 38 | + | |
| 39 | +## 3. Managing channels (#21) | |
| 40 | + | |
| 41 | +1. Click **+ New channel**, type `Apollo`, Enter: `# Apollo` opens, with no member. | |
| 42 | +2. Send `anyone here?`: a grey message says "Nobody was addressed: mention an agent with @ (for example @alice)." | |
| 43 | +3. Open **+ Invite**, pick **Alice**: "Alice (assistant) joined the channel." Click **×** next to Alice's name in the header: "Alice (assistant) left the channel." | |
| 44 | +4. Create another channel called `apollo`: refused, the error appears under the name. | |
| 45 | +5. Hover `# Apollo`, click **✎**, type `Artemis`, Enter: renamed everywhere. | |
| 46 | +6. Hover `# Artemis`, click **🗑**: "Delete #Artemis and its whole history?" — **Cancel** keeps it; **🗑** then **Delete** removes it. | |
| 47 | + | |
| 48 | +- [ ] Points 1 to 6 are as described. | |
| 49 | + | |
| 50 | +## 4. Direct conversations and unread answers | |
| 51 | + | |
| 52 | +1. Click **Alice (assistant)** in the **Agents** panel: the direct conversation opens and appears under **Direct messages**. | |
| 53 | +2. Send `Write a haiku about autumn.` and click **# general** right away. When the answer is done, a blue badge `1` appears next to Alice under Direct messages; clicking it opens the conversation and the badge disappears. | |
| 54 | +3. In the direct conversation, `@bob hello` goes to Alice (mentions are plain text there). | |
| 55 | + | |
| 56 | +- [ ] Points 1 to 3 are as described. | |
| 57 | + | |
| 58 | +## 5. Slash commands (#24) | |
| 59 | + | |
| 60 | +1. Click **# translations** and type `/`: within a second the list shows Garfield's commands (`/compact`, `/new`, `/unload`, `/email-letter`, `/idioms`…), although Garfield never answered; `scripts/agents.sh ps` shows Garfield running and **no model loaded**. Press **Escape**, clear. | |
| 61 | +2. Send `/idioms Il pleut des cordes.` — next to Garfield's name, a turning spinner and "thinking…" while the model loads, then "🛠 read_skill: idioms ✓ done", then a translation such as "It's raining cats and dogs."; the spinner disappears when the answer ends. | |
| 62 | +3. Type `/id`: the list offers **/idioms** with its description; **Tab** completes it. | |
| 63 | +4. In #general, **+ Invite** Garfield, then type `/gar`: the list offers `/garfield:idioms`, `/garfield:email-letter`… Choose `/garfield:idioms`, complete with `Il pleut des cordes.` and send: Garfield answers, not Alice nor Bob. | |
| 64 | + | |
| 65 | +- [ ] Points 1 to 4 are as described. | |
| 66 | + | |
| 67 | +## 6. After a restart (#20, #25) | |
| 68 | + | |
| 69 | +1. Quit (⌘Q). `scripts/agents.sh ps` lists no agent and no model. | |
| 70 | +2. `make dev` again, click **# general**: all the messages are there, with the agents' names. | |
| 71 | +3. Send `@alice What code did you give?` — Alice, a new process, answers `4217`. | |
| 72 | +4. Type `/` in **# translations** before Garfield starts: the commands are already offered. | |
| 73 | + | |
| 74 | +- [ ] Points 1 to 4 are as described. | |
| 75 | + | |
| 76 | +## 7. Two configuration files, two histories | |
| 77 | + | |
| 78 | +1. **Settings → Open…**, choose `examples/test-agents.yaml` of the repository: the Channels panel shows that file's channels, empty (its own history). | |
| 79 | +2. Go back to the default configuration with **Open…** (`~/Library/Application Support/sidekicks/config.yaml` — ⇧⌘G in the dialog to type the path): the conversations of sections 2 to 6 are back. | |
| 80 | + | |
| 81 | +- [ ] Points 1 and 2 are as described. | |
| 82 | + | |
| 83 | +## If something differs | |
| 84 | + | |
| 85 | +| Symptom | Likely cause | What to do | | |
| 86 | +| --- | --- | --- | | |
| 87 | +| `# général` and `# traductions` instead of `general` / `translations` | old test configuration | `make install-test-config`, then relaunch | | |
| 88 | +| Yellow banner "History unavailable" | another Sidekicks has the same file open | quit it and relaunch | | |
| 89 | +| Bob answers about something else | small model; the transcript was given | look at the answer again, or ask more directly | | |
| 90 | + | |
| 91 | +## Close the milestone | |
| 92 | + | |
| 93 | +If everything matches, M3 is confirmed: close issue #21 (the others are closed) and the milestone **M3 · Channels & multi-agents** (or ask the assistant). Otherwise, comment the issue concerned with what differs. | |
| new file mode 100644 | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | +# How to test milestone M3 on macOS | ||
| 2 | + | ||
| 3 | +This guide checks on a Mac what milestone M3 (Channels & multi-agents) produced: channels kept in a history, `@` mentions, several agents in one channel, slash commands, and conversations that survive a restart. It assumes the Mac was prepared for [milestone M2](test-milestone-m2-on-macos.md) and the repository is on the M3 branch. Allow 30 minutes. | ||
| 4 | + | ||
| 5 | +Keep a terminal for `scripts/agents.sh ps`. | ||
| 6 | + | ||
| 7 | +## 0. Prepare | ||
| 8 | + | ||
| 9 | +```bash | ||
| 10 | +make install # Pebble is a new Go dependency; nothing new in the frontend | ||
| 11 | +make check # expected: 0 issues, Tests 56 passed, 14 passed, licence headers ok | ||
| 12 | +make test-real # expected: ok … internal/channels (Riker, Alice + Bob, Alice after a restart) | ||
| 13 | +make install-test-config | ||
| 14 | +make dev | ||
| 15 | +``` | ||
| 16 | + | ||
| 17 | +`make install-test-config` matters: the test channels are now called `general` (Alice, Bob) and `translations` (Garfield). | ||
| 18 | + | ||
| 19 | +- [ ] `make check` and `make test-real` pass; `scripts/agents.sh ps` lists no model afterwards. | ||
| 20 | + | ||
| 21 | +## 1. Channels from the configuration (#20, #21) | ||
| 22 | + | ||
| 23 | +1. The **Channels** panel lists `# general` and `# translations`, then **+ New channel**, then **Direct messages** ("Click an agent to talk with it directly."). | ||
| 24 | +2. Click **# general**: the header shows `# general` and two members, `Alice (assistant) ×` and `Bob (assistant) ×`, and **+ Invite**. | ||
| 25 | + | ||
| 26 | +- [ ] Points 1 and 2 are as described. | ||
| 27 | + | ||
| 28 | +## 2. Mentions and two agents (#22, #23) | ||
| 29 | + | ||
| 30 | +1. In #general, type `@` : a list of the four agents opens above the text box. Type `b`: only Bob remains. Press **Escape**: the list closes. Clear the text box. | ||
| 31 | +2. Type `@al`, press **Tab**: `@alice ` is inserted. Complete: `@alice Reply only with this code: 4217` and send. The header says "Alice (assistant) is answering…"; Alice answers `4217`. In your message, `@alice` is highlighted. | ||
| 32 | +3. Send `@bob What code did Alice give? Answer with the code only.` — Bob starts and answers `4217` (without "Bob:" in front). | ||
| 33 | +4. Send `and you, Bob, which number?` without mention: Bob answers (the last agent who spoke). | ||
| 34 | +5. Send `@alice @bob Say hello in three words.` — Alice answers, then Bob; never both at once. `scripts/agents.sh ps` shows two agents at most. | ||
| 35 | +6. Click **■ Stop** while Alice answers a long request (`@alice @bob Write a poem of 40 lines about the sea.`): Alice's answer ends "Answer stopped." and Bob does not answer. | ||
| 36 | + | ||
| 37 | +- [ ] Points 1 to 6 are as described. | ||
| 38 | + | ||
| 39 | +## 3. Managing channels (#21) | ||
| 40 | + | ||
| 41 | +1. Click **+ New channel**, type `Apollo`, Enter: `# Apollo` opens, with no member. | ||
| 42 | +2. Send `anyone here?`: a grey message says "Nobody was addressed: mention an agent with @ (for example @alice)." | ||
| 43 | +3. Open **+ Invite**, pick **Alice**: "Alice (assistant) joined the channel." Click **×** next to Alice's name in the header: "Alice (assistant) left the channel." | ||
| 44 | +4. Create another channel called `apollo`: refused, the error appears under the name. | ||
| 45 | +5. Hover `# Apollo`, click **✎**, type `Artemis`, Enter: renamed everywhere. | ||
| 46 | +6. Hover `# Artemis`, click **🗑**: "Delete #Artemis and its whole history?" — **Cancel** keeps it; **🗑** then **Delete** removes it. | ||
| 47 | + | ||
| 48 | +- [ ] Points 1 to 6 are as described. | ||
| 49 | + | ||
| 50 | +## 4. Direct conversations and unread answers | ||
| 51 | + | ||
| 52 | +1. Click **Alice (assistant)** in the **Agents** panel: the direct conversation opens and appears under **Direct messages**. | ||
| 53 | +2. Send `Write a haiku about autumn.` and click **# general** right away. When the answer is done, a blue badge `1` appears next to Alice under Direct messages; clicking it opens the conversation and the badge disappears. | ||
| 54 | +3. In the direct conversation, `@bob hello` goes to Alice (mentions are plain text there). | ||
| 55 | + | ||
| 56 | +- [ ] Points 1 to 3 are as described. | ||
| 57 | + | ||
| 58 | +## 5. Slash commands (#24) | ||
| 59 | + | ||
| 60 | +1. Click **# translations** and type `/`: within a second the list shows Garfield's commands (`/compact`, `/new`, `/unload`, `/email-letter`, `/idioms`…), although Garfield never answered; `scripts/agents.sh ps` shows Garfield running and **no model loaded**. Press **Escape**, clear. | ||
| 61 | +2. Send `/idioms Il pleut des cordes.` — next to Garfield's name, a turning spinner and "thinking…" while the model loads, then "🛠 read_skill: idioms ✓ done", then a translation such as "It's raining cats and dogs."; the spinner disappears when the answer ends. | ||
| 62 | +3. Type `/id`: the list offers **/idioms** with its description; **Tab** completes it. | ||
| 63 | +4. In #general, **+ Invite** Garfield, then type `/gar`: the list offers `/garfield:idioms`, `/garfield:email-letter`… Choose `/garfield:idioms`, complete with `Il pleut des cordes.` and send: Garfield answers, not Alice nor Bob. | ||
| 64 | + | ||
| 65 | +- [ ] Points 1 to 4 are as described. | ||
| 66 | + | ||
| 67 | +## 6. After a restart (#20, #25) | ||
| 68 | + | ||
| 69 | +1. Quit (⌘Q). `scripts/agents.sh ps` lists no agent and no model. | ||
| 70 | +2. `make dev` again, click **# general**: all the messages are there, with the agents' names. | ||
| 71 | +3. Send `@alice What code did you give?` — Alice, a new process, answers `4217`. | ||
| 72 | +4. Type `/` in **# translations** before Garfield starts: the commands are already offered. | ||
| 73 | + | ||
| 74 | +- [ ] Points 1 to 4 are as described. | ||
| 75 | + | ||
| 76 | +## 7. Two configuration files, two histories | ||
| 77 | + | ||
| 78 | +1. **Settings → Open…**, choose `examples/test-agents.yaml` of the repository: the Channels panel shows that file's channels, empty (its own history). | ||
| 79 | +2. Go back to the default configuration with **Open…** (`~/Library/Application Support/sidekicks/config.yaml` — ⇧⌘G in the dialog to type the path): the conversations of sections 2 to 6 are back. | ||
| 80 | + | ||
| 81 | +- [ ] Points 1 and 2 are as described. | ||
| 82 | + | ||
| 83 | +## If something differs | ||
| 84 | + | ||
| 85 | +| Symptom | Likely cause | What to do | | ||
| 86 | +| --- | --- | --- | | ||
| 87 | +| `# général` and `# traductions` instead of `general` / `translations` | old test configuration | `make install-test-config`, then relaunch | | ||
| 88 | +| Yellow banner "History unavailable" | another Sidekicks has the same file open | quit it and relaunch | | ||
| 89 | +| Bob answers about something else | small model; the transcript was given | look at the answer again, or ask more directly | | ||
| 90 | + | ||
| 91 | +## Close the milestone | ||
| 92 | + | ||
| 93 | +If everything matches, M3 is confirmed: close issue #21 (the others are closed) and the milestone **M3 · Channels & multi-agents** (or ask the assistant). Otherwise, comment the issue concerned with what differs. | ||
modified
docs/en/reference/agents-and-events.md +61 -18 | @@ -1,4 +1,4 @@ | ||
| 1 | -# Reference: agent states and events | |
| 1 | +# Reference: agents, channels and events | |
| 2 | 2 | |
| 3 | 3 | ## Agent states |
| 4 | 4 | |
| @@ -48,24 +48,46 @@ The Go names and types are in `internal/events`; the TypeScript constants and ty | ||
| 48 | 48 | | `AgentService` | `List()` | Every agent with `name, title, icon, description, state, error, outdated`, in configuration order. | |
| 49 | 49 | | | `Start(name)`, `Stop(name)`, `Restart(name)` | As in the transitions above; an error is returned to the interface. | |
| 50 | 50 | | | `Logs(name)` | The last 200 stderr lines of the agent. | |
| 51 | -| `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. | | |
| 51 | +| `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, historyProblem`. | | |
| 52 | 52 | |
| 53 | -## Conversations (milestone M2) | |
| 53 | +## Channels (milestones M2 and M3) | |
| 54 | 54 | |
| 55 | -One conversation per agent, kept in memory until the application quits or another configuration is opened. | |
| 55 | +A channel holds a conversation between the user and one or several agents. A direct conversation is a channel with one member, no name, and `direct` set to its agent's name. Channels, members and finished messages are kept in the history of the configuration file (below); a streaming answer is kept once it ends. | |
| 56 | + | |
| 57 | +### Channels | |
| 58 | + | |
| 59 | +| Field | Meaning | | |
| 60 | +| --- | --- | | |
| 61 | +| `id` | sortable identifier | | |
| 62 | +| `name`, `description` | 1 to 64 characters, unique whatever the case; empty for a direct conversation | | |
| 63 | +| `direct` | the agent of a direct conversation; empty for a channel | | |
| 64 | +| `members` | agent names, in the order they joined | | |
| 65 | + | |
| 66 | +### Who answers a message | |
| 67 | + | |
| 68 | +| The message | Answered by | | |
| 69 | +| --- | --- | | |
| 70 | +| `/agent:command arguments` (`agent` is a configured agent) | that agent, which receives `/command arguments` | | |
| 71 | +| contains `@name` mentions of configured agents | the mentioned agents, one after the other, in the order of the mentions; a mentioned agent that is not a member joins first | | |
| 72 | +| no mention | the last agent who answered in the channel; with a single member, that member | | |
| 73 | +| no mention and nobody answered yet, several members | nobody: system message `no-recipient` | | |
| 74 | +| any message in a direct conversation | its agent; mentions are plain text there | | |
| 75 | + | |
| 76 | +A message starting with `/` (possibly after mentions) is a command: it is sent alone. Any other message is sent with the channel messages the agent has not seen yet (at most the last 20, each cut at 1500 characters). An agent answers one channel at a time; a channel waiting for it shows whose turn it is. | |
| 56 | 77 | |
| 57 | 78 | ### Messages |
| 58 | 79 | |
| 59 | 80 | | Field | Meaning | |
| 60 | 81 | | --- | --- | |
| 61 | -| `id`, `agent`, `createdAt` | identity | | |
| 82 | +| `id`, `channel`, `createdAt` | identity | | |
| 83 | +| `agent` | the author of an agent message, or the agent a system message is about | | |
| 62 | 84 | | `role` | `user`, `agent` or `system` | |
| 63 | 85 | | `parts` | `text` (Markdown), `thought` (reasoning), `tool` (a tool call), `plan` (plan entries) | |
| 64 | 86 | | `done` | false while an agent message streams | |
| 65 | -| `queued` | a user message waiting for the current answer | | |
| 87 | +| `queued` | a user message waiting for the current answers | | |
| 66 | 88 | | `stopReason` | how an agent message ended: `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` | |
| 67 | 89 | | `error` | why an agent message ended without a stop reason (the agent's own explanation), or `cancelled` for a queued message dropped by Stop | |
| 68 | -| `code` | system messages: `agent-restarted` (new session, the agent forgot the beginning), `agent-unavailable` (could not start or reach it; `error` says why) | | |
| 90 | +| `code` | system messages: `agent-joined`, `agent-left`, `no-recipient`, `agent-unavailable` (could not start or reach it; `error` says why) | | |
| 69 | 91 | |
| 70 | 92 | A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (compact JSON), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`). |
| 71 | 93 | |
| @@ -73,21 +95,42 @@ A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, t | ||
| 73 | 95 | |
| 74 | 96 | | Event | Payload | Sent when | |
| 75 | 97 | | --- | --- | --- | |
| 76 | -| `message:updated` | `{agent, message}` | a message is added, or finished | | |
| 77 | -| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | `text` is appended to the part at `index` (text, thought), or `part` replaces it (tool, plan) | | |
| 78 | -| `turn:changed` | `{agent, busy, queued}` | the agent starts or stops answering; `queued` messages wait | | |
| 79 | -| `permission:requested` | `{id, agent, messageId, toolCall, options[], rejections}` | the agent asks to run a tool; `rejections` counts the refusals already given in this turn | | |
| 80 | -| `permission:resolved` | `{id, agent}` | the request was answered, or cancelled by Stop | | |
| 81 | -| `usage:changed` | `{agent, usage: {used, size}}` | the agent reported its context window use (`size` is 0 when unknown) | | |
| 98 | +| `channels:changed` | `{channels[]}` | a channel is created, renamed or deleted, or its members change | | |
| 99 | +| `message:updated` | `{channel, message}` | a message is added, or finished | | |
| 100 | +| `message:delta` | `{channel, messageId, index, kind, text?, part?}` | `text` is appended to the part at `index` (text, thought), or `part` replaces it (tool, plan) | | |
| 101 | +| `turn:changed` | `{channel, busy, queued, answering}` | the channel starts or stops answering, or another agent's turn begins; `queued` messages wait | | |
| 102 | +| `permission:requested` | `{id, channel, agent, messageId, toolCall, options[], rejections}` | an agent asks to run a tool; `rejections` counts the refusals already given in this turn | | |
| 103 | +| `permission:resolved` | `{id, channel}` | the request was answered, or cancelled by Stop | | |
| 104 | +| `usage:changed` | `{channel, agent, usage: {used, size}}` | an agent reported its context window use (`size` is 0 when unknown) | | |
| 105 | +| `commands:changed` | `{agent, commands[]}` | an agent announced its slash commands (`name, description, hint`) | | |
| 82 | 106 | |
| 83 | -### `ChatService` | |
| 107 | +### `ChannelService` | |
| 84 | 108 | |
| 85 | 109 | | Method | Effect | |
| 86 | 110 | | --- | --- | |
| 87 | -| `Send(agent, text)` | Adds a user message and returns its id; starts the agent if needed; queues the message if the agent is answering. Empty text and unknown agents are errors. | | |
| 88 | -| `Cancel(agent)` | Stops the current answer (`session/cancel`), answers pending permission requests "cancelled", drops the queued messages. | | |
| 111 | +| `List()` | Every channel and direct conversation, oldest first. | | |
| 112 | +| `Create(name, description)` | Creates a channel. A taken or empty name is an error. | | |
| 113 | +| `Rename(id, name, description)` | Renames a channel (not a direct conversation). | | |
| 114 | +| `Delete(id)` | Stops its answers, then deletes the channel and its whole history. | | |
| 115 | +| `AddMember(id, agent)`, `RemoveMember(id, agent)` | Invites or removes an agent; the channel shows `agent-joined` or `agent-left`. | | |
| 116 | +| `Direct(agent)` | The direct conversation with `agent`, created the first time. | | |
| 117 | +| `Send(id, text)` | Adds a user message and returns its id; the agents that answer are started if needed; the message is queued if the channel is answering. Empty text and unknown channels are errors. | | |
| 118 | +| `Cancel(id)` | Stops the current answer (`session/cancel`), the agents still to answer and the queued messages; answers pending permission requests "cancelled". | | |
| 89 | 119 | | `AnswerPermission(id, optionId)` | Answers a pending request with one of its options. | |
| 90 | -| `Conversation(agent)` | The whole conversation: `messages, busy, queued, usage, commands, permissions`. | | |
| 120 | +| `Conversation(id)` | `channel, messages` (the last 100), `more, busy, queued, answering, usage{agent: …}, permissions`. | | |
| 121 | +| `Older(id, beforeId)` | `{messages, more}`: the 100 messages before `beforeId`. | | |
| 122 | +| `Commands(agent)` | The commands the agent announced last, kept from one run to the next; empty if it never announced any. | | |
| 123 | +| `Prepare(id)` | Starts the members of the channel that never announced their commands and opens their session for it (no model is loaded); returns once the sessions are open, the commands arrive as `commands:changed`. A member answering elsewhere is skipped. The session serves the member's first answer, which is still told the channel's past. | | |
| 124 | + | |
| 125 | +### History | |
| 126 | + | |
| 127 | +| | | | |
| 128 | +| --- | --- | | |
| 129 | +| Location | `<user config dir>/sidekicks/data/<16 hex characters>/`: one Pebble database per configuration file (the hash of its absolute path). `<user config dir>` is `~/Library/Application Support` on macOS, `$XDG_CONFIG_HOME` or `~/.config` on Linux. | | |
| 130 | +| Kept | channels, members (with each agent's ACP session id and the last message it saw), finished messages, the last commands of each agent | | |
| 131 | +| Not kept | pending permission requests, queued messages, an answer interrupted by quitting | | |
| 132 | +| Unavailable | if the database cannot be opened (another Sidekicks uses the same file), the history lives in memory until Sidekicks quits, and `ConfigService.Current().historyProblem` says why | | |
| 133 | +| After a restart | an agent that supports `session/load` resumes its session; otherwise its first prompt in a channel carries the last messages of that channel | | |
| 91 | 134 | |
| 92 | 135 | ### Settings methods of `ConfigService` |
| 93 | 136 | |
| @@ -97,7 +140,7 @@ A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, t | ||
| 97 | 140 | | `Validate(text)` | The problems and warnings of `text`, with `line, column, message, warning`, without saving. | |
| 98 | 141 | | `Save(text)` | Writes `text` if it is valid; otherwise returns its problems and writes nothing. | |
| 99 | 142 | | `Schema()` | The configuration JSON Schema. | |
| 100 | -| `Open(path)` | Stops every agent, forgets the conversations, loads and watches `path`, remembers it. | | |
| 143 | +| `Open(path)` | Stops the answers and every agent, closes the history, loads and watches `path` with its own history, remembers it. | | |
| 101 | 144 | | `Choose()` | Shows the native file dialog and opens the chosen file; returns `""` when cancelled. | |
| 102 | 145 | | `Recent()` | The configuration files opened from the application, most recent first (8 at most). | |
| 103 | 146 | |
| @@ -1,4 +1,4 @@ | |||
| 1 | -# Reference: agent states and events | 1 | +# Reference: agents, channels and events |
| 2 | 2 | ||
| 3 | ## Agent states | 3 | ## Agent states |
| 4 | 4 | ||
| @@ -48,24 +48,46 @@ The Go names and types are in `internal/events`; the TypeScript constants and ty | |||
| 48 | | `AgentService` | `List()` | Every agent with `name, title, icon, description, state, error, outdated`, in configuration order. | | 48 | | `AgentService` | `List()` | Every agent with `name, title, icon, description, state, error, outdated`, in configuration order. | |
| 49 | | | `Start(name)`, `Stop(name)`, `Restart(name)` | As in the transitions above; an error is returned to the interface. | | 49 | | | `Start(name)`, `Stop(name)`, `Restart(name)` | As in the transitions above; an error is returned to the interface. | |
| 50 | | | `Logs(name)` | The last 200 stderr lines of the agent. | | 50 | | | `Logs(name)` | The last 200 stderr lines of the agent. | |
| 51 | -| `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. | | 51 | +| `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, historyProblem`. | |
| 52 | 52 | ||
| 53 | -## Conversations (milestone M2) | 53 | +## Channels (milestones M2 and M3) |
| 54 | 54 | ||
| 55 | -One conversation per agent, kept in memory until the application quits or another configuration is opened. | 55 | +A channel holds a conversation between the user and one or several agents. A direct conversation is a channel with one member, no name, and `direct` set to its agent's name. Channels, members and finished messages are kept in the history of the configuration file (below); a streaming answer is kept once it ends. |
| 56 | + | ||
| 57 | +### Channels | ||
| 58 | + | ||
| 59 | +| Field | Meaning | | ||
| 60 | +| --- | --- | | ||
| 61 | +| `id` | sortable identifier | | ||
| 62 | +| `name`, `description` | 1 to 64 characters, unique whatever the case; empty for a direct conversation | | ||
| 63 | +| `direct` | the agent of a direct conversation; empty for a channel | | ||
| 64 | +| `members` | agent names, in the order they joined | | ||
| 65 | + | ||
| 66 | +### Who answers a message | ||
| 67 | + | ||
| 68 | +| The message | Answered by | | ||
| 69 | +| --- | --- | | ||
| 70 | +| `/agent:command arguments` (`agent` is a configured agent) | that agent, which receives `/command arguments` | | ||
| 71 | +| contains `@name` mentions of configured agents | the mentioned agents, one after the other, in the order of the mentions; a mentioned agent that is not a member joins first | | ||
| 72 | +| no mention | the last agent who answered in the channel; with a single member, that member | | ||
| 73 | +| no mention and nobody answered yet, several members | nobody: system message `no-recipient` | | ||
| 74 | +| any message in a direct conversation | its agent; mentions are plain text there | | ||
| 75 | + | ||
| 76 | +A message starting with `/` (possibly after mentions) is a command: it is sent alone. Any other message is sent with the channel messages the agent has not seen yet (at most the last 20, each cut at 1500 characters). An agent answers one channel at a time; a channel waiting for it shows whose turn it is. | ||
| 56 | 77 | ||
| 57 | ### Messages | 78 | ### Messages |
| 58 | 79 | ||
| 59 | | Field | Meaning | | 80 | | Field | Meaning | |
| 60 | | --- | --- | | 81 | | --- | --- | |
| 61 | -| `id`, `agent`, `createdAt` | identity | | 82 | +| `id`, `channel`, `createdAt` | identity | |
| 83 | +| `agent` | the author of an agent message, or the agent a system message is about | | ||
| 62 | | `role` | `user`, `agent` or `system` | | 84 | | `role` | `user`, `agent` or `system` | |
| 63 | | `parts` | `text` (Markdown), `thought` (reasoning), `tool` (a tool call), `plan` (plan entries) | | 85 | | `parts` | `text` (Markdown), `thought` (reasoning), `tool` (a tool call), `plan` (plan entries) | |
| 64 | | `done` | false while an agent message streams | | 86 | | `done` | false while an agent message streams | |
| 65 | -| `queued` | a user message waiting for the current answer | | 87 | +| `queued` | a user message waiting for the current answers | |
| 66 | | `stopReason` | how an agent message ended: `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` | | 88 | | `stopReason` | how an agent message ended: `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` | |
| 67 | | `error` | why an agent message ended without a stop reason (the agent's own explanation), or `cancelled` for a queued message dropped by Stop | | 89 | | `error` | why an agent message ended without a stop reason (the agent's own explanation), or `cancelled` for a queued message dropped by Stop | |
| 68 | -| `code` | system messages: `agent-restarted` (new session, the agent forgot the beginning), `agent-unavailable` (could not start or reach it; `error` says why) | | 90 | +| `code` | system messages: `agent-joined`, `agent-left`, `no-recipient`, `agent-unavailable` (could not start or reach it; `error` says why) | |
| 69 | 91 | ||
| 70 | A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (compact JSON), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`). | 92 | A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (compact JSON), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`). |
| 71 | 93 | ||
| @@ -73,21 +95,42 @@ A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, t | |||
| 73 | 95 | ||
| 74 | | Event | Payload | Sent when | | 96 | | Event | Payload | Sent when | |
| 75 | | --- | --- | --- | | 97 | | --- | --- | --- | |
| 76 | -| `message:updated` | `{agent, message}` | a message is added, or finished | | 98 | +| `channels:changed` | `{channels[]}` | a channel is created, renamed or deleted, or its members change | |
| 77 | -| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | `text` is appended to the part at `index` (text, thought), or `part` replaces it (tool, plan) | | 99 | +| `message:updated` | `{channel, message}` | a message is added, or finished | |
| 78 | -| `turn:changed` | `{agent, busy, queued}` | the agent starts or stops answering; `queued` messages wait | | 100 | +| `message:delta` | `{channel, messageId, index, kind, text?, part?}` | `text` is appended to the part at `index` (text, thought), or `part` replaces it (tool, plan) | |
| 79 | -| `permission:requested` | `{id, agent, messageId, toolCall, options[], rejections}` | the agent asks to run a tool; `rejections` counts the refusals already given in this turn | | 101 | +| `turn:changed` | `{channel, busy, queued, answering}` | the channel starts or stops answering, or another agent's turn begins; `queued` messages wait | |
| 80 | -| `permission:resolved` | `{id, agent}` | the request was answered, or cancelled by Stop | | 102 | +| `permission:requested` | `{id, channel, agent, messageId, toolCall, options[], rejections}` | an agent asks to run a tool; `rejections` counts the refusals already given in this turn | |
| 81 | -| `usage:changed` | `{agent, usage: {used, size}}` | the agent reported its context window use (`size` is 0 when unknown) | | 103 | +| `permission:resolved` | `{id, channel}` | the request was answered, or cancelled by Stop | |
| 104 | +| `usage:changed` | `{channel, agent, usage: {used, size}}` | an agent reported its context window use (`size` is 0 when unknown) | | ||
| 105 | +| `commands:changed` | `{agent, commands[]}` | an agent announced its slash commands (`name, description, hint`) | | ||
| 82 | 106 | ||
| 83 | -### `ChatService` | 107 | +### `ChannelService` |
| 84 | 108 | ||
| 85 | | Method | Effect | | 109 | | Method | Effect | |
| 86 | | --- | --- | | 110 | | --- | --- | |
| 87 | -| `Send(agent, text)` | Adds a user message and returns its id; starts the agent if needed; queues the message if the agent is answering. Empty text and unknown agents are errors. | | 111 | +| `List()` | Every channel and direct conversation, oldest first. | |
| 88 | -| `Cancel(agent)` | Stops the current answer (`session/cancel`), answers pending permission requests "cancelled", drops the queued messages. | | 112 | +| `Create(name, description)` | Creates a channel. A taken or empty name is an error. | |
| 113 | +| `Rename(id, name, description)` | Renames a channel (not a direct conversation). | | ||
| 114 | +| `Delete(id)` | Stops its answers, then deletes the channel and its whole history. | | ||
| 115 | +| `AddMember(id, agent)`, `RemoveMember(id, agent)` | Invites or removes an agent; the channel shows `agent-joined` or `agent-left`. | | ||
| 116 | +| `Direct(agent)` | The direct conversation with `agent`, created the first time. | | ||
| 117 | +| `Send(id, text)` | Adds a user message and returns its id; the agents that answer are started if needed; the message is queued if the channel is answering. Empty text and unknown channels are errors. | | ||
| 118 | +| `Cancel(id)` | Stops the current answer (`session/cancel`), the agents still to answer and the queued messages; answers pending permission requests "cancelled". | | ||
| 89 | | `AnswerPermission(id, optionId)` | Answers a pending request with one of its options. | | 119 | | `AnswerPermission(id, optionId)` | Answers a pending request with one of its options. | |
| 90 | -| `Conversation(agent)` | The whole conversation: `messages, busy, queued, usage, commands, permissions`. | | 120 | +| `Conversation(id)` | `channel, messages` (the last 100), `more, busy, queued, answering, usage{agent: …}, permissions`. | |
| 121 | +| `Older(id, beforeId)` | `{messages, more}`: the 100 messages before `beforeId`. | | ||
| 122 | +| `Commands(agent)` | The commands the agent announced last, kept from one run to the next; empty if it never announced any. | | ||
| 123 | +| `Prepare(id)` | Starts the members of the channel that never announced their commands and opens their session for it (no model is loaded); returns once the sessions are open, the commands arrive as `commands:changed`. A member answering elsewhere is skipped. The session serves the member's first answer, which is still told the channel's past. | | ||
| 124 | + | ||
| 125 | +### History | ||
| 126 | + | ||
| 127 | +| | | | ||
| 128 | +| --- | --- | | ||
| 129 | +| Location | `<user config dir>/sidekicks/data/<16 hex characters>/`: one Pebble database per configuration file (the hash of its absolute path). `<user config dir>` is `~/Library/Application Support` on macOS, `$XDG_CONFIG_HOME` or `~/.config` on Linux. | | ||
| 130 | +| Kept | channels, members (with each agent's ACP session id and the last message it saw), finished messages, the last commands of each agent | | ||
| 131 | +| Not kept | pending permission requests, queued messages, an answer interrupted by quitting | | ||
| 132 | +| Unavailable | if the database cannot be opened (another Sidekicks uses the same file), the history lives in memory until Sidekicks quits, and `ConfigService.Current().historyProblem` says why | | ||
| 133 | +| After a restart | an agent that supports `session/load` resumes its session; otherwise its first prompt in a channel carries the last messages of that channel | | ||
| 91 | 134 | ||
| 92 | ### Settings methods of `ConfigService` | 135 | ### Settings methods of `ConfigService` |
| 93 | 136 | ||
| @@ -97,7 +140,7 @@ A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, t | |||
| 97 | | `Validate(text)` | The problems and warnings of `text`, with `line, column, message, warning`, without saving. | | 140 | | `Validate(text)` | The problems and warnings of `text`, with `line, column, message, warning`, without saving. | |
| 98 | | `Save(text)` | Writes `text` if it is valid; otherwise returns its problems and writes nothing. | | 141 | | `Save(text)` | Writes `text` if it is valid; otherwise returns its problems and writes nothing. | |
| 99 | | `Schema()` | The configuration JSON Schema. | | 142 | | `Schema()` | The configuration JSON Schema. | |
| 100 | -| `Open(path)` | Stops every agent, forgets the conversations, loads and watches `path`, remembers it. | | 143 | +| `Open(path)` | Stops the answers and every agent, closes the history, loads and watches `path` with its own history, remembers it. | |
| 101 | | `Choose()` | Shows the native file dialog and opens the chosen file; returns `""` when cancelled. | | 144 | | `Choose()` | Shows the native file dialog and opens the chosen file; returns `""` when cancelled. | |
| 102 | | `Recent()` | The configuration files opened from the application, most recent first (8 at most). | | 145 | | `Recent()` | The configuration files opened from the application, most recent first (8 at most). | |
| 103 | 146 | ||
modified
docs/en/reference/configuration.md +3 -3 | @@ -88,10 +88,10 @@ The runtime tells Sidekicks how to reach the agent's **files and terminal** (Age | ||
| 88 | 88 | | Key | Type | Required | Meaning | |
| 89 | 89 | | --- | --- | --- | --- | |
| 90 | 90 | | `name` | text, 1–64 characters | yes | Shown as `#name`. **Unique.** May contain accents and spaces. | |
| 91 | -| `description` | text | no | Shown in the channel header. | | |
| 92 | -| `agents` | list of agent names | no | Agents invited when the channel is created. No duplicates. | | |
| 91 | +| `description` | text | no | Shown when hovering the channel's name, and in an empty channel. | | |
| 92 | +| `agents` | list of agent names | no | Members of the channel. A listed agent that is not a member yet is added at startup and when the file is reloaded; removing it from the list removes nothing. No duplicates. | | |
| 93 | 93 | |
| 94 | -A channel declared here is created at startup **if it does not exist yet**; after that it lives in the application like any other channel. Removing it from the file does not delete it (deleting history must be an explicit user action). | |
| 94 | +A channel declared here is created at startup, or when the file is reloaded, **if no channel has this name yet** (whatever the case); after that it lives in the application like any other channel, and can be renamed or deleted there. Removing it from the file does not delete it (deleting history must be an explicit user action). Each configuration file has its own channels and history ([where it is kept](agents-and-events.md#history)). | |
| 95 | 95 | |
| 96 | 96 | ## Rules checked by the loader, not by the schema |
| 97 | 97 | |
| @@ -88,10 +88,10 @@ The runtime tells Sidekicks how to reach the agent's **files and terminal** (Age | |||
| 88 | | Key | Type | Required | Meaning | | 88 | | Key | Type | Required | Meaning | |
| 89 | | --- | --- | --- | --- | | 89 | | --- | --- | --- | --- | |
| 90 | | `name` | text, 1–64 characters | yes | Shown as `#name`. **Unique.** May contain accents and spaces. | | 90 | | `name` | text, 1–64 characters | yes | Shown as `#name`. **Unique.** May contain accents and spaces. | |
| 91 | -| `description` | text | no | Shown in the channel header. | | 91 | +| `description` | text | no | Shown when hovering the channel's name, and in an empty channel. | |
| 92 | -| `agents` | list of agent names | no | Agents invited when the channel is created. No duplicates. | | 92 | +| `agents` | list of agent names | no | Members of the channel. A listed agent that is not a member yet is added at startup and when the file is reloaded; removing it from the list removes nothing. No duplicates. | |
| 93 | 93 | ||
| 94 | -A channel declared here is created at startup **if it does not exist yet**; after that it lives in the application like any other channel. Removing it from the file does not delete it (deleting history must be an explicit user action). | 94 | +A channel declared here is created at startup, or when the file is reloaded, **if no channel has this name yet** (whatever the case); after that it lives in the application like any other channel, and can be renamed or deleted there. Removing it from the file does not delete it (deleting history must be an explicit user action). Each configuration file has its own channels and history ([where it is kept](agents-and-events.md#history)). |
| 95 | 95 | ||
| 96 | ## Rules checked by the loader, not by the schema | 96 | ## Rules checked by the loader, not by the schema |
| 97 | 97 | ||
added
docs/en/tutorials/first-channel.md +64 -0 | new file mode 100644 | ||
| @@ -0,0 +1,64 @@ | ||
| 1 | +# Tutorial: a channel with two agents | |
| 2 | + | |
| 3 | +By the end of this tutorial, you will have asked Alice something in a channel, had Bob answer about what Alice said, run one of Garfield's commands, and found the whole conversation again after quitting Sidekicks. | |
| 4 | + | |
| 5 | +## Prerequisites | |
| 6 | + | |
| 7 | +- The test configuration installed, as in [Your first conversation with an agent](first-conversation.md): run `make install-test-config` again if you installed it before milestone M3, so that the channels are called `general` and `translations`. | |
| 8 | +- llmman running, with the models of Alice, Bob and Garfield pulled. | |
| 9 | + | |
| 10 | +## Step 1 — Open #general | |
| 11 | + | |
| 12 | +Type `make dev` at the root of the repository. In the window, click **# general** in the **CHANNELS** panel. | |
| 13 | + | |
| 14 | +You should see `# general` at the top of the centre, followed by two members, `Alice (assistant)` and `Bob (assistant)`, and the sentence "Mention an agent with @ to bring it into #general." | |
| 15 | + | |
| 16 | +## Step 2 — Mention Alice | |
| 17 | + | |
| 18 | +Click in the text box and type `@al`. A list opens above the text box with **Alice (assistant)**. Press **Tab**: the text box now says `@alice `. Complete the message so that it reads: | |
| 19 | + | |
| 20 | +```text | |
| 21 | +@alice Reply only with this code: 4217 | |
| 22 | +``` | |
| 23 | + | |
| 24 | +and press Enter. You should see your message with `@alice` highlighted, then "Alice (assistant) is answering…" at the top, then Alice's answer: `4217`. | |
| 25 | + | |
| 26 | +## Step 3 — Ask Bob about it | |
| 27 | + | |
| 28 | +Send: | |
| 29 | + | |
| 30 | +```text | |
| 31 | +@bob What code did Alice give? Answer with the code only. | |
| 32 | +``` | |
| 33 | + | |
| 34 | +Bob starts (Bob's dot turns orange, then green) and answers `4217`. Bob's session never contained Alice's answer: Sidekicks added it to Bob's prompt. | |
| 35 | + | |
| 36 | +## Step 4 — Run one of Garfield's commands | |
| 37 | + | |
| 38 | +Click **# translations**. Its only member is Garfield. Type `/`: the list of Garfield's commands appears within a second — Sidekicks asked Garfield for them. Press **Escape**, clear the text box, and type: | |
| 39 | + | |
| 40 | +```text | |
| 41 | +/idioms Il pleut des cordes. | |
| 42 | +``` | |
| 43 | + | |
| 44 | +and press Enter. Next to Garfield's name, a spinner turns with "thinking…" while the model loads. You should then see a folded line "🛠 read_skill: idioms ✓ done", then a translation such as "It's raining cats and dogs." | |
| 45 | + | |
| 46 | +Now type `/i` in the text box: a list offers **/idioms** with its description. Press **Escape** to close it, and clear the text box. | |
| 47 | + | |
| 48 | +## Step 5 — Quit and come back | |
| 49 | + | |
| 50 | +Quit Sidekicks (⌘Q), then type `make dev` again. Click **# general**: your messages and the agents' answers are still there. Send: | |
| 51 | + | |
| 52 | +```text | |
| 53 | +@alice What code did you give? | |
| 54 | +``` | |
| 55 | + | |
| 56 | +Alice answers `4217`, although the agent runs in a new process: Sidekicks added the last messages of the channel to Alice's prompt. | |
| 57 | + | |
| 58 | +## What now? | |
| 59 | + | |
| 60 | +You have talked with two agents in one channel, run a command, and kept the conversation across a restart. To go further: | |
| 61 | + | |
| 62 | +- To create, rename or delete channels and choose their agents → [How to manage channels](../how-to/manage-channels.md) | |
| 63 | +- To understand who answers and what each agent reads → [Channels and what each agent knows](../explanation/channels-and-context.md) | |
| 64 | +- The exact rules and events → [Reference: agents, channels and events](../reference/agents-and-events.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | +# Tutorial: a channel with two agents | ||
| 2 | + | ||
| 3 | +By the end of this tutorial, you will have asked Alice something in a channel, had Bob answer about what Alice said, run one of Garfield's commands, and found the whole conversation again after quitting Sidekicks. | ||
| 4 | + | ||
| 5 | +## Prerequisites | ||
| 6 | + | ||
| 7 | +- The test configuration installed, as in [Your first conversation with an agent](first-conversation.md): run `make install-test-config` again if you installed it before milestone M3, so that the channels are called `general` and `translations`. | ||
| 8 | +- llmman running, with the models of Alice, Bob and Garfield pulled. | ||
| 9 | + | ||
| 10 | +## Step 1 — Open #general | ||
| 11 | + | ||
| 12 | +Type `make dev` at the root of the repository. In the window, click **# general** in the **CHANNELS** panel. | ||
| 13 | + | ||
| 14 | +You should see `# general` at the top of the centre, followed by two members, `Alice (assistant)` and `Bob (assistant)`, and the sentence "Mention an agent with @ to bring it into #general." | ||
| 15 | + | ||
| 16 | +## Step 2 — Mention Alice | ||
| 17 | + | ||
| 18 | +Click in the text box and type `@al`. A list opens above the text box with **Alice (assistant)**. Press **Tab**: the text box now says `@alice `. Complete the message so that it reads: | ||
| 19 | + | ||
| 20 | +```text | ||
| 21 | +@alice Reply only with this code: 4217 | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +and press Enter. You should see your message with `@alice` highlighted, then "Alice (assistant) is answering…" at the top, then Alice's answer: `4217`. | ||
| 25 | + | ||
| 26 | +## Step 3 — Ask Bob about it | ||
| 27 | + | ||
| 28 | +Send: | ||
| 29 | + | ||
| 30 | +```text | ||
| 31 | +@bob What code did Alice give? Answer with the code only. | ||
| 32 | +``` | ||
| 33 | + | ||
| 34 | +Bob starts (Bob's dot turns orange, then green) and answers `4217`. Bob's session never contained Alice's answer: Sidekicks added it to Bob's prompt. | ||
| 35 | + | ||
| 36 | +## Step 4 — Run one of Garfield's commands | ||
| 37 | + | ||
| 38 | +Click **# translations**. Its only member is Garfield. Type `/`: the list of Garfield's commands appears within a second — Sidekicks asked Garfield for them. Press **Escape**, clear the text box, and type: | ||
| 39 | + | ||
| 40 | +```text | ||
| 41 | +/idioms Il pleut des cordes. | ||
| 42 | +``` | ||
| 43 | + | ||
| 44 | +and press Enter. Next to Garfield's name, a spinner turns with "thinking…" while the model loads. You should then see a folded line "🛠 read_skill: idioms ✓ done", then a translation such as "It's raining cats and dogs." | ||
| 45 | + | ||
| 46 | +Now type `/i` in the text box: a list offers **/idioms** with its description. Press **Escape** to close it, and clear the text box. | ||
| 47 | + | ||
| 48 | +## Step 5 — Quit and come back | ||
| 49 | + | ||
| 50 | +Quit Sidekicks (⌘Q), then type `make dev` again. Click **# general**: your messages and the agents' answers are still there. Send: | ||
| 51 | + | ||
| 52 | +```text | ||
| 53 | +@alice What code did you give? | ||
| 54 | +``` | ||
| 55 | + | ||
| 56 | +Alice answers `4217`, although the agent runs in a new process: Sidekicks added the last messages of the channel to Alice's prompt. | ||
| 57 | + | ||
| 58 | +## What now? | ||
| 59 | + | ||
| 60 | +You have talked with two agents in one channel, run a command, and kept the conversation across a restart. To go further: | ||
| 61 | + | ||
| 62 | +- To create, rename or delete channels and choose their agents → [How to manage channels](../how-to/manage-channels.md) | ||
| 63 | +- To understand who answers and what each agent reads → [Channels and what each agent knows](../explanation/channels-and-context.md) | ||
| 64 | +- The exact rules and events → [Reference: agents, channels and events](../reference/agents-and-events.md) | ||
modified
docs/en/tutorials/first-conversation.md +1 -1 | @@ -53,4 +53,4 @@ You have talked with two agents, approved a command and stopped an answer. To go | ||
| 53 | 53 | |
| 54 | 54 | - Change what agents exist from the window → [How to edit or switch the configuration](../how-to/edit-or-switch-the-configuration.md) |
| 55 | 55 | - Why the permission card looks like this → [Permissions and trust](../explanation/permissions.md) |
| 56 | -- The events and messages behind the conversation → [Reference: agent states and events](../reference/agents-and-events.md) | |
| 56 | +- The events and messages behind the conversation → [Reference: agents, channels and events](../reference/agents-and-events.md) | |
| @@ -53,4 +53,4 @@ You have talked with two agents, approved a command and stopped an answer. To go | |||
| 53 | 53 | ||
| 54 | - Change what agents exist from the window → [How to edit or switch the configuration](../how-to/edit-or-switch-the-configuration.md) | 54 | - Change what agents exist from the window → [How to edit or switch the configuration](../how-to/edit-or-switch-the-configuration.md) |
| 55 | - Why the permission card looks like this → [Permissions and trust](../explanation/permissions.md) | 55 | - Why the permission card looks like this → [Permissions and trust](../explanation/permissions.md) |
| 56 | -- The events and messages behind the conversation → [Reference: agent states and events](../reference/agents-and-events.md) | 56 | +- The events and messages behind the conversation → [Reference: agents, channels and events](../reference/agents-and-events.md) |
modified
docs/en/tutorials/first-launch.md +1 -1 | @@ -91,5 +91,5 @@ Hover Alice and click ■: the dot turns grey. Close the window: had Alice still | ||
| 91 | 91 | You have run Sidekicks, driven an agent's life and seen how the configuration is checked. To go further: |
| 92 | 92 | |
| 93 | 93 | - All the keys of the file → [Reference: the configuration file](../reference/configuration.md) |
| 94 | -- What each state means → [Reference: agent states and events](../reference/agents-and-events.md) | |
| 94 | +- What each state means → [Reference: agents, channels and events](../reference/agents-and-events.md) | |
| 95 | 95 | - Why agents start and stop this way → [Memory and local models](../explanation/memory-and-local-models.md) |
| @@ -91,5 +91,5 @@ Hover Alice and click ■: the dot turns grey. Close the window: had Alice still | |||
| 91 | You have run Sidekicks, driven an agent's life and seen how the configuration is checked. To go further: | 91 | You have run Sidekicks, driven an agent's life and seen how the configuration is checked. To go further: |
| 92 | 92 | ||
| 93 | - All the keys of the file → [Reference: the configuration file](../reference/configuration.md) | 93 | - All the keys of the file → [Reference: the configuration file](../reference/configuration.md) |
| 94 | -- What each state means → [Reference: agent states and events](../reference/agents-and-events.md) | 94 | +- What each state means → [Reference: agents, channels and events](../reference/agents-and-events.md) |
| 95 | - Why agents start and stop this way → [Memory and local models](../explanation/memory-and-local-models.md) | 95 | - Why agents start and stop this way → [Memory and local models](../explanation/memory-and-local-models.md) |
modified
docs/fr/README.md +6 -6 | @@ -1,21 +1,21 @@ | ||
| 1 | 1 | # Documentation de Sidekicks (français) |
| 2 | 2 | |
| 3 | -Sidekicks est une application de bureau pour converser avec des agents d'IA dans des channels, grâce à l'Agent Client Protocol, pensée pour des personnes qui ne sont pas développeuses. La milestone M2 (Conversation avec un agent) est terminée : on discute avec un agent à la fois, on lit des réponses mises en forme, on approuve ses outils, et on modifie ou change la configuration depuis la fenêtre. Les channels et les conversations à plusieurs agents arrivent avec la milestone M3. | |
| 3 | +Sidekicks est une application de bureau pour converser avec des agents d'IA dans des channels, grâce à l'Agent Client Protocol, pensée pour des personnes qui ne sont pas développeuses. La milestone M3 (Channels & multi-agents) est terminée : on crée des channels, on y fait venir plusieurs agents avec `@`, on lance leurs commandes avec `/`, on parle directement à un agent, et on retrouve chaque conversation après un redémarrage. Les réponses sont mises en forme, les outils demandent votre accord, et la configuration se modifie depuis la fenêtre. | |
| 4 | 4 | |
| 5 | 5 | Cette documentation suit la méthode [Diátaxis](https://diataxis.fr) : choisissez la section qui correspond à votre besoin du moment. |
| 6 | 6 | |
| 7 | 7 | | Vous voulez… | Section | Pages | |
| 8 | 8 | | --- | --- | --- | |
| 9 | -| **apprendre** en pratiquant | Tutoriels | [Votre première conversation avec un agent](tutorials/premiere-conversation.md) · [Votre premier lancement de Sidekicks](tutorials/premier-lancement.md) · [Votre premier échange ACP avec un agent de test](tutorials/premier-echange-acp.md) | | |
| 10 | -| **réaliser** une tâche précise | Guides pratiques | [Modifier ou changer la configuration](how-to/modifier-ou-changer-la-configuration.md) · [Développer l'application](how-to/developper-l-application.md) · [Lancer les tests](how-to/lancer-les-tests.md) · [Lancer un agent de test](how-to/lancer-un-agent-de-test.md) · [Tester la milestone M2 sous macOS](how-to/tester-la-milestone-m2-sous-macos.md) · [Tester la milestone M1 sous macOS](how-to/tester-la-milestone-m1-sous-macos.md) · [Tester la milestone Initialize sous macOS](how-to/tester-la-milestone-initialize-sous-macos.md) | | |
| 11 | -| **consulter** un détail exact | Référence | [Le fichier de configuration](reference/configuration.md) · [États des agents et événements](reference/agents-et-evenements.md) · [Commandes](reference/commandes.md) · [scripts/agents.sh](reference/script-agents.md) | | |
| 12 | -| **comprendre** le pourquoi | Explications | [Permissions et confiance](explanation/permissions-et-confiance.md) · [Pourquoi ACP](explanation/pourquoi-acp.md) · [La stack de l'interface](explanation/stack-interface.md) · [Architecture](explanation/architecture.md) · [Mémoire et modèles locaux](explanation/memoire-et-modeles-locaux.md) | | |
| 9 | +| **apprendre** en pratiquant | Tutoriels | [Un channel avec deux agents](tutorials/premier-channel.md) · [Votre première conversation avec un agent](tutorials/premiere-conversation.md) · [Votre premier lancement de Sidekicks](tutorials/premier-lancement.md) · [Votre premier échange ACP avec un agent de test](tutorials/premier-echange-acp.md) | | |
| 10 | +| **réaliser** une tâche précise | Guides pratiques | [Gérer les channels et leurs agents](how-to/gerer-les-channels.md) · [Modifier ou changer la configuration](how-to/modifier-ou-changer-la-configuration.md) · [Développer l'application](how-to/developper-l-application.md) · [Lancer les tests](how-to/lancer-les-tests.md) · [Lancer un agent de test](how-to/lancer-un-agent-de-test.md) · [Tester la milestone M3 sous macOS](how-to/tester-la-milestone-m3-sous-macos.md) · [Tester la milestone M2 sous macOS](how-to/tester-la-milestone-m2-sous-macos.md) · [Tester la milestone M1 sous macOS](how-to/tester-la-milestone-m1-sous-macos.md) · [Tester la milestone Initialize sous macOS](how-to/tester-la-milestone-initialize-sous-macos.md) | | |
| 11 | +| **consulter** un détail exact | Référence | [Le fichier de configuration](reference/configuration.md) · [Agents, channels et événements](reference/agents-et-evenements.md) · [Commandes](reference/commandes.md) · [scripts/agents.sh](reference/script-agents.md) | | |
| 12 | +| **comprendre** le pourquoi | Explications | [Les channels et ce que sait chaque agent](explanation/channels-et-contexte.md) · [Permissions et confiance](explanation/permissions-et-confiance.md) · [Pourquoi ACP](explanation/pourquoi-acp.md) · [La stack de l'interface](explanation/stack-interface.md) · [Architecture](explanation/architecture.md) · [Mémoire et modèles locaux](explanation/memoire-et-modeles-locaux.md) | | |
| 13 | 13 | |
| 14 | 14 | ## Documents de conception |
| 15 | 15 | |
| 16 | 16 | Communs aux deux langues et rédigés en anglais : |
| 17 | 17 | |
| 18 | -- [Décisions d'architecture (ADR)](../adr/) — ACP, stack de l'interface, architecture, conversations, langue de l'interface | |
| 18 | +- [Décisions d'architecture (ADR)](../adr/) — ACP, stack de l'interface, architecture, conversations, langue de l'interface, channels | |
| 19 | 19 | - [Maquettes](../design/README.md) — les six écrans principaux (textes d'interface en français) |
| 20 | 20 | - [Schéma des packages](../diagrams/packages.drawio) |
| 21 | 21 | - [Traces ACP](../spikes/acp/README.md) enregistrées avec les vrais agents |
| @@ -1,21 +1,21 @@ | |||
| 1 | # Documentation de Sidekicks (français) | 1 | # Documentation de Sidekicks (français) |
| 2 | 2 | ||
| 3 | -Sidekicks est une application de bureau pour converser avec des agents d'IA dans des channels, grâce à l'Agent Client Protocol, pensée pour des personnes qui ne sont pas développeuses. La milestone M2 (Conversation avec un agent) est terminée : on discute avec un agent à la fois, on lit des réponses mises en forme, on approuve ses outils, et on modifie ou change la configuration depuis la fenêtre. Les channels et les conversations à plusieurs agents arrivent avec la milestone M3. | 3 | +Sidekicks est une application de bureau pour converser avec des agents d'IA dans des channels, grâce à l'Agent Client Protocol, pensée pour des personnes qui ne sont pas développeuses. La milestone M3 (Channels & multi-agents) est terminée : on crée des channels, on y fait venir plusieurs agents avec `@`, on lance leurs commandes avec `/`, on parle directement à un agent, et on retrouve chaque conversation après un redémarrage. Les réponses sont mises en forme, les outils demandent votre accord, et la configuration se modifie depuis la fenêtre. |
| 4 | 4 | ||
| 5 | Cette documentation suit la méthode [Diátaxis](https://diataxis.fr) : choisissez la section qui correspond à votre besoin du moment. | 5 | Cette documentation suit la méthode [Diátaxis](https://diataxis.fr) : choisissez la section qui correspond à votre besoin du moment. |
| 6 | 6 | ||
| 7 | | Vous voulez… | Section | Pages | | 7 | | Vous voulez… | Section | Pages | |
| 8 | | --- | --- | --- | | 8 | | --- | --- | --- | |
| 9 | -| **apprendre** en pratiquant | Tutoriels | [Votre première conversation avec un agent](tutorials/premiere-conversation.md) · [Votre premier lancement de Sidekicks](tutorials/premier-lancement.md) · [Votre premier échange ACP avec un agent de test](tutorials/premier-echange-acp.md) | | 9 | +| **apprendre** en pratiquant | Tutoriels | [Un channel avec deux agents](tutorials/premier-channel.md) · [Votre première conversation avec un agent](tutorials/premiere-conversation.md) · [Votre premier lancement de Sidekicks](tutorials/premier-lancement.md) · [Votre premier échange ACP avec un agent de test](tutorials/premier-echange-acp.md) | |
| 10 | -| **réaliser** une tâche précise | Guides pratiques | [Modifier ou changer la configuration](how-to/modifier-ou-changer-la-configuration.md) · [Développer l'application](how-to/developper-l-application.md) · [Lancer les tests](how-to/lancer-les-tests.md) · [Lancer un agent de test](how-to/lancer-un-agent-de-test.md) · [Tester la milestone M2 sous macOS](how-to/tester-la-milestone-m2-sous-macos.md) · [Tester la milestone M1 sous macOS](how-to/tester-la-milestone-m1-sous-macos.md) · [Tester la milestone Initialize sous macOS](how-to/tester-la-milestone-initialize-sous-macos.md) | | 10 | +| **réaliser** une tâche précise | Guides pratiques | [Gérer les channels et leurs agents](how-to/gerer-les-channels.md) · [Modifier ou changer la configuration](how-to/modifier-ou-changer-la-configuration.md) · [Développer l'application](how-to/developper-l-application.md) · [Lancer les tests](how-to/lancer-les-tests.md) · [Lancer un agent de test](how-to/lancer-un-agent-de-test.md) · [Tester la milestone M3 sous macOS](how-to/tester-la-milestone-m3-sous-macos.md) · [Tester la milestone M2 sous macOS](how-to/tester-la-milestone-m2-sous-macos.md) · [Tester la milestone M1 sous macOS](how-to/tester-la-milestone-m1-sous-macos.md) · [Tester la milestone Initialize sous macOS](how-to/tester-la-milestone-initialize-sous-macos.md) | |
| 11 | -| **consulter** un détail exact | Référence | [Le fichier de configuration](reference/configuration.md) · [États des agents et événements](reference/agents-et-evenements.md) · [Commandes](reference/commandes.md) · [scripts/agents.sh](reference/script-agents.md) | | 11 | +| **consulter** un détail exact | Référence | [Le fichier de configuration](reference/configuration.md) · [Agents, channels et événements](reference/agents-et-evenements.md) · [Commandes](reference/commandes.md) · [scripts/agents.sh](reference/script-agents.md) | |
| 12 | -| **comprendre** le pourquoi | Explications | [Permissions et confiance](explanation/permissions-et-confiance.md) · [Pourquoi ACP](explanation/pourquoi-acp.md) · [La stack de l'interface](explanation/stack-interface.md) · [Architecture](explanation/architecture.md) · [Mémoire et modèles locaux](explanation/memoire-et-modeles-locaux.md) | | 12 | +| **comprendre** le pourquoi | Explications | [Les channels et ce que sait chaque agent](explanation/channels-et-contexte.md) · [Permissions et confiance](explanation/permissions-et-confiance.md) · [Pourquoi ACP](explanation/pourquoi-acp.md) · [La stack de l'interface](explanation/stack-interface.md) · [Architecture](explanation/architecture.md) · [Mémoire et modèles locaux](explanation/memoire-et-modeles-locaux.md) | |
| 13 | 13 | ||
| 14 | ## Documents de conception | 14 | ## Documents de conception |
| 15 | 15 | ||
| 16 | Communs aux deux langues et rédigés en anglais : | 16 | Communs aux deux langues et rédigés en anglais : |
| 17 | 17 | ||
| 18 | -- [Décisions d'architecture (ADR)](../adr/) — ACP, stack de l'interface, architecture, conversations, langue de l'interface | 18 | +- [Décisions d'architecture (ADR)](../adr/) — ACP, stack de l'interface, architecture, conversations, langue de l'interface, channels |
| 19 | - [Maquettes](../design/README.md) — les six écrans principaux (textes d'interface en français) | 19 | - [Maquettes](../design/README.md) — les six écrans principaux (textes d'interface en français) |
| 20 | - [Schéma des packages](../diagrams/packages.drawio) | 20 | - [Schéma des packages](../diagrams/packages.drawio) |
| 21 | - [Traces ACP](../spikes/acp/README.md) enregistrées avec les vrais agents | 21 | - [Traces ACP](../spikes/acp/README.md) enregistrées avec les vrais agents |
added
docs/fr/explanation/channels-et-contexte.md +35 -0 | new file mode 100644 | ||
| @@ -0,0 +1,35 @@ | ||
| 1 | +# Les channels et ce que sait chaque agent | |
| 2 | + | |
| 3 | +## De quoi s'agit-il ? | |
| 4 | + | |
| 5 | +Un channel est une conversation de l'utilisateur avec plusieurs agents à la fois : poser une question à Alice, puis demander à Bob son avis sur la réponse d'Alice. L'Agent Client Protocol ne connaît pas cette situation. Il relie un client à un agent, dans des sessions où ils ne parlent qu'à deux. Tout ce qui fait un channel — qui répond, qui sait ce qui a été dit — est décidé par Sidekicks. Cette page explique ces choix ; les règles exactes sont dans la [référence](../reference/agents-et-evenements.md#channels-milestones-m2-et-m3), les décisions dans l'[ADR 0006](../../adr/0006-channels.md) (en anglais). | |
| 6 | + | |
| 7 | +## Une session par agent et par channel | |
| 8 | + | |
| 9 | +Chaque agent d'un channel a sa propre session ACP pour ce channel. Alice dans #general et Alice dans #translations sont deux sessions du même processus : ce qui est dit à Alice dans un channel ne passe jamais dans l'autre. | |
| 10 | + | |
| 11 | +Une session ne contient que ce qui s'y est dit. Quand on interroge Bob après une réponse d'Alice, la session de Bob ne sait rien de cette réponse. Avant le tour de Bob, Sidekicks ajoute donc à son prompt ce que Bob n'a pas vu : les messages de l'utilisateur aux autres et les réponses des autres agents, depuis le dernier tour de Bob. L'agent lit une courte transcription, puis le message de l'utilisateur. L'agent garde sa propre session — et le souvenir de ses propres réponses — tout en suivant le channel. | |
| 12 | + | |
| 13 | +L'alternative, une session commune lue par tous les agents, n'existe pas dans ACP : une session appartient à un agent. Renvoyer tout le channel à chaque tour remplirait en quelques échanges les petites fenêtres de contexte des modèles locaux. N'envoyer que la nouveauté est la façon la plus économe de rester à jour ; la transcription est limitée aux 20 derniers messages, chacun raccourci, pour qu'un long channel n'étouffe pas la question. | |
| 14 | + | |
| 15 | +## Qui répond, et pourquoi un seul à la fois | |
| 16 | + | |
| 17 | +Un message va aux agents qu'il mentionne, dans l'ordre ; sans mention, au dernier agent qui a parlé. C'est ainsi qu'on parle en groupe : on nomme quelqu'un, ou on continue avec la personne à qui l'on parlait. Personne ne répond à un message qui ne nomme personne dans un channel où plusieurs agents n'ont pas encore parlé — deviner surprendrait davantage l'utilisateur que lui demander de choisir. | |
| 18 | + | |
| 19 | +Les agents répondent l'un après l'autre, jamais en même temps. Sur un portable, deux modèles locaux qui génèrent ensemble sont chacun plus lents qu'à tour de rôle, et la mémoire est la vraie limite (deux petits modèles sur la machine de référence). Répondre à tour de rôle permet aussi au second agent de lire la réponse du premier : « @alice @bob comparez » donne à Bob la réponse d'Alice à comparer. Pour la même raison, un agent répond à un seul channel à la fois : un second channel attend, et indique à qui c'est le tour. | |
| 20 | + | |
| 21 | +## Les commandes partent seules | |
| 22 | + | |
| 23 | +`/idioms Il pleut des cordes` demande à Garfield d'exécuter une skill. L'agent reconnaît une commande à son premier caractère. Si la transcription du channel la précédait, la commande n'en serait plus une. Un message qui commence par `/` est donc envoyé tel quel, sans contexte, et `/garfield:idioms …` choisit l'agent quand le channel en compte plusieurs. Un agent annonce ses commandes dès qu'une session s'ouvre, avant toute question — et mm le fait sans charger son modèle. Le premier `/` tapé dans une conversation démarre donc les membres qui n'ont jamais annoncé les leurs et ouvre leur session (celle que servira leur première réponse) ; la liste se remplit en une fraction de seconde. Sidekicks garde ensuite les commandes et les propose aussitôt les fois suivantes. | |
| 24 | + | |
| 25 | +## Après un redémarrage | |
| 26 | + | |
| 27 | +L'historique est dans une base à côté de la configuration (une par fichier de configuration) : les channels sont de nouveau là après un redémarrage. Les agents, eux, sont de nouveaux processus : leurs sessions ont disparu. ACP permet à un agent de reprendre une session (`session/load`) ; Sidekicks s'en sert quand l'agent le propose. mm ne le propose pas : Sidekicks ouvre une nouvelle session et donne à l'agent, dans son premier prompt, les derniers messages du channel — les siens compris cette fois, puisque la nouvelle session n'en contient aucun. L'utilisateur ne voit pas la différence : Alice connaît toujours son prénom. | |
| 28 | + | |
| 29 | +Le même mécanisme couvre un agent arrêté pour faire de la place à un autre (deux agents au plus) : son tour suivant ouvre une nouvelle session à qui l'on raconte ce qu'elle a manqué. En milestone M2, la conversation disait « Alice a redémarré et ne se souvient plus du début » ; ce message a disparu. | |
| 30 | + | |
| 31 | +## Les liens avec le reste | |
| 32 | + | |
| 33 | +- [Permissions et confiance](permissions-et-confiance.md) : les cartes de permission fonctionnent de la même façon dans un channel ; la carte nomme l'agent qui demande. | |
| 34 | +- [Mémoire et modèles locaux](memoire-et-modeles-locaux.md) : pourquoi deux agents au plus, et pourquoi les réponses se font à tour de rôle. | |
| 35 | +- [Architecture](architecture.md) : `internal/channels` décide, `internal/store` se souvient, `internal/agents` prête les processus. | |
| new file mode 100644 | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | +# Les channels et ce que sait chaque agent | ||
| 2 | + | ||
| 3 | +## De quoi s'agit-il ? | ||
| 4 | + | ||
| 5 | +Un channel est une conversation de l'utilisateur avec plusieurs agents à la fois : poser une question à Alice, puis demander à Bob son avis sur la réponse d'Alice. L'Agent Client Protocol ne connaît pas cette situation. Il relie un client à un agent, dans des sessions où ils ne parlent qu'à deux. Tout ce qui fait un channel — qui répond, qui sait ce qui a été dit — est décidé par Sidekicks. Cette page explique ces choix ; les règles exactes sont dans la [référence](../reference/agents-et-evenements.md#channels-milestones-m2-et-m3), les décisions dans l'[ADR 0006](../../adr/0006-channels.md) (en anglais). | ||
| 6 | + | ||
| 7 | +## Une session par agent et par channel | ||
| 8 | + | ||
| 9 | +Chaque agent d'un channel a sa propre session ACP pour ce channel. Alice dans #general et Alice dans #translations sont deux sessions du même processus : ce qui est dit à Alice dans un channel ne passe jamais dans l'autre. | ||
| 10 | + | ||
| 11 | +Une session ne contient que ce qui s'y est dit. Quand on interroge Bob après une réponse d'Alice, la session de Bob ne sait rien de cette réponse. Avant le tour de Bob, Sidekicks ajoute donc à son prompt ce que Bob n'a pas vu : les messages de l'utilisateur aux autres et les réponses des autres agents, depuis le dernier tour de Bob. L'agent lit une courte transcription, puis le message de l'utilisateur. L'agent garde sa propre session — et le souvenir de ses propres réponses — tout en suivant le channel. | ||
| 12 | + | ||
| 13 | +L'alternative, une session commune lue par tous les agents, n'existe pas dans ACP : une session appartient à un agent. Renvoyer tout le channel à chaque tour remplirait en quelques échanges les petites fenêtres de contexte des modèles locaux. N'envoyer que la nouveauté est la façon la plus économe de rester à jour ; la transcription est limitée aux 20 derniers messages, chacun raccourci, pour qu'un long channel n'étouffe pas la question. | ||
| 14 | + | ||
| 15 | +## Qui répond, et pourquoi un seul à la fois | ||
| 16 | + | ||
| 17 | +Un message va aux agents qu'il mentionne, dans l'ordre ; sans mention, au dernier agent qui a parlé. C'est ainsi qu'on parle en groupe : on nomme quelqu'un, ou on continue avec la personne à qui l'on parlait. Personne ne répond à un message qui ne nomme personne dans un channel où plusieurs agents n'ont pas encore parlé — deviner surprendrait davantage l'utilisateur que lui demander de choisir. | ||
| 18 | + | ||
| 19 | +Les agents répondent l'un après l'autre, jamais en même temps. Sur un portable, deux modèles locaux qui génèrent ensemble sont chacun plus lents qu'à tour de rôle, et la mémoire est la vraie limite (deux petits modèles sur la machine de référence). Répondre à tour de rôle permet aussi au second agent de lire la réponse du premier : « @alice @bob comparez » donne à Bob la réponse d'Alice à comparer. Pour la même raison, un agent répond à un seul channel à la fois : un second channel attend, et indique à qui c'est le tour. | ||
| 20 | + | ||
| 21 | +## Les commandes partent seules | ||
| 22 | + | ||
| 23 | +`/idioms Il pleut des cordes` demande à Garfield d'exécuter une skill. L'agent reconnaît une commande à son premier caractère. Si la transcription du channel la précédait, la commande n'en serait plus une. Un message qui commence par `/` est donc envoyé tel quel, sans contexte, et `/garfield:idioms …` choisit l'agent quand le channel en compte plusieurs. Un agent annonce ses commandes dès qu'une session s'ouvre, avant toute question — et mm le fait sans charger son modèle. Le premier `/` tapé dans une conversation démarre donc les membres qui n'ont jamais annoncé les leurs et ouvre leur session (celle que servira leur première réponse) ; la liste se remplit en une fraction de seconde. Sidekicks garde ensuite les commandes et les propose aussitôt les fois suivantes. | ||
| 24 | + | ||
| 25 | +## Après un redémarrage | ||
| 26 | + | ||
| 27 | +L'historique est dans une base à côté de la configuration (une par fichier de configuration) : les channels sont de nouveau là après un redémarrage. Les agents, eux, sont de nouveaux processus : leurs sessions ont disparu. ACP permet à un agent de reprendre une session (`session/load`) ; Sidekicks s'en sert quand l'agent le propose. mm ne le propose pas : Sidekicks ouvre une nouvelle session et donne à l'agent, dans son premier prompt, les derniers messages du channel — les siens compris cette fois, puisque la nouvelle session n'en contient aucun. L'utilisateur ne voit pas la différence : Alice connaît toujours son prénom. | ||
| 28 | + | ||
| 29 | +Le même mécanisme couvre un agent arrêté pour faire de la place à un autre (deux agents au plus) : son tour suivant ouvre une nouvelle session à qui l'on raconte ce qu'elle a manqué. En milestone M2, la conversation disait « Alice a redémarré et ne se souvient plus du début » ; ce message a disparu. | ||
| 30 | + | ||
| 31 | +## Les liens avec le reste | ||
| 32 | + | ||
| 33 | +- [Permissions et confiance](permissions-et-confiance.md) : les cartes de permission fonctionnent de la même façon dans un channel ; la carte nomme l'agent qui demande. | ||
| 34 | +- [Mémoire et modèles locaux](memoire-et-modeles-locaux.md) : pourquoi deux agents au plus, et pourquoi les réponses se font à tour de rôle. | ||
| 35 | +- [Architecture](architecture.md) : `internal/channels` décide, `internal/store` se souvient, `internal/agents` prête les processus. | ||
added
docs/fr/how-to/gerer-les-channels.md +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +# Gérer les channels et leurs agents | |
| 2 | + | |
| 3 | +Ce guide montre comment créer, renommer et supprimer des channels, choisir les agents qui y participent, et parler directement à un agent. Il suppose que Sidekicks tourne avec une configuration qui contient des agents. Les libellés sont ceux de l'interface en anglais, sa langue par défaut. | |
| 4 | + | |
| 5 | +## Créer un channel | |
| 6 | + | |
| 7 | +1. Dans le panneau **Channels**, cliquez sur **+ New channel** sous la liste. | |
| 8 | +2. Tapez un nom (1 à 64 caractères, espaces et accents autorisés) et appuyez sur Entrée. Le channel s'ouvre au centre, sans membre. | |
| 9 | +3. Invitez des agents (section suivante), ou mentionnez-en un : `@alice …` invite Alice avant sa réponse — le channel affiche « Alice (assistant) joined the channel. » | |
| 10 | + | |
| 11 | +Un nom déjà utilisé par un autre channel, sans tenir compte de la casse, est refusé : l'erreur s'affiche sous le nom. | |
| 12 | + | |
| 13 | +## Choisir les agents d'un channel | |
| 14 | + | |
| 15 | +- **Inviter** : dans l'en-tête du channel, ouvrez **+ Invite** et choisissez un agent. | |
| 16 | +- **Retirer** : cliquez sur **×** à côté du nom d'un agent dans l'en-tête. Ses anciens messages restent ; l'agent ne répond plus dans ce channel, sauf nouvelle mention. | |
| 17 | + | |
| 18 | +## Renommer ou supprimer un channel | |
| 19 | + | |
| 20 | +Survolez le channel dans la liste : | |
| 21 | + | |
| 22 | +- **✎** transforme son nom en zone de saisie : tapez le nouveau nom, Entrée pour le garder, Échap pour annuler. | |
| 23 | +- **🗑** demande « Delete #nom and its whole history? » : **Delete** supprime le channel, ses membres et tous ses messages, définitivement. | |
| 24 | + | |
| 25 | +## Parler directement à un agent | |
| 26 | + | |
| 27 | +Cliquez sur un agent dans le panneau **Agents**. Sa conversation directe s'ouvre, et apparaît sous **Direct messages** dans le panneau Channels. Chaque message va à cet agent ; les mentions `@` y sont du simple texte. | |
| 28 | + | |
| 29 | +## S'adresser au bon agent | |
| 30 | + | |
| 31 | +| Vous voulez | Tapez | | |
| 32 | +| --- | --- | | |
| 33 | +| qu'un agent précis réponde | `@nom votre message` (la liste après `@` se filtre pendant la frappe ; Tab ou Entrée choisit) | | |
| 34 | +| que plusieurs agents répondent à tour de rôle | `@alice @bob comparez vos réponses` | | |
| 35 | +| continuer avec l'agent à qui vous parliez | votre message, sans mention | | |
| 36 | +| une commande d'un agent | `/` puis choisissez-la dans la liste ; avec plusieurs agents, `/agent:commande` | | |
| 37 | + | |
| 38 | +## Variantes | |
| 39 | + | |
| 40 | +- **Channels du fichier de configuration** : les channels déclarés sous `channels:` sont créés au démarrage s'ils n'existent pas encore, avec leurs agents ; ensuite ce sont des channels ordinaires. Les retirer du fichier ne supprime rien ([référence de la configuration](../reference/configuration.md)). | |
| 41 | +- **Repartir d'un historique vide** : quittez Sidekicks et supprimez le dossier `sidekicks/data/<…>` de votre configuration (son emplacement est dans la [référence](../reference/agents-et-evenements.md#historique)). Les channels déclarés dans le fichier reviennent, vides, au lancement suivant. | |
| 42 | +- **Bandeau « History unavailable »** : un autre Sidekicks a ouvert le même fichier de configuration. Celui-ci continue de fonctionner, mais oublie tout en quittant ; fermez l'autre et relancez. | |
| 43 | + | |
| 44 | +## Voir aussi | |
| 45 | + | |
| 46 | +- [Les channels et ce que sait chaque agent](../explanation/channels-et-contexte.md) | |
| 47 | +- [Référence : agents, channels et événements](../reference/agents-et-evenements.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +# Gérer les channels et leurs agents | ||
| 2 | + | ||
| 3 | +Ce guide montre comment créer, renommer et supprimer des channels, choisir les agents qui y participent, et parler directement à un agent. Il suppose que Sidekicks tourne avec une configuration qui contient des agents. Les libellés sont ceux de l'interface en anglais, sa langue par défaut. | ||
| 4 | + | ||
| 5 | +## Créer un channel | ||
| 6 | + | ||
| 7 | +1. Dans le panneau **Channels**, cliquez sur **+ New channel** sous la liste. | ||
| 8 | +2. Tapez un nom (1 à 64 caractères, espaces et accents autorisés) et appuyez sur Entrée. Le channel s'ouvre au centre, sans membre. | ||
| 9 | +3. Invitez des agents (section suivante), ou mentionnez-en un : `@alice …` invite Alice avant sa réponse — le channel affiche « Alice (assistant) joined the channel. » | ||
| 10 | + | ||
| 11 | +Un nom déjà utilisé par un autre channel, sans tenir compte de la casse, est refusé : l'erreur s'affiche sous le nom. | ||
| 12 | + | ||
| 13 | +## Choisir les agents d'un channel | ||
| 14 | + | ||
| 15 | +- **Inviter** : dans l'en-tête du channel, ouvrez **+ Invite** et choisissez un agent. | ||
| 16 | +- **Retirer** : cliquez sur **×** à côté du nom d'un agent dans l'en-tête. Ses anciens messages restent ; l'agent ne répond plus dans ce channel, sauf nouvelle mention. | ||
| 17 | + | ||
| 18 | +## Renommer ou supprimer un channel | ||
| 19 | + | ||
| 20 | +Survolez le channel dans la liste : | ||
| 21 | + | ||
| 22 | +- **✎** transforme son nom en zone de saisie : tapez le nouveau nom, Entrée pour le garder, Échap pour annuler. | ||
| 23 | +- **🗑** demande « Delete #nom and its whole history? » : **Delete** supprime le channel, ses membres et tous ses messages, définitivement. | ||
| 24 | + | ||
| 25 | +## Parler directement à un agent | ||
| 26 | + | ||
| 27 | +Cliquez sur un agent dans le panneau **Agents**. Sa conversation directe s'ouvre, et apparaît sous **Direct messages** dans le panneau Channels. Chaque message va à cet agent ; les mentions `@` y sont du simple texte. | ||
| 28 | + | ||
| 29 | +## S'adresser au bon agent | ||
| 30 | + | ||
| 31 | +| Vous voulez | Tapez | | ||
| 32 | +| --- | --- | | ||
| 33 | +| qu'un agent précis réponde | `@nom votre message` (la liste après `@` se filtre pendant la frappe ; Tab ou Entrée choisit) | | ||
| 34 | +| que plusieurs agents répondent à tour de rôle | `@alice @bob comparez vos réponses` | | ||
| 35 | +| continuer avec l'agent à qui vous parliez | votre message, sans mention | | ||
| 36 | +| une commande d'un agent | `/` puis choisissez-la dans la liste ; avec plusieurs agents, `/agent:commande` | | ||
| 37 | + | ||
| 38 | +## Variantes | ||
| 39 | + | ||
| 40 | +- **Channels du fichier de configuration** : les channels déclarés sous `channels:` sont créés au démarrage s'ils n'existent pas encore, avec leurs agents ; ensuite ce sont des channels ordinaires. Les retirer du fichier ne supprime rien ([référence de la configuration](../reference/configuration.md)). | ||
| 41 | +- **Repartir d'un historique vide** : quittez Sidekicks et supprimez le dossier `sidekicks/data/<…>` de votre configuration (son emplacement est dans la [référence](../reference/agents-et-evenements.md#historique)). Les channels déclarés dans le fichier reviennent, vides, au lancement suivant. | ||
| 42 | +- **Bandeau « History unavailable »** : un autre Sidekicks a ouvert le même fichier de configuration. Celui-ci continue de fonctionner, mais oublie tout en quittant ; fermez l'autre et relancez. | ||
| 43 | + | ||
| 44 | +## Voir aussi | ||
| 45 | + | ||
| 46 | +- [Les channels et ce que sait chaque agent](../explanation/channels-et-contexte.md) | ||
| 47 | +- [Référence : agents, channels et événements](../reference/agents-et-evenements.md) | ||
modified
docs/fr/how-to/lancer-les-tests.md +1 -1 | @@ -35,7 +35,7 @@ La CI (`.github/workflows/ci.yml`) lance la même commande, plus `make bindings- | ||
| 35 | 35 | make test-real |
| 36 | 36 | ``` |
| 37 | 37 | |
| 38 | -La commande lance les tests compilés avec le tag `realagents` : aujourd'hui, la poignée de main ACP avec Alice. Ils ont besoin de `mm` (ou `MM_BIN`) et de llmman, et ne démarrent les agents que via `scripts/agents.sh` : la limite de deux agents et le déchargement s'appliquent. | |
| 38 | +La commande lance les tests compilés avec le tag `realagents` : la poignée de main ACP avec Alice, Riker qui demande la permission avant d'exécuter une commande, Bob qui lit la réponse d'Alice dans un channel, et Alice qui se souvient d'une conversation après un redémarrage (`internal/acp`, `internal/channels`). Comptez environ une minute. Ils ont besoin de `mm` (ou `MM_BIN`) et de llmman, et ne démarrent les agents que via `scripts/agents.sh` : la limite de deux agents et le déchargement s'appliquent. | |
| 39 | 39 | |
| 40 | 40 | ## Variantes |
| 41 | 41 | |
| @@ -35,7 +35,7 @@ La CI (`.github/workflows/ci.yml`) lance la même commande, plus `make bindings- | |||
| 35 | make test-real | 35 | make test-real |
| 36 | ``` | 36 | ``` |
| 37 | 37 | ||
| 38 | -La commande lance les tests compilés avec le tag `realagents` : aujourd'hui, la poignée de main ACP avec Alice. Ils ont besoin de `mm` (ou `MM_BIN`) et de llmman, et ne démarrent les agents que via `scripts/agents.sh` : la limite de deux agents et le déchargement s'appliquent. | 38 | +La commande lance les tests compilés avec le tag `realagents` : la poignée de main ACP avec Alice, Riker qui demande la permission avant d'exécuter une commande, Bob qui lit la réponse d'Alice dans un channel, et Alice qui se souvient d'une conversation après un redémarrage (`internal/acp`, `internal/channels`). Comptez environ une minute. Ils ont besoin de `mm` (ou `MM_BIN`) et de llmman, et ne démarrent les agents que via `scripts/agents.sh` : la limite de deux agents et le déchargement s'appliquent. |
| 39 | 39 | ||
| 40 | ## Variantes | 40 | ## Variantes |
| 41 | 41 | ||
modified
docs/fr/how-to/modifier-ou-changer-la-configuration.md +1 -1 | @@ -13,7 +13,7 @@ Ce guide montre comment modifier le fichier de configuration depuis le panneau * | ||
| 13 | 13 | ## Ouvrir un autre fichier de configuration |
| 14 | 14 | |
| 15 | 15 | 1. Dans **Settings**, cliquez sur **Open…** (**Ouvrir…**) et choisissez un fichier `.yaml`. |
| 16 | -2. Sidekicks arrête tous les agents, oublie les conversations et charge le fichier choisi — même s'il contient des problèmes, qui sont alors affichés. | |
| 16 | +2. Sidekicks arrête tous les agents et charge le fichier choisi avec ses propres channels et son historique — même s'il contient des problèmes, qui sont alors affichés. Rouvrir le premier fichier fait revenir ses channels. | |
| 17 | 17 | 3. Le fichier est mémorisé : **Recent…** (**Récentes…**) liste les 8 derniers, et le prochain lancement ouvre le dernier (sauf si `--config` ou `SIDEKICKS_CONFIG` en décide autrement). |
| 18 | 18 | |
| 19 | 19 | ## Variantes |
| @@ -13,7 +13,7 @@ Ce guide montre comment modifier le fichier de configuration depuis le panneau * | |||
| 13 | ## Ouvrir un autre fichier de configuration | 13 | ## Ouvrir un autre fichier de configuration |
| 14 | 14 | ||
| 15 | 1. Dans **Settings**, cliquez sur **Open…** (**Ouvrir…**) et choisissez un fichier `.yaml`. | 15 | 1. Dans **Settings**, cliquez sur **Open…** (**Ouvrir…**) et choisissez un fichier `.yaml`. |
| 16 | -2. Sidekicks arrête tous les agents, oublie les conversations et charge le fichier choisi — même s'il contient des problèmes, qui sont alors affichés. | 16 | +2. Sidekicks arrête tous les agents et charge le fichier choisi avec ses propres channels et son historique — même s'il contient des problèmes, qui sont alors affichés. Rouvrir le premier fichier fait revenir ses channels. |
| 17 | 3. Le fichier est mémorisé : **Recent…** (**Récentes…**) liste les 8 derniers, et le prochain lancement ouvre le dernier (sauf si `--config` ou `SIDEKICKS_CONFIG` en décide autrement). | 17 | 3. Le fichier est mémorisé : **Recent…** (**Récentes…**) liste les 8 derniers, et le prochain lancement ouvre le dernier (sauf si `--config` ou `SIDEKICKS_CONFIG` en décide autrement). |
| 18 | 18 | ||
| 19 | ## Variantes | 19 | ## Variantes |
modified
docs/fr/how-to/tester-la-milestone-m1-sous-macos.md +1 -1 | @@ -109,4 +109,4 @@ Si tout est conforme, M1 est confirmée : fermez l'issue #12 et passez la milest | ||
| 109 | 109 | |
| 110 | 110 | ## Voir aussi |
| 111 | 111 | |
| 112 | -- [Comment développer l'application](developper-l-application.md) · [Référence : états des agents et événements](../reference/agents-et-evenements.md) | |
| 112 | +- [Comment développer l'application](developper-l-application.md) · [Référence : agents, channels et événements](../reference/agents-et-evenements.md) | |
| @@ -109,4 +109,4 @@ Si tout est conforme, M1 est confirmée : fermez l'issue #12 et passez la milest | |||
| 109 | 109 | ||
| 110 | ## Voir aussi | 110 | ## Voir aussi |
| 111 | 111 | ||
| 112 | -- [Comment développer l'application](developper-l-application.md) · [Référence : états des agents et événements](../reference/agents-et-evenements.md) | 112 | +- [Comment développer l'application](developper-l-application.md) · [Référence : agents, channels et événements](../reference/agents-et-evenements.md) |
modified
docs/fr/how-to/tester-la-milestone-m2-sous-macos.md +4 -4 | @@ -9,7 +9,7 @@ Ouvrez le **Moniteur d'activité** (onglet Mémoire) et gardez un terminal pour | ||
| 9 | 9 | ```bash |
| 10 | 10 | make install # nouvelles dépendances du frontend (CodeMirror, Shiki, markdown-it…) |
| 11 | 11 | make check # attendu : 0 issues, Tests 43 passed, 14 passed, licence headers ok |
| 12 | -make test-real # attendu : ok … internal/acp et ok … internal/conversations (Alice puis Riker) | |
| 12 | +make test-real # attendu : ok … internal/acp et ok … internal/channels (Riker, Alice et Bob, Alice après un redémarrage ; depuis M3) | |
| 13 | 13 | make install-test-config |
| 14 | 14 | make dev |
| 15 | 15 | ``` |
| @@ -48,9 +48,9 @@ Cliquez sur **Garfield**, envoyez `/idioms Il pleut des cordes.` — une ligne | ||
| 48 | 48 | |
| 49 | 49 | ## 4. La limite de mémoire avec les conversations |
| 50 | 50 | |
| 51 | -Avec Alice et Riker démarrés, écrivez à **Garfield** : l'agent inoccupé le plus ancien est arrêté pour lui faire de la place. Revenez ensuite à cet agent et écrivez-lui : un message gris dit qu'il a redémarré et ne se souvient plus du début. | |
| 51 | +Avec Alice et Riker démarrés, écrivez à **Garfield** : l'agent inoccupé le plus ancien est arrêté pour lui faire de la place. Revenez ensuite à cet agent et écrivez-lui. Depuis M3, on lui transmet les derniers messages et aucun message gris n'apparaît (en M2, un message gris disait que l'agent avait redémarré et ne se souvenait plus du début). | |
| 52 | 52 | |
| 53 | -- [ ] Deux agents au plus (`scripts/agents.sh ps`), et le message de redémarrage apparaît. | |
| 53 | +- [ ] Deux agents au plus (`scripts/agents.sh ps`). | |
| 54 | 54 | |
| 55 | 55 | ## 5. L'éditeur de configuration (#18) |
| 56 | 56 | |
| @@ -96,4 +96,4 @@ Si tout est conforme, M2 est confirmée : fermez l'issue #15 et la milestone **M | ||
| 96 | 96 | ## Voir aussi |
| 97 | 97 | |
| 98 | 98 | - [Votre première conversation](../tutorials/premiere-conversation.md) · [Modifier ou changer la configuration](modifier-ou-changer-la-configuration.md) |
| 99 | -- [Référence : états des agents et événements](../reference/agents-et-evenements.md) · [Permissions et confiance](../explanation/permissions-et-confiance.md) | |
| 99 | +- [Référence : agents, channels et événements](../reference/agents-et-evenements.md) · [Permissions et confiance](../explanation/permissions-et-confiance.md) | |
| @@ -9,7 +9,7 @@ Ouvrez le **Moniteur d'activité** (onglet Mémoire) et gardez un terminal pour | |||
| 9 | ```bash | 9 | ```bash |
| 10 | make install # nouvelles dépendances du frontend (CodeMirror, Shiki, markdown-it…) | 10 | make install # nouvelles dépendances du frontend (CodeMirror, Shiki, markdown-it…) |
| 11 | make check # attendu : 0 issues, Tests 43 passed, 14 passed, licence headers ok | 11 | make check # attendu : 0 issues, Tests 43 passed, 14 passed, licence headers ok |
| 12 | -make test-real # attendu : ok … internal/acp et ok … internal/conversations (Alice puis Riker) | 12 | +make test-real # attendu : ok … internal/acp et ok … internal/channels (Riker, Alice et Bob, Alice après un redémarrage ; depuis M3) |
| 13 | make install-test-config | 13 | make install-test-config |
| 14 | make dev | 14 | make dev |
| 15 | ``` | 15 | ``` |
| @@ -48,9 +48,9 @@ Cliquez sur **Garfield**, envoyez `/idioms Il pleut des cordes.` — une ligne | |||
| 48 | 48 | ||
| 49 | ## 4. La limite de mémoire avec les conversations | 49 | ## 4. La limite de mémoire avec les conversations |
| 50 | 50 | ||
| 51 | -Avec Alice et Riker démarrés, écrivez à **Garfield** : l'agent inoccupé le plus ancien est arrêté pour lui faire de la place. Revenez ensuite à cet agent et écrivez-lui : un message gris dit qu'il a redémarré et ne se souvient plus du début. | 51 | +Avec Alice et Riker démarrés, écrivez à **Garfield** : l'agent inoccupé le plus ancien est arrêté pour lui faire de la place. Revenez ensuite à cet agent et écrivez-lui. Depuis M3, on lui transmet les derniers messages et aucun message gris n'apparaît (en M2, un message gris disait que l'agent avait redémarré et ne se souvenait plus du début). |
| 52 | 52 | ||
| 53 | -- [ ] Deux agents au plus (`scripts/agents.sh ps`), et le message de redémarrage apparaît. | 53 | +- [ ] Deux agents au plus (`scripts/agents.sh ps`). |
| 54 | 54 | ||
| 55 | ## 5. L'éditeur de configuration (#18) | 55 | ## 5. L'éditeur de configuration (#18) |
| 56 | 56 | ||
| @@ -96,4 +96,4 @@ Si tout est conforme, M2 est confirmée : fermez l'issue #15 et la milestone **M | |||
| 96 | ## Voir aussi | 96 | ## Voir aussi |
| 97 | 97 | ||
| 98 | - [Votre première conversation](../tutorials/premiere-conversation.md) · [Modifier ou changer la configuration](modifier-ou-changer-la-configuration.md) | 98 | - [Votre première conversation](../tutorials/premiere-conversation.md) · [Modifier ou changer la configuration](modifier-ou-changer-la-configuration.md) |
| 99 | -- [Référence : états des agents et événements](../reference/agents-et-evenements.md) · [Permissions et confiance](../explanation/permissions-et-confiance.md) | 99 | +- [Référence : agents, channels et événements](../reference/agents-et-evenements.md) · [Permissions et confiance](../explanation/permissions-et-confiance.md) |
added
docs/fr/how-to/tester-la-milestone-m3-sous-macos.md +93 -0 | new file mode 100644 | ||
| @@ -0,0 +1,93 @@ | ||
| 1 | +# Tester la milestone M3 sous macOS | |
| 2 | + | |
| 3 | +Ce guide vérifie sur un Mac ce qu'a produit la milestone M3 (Channels & multi-agents) : des channels gardés dans un historique, les mentions `@`, plusieurs agents dans un même channel, les commandes slash, et des conversations qui survivent à un redémarrage. Il suppose le Mac préparé pour la [milestone M2](tester-la-milestone-m2-sous-macos.md) et le dépôt sur la branche de M3. Comptez 30 minutes. Les libellés cités sont ceux de l'interface en anglais, sa langue par défaut. | |
| 4 | + | |
| 5 | +Gardez un terminal pour `scripts/agents.sh ps`. | |
| 6 | + | |
| 7 | +## 0. Préparer | |
| 8 | + | |
| 9 | +```bash | |
| 10 | +make install # Pebble est une nouvelle dépendance Go ; rien de nouveau côté frontend | |
| 11 | +make check # attendu : 0 issues, Tests 56 passed, 14 passed, en-têtes de licence ok | |
| 12 | +make test-real # attendu : ok … internal/channels (Riker, Alice + Bob, Alice après un redémarrage) | |
| 13 | +make install-test-config | |
| 14 | +make dev | |
| 15 | +``` | |
| 16 | + | |
| 17 | +`make install-test-config` compte : les channels de test s'appellent désormais `general` (Alice, Bob) et `translations` (Garfield). | |
| 18 | + | |
| 19 | +- [ ] `make check` et `make test-real` passent ; `scripts/agents.sh ps` ne liste plus aucun modèle ensuite. | |
| 20 | + | |
| 21 | +## 1. Les channels de la configuration (#20, #21) | |
| 22 | + | |
| 23 | +1. Le panneau **Channels** liste `# general` et `# translations`, puis **+ New channel**, puis **Direct messages** (« Click an agent to talk with it directly. »). | |
| 24 | +2. Cliquez sur **# general** : l'en-tête affiche `# general` et deux membres, `Alice (assistant) ×` et `Bob (assistant) ×`, et **+ Invite**. | |
| 25 | + | |
| 26 | +- [ ] Les points 1 et 2 sont conformes. | |
| 27 | + | |
| 28 | +## 2. Mentions et deux agents (#22, #23) | |
| 29 | + | |
| 30 | +1. Dans #general, tapez `@` : la liste des quatre agents s'ouvre au-dessus de la zone de saisie. Tapez `b` : seul Bob reste. Appuyez sur **Échap** : la liste se ferme. Videz la zone. | |
| 31 | +2. Tapez `@al`, appuyez sur **Tab** : `@alice ` est inséré. Complétez : `@alice Reply only with this code: 4217` et envoyez. L'en-tête indique « Alice (assistant) is answering… » ; Alice répond `4217`. Dans votre message, `@alice` est mis en évidence. | |
| 32 | +3. Envoyez `@bob What code did Alice give? Answer with the code only.` — Bob démarre et répond `4217` (sans « Bob: » devant). | |
| 33 | +4. Envoyez `and you, Bob, which number?` sans mention : Bob répond (le dernier agent qui a parlé). | |
| 34 | +5. Envoyez `@alice @bob Say hello in three words.` — Alice répond, puis Bob ; jamais les deux en même temps. `scripts/agents.sh ps` montre deux agents au plus. | |
| 35 | +6. Cliquez sur **■ Stop** pendant qu'Alice répond à une longue demande (`@alice @bob Write a poem of 40 lines about the sea.`) : la réponse d'Alice se termine par « Answer stopped. » et Bob ne répond pas. | |
| 36 | + | |
| 37 | +- [ ] Les points 1 à 6 sont conformes. | |
| 38 | + | |
| 39 | +## 3. Gérer les channels (#21) | |
| 40 | + | |
| 41 | +1. Cliquez sur **+ New channel**, tapez `Apollo`, Entrée : `# Apollo` s'ouvre, sans membre. | |
| 42 | +2. Envoyez `anyone here?` : un message gris dit « Nobody was addressed: mention an agent with @ (for example @alice). » | |
| 43 | +3. Ouvrez **+ Invite**, choisissez **Alice** : « Alice (assistant) joined the channel. » Cliquez sur **×** à côté du nom d'Alice dans l'en-tête : « Alice (assistant) left the channel. » | |
| 44 | +4. Créez un autre channel nommé `apollo` : refusé, l'erreur s'affiche sous le nom. | |
| 45 | +5. Survolez `# Apollo`, cliquez sur **✎**, tapez `Artemis`, Entrée : renommé partout. | |
| 46 | +6. Survolez `# Artemis`, cliquez sur **🗑** : « Delete #Artemis and its whole history? » — **Cancel** le garde ; **🗑** puis **Delete** le supprime. | |
| 47 | + | |
| 48 | +- [ ] Les points 1 à 6 sont conformes. | |
| 49 | + | |
| 50 | +## 4. Conversations directes et réponses non lues | |
| 51 | + | |
| 52 | +1. Cliquez sur **Alice (assistant)** dans le panneau **Agents** : la conversation directe s'ouvre et apparaît sous **Direct messages**. | |
| 53 | +2. Envoyez `Write a haiku about autumn.` et cliquez aussitôt sur **# general**. Quand la réponse est terminée, une pastille bleue `1` apparaît à côté d'Alice sous Direct messages ; un clic ouvre la conversation et la pastille disparaît. | |
| 54 | +3. Dans la conversation directe, `@bob hello` va à Alice (les mentions y sont du simple texte). | |
| 55 | + | |
| 56 | +- [ ] Les points 1 à 3 sont conformes. | |
| 57 | + | |
| 58 | +## 5. Commandes slash (#24) | |
| 59 | + | |
| 60 | +1. Cliquez sur **# translations** et tapez `/` : en moins d'une seconde, la liste affiche les commandes de Garfield (`/compact`, `/new`, `/unload`, `/email-letter`, `/idioms`…), bien que Garfield n'ait jamais répondu ; `scripts/agents.sh ps` montre Garfield démarré et **aucun modèle chargé**. Appuyez sur **Échap**, videz la zone. | |
| 61 | +2. Envoyez `/idioms Il pleut des cordes.` — à côté du nom de Garfield, un spinner qui tourne et « thinking… » pendant le chargement du modèle, puis « 🛠 read_skill: idioms ✓ done », puis une traduction du type « It's raining cats and dogs. » ; le spinner disparaît à la fin de la réponse. | |
| 62 | +3. Tapez `/id` : la liste propose **/idioms** avec sa description ; **Tab** la complète. | |
| 63 | +4. Dans #general, invitez Garfield avec **+ Invite**, puis tapez `/gar` : la liste propose `/garfield:idioms`, `/garfield:email-letter`… Choisissez `/garfield:idioms`, complétez avec `Il pleut des cordes.` et envoyez : Garfield répond, ni Alice ni Bob. | |
| 64 | + | |
| 65 | +- [ ] Les points 1 à 4 sont conformes. | |
| 66 | + | |
| 67 | +## 6. Après un redémarrage (#20, #25) | |
| 68 | + | |
| 69 | +1. Quittez (⌘Q). `scripts/agents.sh ps` ne liste ni agent ni modèle. | |
| 70 | +2. Relancez `make dev`, cliquez sur **# general** : tous les messages sont là, avec le nom des agents. | |
| 71 | +3. Envoyez `@alice What code did you give?` — Alice, nouveau processus, répond `4217`. | |
| 72 | +4. Tapez `/` dans **# translations** avant que Garfield ne démarre : les commandes sont déjà proposées. | |
| 73 | + | |
| 74 | +- [ ] Les points 1 à 4 sont conformes. | |
| 75 | + | |
| 76 | +## 7. Deux fichiers de configuration, deux historiques | |
| 77 | + | |
| 78 | +1. **Settings → Open…**, choisissez `examples/test-agents.yaml` du dépôt : le panneau Channels affiche les channels de ce fichier, vides (son propre historique). | |
| 79 | +2. Revenez à la configuration par défaut avec **Open…** (`~/Library/Application Support/sidekicks/config.yaml` — ⇧⌘G dans le sélecteur pour taper le chemin) : les conversations des sections 2 à 6 reviennent. | |
| 80 | + | |
| 81 | +- [ ] Les points 1 et 2 sont conformes. | |
| 82 | + | |
| 83 | +## Si quelque chose diffère | |
| 84 | + | |
| 85 | +| Symptôme | Cause probable | Que faire | | |
| 86 | +| --- | --- | --- | | |
| 87 | +| `# général` et `# traductions` au lieu de `general` / `translations` | ancienne configuration de test | `make install-test-config`, puis relancer | | |
| 88 | +| Bandeau jaune « History unavailable » | un autre Sidekicks a ouvert le même fichier | le quitter et relancer | | |
| 89 | +| Bob répond à côté | petit modèle ; la transcription lui a bien été donnée | relire la réponse, ou poser la question plus directement | | |
| 90 | + | |
| 91 | +## Clore la milestone | |
| 92 | + | |
| 93 | +Si tout est conforme, M3 est confirmée : fermez l'issue #21 (les autres sont fermées) et la milestone **M3 · Channels & multi-agents** (ou demandez-le à l'assistant). Sinon, commentez l'issue concernée avec ce qui diffère. | |
| new file mode 100644 | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | +# Tester la milestone M3 sous macOS | ||
| 2 | + | ||
| 3 | +Ce guide vérifie sur un Mac ce qu'a produit la milestone M3 (Channels & multi-agents) : des channels gardés dans un historique, les mentions `@`, plusieurs agents dans un même channel, les commandes slash, et des conversations qui survivent à un redémarrage. Il suppose le Mac préparé pour la [milestone M2](tester-la-milestone-m2-sous-macos.md) et le dépôt sur la branche de M3. Comptez 30 minutes. Les libellés cités sont ceux de l'interface en anglais, sa langue par défaut. | ||
| 4 | + | ||
| 5 | +Gardez un terminal pour `scripts/agents.sh ps`. | ||
| 6 | + | ||
| 7 | +## 0. Préparer | ||
| 8 | + | ||
| 9 | +```bash | ||
| 10 | +make install # Pebble est une nouvelle dépendance Go ; rien de nouveau côté frontend | ||
| 11 | +make check # attendu : 0 issues, Tests 56 passed, 14 passed, en-têtes de licence ok | ||
| 12 | +make test-real # attendu : ok … internal/channels (Riker, Alice + Bob, Alice après un redémarrage) | ||
| 13 | +make install-test-config | ||
| 14 | +make dev | ||
| 15 | +``` | ||
| 16 | + | ||
| 17 | +`make install-test-config` compte : les channels de test s'appellent désormais `general` (Alice, Bob) et `translations` (Garfield). | ||
| 18 | + | ||
| 19 | +- [ ] `make check` et `make test-real` passent ; `scripts/agents.sh ps` ne liste plus aucun modèle ensuite. | ||
| 20 | + | ||
| 21 | +## 1. Les channels de la configuration (#20, #21) | ||
| 22 | + | ||
| 23 | +1. Le panneau **Channels** liste `# general` et `# translations`, puis **+ New channel**, puis **Direct messages** (« Click an agent to talk with it directly. »). | ||
| 24 | +2. Cliquez sur **# general** : l'en-tête affiche `# general` et deux membres, `Alice (assistant) ×` et `Bob (assistant) ×`, et **+ Invite**. | ||
| 25 | + | ||
| 26 | +- [ ] Les points 1 et 2 sont conformes. | ||
| 27 | + | ||
| 28 | +## 2. Mentions et deux agents (#22, #23) | ||
| 29 | + | ||
| 30 | +1. Dans #general, tapez `@` : la liste des quatre agents s'ouvre au-dessus de la zone de saisie. Tapez `b` : seul Bob reste. Appuyez sur **Échap** : la liste se ferme. Videz la zone. | ||
| 31 | +2. Tapez `@al`, appuyez sur **Tab** : `@alice ` est inséré. Complétez : `@alice Reply only with this code: 4217` et envoyez. L'en-tête indique « Alice (assistant) is answering… » ; Alice répond `4217`. Dans votre message, `@alice` est mis en évidence. | ||
| 32 | +3. Envoyez `@bob What code did Alice give? Answer with the code only.` — Bob démarre et répond `4217` (sans « Bob: » devant). | ||
| 33 | +4. Envoyez `and you, Bob, which number?` sans mention : Bob répond (le dernier agent qui a parlé). | ||
| 34 | +5. Envoyez `@alice @bob Say hello in three words.` — Alice répond, puis Bob ; jamais les deux en même temps. `scripts/agents.sh ps` montre deux agents au plus. | ||
| 35 | +6. Cliquez sur **■ Stop** pendant qu'Alice répond à une longue demande (`@alice @bob Write a poem of 40 lines about the sea.`) : la réponse d'Alice se termine par « Answer stopped. » et Bob ne répond pas. | ||
| 36 | + | ||
| 37 | +- [ ] Les points 1 à 6 sont conformes. | ||
| 38 | + | ||
| 39 | +## 3. Gérer les channels (#21) | ||
| 40 | + | ||
| 41 | +1. Cliquez sur **+ New channel**, tapez `Apollo`, Entrée : `# Apollo` s'ouvre, sans membre. | ||
| 42 | +2. Envoyez `anyone here?` : un message gris dit « Nobody was addressed: mention an agent with @ (for example @alice). » | ||
| 43 | +3. Ouvrez **+ Invite**, choisissez **Alice** : « Alice (assistant) joined the channel. » Cliquez sur **×** à côté du nom d'Alice dans l'en-tête : « Alice (assistant) left the channel. » | ||
| 44 | +4. Créez un autre channel nommé `apollo` : refusé, l'erreur s'affiche sous le nom. | ||
| 45 | +5. Survolez `# Apollo`, cliquez sur **✎**, tapez `Artemis`, Entrée : renommé partout. | ||
| 46 | +6. Survolez `# Artemis`, cliquez sur **🗑** : « Delete #Artemis and its whole history? » — **Cancel** le garde ; **🗑** puis **Delete** le supprime. | ||
| 47 | + | ||
| 48 | +- [ ] Les points 1 à 6 sont conformes. | ||
| 49 | + | ||
| 50 | +## 4. Conversations directes et réponses non lues | ||
| 51 | + | ||
| 52 | +1. Cliquez sur **Alice (assistant)** dans le panneau **Agents** : la conversation directe s'ouvre et apparaît sous **Direct messages**. | ||
| 53 | +2. Envoyez `Write a haiku about autumn.` et cliquez aussitôt sur **# general**. Quand la réponse est terminée, une pastille bleue `1` apparaît à côté d'Alice sous Direct messages ; un clic ouvre la conversation et la pastille disparaît. | ||
| 54 | +3. Dans la conversation directe, `@bob hello` va à Alice (les mentions y sont du simple texte). | ||
| 55 | + | ||
| 56 | +- [ ] Les points 1 à 3 sont conformes. | ||
| 57 | + | ||
| 58 | +## 5. Commandes slash (#24) | ||
| 59 | + | ||
| 60 | +1. Cliquez sur **# translations** et tapez `/` : en moins d'une seconde, la liste affiche les commandes de Garfield (`/compact`, `/new`, `/unload`, `/email-letter`, `/idioms`…), bien que Garfield n'ait jamais répondu ; `scripts/agents.sh ps` montre Garfield démarré et **aucun modèle chargé**. Appuyez sur **Échap**, videz la zone. | ||
| 61 | +2. Envoyez `/idioms Il pleut des cordes.` — à côté du nom de Garfield, un spinner qui tourne et « thinking… » pendant le chargement du modèle, puis « 🛠 read_skill: idioms ✓ done », puis une traduction du type « It's raining cats and dogs. » ; le spinner disparaît à la fin de la réponse. | ||
| 62 | +3. Tapez `/id` : la liste propose **/idioms** avec sa description ; **Tab** la complète. | ||
| 63 | +4. Dans #general, invitez Garfield avec **+ Invite**, puis tapez `/gar` : la liste propose `/garfield:idioms`, `/garfield:email-letter`… Choisissez `/garfield:idioms`, complétez avec `Il pleut des cordes.` et envoyez : Garfield répond, ni Alice ni Bob. | ||
| 64 | + | ||
| 65 | +- [ ] Les points 1 à 4 sont conformes. | ||
| 66 | + | ||
| 67 | +## 6. Après un redémarrage (#20, #25) | ||
| 68 | + | ||
| 69 | +1. Quittez (⌘Q). `scripts/agents.sh ps` ne liste ni agent ni modèle. | ||
| 70 | +2. Relancez `make dev`, cliquez sur **# general** : tous les messages sont là, avec le nom des agents. | ||
| 71 | +3. Envoyez `@alice What code did you give?` — Alice, nouveau processus, répond `4217`. | ||
| 72 | +4. Tapez `/` dans **# translations** avant que Garfield ne démarre : les commandes sont déjà proposées. | ||
| 73 | + | ||
| 74 | +- [ ] Les points 1 à 4 sont conformes. | ||
| 75 | + | ||
| 76 | +## 7. Deux fichiers de configuration, deux historiques | ||
| 77 | + | ||
| 78 | +1. **Settings → Open…**, choisissez `examples/test-agents.yaml` du dépôt : le panneau Channels affiche les channels de ce fichier, vides (son propre historique). | ||
| 79 | +2. Revenez à la configuration par défaut avec **Open…** (`~/Library/Application Support/sidekicks/config.yaml` — ⇧⌘G dans le sélecteur pour taper le chemin) : les conversations des sections 2 à 6 reviennent. | ||
| 80 | + | ||
| 81 | +- [ ] Les points 1 et 2 sont conformes. | ||
| 82 | + | ||
| 83 | +## Si quelque chose diffère | ||
| 84 | + | ||
| 85 | +| Symptôme | Cause probable | Que faire | | ||
| 86 | +| --- | --- | --- | | ||
| 87 | +| `# général` et `# traductions` au lieu de `general` / `translations` | ancienne configuration de test | `make install-test-config`, puis relancer | | ||
| 88 | +| Bandeau jaune « History unavailable » | un autre Sidekicks a ouvert le même fichier | le quitter et relancer | | ||
| 89 | +| Bob répond à côté | petit modèle ; la transcription lui a bien été donnée | relire la réponse, ou poser la question plus directement | | ||
| 90 | + | ||
| 91 | +## Clore la milestone | ||
| 92 | + | ||
| 93 | +Si tout est conforme, M3 est confirmée : fermez l'issue #21 (les autres sont fermées) et la milestone **M3 · Channels & multi-agents** (ou demandez-le à l'assistant). Sinon, commentez l'issue concernée avec ce qui diffère. | ||
modified
docs/fr/reference/agents-et-evenements.md +68 -25 | @@ -1,4 +1,4 @@ | ||
| 1 | -# Référence : états des agents et événements | |
| 1 | +# Référence : agents, channels et événements | |
| 2 | 2 | |
| 3 | 3 | ## États d'un agent |
| 4 | 4 | |
| @@ -48,46 +48,89 @@ Les noms et types Go sont dans `internal/events` ; les constantes et types TypeS | ||
| 48 | 48 | | `AgentService` | `List()` | Tous les agents avec `name, title, icon, description, state, error, outdated`, dans l'ordre de la configuration. | |
| 49 | 49 | | | `Start(name)`, `Stop(name)`, `Restart(name)` | Comme dans les transitions ci-dessus ; une erreur est renvoyée à l'interface. | |
| 50 | 50 | | | `Logs(name)` | Les 200 dernières lignes stderr de l'agent. | |
| 51 | -| `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. | | |
| 51 | +| `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, historyProblem`. | | |
| 52 | 52 | |
| 53 | -## Conversations (milestone M2) | |
| 53 | +## Channels (milestones M2 et M3) | |
| 54 | 54 | |
| 55 | -Une conversation par agent, gardée en mémoire jusqu'à la fermeture de l'application ou l'ouverture d'une autre configuration. | |
| 55 | +Un channel contient une conversation entre l'utilisateur et un ou plusieurs agents. Une conversation directe est un channel à un seul membre, sans nom, dont `direct` vaut le nom de son agent. Les channels, les membres et les messages terminés sont gardés dans l'historique du fichier de configuration (ci-dessous) ; une réponse en cours est gardée quand elle se termine. | |
| 56 | + | |
| 57 | +### Channels | |
| 58 | + | |
| 59 | +| Champ | Signification | | |
| 60 | +| --- | --- | | |
| 61 | +| `id` | identifiant triable | | |
| 62 | +| `name`, `description` | 1 à 64 caractères, unique sans tenir compte de la casse ; vide pour une conversation directe | | |
| 63 | +| `direct` | l'agent d'une conversation directe ; vide pour un channel | | |
| 64 | +| `members` | noms des agents, dans l'ordre d'arrivée | | |
| 65 | + | |
| 66 | +### Qui répond à un message | |
| 67 | + | |
| 68 | +| Le message | Répondu par | | |
| 69 | +| --- | --- | | |
| 70 | +| `/agent:commande arguments` (`agent` est un agent de la configuration) | cet agent, qui reçoit `/commande arguments` | | |
| 71 | +| contient des mentions `@nom` d'agents de la configuration | les agents mentionnés, l'un après l'autre, dans l'ordre des mentions ; un agent mentionné qui n'est pas membre rejoint d'abord le channel | | |
| 72 | +| sans mention | le dernier agent qui a répondu dans le channel ; avec un seul membre, ce membre | | |
| 73 | +| sans mention, personne n'a encore répondu, plusieurs membres | personne : message système `no-recipient` | | |
| 74 | +| tout message d'une conversation directe | son agent ; les mentions y sont du simple texte | | |
| 75 | + | |
| 76 | +Un message qui commence par `/` (éventuellement après des mentions) est une commande : il est envoyé seul. Tout autre message est envoyé avec les messages du channel que l'agent n'a pas encore vus (au plus les 20 derniers, chacun coupé à 1500 caractères). Un agent répond à un seul channel à la fois ; un channel qui l'attend indique à qui c'est le tour. | |
| 56 | 77 | |
| 57 | 78 | ### Messages |
| 58 | 79 | |
| 59 | 80 | | Champ | Signification | |
| 60 | 81 | | --- | --- | |
| 61 | -| `id`, `agent`, `createdAt` | identité | | |
| 82 | +| `id`, `channel`, `createdAt` | identité | | |
| 83 | +| `agent` | l'auteur d'un message d'agent, ou l'agent dont parle un message système | | |
| 62 | 84 | | `role` | `user`, `agent` ou `system` | |
| 63 | -| `parts` | `text` (Markdown), `thought` (réflexion), `tool` (un tool call), `plan` (étapes du plan) | | |
| 64 | -| `done` | faux tant qu'un message d'agent arrive | | |
| 65 | -| `queued` | un message de l'utilisateur qui attend la fin de la réponse en cours | | |
| 66 | -| `stopReason` | comment s'est terminé un message d'agent : `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` | | |
| 67 | -| `error` | pourquoi un message d'agent s'est terminé sans stop reason (l'explication de l'agent lui-même), ou `cancelled` pour un message en attente abandonné par Arrêter | | |
| 68 | -| `code` | messages système : `agent-restarted` (nouvelle session, l'agent a oublié le début), `agent-unavailable` (impossible de le démarrer ou de le joindre ; `error` dit pourquoi) | | |
| 85 | +| `parts` | `text` (Markdown), `thought` (raisonnement), `tool` (un appel d'outil), `plan` (étapes d'un plan) | | |
| 86 | +| `done` | faux pendant qu'un message d'agent s'affiche | | |
| 87 | +| `queued` | un message de l'utilisateur qui attend la fin des réponses en cours | | |
| 88 | +| `stopReason` | comment un message d'agent s'est terminé : `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` | | |
| 89 | +| `error` | pourquoi un message d'agent s'est terminé sans raison d'arrêt (l'explication de l'agent), ou `cancelled` pour un message en attente abandonné par Stop | | |
| 90 | +| `code` | messages système : `agent-joined`, `agent-left`, `no-recipient`, `agent-unavailable` (impossible de le démarrer ou de le joindre ; `error` dit pourquoi) | | |
| 69 | 91 | |
| 70 | -Un tool call a `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (JSON compact), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`). | |
| 92 | +Un appel d'outil a `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (JSON compact), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`). | |
| 71 | 93 | |
| 72 | 94 | ### Événements |
| 73 | 95 | |
| 74 | -| Événement | Charge | Envoyé quand | | |
| 96 | +| Événement | Contenu | Envoyé quand | | |
| 75 | 97 | | --- | --- | --- | |
| 76 | -| `message:updated` | `{agent, message}` | un message est ajouté, ou terminé | | |
| 77 | -| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | `text` s'ajoute à la partie `index` (texte, réflexion), ou `part` la remplace (outil, plan) | | |
| 78 | -| `turn:changed` | `{agent, busy, queued}` | l'agent commence ou finit de répondre ; `queued` messages attendent | | |
| 79 | -| `permission:requested` | `{id, agent, messageId, toolCall, options[], rejections}` | l'agent demande à utiliser un outil ; `rejections` compte les refus déjà donnés dans ce tour | | |
| 80 | -| `permission:resolved` | `{id, agent}` | la demande a reçu une réponse, ou a été annulée par Arrêter | | |
| 81 | -| `usage:changed` | `{agent, usage: {used, size}}` | l'agent a indiqué l'usage de sa fenêtre de contexte (`size` vaut 0 si inconnue) | | |
| 98 | +| `channels:changed` | `{channels[]}` | un channel est créé, renommé ou supprimé, ou ses membres changent | | |
| 99 | +| `message:updated` | `{channel, message}` | un message est ajouté, ou terminé | | |
| 100 | +| `message:delta` | `{channel, messageId, index, kind, text?, part?}` | `text` s'ajoute à la partie `index` (texte, raisonnement), ou `part` la remplace (outil, plan) | | |
| 101 | +| `turn:changed` | `{channel, busy, queued, answering}` | le channel commence ou arrête de répondre, ou le tour d'un autre agent commence ; `queued` messages attendent | | |
| 102 | +| `permission:requested` | `{id, channel, agent, messageId, toolCall, options[], rejections}` | un agent demande à utiliser un outil ; `rejections` compte les refus déjà donnés pendant ce tour | | |
| 103 | +| `permission:resolved` | `{id, channel}` | la demande a reçu une réponse, ou a été annulée par Stop | | |
| 104 | +| `usage:changed` | `{channel, agent, usage: {used, size}}` | un agent a indiqué l'occupation de sa fenêtre de contexte (`size` vaut 0 si elle est inconnue) | | |
| 105 | +| `commands:changed` | `{agent, commands[]}` | un agent a annoncé ses commandes slash (`name, description, hint`) | | |
| 82 | 106 | |
| 83 | -### `ChatService` | |
| 107 | +### `ChannelService` | |
| 84 | 108 | |
| 85 | 109 | | Méthode | Effet | |
| 86 | 110 | | --- | --- | |
| 87 | -| `Send(agent, text)` | Ajoute un message de l'utilisateur et renvoie son id ; démarre l'agent si besoin ; met le message en attente si l'agent répond. Texte vide et agent inconnu sont des erreurs. | | |
| 88 | -| `Cancel(agent)` | Arrête la réponse en cours (`session/cancel`), répond « annulé » aux demandes de permission en attente, abandonne les messages en attente. | | |
| 89 | -| `AnswerPermission(id, optionId)` | Répond à une demande avec l'une de ses options. | | |
| 90 | -| `Conversation(agent)` | Toute la conversation : `messages, busy, queued, usage, commands, permissions`. | | |
| 111 | +| `List()` | Tous les channels et conversations directes, du plus ancien au plus récent. | | |
| 112 | +| `Create(name, description)` | Crée un channel. Un nom déjà pris ou vide est une erreur. | | |
| 113 | +| `Rename(id, name, description)` | Renomme un channel (pas une conversation directe). | | |
| 114 | +| `Delete(id)` | Arrête ses réponses, puis supprime le channel et tout son historique. | | |
| 115 | +| `AddMember(id, agent)`, `RemoveMember(id, agent)` | Invite ou retire un agent ; le channel affiche `agent-joined` ou `agent-left`. | | |
| 116 | +| `Direct(agent)` | La conversation directe avec `agent`, créée la première fois. | | |
| 117 | +| `Send(id, text)` | Ajoute un message de l'utilisateur et renvoie son id ; les agents qui répondent sont démarrés si besoin ; le message attend si le channel répond déjà. Un texte vide ou un channel inconnu sont des erreurs. | | |
| 118 | +| `Cancel(id)` | Arrête la réponse en cours (`session/cancel`), les agents qui devaient encore répondre et les messages en attente ; répond « cancelled » aux demandes de permission en attente. | | |
| 119 | +| `AnswerPermission(id, optionId)` | Répond à une demande en attente avec l'une de ses options. | | |
| 120 | +| `Conversation(id)` | `channel, messages` (les 100 derniers), `more, busy, queued, answering, usage{agent: …}, permissions`. | | |
| 121 | +| `Older(id, beforeId)` | `{messages, more}` : les 100 messages précédant `beforeId`. | | |
| 122 | +| `Commands(agent)` | Les dernières commandes annoncées par l'agent, gardées d'un lancement à l'autre ; vide s'il n'en a jamais annoncé. | | |
| 123 | +| `Prepare(id)` | Démarre les membres du channel qui n'ont jamais annoncé leurs commandes et ouvre leur session pour ce channel (aucun modèle n'est chargé) ; rend la main une fois les sessions ouvertes, les commandes arrivent par `commands:changed`. Un membre qui répond ailleurs est ignoré. La session sert la première réponse du membre, à qui l'on donne quand même le passé du channel. | | |
| 124 | + | |
| 125 | +### Historique | |
| 126 | + | |
| 127 | +| | | | |
| 128 | +| --- | --- | | |
| 129 | +| Emplacement | `<dossier de config utilisateur>/sidekicks/data/<16 caractères hexadécimaux>/` : une base Pebble par fichier de configuration (l'empreinte de son chemin absolu). Le dossier de config utilisateur est `~/Library/Application Support` sous macOS, `$XDG_CONFIG_HOME` ou `~/.config` sous Linux. | | |
| 130 | +| Gardé | les channels, les membres (avec l'id de session ACP de chaque agent et le dernier message qu'il a vu), les messages terminés, les dernières commandes de chaque agent | | |
| 131 | +| Non gardé | les demandes de permission en attente, les messages en attente, une réponse interrompue par la fermeture | | |
| 132 | +| Indisponible | si la base ne peut pas être ouverte (un autre Sidekicks utilise le même fichier), l'historique vit en mémoire jusqu'à la fermeture, et `ConfigService.Current().historyProblem` dit pourquoi | | |
| 133 | +| Après un redémarrage | un agent qui sait faire `session/load` reprend sa session ; sinon, son premier prompt dans un channel contient les derniers messages de ce channel | | |
| 91 | 134 | |
| 92 | 135 | ### Méthodes Settings de `ConfigService` |
| 93 | 136 | |
| @@ -97,7 +140,7 @@ Un tool call a `id, title, kind` (`read, edit, delete, move, search, execute, th | ||
| 97 | 140 | | `Validate(text)` | Les problèmes et avertissements de `text`, avec `line, column, message, warning`, sans enregistrer. | |
| 98 | 141 | | `Save(text)` | Écrit `text` s'il est valide ; sinon renvoie ses problèmes et n'écrit rien. | |
| 99 | 142 | | `Schema()` | Le JSON Schema de la configuration. | |
| 100 | -| `Open(path)` | Arrête tous les agents, oublie les conversations, charge et surveille `path`, le mémorise. | | |
| 143 | +| `Open(path)` | Arrête les réponses et tous les agents, ferme l'historique, charge et surveille `path` avec son propre historique, le mémorise. | | |
| 101 | 144 | | `Choose()` | Affiche le sélecteur de fichier natif et ouvre le fichier choisi ; renvoie `""` en cas d'annulation. | |
| 102 | 145 | | `Recent()` | Les fichiers de configuration ouverts depuis l'application, du plus récent au plus ancien (8 au plus). | |
| 103 | 146 | |
| @@ -1,4 +1,4 @@ | |||
| 1 | -# Référence : états des agents et événements | 1 | +# Référence : agents, channels et événements |
| 2 | 2 | ||
| 3 | ## États d'un agent | 3 | ## États d'un agent |
| 4 | 4 | ||
| @@ -48,46 +48,89 @@ Les noms et types Go sont dans `internal/events` ; les constantes et types TypeS | |||
| 48 | | `AgentService` | `List()` | Tous les agents avec `name, title, icon, description, state, error, outdated`, dans l'ordre de la configuration. | | 48 | | `AgentService` | `List()` | Tous les agents avec `name, title, icon, description, state, error, outdated`, dans l'ordre de la configuration. | |
| 49 | | | `Start(name)`, `Stop(name)`, `Restart(name)` | Comme dans les transitions ci-dessus ; une erreur est renvoyée à l'interface. | | 49 | | | `Start(name)`, `Stop(name)`, `Restart(name)` | Comme dans les transitions ci-dessus ; une erreur est renvoyée à l'interface. | |
| 50 | | | `Logs(name)` | Les 200 dernières lignes stderr de l'agent. | | 50 | | | `Logs(name)` | Les 200 dernières lignes stderr de l'agent. | |
| 51 | -| `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. | | 51 | +| `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, historyProblem`. | |
| 52 | 52 | ||
| 53 | -## Conversations (milestone M2) | 53 | +## Channels (milestones M2 et M3) |
| 54 | 54 | ||
| 55 | -Une conversation par agent, gardée en mémoire jusqu'à la fermeture de l'application ou l'ouverture d'une autre configuration. | 55 | +Un channel contient une conversation entre l'utilisateur et un ou plusieurs agents. Une conversation directe est un channel à un seul membre, sans nom, dont `direct` vaut le nom de son agent. Les channels, les membres et les messages terminés sont gardés dans l'historique du fichier de configuration (ci-dessous) ; une réponse en cours est gardée quand elle se termine. |
| 56 | + | ||
| 57 | +### Channels | ||
| 58 | + | ||
| 59 | +| Champ | Signification | | ||
| 60 | +| --- | --- | | ||
| 61 | +| `id` | identifiant triable | | ||
| 62 | +| `name`, `description` | 1 à 64 caractères, unique sans tenir compte de la casse ; vide pour une conversation directe | | ||
| 63 | +| `direct` | l'agent d'une conversation directe ; vide pour un channel | | ||
| 64 | +| `members` | noms des agents, dans l'ordre d'arrivée | | ||
| 65 | + | ||
| 66 | +### Qui répond à un message | ||
| 67 | + | ||
| 68 | +| Le message | Répondu par | | ||
| 69 | +| --- | --- | | ||
| 70 | +| `/agent:commande arguments` (`agent` est un agent de la configuration) | cet agent, qui reçoit `/commande arguments` | | ||
| 71 | +| contient des mentions `@nom` d'agents de la configuration | les agents mentionnés, l'un après l'autre, dans l'ordre des mentions ; un agent mentionné qui n'est pas membre rejoint d'abord le channel | | ||
| 72 | +| sans mention | le dernier agent qui a répondu dans le channel ; avec un seul membre, ce membre | | ||
| 73 | +| sans mention, personne n'a encore répondu, plusieurs membres | personne : message système `no-recipient` | | ||
| 74 | +| tout message d'une conversation directe | son agent ; les mentions y sont du simple texte | | ||
| 75 | + | ||
| 76 | +Un message qui commence par `/` (éventuellement après des mentions) est une commande : il est envoyé seul. Tout autre message est envoyé avec les messages du channel que l'agent n'a pas encore vus (au plus les 20 derniers, chacun coupé à 1500 caractères). Un agent répond à un seul channel à la fois ; un channel qui l'attend indique à qui c'est le tour. | ||
| 56 | 77 | ||
| 57 | ### Messages | 78 | ### Messages |
| 58 | 79 | ||
| 59 | | Champ | Signification | | 80 | | Champ | Signification | |
| 60 | | --- | --- | | 81 | | --- | --- | |
| 61 | -| `id`, `agent`, `createdAt` | identité | | 82 | +| `id`, `channel`, `createdAt` | identité | |
| 83 | +| `agent` | l'auteur d'un message d'agent, ou l'agent dont parle un message système | | ||
| 62 | | `role` | `user`, `agent` ou `system` | | 84 | | `role` | `user`, `agent` ou `system` | |
| 63 | -| `parts` | `text` (Markdown), `thought` (réflexion), `tool` (un tool call), `plan` (étapes du plan) | | 85 | +| `parts` | `text` (Markdown), `thought` (raisonnement), `tool` (un appel d'outil), `plan` (étapes d'un plan) | |
| 64 | -| `done` | faux tant qu'un message d'agent arrive | | 86 | +| `done` | faux pendant qu'un message d'agent s'affiche | |
| 65 | -| `queued` | un message de l'utilisateur qui attend la fin de la réponse en cours | | 87 | +| `queued` | un message de l'utilisateur qui attend la fin des réponses en cours | |
| 66 | -| `stopReason` | comment s'est terminé un message d'agent : `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` | | 88 | +| `stopReason` | comment un message d'agent s'est terminé : `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` | |
| 67 | -| `error` | pourquoi un message d'agent s'est terminé sans stop reason (l'explication de l'agent lui-même), ou `cancelled` pour un message en attente abandonné par Arrêter | | 89 | +| `error` | pourquoi un message d'agent s'est terminé sans raison d'arrêt (l'explication de l'agent), ou `cancelled` pour un message en attente abandonné par Stop | |
| 68 | -| `code` | messages système : `agent-restarted` (nouvelle session, l'agent a oublié le début), `agent-unavailable` (impossible de le démarrer ou de le joindre ; `error` dit pourquoi) | | 90 | +| `code` | messages système : `agent-joined`, `agent-left`, `no-recipient`, `agent-unavailable` (impossible de le démarrer ou de le joindre ; `error` dit pourquoi) | |
| 69 | 91 | ||
| 70 | -Un tool call a `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (JSON compact), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`). | 92 | +Un appel d'outil a `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (JSON compact), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`). |
| 71 | 93 | ||
| 72 | ### Événements | 94 | ### Événements |
| 73 | 95 | ||
| 74 | -| Événement | Charge | Envoyé quand | | 96 | +| Événement | Contenu | Envoyé quand | |
| 75 | | --- | --- | --- | | 97 | | --- | --- | --- | |
| 76 | -| `message:updated` | `{agent, message}` | un message est ajouté, ou terminé | | 98 | +| `channels:changed` | `{channels[]}` | un channel est créé, renommé ou supprimé, ou ses membres changent | |
| 77 | -| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | `text` s'ajoute à la partie `index` (texte, réflexion), ou `part` la remplace (outil, plan) | | 99 | +| `message:updated` | `{channel, message}` | un message est ajouté, ou terminé | |
| 78 | -| `turn:changed` | `{agent, busy, queued}` | l'agent commence ou finit de répondre ; `queued` messages attendent | | 100 | +| `message:delta` | `{channel, messageId, index, kind, text?, part?}` | `text` s'ajoute à la partie `index` (texte, raisonnement), ou `part` la remplace (outil, plan) | |
| 79 | -| `permission:requested` | `{id, agent, messageId, toolCall, options[], rejections}` | l'agent demande à utiliser un outil ; `rejections` compte les refus déjà donnés dans ce tour | | 101 | +| `turn:changed` | `{channel, busy, queued, answering}` | le channel commence ou arrête de répondre, ou le tour d'un autre agent commence ; `queued` messages attendent | |
| 80 | -| `permission:resolved` | `{id, agent}` | la demande a reçu une réponse, ou a été annulée par Arrêter | | 102 | +| `permission:requested` | `{id, channel, agent, messageId, toolCall, options[], rejections}` | un agent demande à utiliser un outil ; `rejections` compte les refus déjà donnés pendant ce tour | |
| 81 | -| `usage:changed` | `{agent, usage: {used, size}}` | l'agent a indiqué l'usage de sa fenêtre de contexte (`size` vaut 0 si inconnue) | | 103 | +| `permission:resolved` | `{id, channel}` | la demande a reçu une réponse, ou a été annulée par Stop | |
| 104 | +| `usage:changed` | `{channel, agent, usage: {used, size}}` | un agent a indiqué l'occupation de sa fenêtre de contexte (`size` vaut 0 si elle est inconnue) | | ||
| 105 | +| `commands:changed` | `{agent, commands[]}` | un agent a annoncé ses commandes slash (`name, description, hint`) | | ||
| 82 | 106 | ||
| 83 | -### `ChatService` | 107 | +### `ChannelService` |
| 84 | 108 | ||
| 85 | | Méthode | Effet | | 109 | | Méthode | Effet | |
| 86 | | --- | --- | | 110 | | --- | --- | |
| 87 | -| `Send(agent, text)` | Ajoute un message de l'utilisateur et renvoie son id ; démarre l'agent si besoin ; met le message en attente si l'agent répond. Texte vide et agent inconnu sont des erreurs. | | 111 | +| `List()` | Tous les channels et conversations directes, du plus ancien au plus récent. | |
| 88 | -| `Cancel(agent)` | Arrête la réponse en cours (`session/cancel`), répond « annulé » aux demandes de permission en attente, abandonne les messages en attente. | | 112 | +| `Create(name, description)` | Crée un channel. Un nom déjà pris ou vide est une erreur. | |
| 89 | -| `AnswerPermission(id, optionId)` | Répond à une demande avec l'une de ses options. | | 113 | +| `Rename(id, name, description)` | Renomme un channel (pas une conversation directe). | |
| 90 | -| `Conversation(agent)` | Toute la conversation : `messages, busy, queued, usage, commands, permissions`. | | 114 | +| `Delete(id)` | Arrête ses réponses, puis supprime le channel et tout son historique. | |
| 115 | +| `AddMember(id, agent)`, `RemoveMember(id, agent)` | Invite ou retire un agent ; le channel affiche `agent-joined` ou `agent-left`. | | ||
| 116 | +| `Direct(agent)` | La conversation directe avec `agent`, créée la première fois. | | ||
| 117 | +| `Send(id, text)` | Ajoute un message de l'utilisateur et renvoie son id ; les agents qui répondent sont démarrés si besoin ; le message attend si le channel répond déjà. Un texte vide ou un channel inconnu sont des erreurs. | | ||
| 118 | +| `Cancel(id)` | Arrête la réponse en cours (`session/cancel`), les agents qui devaient encore répondre et les messages en attente ; répond « cancelled » aux demandes de permission en attente. | | ||
| 119 | +| `AnswerPermission(id, optionId)` | Répond à une demande en attente avec l'une de ses options. | | ||
| 120 | +| `Conversation(id)` | `channel, messages` (les 100 derniers), `more, busy, queued, answering, usage{agent: …}, permissions`. | | ||
| 121 | +| `Older(id, beforeId)` | `{messages, more}` : les 100 messages précédant `beforeId`. | | ||
| 122 | +| `Commands(agent)` | Les dernières commandes annoncées par l'agent, gardées d'un lancement à l'autre ; vide s'il n'en a jamais annoncé. | | ||
| 123 | +| `Prepare(id)` | Démarre les membres du channel qui n'ont jamais annoncé leurs commandes et ouvre leur session pour ce channel (aucun modèle n'est chargé) ; rend la main une fois les sessions ouvertes, les commandes arrivent par `commands:changed`. Un membre qui répond ailleurs est ignoré. La session sert la première réponse du membre, à qui l'on donne quand même le passé du channel. | | ||
| 124 | + | ||
| 125 | +### Historique | ||
| 126 | + | ||
| 127 | +| | | | ||
| 128 | +| --- | --- | | ||
| 129 | +| Emplacement | `<dossier de config utilisateur>/sidekicks/data/<16 caractères hexadécimaux>/` : une base Pebble par fichier de configuration (l'empreinte de son chemin absolu). Le dossier de config utilisateur est `~/Library/Application Support` sous macOS, `$XDG_CONFIG_HOME` ou `~/.config` sous Linux. | | ||
| 130 | +| Gardé | les channels, les membres (avec l'id de session ACP de chaque agent et le dernier message qu'il a vu), les messages terminés, les dernières commandes de chaque agent | | ||
| 131 | +| Non gardé | les demandes de permission en attente, les messages en attente, une réponse interrompue par la fermeture | | ||
| 132 | +| Indisponible | si la base ne peut pas être ouverte (un autre Sidekicks utilise le même fichier), l'historique vit en mémoire jusqu'à la fermeture, et `ConfigService.Current().historyProblem` dit pourquoi | | ||
| 133 | +| Après un redémarrage | un agent qui sait faire `session/load` reprend sa session ; sinon, son premier prompt dans un channel contient les derniers messages de ce channel | | ||
| 91 | 134 | ||
| 92 | ### Méthodes Settings de `ConfigService` | 135 | ### Méthodes Settings de `ConfigService` |
| 93 | 136 | ||
| @@ -97,7 +140,7 @@ Un tool call a `id, title, kind` (`read, edit, delete, move, search, execute, th | |||
| 97 | | `Validate(text)` | Les problèmes et avertissements de `text`, avec `line, column, message, warning`, sans enregistrer. | | 140 | | `Validate(text)` | Les problèmes et avertissements de `text`, avec `line, column, message, warning`, sans enregistrer. | |
| 98 | | `Save(text)` | Écrit `text` s'il est valide ; sinon renvoie ses problèmes et n'écrit rien. | | 141 | | `Save(text)` | Écrit `text` s'il est valide ; sinon renvoie ses problèmes et n'écrit rien. | |
| 99 | | `Schema()` | Le JSON Schema de la configuration. | | 142 | | `Schema()` | Le JSON Schema de la configuration. | |
| 100 | -| `Open(path)` | Arrête tous les agents, oublie les conversations, charge et surveille `path`, le mémorise. | | 143 | +| `Open(path)` | Arrête les réponses et tous les agents, ferme l'historique, charge et surveille `path` avec son propre historique, le mémorise. | |
| 101 | | `Choose()` | Affiche le sélecteur de fichier natif et ouvre le fichier choisi ; renvoie `""` en cas d'annulation. | | 144 | | `Choose()` | Affiche le sélecteur de fichier natif et ouvre le fichier choisi ; renvoie `""` en cas d'annulation. | |
| 102 | | `Recent()` | Les fichiers de configuration ouverts depuis l'application, du plus récent au plus ancien (8 au plus). | | 145 | | `Recent()` | Les fichiers de configuration ouverts depuis l'application, du plus récent au plus ancien (8 au plus). | |
| 103 | 146 | ||
modified
docs/fr/reference/configuration.md +3 -3 | @@ -88,10 +88,10 @@ Le runtime indique à Sidekicks comment atteindre les **fichiers et le terminal* | ||
| 88 | 88 | | Clé | Type | Obligatoire | Signification | |
| 89 | 89 | | --- | --- | --- | --- | |
| 90 | 90 | | `name` | texte, 1 à 64 caractères | oui | Affiché `#nom`. **Unique.** Accents et espaces autorisés. | |
| 91 | -| `description` | texte | non | Affichée dans l'en-tête du channel. | | |
| 92 | -| `agents` | liste de noms d'agents | non | Agents invités à la création du channel. Sans doublon. | | |
| 91 | +| `description` | texte | non | Affichée au survol du nom du channel, et dans un channel vide. | | |
| 92 | +| `agents` | liste de noms d'agents | non | Membres du channel. Un agent de la liste qui n'est pas encore membre est ajouté au démarrage et au rechargement du fichier ; le retirer de la liste ne retire rien. Sans doublon. | | |
| 93 | 93 | |
| 94 | -Un channel déclaré ici est créé au démarrage **s'il n'existe pas encore** ; il vit ensuite dans l'application comme n'importe quel channel. Le retirer du fichier ne le supprime pas (supprimer un historique doit être une action explicite de l'utilisateur). | |
| 94 | +Un channel déclaré ici est créé au démarrage, ou au rechargement du fichier, **si aucun channel ne porte encore ce nom** (sans tenir compte de la casse) ; il vit ensuite dans l'application comme n'importe quel channel, et peut y être renommé ou supprimé. Le retirer du fichier ne le supprime pas (supprimer un historique doit être une action explicite de l'utilisateur). Chaque fichier de configuration a ses propres channels et son propre historique ([où il est gardé](agents-et-evenements.md#historique)). | |
| 95 | 95 | |
| 96 | 96 | ## Règles vérifiées par le chargeur, pas par le schéma |
| 97 | 97 | |
| @@ -88,10 +88,10 @@ Le runtime indique à Sidekicks comment atteindre les **fichiers et le terminal* | |||
| 88 | | Clé | Type | Obligatoire | Signification | | 88 | | Clé | Type | Obligatoire | Signification | |
| 89 | | --- | --- | --- | --- | | 89 | | --- | --- | --- | --- | |
| 90 | | `name` | texte, 1 à 64 caractères | oui | Affiché `#nom`. **Unique.** Accents et espaces autorisés. | | 90 | | `name` | texte, 1 à 64 caractères | oui | Affiché `#nom`. **Unique.** Accents et espaces autorisés. | |
| 91 | -| `description` | texte | non | Affichée dans l'en-tête du channel. | | 91 | +| `description` | texte | non | Affichée au survol du nom du channel, et dans un channel vide. | |
| 92 | -| `agents` | liste de noms d'agents | non | Agents invités à la création du channel. Sans doublon. | | 92 | +| `agents` | liste de noms d'agents | non | Membres du channel. Un agent de la liste qui n'est pas encore membre est ajouté au démarrage et au rechargement du fichier ; le retirer de la liste ne retire rien. Sans doublon. | |
| 93 | 93 | ||
| 94 | -Un channel déclaré ici est créé au démarrage **s'il n'existe pas encore** ; il vit ensuite dans l'application comme n'importe quel channel. Le retirer du fichier ne le supprime pas (supprimer un historique doit être une action explicite de l'utilisateur). | 94 | +Un channel déclaré ici est créé au démarrage, ou au rechargement du fichier, **si aucun channel ne porte encore ce nom** (sans tenir compte de la casse) ; il vit ensuite dans l'application comme n'importe quel channel, et peut y être renommé ou supprimé. Le retirer du fichier ne le supprime pas (supprimer un historique doit être une action explicite de l'utilisateur). Chaque fichier de configuration a ses propres channels et son propre historique ([où il est gardé](agents-et-evenements.md#historique)). |
| 95 | 95 | ||
| 96 | ## Règles vérifiées par le chargeur, pas par le schéma | 96 | ## Règles vérifiées par le chargeur, pas par le schéma |
| 97 | 97 | ||
added
docs/fr/tutorials/premier-channel.md +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +# Tutoriel : un channel avec deux agents | |
| 2 | + | |
| 3 | +À la fin de ce tutoriel, vous aurez posé une question à Alice dans un channel, fait répondre Bob sur ce qu'Alice a dit, exécuté une commande de Garfield, et retrouvé toute la conversation après avoir quitté Sidekicks. | |
| 4 | + | |
| 5 | +## Prérequis | |
| 6 | + | |
| 7 | +- La configuration de test installée, comme dans [Votre première conversation avec un agent](premiere-conversation.md) : relancez `make install-test-config` si vous l'avez installée avant la milestone M3, pour que les channels s'appellent `general` et `translations`. | |
| 8 | +- llmman lancé, avec les modèles d'Alice, de Bob et de Garfield téléchargés. | |
| 9 | + | |
| 10 | +L'interface s'affiche en anglais, sa langue par défaut : les libellés sont cités en anglais. | |
| 11 | + | |
| 12 | +## Étape 1 — Ouvrir #general | |
| 13 | + | |
| 14 | +Tapez `make dev` à la racine du dépôt. Dans la fenêtre, cliquez sur **# general** dans le panneau **CHANNELS**. | |
| 15 | + | |
| 16 | +Vous devriez voir `# general` en haut du centre, suivi de deux membres, `Alice (assistant)` et `Bob (assistant)`, et la phrase « Mention an agent with @ to bring it into #general. » | |
| 17 | + | |
| 18 | +## Étape 2 — Mentionner Alice | |
| 19 | + | |
| 20 | +Cliquez dans la zone de saisie et tapez `@al`. Une liste s'ouvre au-dessus avec **Alice (assistant)**. Appuyez sur **Tab** : la zone contient maintenant `@alice `. Complétez le message pour obtenir : | |
| 21 | + | |
| 22 | +```text | |
| 23 | +@alice Reply only with this code: 4217 | |
| 24 | +``` | |
| 25 | + | |
| 26 | +et appuyez sur Entrée. Vous devriez voir votre message avec `@alice` mis en évidence, puis « Alice (assistant) is answering… » en haut, puis la réponse d'Alice : `4217`. | |
| 27 | + | |
| 28 | +## Étape 3 — Interroger Bob à ce sujet | |
| 29 | + | |
| 30 | +Envoyez : | |
| 31 | + | |
| 32 | +```text | |
| 33 | +@bob What code did Alice give? Answer with the code only. | |
| 34 | +``` | |
| 35 | + | |
| 36 | +Bob démarre (sa pastille passe à l'orange, puis au vert) et répond `4217`. La session de Bob n'a jamais contenu la réponse d'Alice : Sidekicks l'a ajoutée au prompt de Bob. | |
| 37 | + | |
| 38 | +## Étape 4 — Exécuter une commande de Garfield | |
| 39 | + | |
| 40 | +Cliquez sur **# translations**. Son seul membre est Garfield. Tapez `/` : la liste des commandes de Garfield apparaît en moins d'une seconde — Sidekicks les lui a demandées. Appuyez sur **Échap**, videz la zone, et tapez : | |
| 41 | + | |
| 42 | +```text | |
| 43 | +/idioms Il pleut des cordes. | |
| 44 | +``` | |
| 45 | + | |
| 46 | +et appuyez sur Entrée. À côté du nom de Garfield, un spinner tourne avec « thinking… » pendant le chargement du modèle. Vous devriez ensuite voir une ligne repliée « 🛠 read_skill: idioms ✓ done », puis une traduction du type « It's raining cats and dogs. » | |
| 47 | + | |
| 48 | +Tapez maintenant `/i` dans la zone de saisie : une liste propose **/idioms** avec sa description. Appuyez sur **Échap** pour la fermer, et videz la zone de saisie. | |
| 49 | + | |
| 50 | +## Étape 5 — Quitter et revenir | |
| 51 | + | |
| 52 | +Quittez Sidekicks (⌘Q), puis tapez de nouveau `make dev`. Cliquez sur **# general** : vos messages et les réponses des agents sont toujours là. Envoyez : | |
| 53 | + | |
| 54 | +```text | |
| 55 | +@alice What code did you give? | |
| 56 | +``` | |
| 57 | + | |
| 58 | +Alice répond `4217`, alors que l'agent tourne dans un nouveau processus : Sidekicks a ajouté les derniers messages du channel au prompt d'Alice. | |
| 59 | + | |
| 60 | +## Et maintenant ? | |
| 61 | + | |
| 62 | +Vous avez parlé à deux agents dans un même channel, exécuté une commande et gardé la conversation d'un lancement à l'autre. Pour aller plus loin : | |
| 63 | + | |
| 64 | +- Pour créer, renommer ou supprimer des channels et choisir leurs agents → [Gérer les channels](../how-to/gerer-les-channels.md) | |
| 65 | +- Pour comprendre qui répond et ce que lit chaque agent → [Les channels et ce que sait chaque agent](../explanation/channels-et-contexte.md) | |
| 66 | +- Les règles exactes et les événements → [Référence : agents, channels et événements](../reference/agents-et-evenements.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +# Tutoriel : un channel avec deux agents | ||
| 2 | + | ||
| 3 | +À la fin de ce tutoriel, vous aurez posé une question à Alice dans un channel, fait répondre Bob sur ce qu'Alice a dit, exécuté une commande de Garfield, et retrouvé toute la conversation après avoir quitté Sidekicks. | ||
| 4 | + | ||
| 5 | +## Prérequis | ||
| 6 | + | ||
| 7 | +- La configuration de test installée, comme dans [Votre première conversation avec un agent](premiere-conversation.md) : relancez `make install-test-config` si vous l'avez installée avant la milestone M3, pour que les channels s'appellent `general` et `translations`. | ||
| 8 | +- llmman lancé, avec les modèles d'Alice, de Bob et de Garfield téléchargés. | ||
| 9 | + | ||
| 10 | +L'interface s'affiche en anglais, sa langue par défaut : les libellés sont cités en anglais. | ||
| 11 | + | ||
| 12 | +## Étape 1 — Ouvrir #general | ||
| 13 | + | ||
| 14 | +Tapez `make dev` à la racine du dépôt. Dans la fenêtre, cliquez sur **# general** dans le panneau **CHANNELS**. | ||
| 15 | + | ||
| 16 | +Vous devriez voir `# general` en haut du centre, suivi de deux membres, `Alice (assistant)` et `Bob (assistant)`, et la phrase « Mention an agent with @ to bring it into #general. » | ||
| 17 | + | ||
| 18 | +## Étape 2 — Mentionner Alice | ||
| 19 | + | ||
| 20 | +Cliquez dans la zone de saisie et tapez `@al`. Une liste s'ouvre au-dessus avec **Alice (assistant)**. Appuyez sur **Tab** : la zone contient maintenant `@alice `. Complétez le message pour obtenir : | ||
| 21 | + | ||
| 22 | +```text | ||
| 23 | +@alice Reply only with this code: 4217 | ||
| 24 | +``` | ||
| 25 | + | ||
| 26 | +et appuyez sur Entrée. Vous devriez voir votre message avec `@alice` mis en évidence, puis « Alice (assistant) is answering… » en haut, puis la réponse d'Alice : `4217`. | ||
| 27 | + | ||
| 28 | +## Étape 3 — Interroger Bob à ce sujet | ||
| 29 | + | ||
| 30 | +Envoyez : | ||
| 31 | + | ||
| 32 | +```text | ||
| 33 | +@bob What code did Alice give? Answer with the code only. | ||
| 34 | +``` | ||
| 35 | + | ||
| 36 | +Bob démarre (sa pastille passe à l'orange, puis au vert) et répond `4217`. La session de Bob n'a jamais contenu la réponse d'Alice : Sidekicks l'a ajoutée au prompt de Bob. | ||
| 37 | + | ||
| 38 | +## Étape 4 — Exécuter une commande de Garfield | ||
| 39 | + | ||
| 40 | +Cliquez sur **# translations**. Son seul membre est Garfield. Tapez `/` : la liste des commandes de Garfield apparaît en moins d'une seconde — Sidekicks les lui a demandées. Appuyez sur **Échap**, videz la zone, et tapez : | ||
| 41 | + | ||
| 42 | +```text | ||
| 43 | +/idioms Il pleut des cordes. | ||
| 44 | +``` | ||
| 45 | + | ||
| 46 | +et appuyez sur Entrée. À côté du nom de Garfield, un spinner tourne avec « thinking… » pendant le chargement du modèle. Vous devriez ensuite voir une ligne repliée « 🛠 read_skill: idioms ✓ done », puis une traduction du type « It's raining cats and dogs. » | ||
| 47 | + | ||
| 48 | +Tapez maintenant `/i` dans la zone de saisie : une liste propose **/idioms** avec sa description. Appuyez sur **Échap** pour la fermer, et videz la zone de saisie. | ||
| 49 | + | ||
| 50 | +## Étape 5 — Quitter et revenir | ||
| 51 | + | ||
| 52 | +Quittez Sidekicks (⌘Q), puis tapez de nouveau `make dev`. Cliquez sur **# general** : vos messages et les réponses des agents sont toujours là. Envoyez : | ||
| 53 | + | ||
| 54 | +```text | ||
| 55 | +@alice What code did you give? | ||
| 56 | +``` | ||
| 57 | + | ||
| 58 | +Alice répond `4217`, alors que l'agent tourne dans un nouveau processus : Sidekicks a ajouté les derniers messages du channel au prompt d'Alice. | ||
| 59 | + | ||
| 60 | +## Et maintenant ? | ||
| 61 | + | ||
| 62 | +Vous avez parlé à deux agents dans un même channel, exécuté une commande et gardé la conversation d'un lancement à l'autre. Pour aller plus loin : | ||
| 63 | + | ||
| 64 | +- Pour créer, renommer ou supprimer des channels et choisir leurs agents → [Gérer les channels](../how-to/gerer-les-channels.md) | ||
| 65 | +- Pour comprendre qui répond et ce que lit chaque agent → [Les channels et ce que sait chaque agent](../explanation/channels-et-contexte.md) | ||
| 66 | +- Les règles exactes et les événements → [Référence : agents, channels et événements](../reference/agents-et-evenements.md) | ||
modified
docs/fr/tutorials/premier-lancement.md +1 -1 | @@ -93,5 +93,5 @@ Passez sur Alice et cliquez sur ■ : sa pastille devient grise. Fermez la fenê | ||
| 93 | 93 | Vous avez lancé Sidekicks, piloté la vie d'un agent et vu comment la configuration est vérifiée. Pour aller plus loin : |
| 94 | 94 | |
| 95 | 95 | - Toutes les clés du fichier → [Référence : le fichier de configuration](../reference/configuration.md) |
| 96 | -- Ce que veut dire chaque état → [Référence : états des agents et événements](../reference/agents-et-evenements.md) | |
| 96 | +- Ce que veut dire chaque état → [Référence : agents, channels et événements](../reference/agents-et-evenements.md) | |
| 97 | 97 | - Pourquoi les agents démarrent et s'arrêtent ainsi → [Mémoire et modèles locaux](../explanation/memoire-et-modeles-locaux.md) |
| @@ -93,5 +93,5 @@ Passez sur Alice et cliquez sur ■ : sa pastille devient grise. Fermez la fenê | |||
| 93 | Vous avez lancé Sidekicks, piloté la vie d'un agent et vu comment la configuration est vérifiée. Pour aller plus loin : | 93 | Vous avez lancé Sidekicks, piloté la vie d'un agent et vu comment la configuration est vérifiée. Pour aller plus loin : |
| 94 | 94 | ||
| 95 | - Toutes les clés du fichier → [Référence : le fichier de configuration](../reference/configuration.md) | 95 | - Toutes les clés du fichier → [Référence : le fichier de configuration](../reference/configuration.md) |
| 96 | -- Ce que veut dire chaque état → [Référence : états des agents et événements](../reference/agents-et-evenements.md) | 96 | +- Ce que veut dire chaque état → [Référence : agents, channels et événements](../reference/agents-et-evenements.md) |
| 97 | - Pourquoi les agents démarrent et s'arrêtent ainsi → [Mémoire et modèles locaux](../explanation/memoire-et-modeles-locaux.md) | 97 | - Pourquoi les agents démarrent et s'arrêtent ainsi → [Mémoire et modèles locaux](../explanation/memoire-et-modeles-locaux.md) |
modified
docs/fr/tutorials/premiere-conversation.md +1 -1 | @@ -55,4 +55,4 @@ Vous avez parlé avec deux agents, approuvé une commande et arrêté une répon | ||
| 55 | 55 | |
| 56 | 56 | - Changer les agents depuis la fenêtre → [Comment modifier ou changer de configuration](../how-to/modifier-ou-changer-la-configuration.md) |
| 57 | 57 | - Pourquoi la carte de permission ressemble à cela → [Permissions et confiance](../explanation/permissions-et-confiance.md) |
| 58 | -- Les événements et messages derrière la conversation → [Référence : états des agents et événements](../reference/agents-et-evenements.md) | |
| 58 | +- Les événements et messages derrière la conversation → [Référence : agents, channels et événements](../reference/agents-et-evenements.md) | |
| @@ -55,4 +55,4 @@ Vous avez parlé avec deux agents, approuvé une commande et arrêté une répon | |||
| 55 | 55 | ||
| 56 | - Changer les agents depuis la fenêtre → [Comment modifier ou changer de configuration](../how-to/modifier-ou-changer-la-configuration.md) | 56 | - Changer les agents depuis la fenêtre → [Comment modifier ou changer de configuration](../how-to/modifier-ou-changer-la-configuration.md) |
| 57 | - Pourquoi la carte de permission ressemble à cela → [Permissions et confiance](../explanation/permissions-et-confiance.md) | 57 | - Pourquoi la carte de permission ressemble à cela → [Permissions et confiance](../explanation/permissions-et-confiance.md) |
| 58 | -- Les événements et messages derrière la conversation → [Référence : états des agents et événements](../reference/agents-et-evenements.md) | 58 | +- Les événements et messages derrière la conversation → [Référence : agents, channels et événements](../reference/agents-et-evenements.md) |
modified
frontend/bindings/events/index.ts +10 -0 | @@ -18,6 +18,16 @@ export const AgentLog = "agent:log"; | ||
| 18 | 18 | */ |
| 19 | 19 | export const AgentState = "agent:state"; |
| 20 | 20 | |
| 21 | +/** | |
| 22 | + * Name of the custom event "channels:changed". | |
| 23 | + */ | |
| 24 | +export const ChannelsChanged = "channels:changed"; | |
| 25 | + | |
| 26 | +/** | |
| 27 | + * Name of the custom event "commands:changed". | |
| 28 | + */ | |
| 29 | +export const CommandsChanged = "commands:changed"; | |
| 30 | + | |
| 21 | 31 | /** |
| 22 | 32 | * Name of the custom event "config:changed". |
| 23 | 33 | */ |
| @@ -18,6 +18,16 @@ export const AgentLog = "agent:log"; | |||
| 18 | */ | 18 | */ |
| 19 | export const AgentState = "agent:state"; | 19 | export const AgentState = "agent:state"; |
| 20 | 20 | ||
| 21 | +/** | ||
| 22 | + * Name of the custom event "channels:changed". | ||
| 23 | + */ | ||
| 24 | +export const ChannelsChanged = "channels:changed"; | ||
| 25 | + | ||
| 26 | +/** | ||
| 27 | + * Name of the custom event "commands:changed". | ||
| 28 | + */ | ||
| 29 | +export const CommandsChanged = "commands:changed"; | ||
| 30 | + | ||
| 21 | /** | 31 | /** |
| 22 | * Name of the custom event "config:changed". | 32 | * Name of the custom event "config:changed". |
| 23 | */ | 33 | */ |
added
frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/app/channelservice.ts +127 -0 | new file mode 100644 | ||
| @@ -0,0 +1,127 @@ | ||
| 1 | +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL | |
| 2 | +// This file is automatically generated. DO NOT EDIT | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * ChannelService is the channels and direct conversations (milestone M3). | |
| 6 | + * Changes arrive as channels:changed events; answers as message:updated, | |
| 7 | + * message:delta, turn:changed, permission:requested, permission:resolved, | |
| 8 | + * usage:changed and commands:changed events. | |
| 9 | + * @module | |
| 10 | + */ | |
| 11 | + | |
| 12 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
| 13 | +// @ts-ignore: Unused imports | |
| 14 | +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; | |
| 15 | + | |
| 16 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
| 17 | +// @ts-ignore: Unused imports | |
| 18 | +import * as acp$0 from "../acp/models.js"; | |
| 19 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
| 20 | +// @ts-ignore: Unused imports | |
| 21 | +import * as channels$0 from "../channels/models.js"; | |
| 22 | + | |
| 23 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
| 24 | +// @ts-ignore: Unused imports | |
| 25 | +import * as $models from "./models.js"; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * AddMember invites an agent into a channel. | |
| 29 | + */ | |
| 30 | +export function AddMember(id: string, agent: string): $CancellablePromise<void> { | |
| 31 | + return $Call.ByID(863382753, id, agent); | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * AnswerPermission gives the user's choice for a permission request. | |
| 36 | + */ | |
| 37 | +export function AnswerPermission(id: string, optionID: string): $CancellablePromise<void> { | |
| 38 | + return $Call.ByID(3389093261, id, optionID); | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * Cancel stops the current answer of a channel, the agents still to answer | |
| 43 | + * and the queued messages. | |
| 44 | + */ | |
| 45 | +export function Cancel(id: string): $CancellablePromise<void> { | |
| 46 | + return $Call.ByID(1498855258, id); | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * Commands returns the slash commands an agent offers. | |
| 51 | + */ | |
| 52 | +export function Commands(agent: string): $CancellablePromise<acp$0.Command[] | null> { | |
| 53 | + return $Call.ByID(3466813774, agent); | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** | |
| 57 | + * Conversation returns the last messages and the state of a channel. | |
| 58 | + */ | |
| 59 | +export function Conversation(id: string): $CancellablePromise<channels$0.Conversation> { | |
| 60 | + return $Call.ByID(1294302503, id); | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** | |
| 64 | + * Create creates a channel. | |
| 65 | + */ | |
| 66 | +export function Create(name: string, description: string): $CancellablePromise<channels$0.Channel> { | |
| 67 | + return $Call.ByID(3703305956, name, description); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * Delete deletes a channel and its whole history. | |
| 72 | + */ | |
| 73 | +export function Delete(id: string): $CancellablePromise<void> { | |
| 74 | + return $Call.ByID(4265155931, id); | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** | |
| 78 | + * Direct returns the direct conversation with an agent, created the first time. | |
| 79 | + */ | |
| 80 | +export function Direct(agent: string): $CancellablePromise<channels$0.Channel> { | |
| 81 | + return $Call.ByID(2821352801, agent); | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** | |
| 85 | + * List returns every channel and direct conversation, oldest first. | |
| 86 | + */ | |
| 87 | +export function List(): $CancellablePromise<channels$0.Channel[] | null> { | |
| 88 | + return $Call.ByID(1603911112); | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** | |
| 92 | + * Older returns the messages before beforeID and whether older ones exist. | |
| 93 | + */ | |
| 94 | +export function Older(id: string, beforeID: string): $CancellablePromise<$models.OlderView> { | |
| 95 | + return $Call.ByID(2417476022, id, beforeID); | |
| 96 | +} | |
| 97 | + | |
| 98 | +/** | |
| 99 | + * Prepare asks the members of a channel whose commands are not known yet | |
| 100 | + * for them (the user typed "/"): they are started and their session opened, | |
| 101 | + * without loading a model; the commands arrive as commands:changed events. | |
| 102 | + */ | |
| 103 | +export function Prepare(id: string): $CancellablePromise<void> { | |
| 104 | + return $Call.ByID(3873138035, id); | |
| 105 | +} | |
| 106 | + | |
| 107 | +/** | |
| 108 | + * RemoveMember removes an agent from a channel. | |
| 109 | + */ | |
| 110 | +export function RemoveMember(id: string, agent: string): $CancellablePromise<void> { | |
| 111 | + return $Call.ByID(2660328730, id, agent); | |
| 112 | +} | |
| 113 | + | |
| 114 | +/** | |
| 115 | + * Rename changes the name and description of a channel. | |
| 116 | + */ | |
| 117 | +export function Rename(id: string, name: string, description: string): $CancellablePromise<channels$0.Channel> { | |
| 118 | + return $Call.ByID(4023286338, id, name, description); | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** | |
| 122 | + * Send sends a message to a channel and returns its id. The agents it | |
| 123 | + * addresses are started if needed; a message sent during an answer is queued. | |
| 124 | + */ | |
| 125 | +export function Send(id: string, text: string): $CancellablePromise<string> { | |
| 126 | + return $Call.ByID(4255482702, id, text); | |
| 127 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,127 @@ | |||
| 1 | +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL | ||
| 2 | +// This file is automatically generated. DO NOT EDIT | ||
| 3 | + | ||
| 4 | +/** | ||
| 5 | + * ChannelService is the channels and direct conversations (milestone M3). | ||
| 6 | + * Changes arrive as channels:changed events; answers as message:updated, | ||
| 7 | + * message:delta, turn:changed, permission:requested, permission:resolved, | ||
| 8 | + * usage:changed and commands:changed events. | ||
| 9 | + * @module | ||
| 10 | + */ | ||
| 11 | + | ||
| 12 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
| 13 | +// @ts-ignore: Unused imports | ||
| 14 | +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; | ||
| 15 | + | ||
| 16 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
| 17 | +// @ts-ignore: Unused imports | ||
| 18 | +import * as acp$0 from "../acp/models.js"; | ||
| 19 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
| 20 | +// @ts-ignore: Unused imports | ||
| 21 | +import * as channels$0 from "../channels/models.js"; | ||
| 22 | + | ||
| 23 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
| 24 | +// @ts-ignore: Unused imports | ||
| 25 | +import * as $models from "./models.js"; | ||
| 26 | + | ||
| 27 | +/** | ||
| 28 | + * AddMember invites an agent into a channel. | ||
| 29 | + */ | ||
| 30 | +export function AddMember(id: string, agent: string): $CancellablePromise<void> { | ||
| 31 | + return $Call.ByID(863382753, id, agent); | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +/** | ||
| 35 | + * AnswerPermission gives the user's choice for a permission request. | ||
| 36 | + */ | ||
| 37 | +export function AnswerPermission(id: string, optionID: string): $CancellablePromise<void> { | ||
| 38 | + return $Call.ByID(3389093261, id, optionID); | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +/** | ||
| 42 | + * Cancel stops the current answer of a channel, the agents still to answer | ||
| 43 | + * and the queued messages. | ||
| 44 | + */ | ||
| 45 | +export function Cancel(id: string): $CancellablePromise<void> { | ||
| 46 | + return $Call.ByID(1498855258, id); | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +/** | ||
| 50 | + * Commands returns the slash commands an agent offers. | ||
| 51 | + */ | ||
| 52 | +export function Commands(agent: string): $CancellablePromise<acp$0.Command[] | null> { | ||
| 53 | + return $Call.ByID(3466813774, agent); | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +/** | ||
| 57 | + * Conversation returns the last messages and the state of a channel. | ||
| 58 | + */ | ||
| 59 | +export function Conversation(id: string): $CancellablePromise<channels$0.Conversation> { | ||
| 60 | + return $Call.ByID(1294302503, id); | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +/** | ||
| 64 | + * Create creates a channel. | ||
| 65 | + */ | ||
| 66 | +export function Create(name: string, description: string): $CancellablePromise<channels$0.Channel> { | ||
| 67 | + return $Call.ByID(3703305956, name, description); | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +/** | ||
| 71 | + * Delete deletes a channel and its whole history. | ||
| 72 | + */ | ||
| 73 | +export function Delete(id: string): $CancellablePromise<void> { | ||
| 74 | + return $Call.ByID(4265155931, id); | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +/** | ||
| 78 | + * Direct returns the direct conversation with an agent, created the first time. | ||
| 79 | + */ | ||
| 80 | +export function Direct(agent: string): $CancellablePromise<channels$0.Channel> { | ||
| 81 | + return $Call.ByID(2821352801, agent); | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +/** | ||
| 85 | + * List returns every channel and direct conversation, oldest first. | ||
| 86 | + */ | ||
| 87 | +export function List(): $CancellablePromise<channels$0.Channel[] | null> { | ||
| 88 | + return $Call.ByID(1603911112); | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +/** | ||
| 92 | + * Older returns the messages before beforeID and whether older ones exist. | ||
| 93 | + */ | ||
| 94 | +export function Older(id: string, beforeID: string): $CancellablePromise<$models.OlderView> { | ||
| 95 | + return $Call.ByID(2417476022, id, beforeID); | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +/** | ||
| 99 | + * Prepare asks the members of a channel whose commands are not known yet | ||
| 100 | + * for them (the user typed "/"): they are started and their session opened, | ||
| 101 | + * without loading a model; the commands arrive as commands:changed events. | ||
| 102 | + */ | ||
| 103 | +export function Prepare(id: string): $CancellablePromise<void> { | ||
| 104 | + return $Call.ByID(3873138035, id); | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +/** | ||
| 108 | + * RemoveMember removes an agent from a channel. | ||
| 109 | + */ | ||
| 110 | +export function RemoveMember(id: string, agent: string): $CancellablePromise<void> { | ||
| 111 | + return $Call.ByID(2660328730, id, agent); | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +/** | ||
| 115 | + * Rename changes the name and description of a channel. | ||
| 116 | + */ | ||
| 117 | +export function Rename(id: string, name: string, description: string): $CancellablePromise<channels$0.Channel> { | ||
| 118 | + return $Call.ByID(4023286338, id, name, description); | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +/** | ||
| 122 | + * Send sends a message to a channel and returns its id. The agents it | ||
| 123 | + * addresses are started if needed; a message sent during an answer is queued. | ||
| 124 | + */ | ||
| 125 | +export function Send(id: string, text: string): $CancellablePromise<string> { | ||
| 126 | + return $Call.ByID(4255482702, id, text); | ||
| 127 | +} | ||
deleted
frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/app/chatservice.ts +0 -47 | deleted file mode 100644 | ||
| @@ -1,47 +0,0 @@ | ||
| 1 | -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL | |
| 2 | -// This file is automatically generated. DO NOT EDIT | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * ChatService is the direct conversation with each agent (milestone M2). | |
| 6 | - * Answers arrive as message:updated, message:delta, turn:changed, | |
| 7 | - * permission:requested, permission:resolved and usage:changed events. | |
| 8 | - * @module | |
| 9 | - */ | |
| 10 | - | |
| 11 | -// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
| 12 | -// @ts-ignore: Unused imports | |
| 13 | -import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; | |
| 14 | - | |
| 15 | -// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
| 16 | -// @ts-ignore: Unused imports | |
| 17 | -import * as conversations$0 from "../conversations/models.js"; | |
| 18 | - | |
| 19 | -/** | |
| 20 | - * AnswerPermission gives the user's choice for a permission request. | |
| 21 | - */ | |
| 22 | -export function AnswerPermission(id: string, optionID: string): $CancellablePromise<void> { | |
| 23 | - return $Call.ByID(4116865548, id, optionID); | |
| 24 | -} | |
| 25 | - | |
| 26 | -/** | |
| 27 | - * Cancel stops the agent's current answer and drops the queued messages. | |
| 28 | - */ | |
| 29 | -export function Cancel(agent: string): $CancellablePromise<void> { | |
| 30 | - return $Call.ByID(1337893815, agent); | |
| 31 | -} | |
| 32 | - | |
| 33 | -/** | |
| 34 | - * Conversation returns the whole conversation with the agent, e.g. when the | |
| 35 | - * interface switches to it. | |
| 36 | - */ | |
| 37 | -export function Conversation(agent: string): $CancellablePromise<conversations$0.Conversation> { | |
| 38 | - return $Call.ByID(620773522, agent); | |
| 39 | -} | |
| 40 | - | |
| 41 | -/** | |
| 42 | - * Send sends a message to the agent and returns the message id; the agent | |
| 43 | - * is started if needed. A message sent while the agent answers is queued. | |
| 44 | - */ | |
| 45 | -export function Send(agent: string, text: string): $CancellablePromise<string> { | |
| 46 | - return $Call.ByID(3559130523, agent, text); | |
| 47 | -} | |
| deleted file mode 100644 | |||
| @@ -1,47 +0,0 @@ | |||
| 1 | -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL | ||
| 2 | -// This file is automatically generated. DO NOT EDIT | ||
| 3 | - | ||
| 4 | -/** | ||
| 5 | - * ChatService is the direct conversation with each agent (milestone M2). | ||
| 6 | - * Answers arrive as message:updated, message:delta, turn:changed, | ||
| 7 | - * permission:requested, permission:resolved and usage:changed events. | ||
| 8 | - * @module | ||
| 9 | - */ | ||
| 10 | - | ||
| 11 | -// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
| 12 | -// @ts-ignore: Unused imports | ||
| 13 | -import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; | ||
| 14 | - | ||
| 15 | -// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
| 16 | -// @ts-ignore: Unused imports | ||
| 17 | -import * as conversations$0 from "../conversations/models.js"; | ||
| 18 | - | ||
| 19 | -/** | ||
| 20 | - * AnswerPermission gives the user's choice for a permission request. | ||
| 21 | - */ | ||
| 22 | -export function AnswerPermission(id: string, optionID: string): $CancellablePromise<void> { | ||
| 23 | - return $Call.ByID(4116865548, id, optionID); | ||
| 24 | -} | ||
| 25 | - | ||
| 26 | -/** | ||
| 27 | - * Cancel stops the agent's current answer and drops the queued messages. | ||
| 28 | - */ | ||
| 29 | -export function Cancel(agent: string): $CancellablePromise<void> { | ||
| 30 | - return $Call.ByID(1337893815, agent); | ||
| 31 | -} | ||
| 32 | - | ||
| 33 | -/** | ||
| 34 | - * Conversation returns the whole conversation with the agent, e.g. when the | ||
| 35 | - * interface switches to it. | ||
| 36 | - */ | ||
| 37 | -export function Conversation(agent: string): $CancellablePromise<conversations$0.Conversation> { | ||
| 38 | - return $Call.ByID(620773522, agent); | ||
| 39 | -} | ||
| 40 | - | ||
| 41 | -/** | ||
| 42 | - * Send sends a message to the agent and returns the message id; the agent | ||
| 43 | - * is started if needed. A message sent while the agent answers is queued. | ||
| 44 | - */ | ||
| 45 | -export function Send(agent: string, text: string): $CancellablePromise<string> { | ||
| 46 | - return $Call.ByID(3559130523, agent, text); | ||
| 47 | -} | ||
modified
frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/app/index.ts +3 -3 | @@ -2,16 +2,16 @@ | ||
| 2 | 2 | // This file is automatically generated. DO NOT EDIT |
| 3 | 3 | |
| 4 | 4 | import * as AgentService from "./agentservice.js"; |
| 5 | -import * as ChatService from "./chatservice.js"; | |
| 5 | +import * as ChannelService from "./channelservice.js"; | |
| 6 | 6 | import * as ConfigService from "./configservice.js"; |
| 7 | 7 | export { |
| 8 | 8 | AgentService, |
| 9 | - ChatService, | |
| 9 | + ChannelService, | |
| 10 | 10 | ConfigService |
| 11 | 11 | }; |
| 12 | 12 | |
| 13 | 13 | export type { |
| 14 | - ChannelView, | |
| 15 | 14 | ConfigView, |
| 15 | + OlderView, | |
| 16 | 16 | ProblemView |
| 17 | 17 | } from "./models.js"; |
| @@ -2,16 +2,16 @@ | |||
| 2 | // This file is automatically generated. DO NOT EDIT | 2 | // This file is automatically generated. DO NOT EDIT |
| 3 | 3 | ||
| 4 | import * as AgentService from "./agentservice.js"; | 4 | import * as AgentService from "./agentservice.js"; |
| 5 | -import * as ChatService from "./chatservice.js"; | 5 | +import * as ChannelService from "./channelservice.js"; |
| 6 | import * as ConfigService from "./configservice.js"; | 6 | import * as ConfigService from "./configservice.js"; |
| 7 | export { | 7 | export { |
| 8 | AgentService, | 8 | AgentService, |
| 9 | - ChatService, | 9 | + ChannelService, |
| 10 | ConfigService | 10 | ConfigService |
| 11 | }; | 11 | }; |
| 12 | 12 | ||
| 13 | export type { | 13 | export type { |
| 14 | - ChannelView, | ||
| 15 | ConfigView, | 14 | ConfigView, |
| 15 | + OlderView, | ||
| 16 | ProblemView | 16 | ProblemView |
| 17 | } from "./models.js"; | 17 | } from "./models.js"; |
modified
frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/app/models.ts +17 -9 | @@ -1,14 +1,9 @@ | ||
| 1 | 1 | // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL |
| 2 | 2 | // This file is automatically generated. DO NOT EDIT |
| 3 | 3 | |
| 4 | -/** | |
| 5 | - * ChannelView is a channel declared in the configuration. | |
| 6 | - */ | |
| 7 | -export interface ChannelView { | |
| 8 | - "name": string; | |
| 9 | - "description": string; | |
| 10 | - "agents": string[] | null; | |
| 11 | -} | |
| 4 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
| 5 | +// @ts-ignore: Unused imports | |
| 6 | +import * as channels$0 from "../channels/models.js"; | |
| 12 | 7 | |
| 13 | 8 | /** |
| 14 | 9 | * ConfigView is the configuration as the interface shows it. |
| @@ -35,7 +30,20 @@ export interface ConfigView { | ||
| 35 | 30 | "warnings": string[] | null; |
| 36 | 31 | "theme": string; |
| 37 | 32 | "language": string; |
| 38 | - "channels": ChannelView[] | null; | |
| 33 | + | |
| 34 | + /** | |
| 35 | + * HistoryProblem says why the history of this file could not be opened: | |
| 36 | + * it is then kept in memory only, until Sidekicks quits. | |
| 37 | + */ | |
| 38 | + "historyProblem": string; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * OlderView is a page of older messages. | |
| 43 | + */ | |
| 44 | +export interface OlderView { | |
| 45 | + "messages": channels$0.Message[] | null; | |
| 46 | + "more": boolean; | |
| 39 | 47 | } |
| 40 | 48 | |
| 41 | 49 | /** |
| @@ -1,14 +1,9 @@ | |||
| 1 | // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL | 1 | // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL |
| 2 | // This file is automatically generated. DO NOT EDIT | 2 | // This file is automatically generated. DO NOT EDIT |
| 3 | 3 | ||
| 4 | -/** | 4 | +// eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 5 | - * ChannelView is a channel declared in the configuration. | 5 | +// @ts-ignore: Unused imports |
| 6 | - */ | 6 | +import * as channels$0 from "../channels/models.js"; |
| 7 | -export interface ChannelView { | ||
| 8 | - "name": string; | ||
| 9 | - "description": string; | ||
| 10 | - "agents": string[] | null; | ||
| 11 | -} | ||
| 12 | 7 | ||
| 13 | /** | 8 | /** |
| 14 | * ConfigView is the configuration as the interface shows it. | 9 | * ConfigView is the configuration as the interface shows it. |
| @@ -35,7 +30,20 @@ export interface ConfigView { | |||
| 35 | "warnings": string[] | null; | 30 | "warnings": string[] | null; |
| 36 | "theme": string; | 31 | "theme": string; |
| 37 | "language": string; | 32 | "language": string; |
| 38 | - "channels": ChannelView[] | null; | 33 | + |
| 34 | + /** | ||
| 35 | + * HistoryProblem says why the history of this file could not be opened: | ||
| 36 | + * it is then kept in memory only, until Sidekicks quits. | ||
| 37 | + */ | ||
| 38 | + "historyProblem": string; | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +/** | ||
| 42 | + * OlderView is a page of older messages. | ||
| 43 | + */ | ||
| 44 | +export interface OlderView { | ||
| 45 | + "messages": channels$0.Message[] | null; | ||
| 46 | + "more": boolean; | ||
| 39 | } | 47 | } |
| 40 | 48 | ||
| 41 | /** | 49 | /** |
renamed
frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/channels/index.ts +3 -0 | similarity index 87% | ||
| rename from frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations/index.ts | ||
| rename to frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/channels/index.ts | ||
| @@ -6,8 +6,11 @@ export { | ||
| 6 | 6 | } from "./models.js"; |
| 7 | 7 | |
| 8 | 8 | export type { |
| 9 | + Channel, | |
| 10 | + CommandsPayload, | |
| 9 | 11 | Conversation, |
| 10 | 12 | DeltaPayload, |
| 13 | + ListPayload, | |
| 11 | 14 | Message, |
| 12 | 15 | MessagePayload, |
| 13 | 16 | Part, |
| similarity index 87% | |||
| rename from frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations/index.ts | |||
| rename to frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/channels/index.ts | |||
| @@ -6,8 +6,11 @@ export { | |||
| 6 | } from "./models.js"; | 6 | } from "./models.js"; |
| 7 | 7 | ||
| 8 | export type { | 8 | export type { |
| 9 | + Channel, | ||
| 10 | + CommandsPayload, | ||
| 9 | Conversation, | 11 | Conversation, |
| 10 | DeltaPayload, | 12 | DeltaPayload, |
| 13 | + ListPayload, | ||
| 11 | Message, | 14 | Message, |
| 12 | MessagePayload, | 15 | MessagePayload, |
| 13 | Part, | 16 | Part, |
renamed
frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/channels/models.ts +67 -14 | similarity index 62% | ||
| rename from frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations/models.ts | ||
| rename to frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/channels/models.ts | ||
| @@ -6,15 +6,51 @@ | ||
| 6 | 6 | import * as acp$0 from "../acp/models.js"; |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | - * Conversation is the state of the conversation with one agent. | |
| 9 | + * Channel is a channel as the interface lists it. | |
| 10 | 10 | */ |
| 11 | -export interface Conversation { | |
| 11 | +export interface Channel { | |
| 12 | + "id": string; | |
| 13 | + "name": string; | |
| 14 | + "description": string; | |
| 15 | + | |
| 16 | + /** | |
| 17 | + * Direct names the agent of a direct conversation; empty for a channel. | |
| 18 | + */ | |
| 19 | + "direct": string; | |
| 20 | + "members": string[] | null; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * CommandsPayload lists the slash commands an agent offers. | |
| 25 | + */ | |
| 26 | +export interface CommandsPayload { | |
| 12 | 27 | "agent": string; |
| 28 | + "commands": acp$0.Command[] | null; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * Conversation is what the interface shows of a channel. | |
| 33 | + */ | |
| 34 | +export interface Conversation { | |
| 35 | + "channel": Channel; | |
| 13 | 36 | "messages": Message[] | null; |
| 37 | + | |
| 38 | + /** | |
| 39 | + * More tells whether older messages can be loaded with Older. | |
| 40 | + */ | |
| 41 | + "more": boolean; | |
| 14 | 42 | "busy": boolean; |
| 15 | 43 | "queued": number; |
| 16 | - "usage": acp$0.Usage; | |
| 17 | - "commands": acp$0.Command[] | null; | |
| 44 | + | |
| 45 | + /** | |
| 46 | + * Answering is the agent answering now, if any. | |
| 47 | + */ | |
| 48 | + "answering": string; | |
| 49 | + | |
| 50 | + /** | |
| 51 | + * Usage is the context window use of each member, by agent name. | |
| 52 | + */ | |
| 53 | + "usage": { [_ in string]?: acp$0.Usage } | null; | |
| 18 | 54 | "permissions": Permission[] | null; |
| 19 | 55 | } |
| 20 | 56 | |
| @@ -23,7 +59,7 @@ export interface Conversation { | ||
| 23 | 59 | * a text or thought part; Part replaces a tool or plan part. |
| 24 | 60 | */ |
| 25 | 61 | export interface DeltaPayload { |
| 26 | - "agent": string; | |
| 62 | + "channel": string; | |
| 27 | 63 | "messageId": string; |
| 28 | 64 | "index": number; |
| 29 | 65 | "kind": string; |
| @@ -32,11 +68,24 @@ export interface DeltaPayload { | ||
| 32 | 68 | } |
| 33 | 69 | |
| 34 | 70 | /** |
| 35 | - * Message is one message of a conversation. | |
| 71 | + * ListPayload is the whole list of channels, after any change (channels:changed). | |
| 72 | + */ | |
| 73 | +export interface ListPayload { | |
| 74 | + "channels": Channel[] | null; | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** | |
| 78 | + * Message is one message of a channel. | |
| 36 | 79 | */ |
| 37 | 80 | export interface Message { |
| 38 | 81 | "id": string; |
| 39 | - "agent": string; | |
| 82 | + "channel": string; | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * Agent is the author of an agent message, or the agent a system message | |
| 86 | + * is about. | |
| 87 | + */ | |
| 88 | + "agent"?: string; | |
| 40 | 89 | "role": Role; |
| 41 | 90 | "parts": Part[] | null; |
| 42 | 91 | |
| @@ -46,7 +95,7 @@ export interface Message { | ||
| 46 | 95 | "done": boolean; |
| 47 | 96 | |
| 48 | 97 | /** |
| 49 | - * Queued: a user message waiting for the current answer to end. | |
| 98 | + * Queued: a user message waiting for the current answers to end. | |
| 50 | 99 | */ |
| 51 | 100 | "queued": boolean; |
| 52 | 101 | |
| @@ -61,7 +110,7 @@ export interface Message { | ||
| 61 | 110 | "error"?: string; |
| 62 | 111 | |
| 63 | 112 | /** |
| 64 | - * Code identifies a system message (CodeAgentRestarted…). | |
| 113 | + * Code identifies a system message (CodeAgentJoined…). | |
| 65 | 114 | */ |
| 66 | 115 | "code"?: string; |
| 67 | 116 | "createdAt": string; |
| @@ -71,7 +120,7 @@ export interface Message { | ||
| 71 | 120 | * MessagePayload carries a whole message: added, or finished. |
| 72 | 121 | */ |
| 73 | 122 | export interface MessagePayload { |
| 74 | - "agent": string; | |
| 123 | + "channel": string; | |
| 75 | 124 | "message": Message; |
| 76 | 125 | } |
| 77 | 126 | |
| @@ -90,6 +139,7 @@ export interface Part { | ||
| 90 | 139 | */ |
| 91 | 140 | export interface Permission { |
| 92 | 141 | "id": string; |
| 142 | + "channel": string; | |
| 93 | 143 | "agent": string; |
| 94 | 144 | "messageId": string; |
| 95 | 145 | "toolCall": acp$0.ToolCall; |
| @@ -107,7 +157,7 @@ export interface Permission { | ||
| 107 | 157 | */ |
| 108 | 158 | export interface PermissionResolvedPayload { |
| 109 | 159 | "id": string; |
| 110 | - "agent": string; | |
| 160 | + "channel": string; | |
| 111 | 161 | } |
| 112 | 162 | |
| 113 | 163 | /** |
| @@ -128,18 +178,21 @@ export enum Role { | ||
| 128 | 178 | }; |
| 129 | 179 | |
| 130 | 180 | /** |
| 131 | - * TurnPayload tells whether the agent is answering and how many messages wait. | |
| 181 | + * TurnPayload tells whether a channel is answering, who answers and how many | |
| 182 | + * messages wait. | |
| 132 | 183 | */ |
| 133 | 184 | export interface TurnPayload { |
| 134 | - "agent": string; | |
| 185 | + "channel": string; | |
| 135 | 186 | "busy": boolean; |
| 136 | 187 | "queued": number; |
| 188 | + "answering": string; | |
| 137 | 189 | } |
| 138 | 190 | |
| 139 | 191 | /** |
| 140 | - * UsagePayload is the context window use of a conversation. | |
| 192 | + * UsagePayload is the context window use of an agent in a channel. | |
| 141 | 193 | */ |
| 142 | 194 | export interface UsagePayload { |
| 195 | + "channel": string; | |
| 143 | 196 | "agent": string; |
| 144 | 197 | "usage": acp$0.Usage; |
| 145 | 198 | } |
| similarity index 62% | |||
| rename from frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations/models.ts | |||
| rename to frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/channels/models.ts | |||
| @@ -6,15 +6,51 @@ | |||
| 6 | import * as acp$0 from "../acp/models.js"; | 6 | import * as acp$0 from "../acp/models.js"; |
| 7 | 7 | ||
| 8 | /** | 8 | /** |
| 9 | - * Conversation is the state of the conversation with one agent. | 9 | + * Channel is a channel as the interface lists it. |
| 10 | */ | 10 | */ |
| 11 | -export interface Conversation { | 11 | +export interface Channel { |
| 12 | + "id": string; | ||
| 13 | + "name": string; | ||
| 14 | + "description": string; | ||
| 15 | + | ||
| 16 | + /** | ||
| 17 | + * Direct names the agent of a direct conversation; empty for a channel. | ||
| 18 | + */ | ||
| 19 | + "direct": string; | ||
| 20 | + "members": string[] | null; | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +/** | ||
| 24 | + * CommandsPayload lists the slash commands an agent offers. | ||
| 25 | + */ | ||
| 26 | +export interface CommandsPayload { | ||
| 12 | "agent": string; | 27 | "agent": string; |
| 28 | + "commands": acp$0.Command[] | null; | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +/** | ||
| 32 | + * Conversation is what the interface shows of a channel. | ||
| 33 | + */ | ||
| 34 | +export interface Conversation { | ||
| 35 | + "channel": Channel; | ||
| 13 | "messages": Message[] | null; | 36 | "messages": Message[] | null; |
| 37 | + | ||
| 38 | + /** | ||
| 39 | + * More tells whether older messages can be loaded with Older. | ||
| 40 | + */ | ||
| 41 | + "more": boolean; | ||
| 14 | "busy": boolean; | 42 | "busy": boolean; |
| 15 | "queued": number; | 43 | "queued": number; |
| 16 | - "usage": acp$0.Usage; | 44 | + |
| 17 | - "commands": acp$0.Command[] | null; | 45 | + /** |
| 46 | + * Answering is the agent answering now, if any. | ||
| 47 | + */ | ||
| 48 | + "answering": string; | ||
| 49 | + | ||
| 50 | + /** | ||
| 51 | + * Usage is the context window use of each member, by agent name. | ||
| 52 | + */ | ||
| 53 | + "usage": { [_ in string]?: acp$0.Usage } | null; | ||
| 18 | "permissions": Permission[] | null; | 54 | "permissions": Permission[] | null; |
| 19 | } | 55 | } |
| 20 | 56 | ||
| @@ -23,7 +59,7 @@ export interface Conversation { | |||
| 23 | * a text or thought part; Part replaces a tool or plan part. | 59 | * a text or thought part; Part replaces a tool or plan part. |
| 24 | */ | 60 | */ |
| 25 | export interface DeltaPayload { | 61 | export interface DeltaPayload { |
| 26 | - "agent": string; | 62 | + "channel": string; |
| 27 | "messageId": string; | 63 | "messageId": string; |
| 28 | "index": number; | 64 | "index": number; |
| 29 | "kind": string; | 65 | "kind": string; |
| @@ -32,11 +68,24 @@ export interface DeltaPayload { | |||
| 32 | } | 68 | } |
| 33 | 69 | ||
| 34 | /** | 70 | /** |
| 35 | - * Message is one message of a conversation. | 71 | + * ListPayload is the whole list of channels, after any change (channels:changed). |
| 72 | + */ | ||
| 73 | +export interface ListPayload { | ||
| 74 | + "channels": Channel[] | null; | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +/** | ||
| 78 | + * Message is one message of a channel. | ||
| 36 | */ | 79 | */ |
| 37 | export interface Message { | 80 | export interface Message { |
| 38 | "id": string; | 81 | "id": string; |
| 39 | - "agent": string; | 82 | + "channel": string; |
| 83 | + | ||
| 84 | + /** | ||
| 85 | + * Agent is the author of an agent message, or the agent a system message | ||
| 86 | + * is about. | ||
| 87 | + */ | ||
| 88 | + "agent"?: string; | ||
| 40 | "role": Role; | 89 | "role": Role; |
| 41 | "parts": Part[] | null; | 90 | "parts": Part[] | null; |
| 42 | 91 | ||
| @@ -46,7 +95,7 @@ export interface Message { | |||
| 46 | "done": boolean; | 95 | "done": boolean; |
| 47 | 96 | ||
| 48 | /** | 97 | /** |
| 49 | - * Queued: a user message waiting for the current answer to end. | 98 | + * Queued: a user message waiting for the current answers to end. |
| 50 | */ | 99 | */ |
| 51 | "queued": boolean; | 100 | "queued": boolean; |
| 52 | 101 | ||
| @@ -61,7 +110,7 @@ export interface Message { | |||
| 61 | "error"?: string; | 110 | "error"?: string; |
| 62 | 111 | ||
| 63 | /** | 112 | /** |
| 64 | - * Code identifies a system message (CodeAgentRestarted…). | 113 | + * Code identifies a system message (CodeAgentJoined…). |
| 65 | */ | 114 | */ |
| 66 | "code"?: string; | 115 | "code"?: string; |
| 67 | "createdAt": string; | 116 | "createdAt": string; |
| @@ -71,7 +120,7 @@ export interface Message { | |||
| 71 | * MessagePayload carries a whole message: added, or finished. | 120 | * MessagePayload carries a whole message: added, or finished. |
| 72 | */ | 121 | */ |
| 73 | export interface MessagePayload { | 122 | export interface MessagePayload { |
| 74 | - "agent": string; | 123 | + "channel": string; |
| 75 | "message": Message; | 124 | "message": Message; |
| 76 | } | 125 | } |
| 77 | 126 | ||
| @@ -90,6 +139,7 @@ export interface Part { | |||
| 90 | */ | 139 | */ |
| 91 | export interface Permission { | 140 | export interface Permission { |
| 92 | "id": string; | 141 | "id": string; |
| 142 | + "channel": string; | ||
| 93 | "agent": string; | 143 | "agent": string; |
| 94 | "messageId": string; | 144 | "messageId": string; |
| 95 | "toolCall": acp$0.ToolCall; | 145 | "toolCall": acp$0.ToolCall; |
| @@ -107,7 +157,7 @@ export interface Permission { | |||
| 107 | */ | 157 | */ |
| 108 | export interface PermissionResolvedPayload { | 158 | export interface PermissionResolvedPayload { |
| 109 | "id": string; | 159 | "id": string; |
| 110 | - "agent": string; | 160 | + "channel": string; |
| 111 | } | 161 | } |
| 112 | 162 | ||
| 113 | /** | 163 | /** |
| @@ -128,18 +178,21 @@ export enum Role { | |||
| 128 | }; | 178 | }; |
| 129 | 179 | ||
| 130 | /** | 180 | /** |
| 131 | - * TurnPayload tells whether the agent is answering and how many messages wait. | 181 | + * TurnPayload tells whether a channel is answering, who answers and how many |
| 182 | + * messages wait. | ||
| 132 | */ | 183 | */ |
| 133 | export interface TurnPayload { | 184 | export interface TurnPayload { |
| 134 | - "agent": string; | 185 | + "channel": string; |
| 135 | "busy": boolean; | 186 | "busy": boolean; |
| 136 | "queued": number; | 187 | "queued": number; |
| 188 | + "answering": string; | ||
| 137 | } | 189 | } |
| 138 | 190 | ||
| 139 | /** | 191 | /** |
| 140 | - * UsagePayload is the context window use of a conversation. | 192 | + * UsagePayload is the context window use of an agent in a channel. |
| 141 | */ | 193 | */ |
| 142 | export interface UsagePayload { | 194 | export interface UsagePayload { |
| 195 | + "channel": string; | ||
| 143 | "agent": string; | 196 | "agent": string; |
| 144 | "usage": acp$0.Usage; | 197 | "usage": acp$0.Usage; |
| 145 | } | 198 | } |
modified
frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts +9 -7 | @@ -7,7 +7,7 @@ import type { Events } from "@wailsio/runtime"; | ||
| 7 | 7 | |
| 8 | 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 9 | 9 | // @ts-ignore: Unused imports |
| 10 | -import type * as conversations$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/conversations/models.js"; | |
| 10 | +import type * as channels$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/channels/models.js"; | |
| 11 | 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 12 | 12 | // @ts-ignore: Unused imports |
| 13 | 13 | import type * as events$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/events/models.js"; |
| @@ -17,13 +17,15 @@ declare module "@wailsio/runtime" { | ||
| 17 | 17 | interface CustomEvents { |
| 18 | 18 | "agent:log": events$0.AgentLogPayload; |
| 19 | 19 | "agent:state": events$0.AgentStatePayload; |
| 20 | + "channels:changed": channels$0.ListPayload; | |
| 21 | + "commands:changed": channels$0.CommandsPayload; | |
| 20 | 22 | "config:changed": events$0.ConfigChangedPayload; |
| 21 | - "message:delta": conversations$0.DeltaPayload; | |
| 22 | - "message:updated": conversations$0.MessagePayload; | |
| 23 | - "permission:requested": conversations$0.Permission; | |
| 24 | - "permission:resolved": conversations$0.PermissionResolvedPayload; | |
| 25 | - "turn:changed": conversations$0.TurnPayload; | |
| 26 | - "usage:changed": conversations$0.UsagePayload; | |
| 23 | + "message:delta": channels$0.DeltaPayload; | |
| 24 | + "message:updated": channels$0.MessagePayload; | |
| 25 | + "permission:requested": channels$0.Permission; | |
| 26 | + "permission:resolved": channels$0.PermissionResolvedPayload; | |
| 27 | + "turn:changed": channels$0.TurnPayload; | |
| 28 | + "usage:changed": channels$0.UsagePayload; | |
| 27 | 29 | } |
| 28 | 30 | } |
| 29 | 31 | } |
| @@ -7,7 +7,7 @@ import type { Events } from "@wailsio/runtime"; | |||
| 7 | 7 | ||
| 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment | 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 9 | // @ts-ignore: Unused imports | 9 | // @ts-ignore: Unused imports |
| 10 | -import type * as conversations$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/conversations/models.js"; | 10 | +import type * as channels$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/channels/models.js"; |
| 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment | 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 12 | // @ts-ignore: Unused imports | 12 | // @ts-ignore: Unused imports |
| 13 | import type * as events$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/events/models.js"; | 13 | import type * as events$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/events/models.js"; |
| @@ -17,13 +17,15 @@ declare module "@wailsio/runtime" { | |||
| 17 | interface CustomEvents { | 17 | interface CustomEvents { |
| 18 | "agent:log": events$0.AgentLogPayload; | 18 | "agent:log": events$0.AgentLogPayload; |
| 19 | "agent:state": events$0.AgentStatePayload; | 19 | "agent:state": events$0.AgentStatePayload; |
| 20 | + "channels:changed": channels$0.ListPayload; | ||
| 21 | + "commands:changed": channels$0.CommandsPayload; | ||
| 20 | "config:changed": events$0.ConfigChangedPayload; | 22 | "config:changed": events$0.ConfigChangedPayload; |
| 21 | - "message:delta": conversations$0.DeltaPayload; | 23 | + "message:delta": channels$0.DeltaPayload; |
| 22 | - "message:updated": conversations$0.MessagePayload; | 24 | + "message:updated": channels$0.MessagePayload; |
| 23 | - "permission:requested": conversations$0.Permission; | 25 | + "permission:requested": channels$0.Permission; |
| 24 | - "permission:resolved": conversations$0.PermissionResolvedPayload; | 26 | + "permission:resolved": channels$0.PermissionResolvedPayload; |
| 25 | - "turn:changed": conversations$0.TurnPayload; | 27 | + "turn:changed": channels$0.TurnPayload; |
| 26 | - "usage:changed": conversations$0.UsagePayload; | 28 | + "usage:changed": channels$0.UsagePayload; |
| 27 | } | 29 | } |
| 28 | } | 30 | } |
| 29 | } | 31 | } |
modified
frontend/src/lib/chat/Composer.svelte +90 -7 | @@ -1,42 +1,99 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 2 | <script lang="ts"> |
| 3 | 3 | import { t } from '../i18n/index.svelte' |
| 4 | - import { createHistory, findTrigger, type Trigger } from '../composer' | |
| 4 | + import { completeTrigger, createHistory, findTrigger, type Trigger } from '../composer' | |
| 5 | + import type { Suggestion, Suggestions } from '../suggestions' | |
| 5 | 6 | |
| 6 | 7 | let { |
| 7 | 8 | placeholder, |
| 8 | 9 | busy = false, |
| 9 | 10 | disabled = false, |
| 10 | 11 | triggers = [], |
| 12 | + suggest = () => null, | |
| 13 | + onTrigger = () => {}, | |
| 11 | 14 | onSend, |
| 12 | 15 | onStop, |
| 13 | - onTrigger = () => {}, | |
| 14 | 16 | }: { |
| 15 | 17 | placeholder: string |
| 16 | 18 | busy?: boolean |
| 17 | 19 | disabled?: boolean |
| 18 | - /** Characters that open a suggestion popup (@ and / in milestone M3). */ | |
| 20 | + /** Characters that open the suggestion popup (@ and /). */ | |
| 19 | 21 | triggers?: string[] |
| 22 | + /** The suggestions for the trigger the caret is in, or null for none. */ | |
| 23 | + suggest?: (trigger: Trigger, text: string) => Suggestions | null | |
| 24 | + /** Called (from the input event) when the caret enters a trigger, e.g. to load what suggest needs. */ | |
| 25 | + onTrigger?: (trigger: Trigger, text: string) => void | |
| 20 | 26 | onSend: (text: string) => void |
| 21 | 27 | onStop: () => void |
| 22 | - /** Called with the trigger the caret is in, or null. */ | |
| 23 | - onTrigger?: (trigger: Trigger | null) => void | |
| 24 | 28 | } = $props() |
| 25 | 29 | |
| 26 | 30 | let text = $state('') |
| 27 | 31 | let textarea: HTMLTextAreaElement |
| 28 | 32 | const history = createHistory() |
| 29 | 33 | |
| 34 | + // The popup (issues #22 and #24): open while the caret is in a trigger. | |
| 35 | + let trigger: Trigger | null = $state(null) | |
| 36 | + let active = $state(0) | |
| 37 | + // Escape closes the popup until the caret leaves the trigger. | |
| 38 | + let dismissed: number | null = null | |
| 39 | + // Derived, so that suggestions arriving later (an agent's commands) show | |
| 40 | + // up in the open popup. suggest must only read state. | |
| 41 | + const suggestions: Suggestions | null = $derived(trigger ? suggest(trigger, text) : null) | |
| 42 | + | |
| 43 | + function updatePopup() { | |
| 44 | + const found = triggers.length > 0 ? findTrigger(text, textarea.selectionStart, triggers) : null | |
| 45 | + if (!found || found.start === dismissed) { | |
| 46 | + if (!found) dismissed = null | |
| 47 | + trigger = null | |
| 48 | + return | |
| 49 | + } | |
| 50 | + if (found.start !== trigger?.start || found.char !== trigger?.char) onTrigger(found, text) | |
| 51 | + trigger = found | |
| 52 | + active = 0 | |
| 53 | + } | |
| 54 | + | |
| 55 | + function choose(suggestion: Suggestion) { | |
| 56 | + if (!trigger) return | |
| 57 | + const completed = completeTrigger(text, trigger, suggestion.value) | |
| 58 | + text = completed.text | |
| 59 | + trigger = null | |
| 60 | + queueMicrotask(() => { | |
| 61 | + textarea.focus() | |
| 62 | + textarea.setSelectionRange(completed.caret, completed.caret) | |
| 63 | + }) | |
| 64 | + } | |
| 65 | + | |
| 30 | 66 | function submit() { |
| 31 | 67 | const message = text.trim() |
| 32 | 68 | if (!message || disabled) return |
| 33 | 69 | history.add(message) |
| 34 | 70 | onSend(message) |
| 35 | 71 | text = '' |
| 72 | + trigger = null | |
| 73 | + } | |
| 74 | + | |
| 75 | + // Keys for the popup when it has suggestions; true when the key was used. | |
| 76 | + function popupKey(event: KeyboardEvent): boolean { | |
| 77 | + const items = suggestions?.items ?? [] | |
| 78 | + if (event.key === 'Escape' && suggestions) { | |
| 79 | + dismissed = trigger?.start ?? null | |
| 80 | + trigger = null | |
| 81 | + return true | |
| 82 | + } | |
| 83 | + if (items.length === 0) return false | |
| 84 | + if (event.key === 'ArrowDown') active = (active + 1) % items.length | |
| 85 | + else if (event.key === 'ArrowUp') active = (active - 1 + items.length) % items.length | |
| 86 | + else if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) choose(items[active]) | |
| 87 | + else return false | |
| 88 | + return true | |
| 36 | 89 | } |
| 37 | 90 | |
| 38 | 91 | function onKeydown(event: KeyboardEvent) { |
| 39 | 92 | if (event.isComposing) return // an accent or a character being composed |
| 93 | + if (popupKey(event)) { | |
| 94 | + event.preventDefault() | |
| 95 | + return | |
| 96 | + } | |
| 40 | 97 | if (event.key === 'Enter' && !event.shiftKey) { |
| 41 | 98 | event.preventDefault() |
| 42 | 99 | submit() |
| @@ -53,7 +110,7 @@ | ||
| 53 | 110 | // Grow with the text, up to a third of the window. |
| 54 | 111 | textarea.style.height = 'auto' |
| 55 | 112 | textarea.style.height = Math.min(textarea.scrollHeight, window.innerHeight / 3) + 'px' |
| 56 | - if (triggers.length > 0) onTrigger(findTrigger(text, textarea.selectionStart, triggers)) | |
| 113 | + updatePopup() | |
| 57 | 114 | } |
| 58 | 115 | |
| 59 | 116 | /** Puts the caret in the composer. */ |
| @@ -63,6 +120,24 @@ | ||
| 63 | 120 | </script> |
| 64 | 121 | |
| 65 | 122 | <div class="composer"> |
| 123 | + {#if suggestions} | |
| 124 | + <div class="popup" role="listbox" aria-label={t('composer.suggestions')}> | |
| 125 | + {#each suggestions.items as suggestion, index (suggestion.value)} | |
| 126 | + <button | |
| 127 | + type="button" | |
| 128 | + role="option" | |
| 129 | + aria-selected={index === active} | |
| 130 | + class:active={index === active} | |
| 131 | + onmousedown={(event) => { event.preventDefault(); choose(suggestion) }} | |
| 132 | + > | |
| 133 | + <span class="label">{suggestion.label}</span> | |
| 134 | + <span class="detail">{suggestion.detail}</span> | |
| 135 | + </button> | |
| 136 | + {:else} | |
| 137 | + {#if suggestions.empty}<p class="none">{suggestions.empty}</p>{/if} | |
| 138 | + {/each} | |
| 139 | + </div> | |
| 140 | + {/if} | |
| 66 | 141 | <textarea |
| 67 | 142 | bind:this={textarea} |
| 68 | 143 | bind:value={text} |
| @@ -72,6 +147,8 @@ | ||
| 72 | 147 | aria-label={t('composer.label')} |
| 73 | 148 | onkeydown={onKeydown} |
| 74 | 149 | oninput={onInput} |
| 150 | + onclick={updatePopup} | |
| 151 | + onblur={() => (trigger = null)} | |
| 75 | 152 | ></textarea> |
| 76 | 153 | <div class="actions"> |
| 77 | 154 | <span class="hint">{t('composer.hint')}</span> |
| @@ -83,10 +160,16 @@ | ||
| 83 | 160 | </div> |
| 84 | 161 | |
| 85 | 162 | <style> |
| 86 | - .composer { border: 1.5px solid var(--accent); border-radius: 10px; background: var(--panel); padding: 8px 10px 6px; } | |
| 163 | + .composer { position: relative; border: 1.5px solid var(--accent); border-radius: 10px; background: var(--panel); padding: 8px 10px 6px; } | |
| 87 | 164 | textarea { width: 100%; resize: none; border: none; outline: none; background: none; color: var(--text); font: inherit; line-height: 1.45; min-height: 40px; } |
| 88 | 165 | .actions { display: flex; align-items: center; gap: 8px; } |
| 89 | 166 | .hint { flex: 1; color: var(--muted); font-size: 11px; } |
| 90 | 167 | .send { background: var(--accent); border-color: var(--accent); color: #fff; } |
| 91 | 168 | .stop { color: var(--danger); border-color: var(--danger); } |
| 169 | + .popup { position: absolute; left: 8px; right: 8px; bottom: calc(100% + 4px); max-height: 240px; overflow-y: auto; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 6px 24px rgb(0 0 0 / 0.18); padding: 4px; z-index: 10; } | |
| 170 | + .popup button { display: flex; gap: 10px; align-items: baseline; width: 100%; text-align: left; border: none; background: none; padding: 5px 8px; border-radius: 6px; } | |
| 171 | + .popup button.active { background: var(--panel-2); } | |
| 172 | + .label { font-weight: 600; white-space: nowrap; } | |
| 173 | + .detail { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 174 | + .none { margin: 4px 8px; color: var(--muted); font-size: 12px; } | |
| 92 | 175 | </style> |
| @@ -1,42 +1,99 @@ | |||
| 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | <script lang="ts"> | 2 | <script lang="ts"> |
| 3 | import { t } from '../i18n/index.svelte' | 3 | import { t } from '../i18n/index.svelte' |
| 4 | - import { createHistory, findTrigger, type Trigger } from '../composer' | 4 | + import { completeTrigger, createHistory, findTrigger, type Trigger } from '../composer' |
| 5 | + import type { Suggestion, Suggestions } from '../suggestions' | ||
| 5 | 6 | ||
| 6 | let { | 7 | let { |
| 7 | placeholder, | 8 | placeholder, |
| 8 | busy = false, | 9 | busy = false, |
| 9 | disabled = false, | 10 | disabled = false, |
| 10 | triggers = [], | 11 | triggers = [], |
| 12 | + suggest = () => null, | ||
| 13 | + onTrigger = () => {}, | ||
| 11 | onSend, | 14 | onSend, |
| 12 | onStop, | 15 | onStop, |
| 13 | - onTrigger = () => {}, | ||
| 14 | }: { | 16 | }: { |
| 15 | placeholder: string | 17 | placeholder: string |
| 16 | busy?: boolean | 18 | busy?: boolean |
| 17 | disabled?: boolean | 19 | disabled?: boolean |
| 18 | - /** Characters that open a suggestion popup (@ and / in milestone M3). */ | 20 | + /** Characters that open the suggestion popup (@ and /). */ |
| 19 | triggers?: string[] | 21 | triggers?: string[] |
| 22 | + /** The suggestions for the trigger the caret is in, or null for none. */ | ||
| 23 | + suggest?: (trigger: Trigger, text: string) => Suggestions | null | ||
| 24 | + /** Called (from the input event) when the caret enters a trigger, e.g. to load what suggest needs. */ | ||
| 25 | + onTrigger?: (trigger: Trigger, text: string) => void | ||
| 20 | onSend: (text: string) => void | 26 | onSend: (text: string) => void |
| 21 | onStop: () => void | 27 | onStop: () => void |
| 22 | - /** Called with the trigger the caret is in, or null. */ | ||
| 23 | - onTrigger?: (trigger: Trigger | null) => void | ||
| 24 | } = $props() | 28 | } = $props() |
| 25 | 29 | ||
| 26 | let text = $state('') | 30 | let text = $state('') |
| 27 | let textarea: HTMLTextAreaElement | 31 | let textarea: HTMLTextAreaElement |
| 28 | const history = createHistory() | 32 | const history = createHistory() |
| 29 | 33 | ||
| 34 | + // The popup (issues #22 and #24): open while the caret is in a trigger. | ||
| 35 | + let trigger: Trigger | null = $state(null) | ||
| 36 | + let active = $state(0) | ||
| 37 | + // Escape closes the popup until the caret leaves the trigger. | ||
| 38 | + let dismissed: number | null = null | ||
| 39 | + // Derived, so that suggestions arriving later (an agent's commands) show | ||
| 40 | + // up in the open popup. suggest must only read state. | ||
| 41 | + const suggestions: Suggestions | null = $derived(trigger ? suggest(trigger, text) : null) | ||
| 42 | + | ||
| 43 | + function updatePopup() { | ||
| 44 | + const found = triggers.length > 0 ? findTrigger(text, textarea.selectionStart, triggers) : null | ||
| 45 | + if (!found || found.start === dismissed) { | ||
| 46 | + if (!found) dismissed = null | ||
| 47 | + trigger = null | ||
| 48 | + return | ||
| 49 | + } | ||
| 50 | + if (found.start !== trigger?.start || found.char !== trigger?.char) onTrigger(found, text) | ||
| 51 | + trigger = found | ||
| 52 | + active = 0 | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + function choose(suggestion: Suggestion) { | ||
| 56 | + if (!trigger) return | ||
| 57 | + const completed = completeTrigger(text, trigger, suggestion.value) | ||
| 58 | + text = completed.text | ||
| 59 | + trigger = null | ||
| 60 | + queueMicrotask(() => { | ||
| 61 | + textarea.focus() | ||
| 62 | + textarea.setSelectionRange(completed.caret, completed.caret) | ||
| 63 | + }) | ||
| 64 | + } | ||
| 65 | + | ||
| 30 | function submit() { | 66 | function submit() { |
| 31 | const message = text.trim() | 67 | const message = text.trim() |
| 32 | if (!message || disabled) return | 68 | if (!message || disabled) return |
| 33 | history.add(message) | 69 | history.add(message) |
| 34 | onSend(message) | 70 | onSend(message) |
| 35 | text = '' | 71 | text = '' |
| 72 | + trigger = null | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + // Keys for the popup when it has suggestions; true when the key was used. | ||
| 76 | + function popupKey(event: KeyboardEvent): boolean { | ||
| 77 | + const items = suggestions?.items ?? [] | ||
| 78 | + if (event.key === 'Escape' && suggestions) { | ||
| 79 | + dismissed = trigger?.start ?? null | ||
| 80 | + trigger = null | ||
| 81 | + return true | ||
| 82 | + } | ||
| 83 | + if (items.length === 0) return false | ||
| 84 | + if (event.key === 'ArrowDown') active = (active + 1) % items.length | ||
| 85 | + else if (event.key === 'ArrowUp') active = (active - 1 + items.length) % items.length | ||
| 86 | + else if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) choose(items[active]) | ||
| 87 | + else return false | ||
| 88 | + return true | ||
| 36 | } | 89 | } |
| 37 | 90 | ||
| 38 | function onKeydown(event: KeyboardEvent) { | 91 | function onKeydown(event: KeyboardEvent) { |
| 39 | if (event.isComposing) return // an accent or a character being composed | 92 | if (event.isComposing) return // an accent or a character being composed |
| 93 | + if (popupKey(event)) { | ||
| 94 | + event.preventDefault() | ||
| 95 | + return | ||
| 96 | + } | ||
| 40 | if (event.key === 'Enter' && !event.shiftKey) { | 97 | if (event.key === 'Enter' && !event.shiftKey) { |
| 41 | event.preventDefault() | 98 | event.preventDefault() |
| 42 | submit() | 99 | submit() |
| @@ -53,7 +110,7 @@ | |||
| 53 | // Grow with the text, up to a third of the window. | 110 | // Grow with the text, up to a third of the window. |
| 54 | textarea.style.height = 'auto' | 111 | textarea.style.height = 'auto' |
| 55 | textarea.style.height = Math.min(textarea.scrollHeight, window.innerHeight / 3) + 'px' | 112 | textarea.style.height = Math.min(textarea.scrollHeight, window.innerHeight / 3) + 'px' |
| 56 | - if (triggers.length > 0) onTrigger(findTrigger(text, textarea.selectionStart, triggers)) | 113 | + updatePopup() |
| 57 | } | 114 | } |
| 58 | 115 | ||
| 59 | /** Puts the caret in the composer. */ | 116 | /** Puts the caret in the composer. */ |
| @@ -63,6 +120,24 @@ | |||
| 63 | </script> | 120 | </script> |
| 64 | 121 | ||
| 65 | <div class="composer"> | 122 | <div class="composer"> |
| 123 | + {#if suggestions} | ||
| 124 | + <div class="popup" role="listbox" aria-label={t('composer.suggestions')}> | ||
| 125 | + {#each suggestions.items as suggestion, index (suggestion.value)} | ||
| 126 | + <button | ||
| 127 | + type="button" | ||
| 128 | + role="option" | ||
| 129 | + aria-selected={index === active} | ||
| 130 | + class:active={index === active} | ||
| 131 | + onmousedown={(event) => { event.preventDefault(); choose(suggestion) }} | ||
| 132 | + > | ||
| 133 | + <span class="label">{suggestion.label}</span> | ||
| 134 | + <span class="detail">{suggestion.detail}</span> | ||
| 135 | + </button> | ||
| 136 | + {:else} | ||
| 137 | + {#if suggestions.empty}<p class="none">{suggestions.empty}</p>{/if} | ||
| 138 | + {/each} | ||
| 139 | + </div> | ||
| 140 | + {/if} | ||
| 66 | <textarea | 141 | <textarea |
| 67 | bind:this={textarea} | 142 | bind:this={textarea} |
| 68 | bind:value={text} | 143 | bind:value={text} |
| @@ -72,6 +147,8 @@ | |||
| 72 | aria-label={t('composer.label')} | 147 | aria-label={t('composer.label')} |
| 73 | onkeydown={onKeydown} | 148 | onkeydown={onKeydown} |
| 74 | oninput={onInput} | 149 | oninput={onInput} |
| 150 | + onclick={updatePopup} | ||
| 151 | + onblur={() => (trigger = null)} | ||
| 75 | ></textarea> | 152 | ></textarea> |
| 76 | <div class="actions"> | 153 | <div class="actions"> |
| 77 | <span class="hint">{t('composer.hint')}</span> | 154 | <span class="hint">{t('composer.hint')}</span> |
| @@ -83,10 +160,16 @@ | |||
| 83 | </div> | 160 | </div> |
| 84 | 161 | ||
| 85 | <style> | 162 | <style> |
| 86 | - .composer { border: 1.5px solid var(--accent); border-radius: 10px; background: var(--panel); padding: 8px 10px 6px; } | 163 | + .composer { position: relative; border: 1.5px solid var(--accent); border-radius: 10px; background: var(--panel); padding: 8px 10px 6px; } |
| 87 | textarea { width: 100%; resize: none; border: none; outline: none; background: none; color: var(--text); font: inherit; line-height: 1.45; min-height: 40px; } | 164 | textarea { width: 100%; resize: none; border: none; outline: none; background: none; color: var(--text); font: inherit; line-height: 1.45; min-height: 40px; } |
| 88 | .actions { display: flex; align-items: center; gap: 8px; } | 165 | .actions { display: flex; align-items: center; gap: 8px; } |
| 89 | .hint { flex: 1; color: var(--muted); font-size: 11px; } | 166 | .hint { flex: 1; color: var(--muted); font-size: 11px; } |
| 90 | .send { background: var(--accent); border-color: var(--accent); color: #fff; } | 167 | .send { background: var(--accent); border-color: var(--accent); color: #fff; } |
| 91 | .stop { color: var(--danger); border-color: var(--danger); } | 168 | .stop { color: var(--danger); border-color: var(--danger); } |
| 169 | + .popup { position: absolute; left: 8px; right: 8px; bottom: calc(100% + 4px); max-height: 240px; overflow-y: auto; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 6px 24px rgb(0 0 0 / 0.18); padding: 4px; z-index: 10; } | ||
| 170 | + .popup button { display: flex; gap: 10px; align-items: baseline; width: 100%; text-align: left; border: none; background: none; padding: 5px 8px; border-radius: 6px; } | ||
| 171 | + .popup button.active { background: var(--panel-2); } | ||
| 172 | + .label { font-weight: 600; white-space: nowrap; } | ||
| 173 | + .detail { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 174 | + .none { margin: 4px 8px; color: var(--muted); font-size: 12px; } | ||
| 92 | </style> | 175 | </style> |
modified
frontend/src/lib/chat/MessageView.svelte +19 -7 | @@ -1,11 +1,22 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 2 | <script lang="ts"> |
| 3 | 3 | import { hasMessage, t } from '../i18n/index.svelte' |
| 4 | - import type { Message } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | |
| 4 | + import type { Message } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' | |
| 5 | + import { agentTitle, appState } from '../state.svelte' | |
| 6 | + import { splitMentions } from '../suggestions' | |
| 7 | + import { activityOf } from '../conversation' | |
| 5 | 8 | import Markdown from './Markdown.svelte' |
| 9 | + import Spinner from './Spinner.svelte' | |
| 6 | 10 | import ToolCallCard from './ToolCallCard.svelte' |
| 7 | 11 | |
| 8 | - let { message, agentTitle }: { message: Message; agentTitle: string } = $props() | |
| 12 | + let { message }: { message: Message } = $props() | |
| 13 | + | |
| 14 | + const author = $derived(agentTitle(message.agent ?? '')) | |
| 15 | + // Shown with a spinner while the answer streams. | |
| 16 | + const activity = $derived(activityOf(message)) | |
| 17 | + const isAgent = (name: string) => appState.agents.some((agent) => agent.name === name) | |
| 18 | + // An example of mention for the "nobody was addressed" notice. | |
| 19 | + const example = $derived(appState.agents[0]?.name ?? 'alice') | |
| 9 | 20 | |
| 10 | 21 | // Codes and stop reasons come from Go; their sentences live in i18n. |
| 11 | 22 | const systemKey = $derived(`system.${message.code ?? ''}`) |
| @@ -14,17 +25,17 @@ | ||
| 14 | 25 | </script> |
| 15 | 26 | |
| 16 | 27 | {#if message.role === 'system'} |
| 17 | - <p class="system">{hasMessage(systemKey) ? t(systemKey, { agent: agentTitle }) : agentTitle}{#if message.error}<br /><span class="detail">{message.error}</span>{/if}</p> | |
| 28 | + <p class="system">{hasMessage(systemKey) ? t(systemKey, { agent: author, example }) : author}{#if message.error}<br /><span class="detail">{message.error}</span>{/if}</p> | |
| 18 | 29 | {:else} |
| 19 | 30 | <article class="message {message.role}"> |
| 20 | 31 | <div class="author"> |
| 21 | - {message.role === 'user' ? t('message.you') : agentTitle} | |
| 32 | + {message.role === 'user' ? t('message.you') : author} | |
| 22 | 33 | {#if message.queued}<span class="note">{t('message.queued')}</span>{/if} |
| 23 | - {#if message.role === 'agent' && !message.done}<span class="note typing">{t('message.typing')}</span>{/if} | |
| 34 | + {#if activity}<span class="note activity"><Spinner /> {t(`message.${activity}`)}</span>{/if} | |
| 24 | 35 | </div> |
| 25 | 36 | {#each message.parts ?? [] as part, index (index)} |
| 26 | 37 | {#if part.kind === 'text' && message.role === 'user'} |
| 27 | - <p class="user-text">{part.text}</p> | |
| 38 | + <p class="user-text">{#each splitMentions(part.text ?? '', isAgent) as segment, index (index)}{#if segment.agent}<span class="mention" title={agentTitle(segment.agent)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}</p> | |
| 28 | 39 | {:else if part.kind === 'text'} |
| 29 | 40 | <Markdown text={part.text ?? ''} /> |
| 30 | 41 | {:else if part.kind === 'thought'} |
| @@ -55,8 +66,9 @@ | ||
| 55 | 66 | .author { font-weight: 600; margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; } |
| 56 | 67 | .agent .author { color: var(--accent); } |
| 57 | 68 | .note { color: var(--muted); font-weight: 400; font-size: 12px; } |
| 58 | - .typing { font-style: italic; } | |
| 69 | + .activity { font-style: italic; display: inline-flex; align-items: center; gap: 5px; } | |
| 59 | 70 | .user-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; } |
| 71 | + .mention { color: var(--accent); font-weight: 600; background: var(--panel-2); border-radius: 4px; padding: 0 3px; } | |
| 60 | 72 | .system { color: var(--muted); font-style: italic; text-align: center; font-size: 13px; margin: 4px 0; } |
| 61 | 73 | .detail { font-style: normal; font-size: 12px; } |
| 62 | 74 | .thought { color: var(--muted); font-size: 13px; margin: 4px 0; } |
| @@ -1,11 +1,22 @@ | |||
| 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | <script lang="ts"> | 2 | <script lang="ts"> |
| 3 | import { hasMessage, t } from '../i18n/index.svelte' | 3 | import { hasMessage, t } from '../i18n/index.svelte' |
| 4 | - import type { Message } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | 4 | + import type { Message } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' |
| 5 | + import { agentTitle, appState } from '../state.svelte' | ||
| 6 | + import { splitMentions } from '../suggestions' | ||
| 7 | + import { activityOf } from '../conversation' | ||
| 5 | import Markdown from './Markdown.svelte' | 8 | import Markdown from './Markdown.svelte' |
| 9 | + import Spinner from './Spinner.svelte' | ||
| 6 | import ToolCallCard from './ToolCallCard.svelte' | 10 | import ToolCallCard from './ToolCallCard.svelte' |
| 7 | 11 | ||
| 8 | - let { message, agentTitle }: { message: Message; agentTitle: string } = $props() | 12 | + let { message }: { message: Message } = $props() |
| 13 | + | ||
| 14 | + const author = $derived(agentTitle(message.agent ?? '')) | ||
| 15 | + // Shown with a spinner while the answer streams. | ||
| 16 | + const activity = $derived(activityOf(message)) | ||
| 17 | + const isAgent = (name: string) => appState.agents.some((agent) => agent.name === name) | ||
| 18 | + // An example of mention for the "nobody was addressed" notice. | ||
| 19 | + const example = $derived(appState.agents[0]?.name ?? 'alice') | ||
| 9 | 20 | ||
| 10 | // Codes and stop reasons come from Go; their sentences live in i18n. | 21 | // Codes and stop reasons come from Go; their sentences live in i18n. |
| 11 | const systemKey = $derived(`system.${message.code ?? ''}`) | 22 | const systemKey = $derived(`system.${message.code ?? ''}`) |
| @@ -14,17 +25,17 @@ | |||
| 14 | </script> | 25 | </script> |
| 15 | 26 | ||
| 16 | {#if message.role === 'system'} | 27 | {#if message.role === 'system'} |
| 17 | - <p class="system">{hasMessage(systemKey) ? t(systemKey, { agent: agentTitle }) : agentTitle}{#if message.error}<br /><span class="detail">{message.error}</span>{/if}</p> | 28 | + <p class="system">{hasMessage(systemKey) ? t(systemKey, { agent: author, example }) : author}{#if message.error}<br /><span class="detail">{message.error}</span>{/if}</p> |
| 18 | {:else} | 29 | {:else} |
| 19 | <article class="message {message.role}"> | 30 | <article class="message {message.role}"> |
| 20 | <div class="author"> | 31 | <div class="author"> |
| 21 | - {message.role === 'user' ? t('message.you') : agentTitle} | 32 | + {message.role === 'user' ? t('message.you') : author} |
| 22 | {#if message.queued}<span class="note">{t('message.queued')}</span>{/if} | 33 | {#if message.queued}<span class="note">{t('message.queued')}</span>{/if} |
| 23 | - {#if message.role === 'agent' && !message.done}<span class="note typing">{t('message.typing')}</span>{/if} | 34 | + {#if activity}<span class="note activity"><Spinner /> {t(`message.${activity}`)}</span>{/if} |
| 24 | </div> | 35 | </div> |
| 25 | {#each message.parts ?? [] as part, index (index)} | 36 | {#each message.parts ?? [] as part, index (index)} |
| 26 | {#if part.kind === 'text' && message.role === 'user'} | 37 | {#if part.kind === 'text' && message.role === 'user'} |
| 27 | - <p class="user-text">{part.text}</p> | 38 | + <p class="user-text">{#each splitMentions(part.text ?? '', isAgent) as segment, index (index)}{#if segment.agent}<span class="mention" title={agentTitle(segment.agent)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}</p> |
| 28 | {:else if part.kind === 'text'} | 39 | {:else if part.kind === 'text'} |
| 29 | <Markdown text={part.text ?? ''} /> | 40 | <Markdown text={part.text ?? ''} /> |
| 30 | {:else if part.kind === 'thought'} | 41 | {:else if part.kind === 'thought'} |
| @@ -55,8 +66,9 @@ | |||
| 55 | .author { font-weight: 600; margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; } | 66 | .author { font-weight: 600; margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; } |
| 56 | .agent .author { color: var(--accent); } | 67 | .agent .author { color: var(--accent); } |
| 57 | .note { color: var(--muted); font-weight: 400; font-size: 12px; } | 68 | .note { color: var(--muted); font-weight: 400; font-size: 12px; } |
| 58 | - .typing { font-style: italic; } | 69 | + .activity { font-style: italic; display: inline-flex; align-items: center; gap: 5px; } |
| 59 | .user-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; } | 70 | .user-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; } |
| 71 | + .mention { color: var(--accent); font-weight: 600; background: var(--panel-2); border-radius: 4px; padding: 0 3px; } | ||
| 60 | .system { color: var(--muted); font-style: italic; text-align: center; font-size: 13px; margin: 4px 0; } | 72 | .system { color: var(--muted); font-style: italic; text-align: center; font-size: 13px; margin: 4px 0; } |
| 61 | .detail { font-style: normal; font-size: 12px; } | 73 | .detail { font-style: normal; font-size: 12px; } |
| 62 | .thought { color: var(--muted); font-size: 13px; margin: 4px 0; } | 74 | .thought { color: var(--muted); font-size: 13px; margin: 4px 0; } |
modified
frontend/src/lib/chat/PermissionCard.svelte +6 -4 | @@ -1,11 +1,13 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 2 | <script lang="ts"> |
| 3 | 3 | import { hasMessage, t } from '../i18n/index.svelte' |
| 4 | - import type { Permission } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | |
| 5 | - import { answerPermission, cancel } from '../state.svelte' | |
| 4 | + import type { Permission } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' | |
| 5 | + import { agentTitle as titleOf, answerPermission, cancel } from '../state.svelte' | |
| 6 | 6 | import { describeAction, isIrreversible, mainDetail, orderOptions } from '../tools' |
| 7 | 7 | |
| 8 | - let { permission, agentTitle }: { permission: Permission; agentTitle: string } = $props() | |
| 8 | + let { permission }: { permission: Permission } = $props() | |
| 9 | + | |
| 10 | + const agentTitle = $derived(titleOf(permission.agent)) | |
| 9 | 11 | |
| 10 | 12 | // ACP option kinds in words a citizen user understands; the agent's own |
| 11 | 13 | // label is kept as a tooltip. |
| @@ -34,7 +36,7 @@ | ||
| 34 | 36 | {label(option.kind, option.name)} |
| 35 | 37 | </button> |
| 36 | 38 | {/each} |
| 37 | - <button class="stop" onclick={() => cancel(permission.agent)}>{t('conversation.stop', { agent: agentTitle })}</button> | |
| 39 | + <button class="stop" onclick={() => cancel(permission.channel)}>{t('conversation.stop', { agent: agentTitle })}</button> | |
| 38 | 40 | </div> |
| 39 | 41 | <details> |
| 40 | 42 | <summary>{t('permission.details')}</summary> |
| @@ -1,11 +1,13 @@ | |||
| 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | <script lang="ts"> | 2 | <script lang="ts"> |
| 3 | import { hasMessage, t } from '../i18n/index.svelte' | 3 | import { hasMessage, t } from '../i18n/index.svelte' |
| 4 | - import type { Permission } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | 4 | + import type { Permission } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' |
| 5 | - import { answerPermission, cancel } from '../state.svelte' | 5 | + import { agentTitle as titleOf, answerPermission, cancel } from '../state.svelte' |
| 6 | import { describeAction, isIrreversible, mainDetail, orderOptions } from '../tools' | 6 | import { describeAction, isIrreversible, mainDetail, orderOptions } from '../tools' |
| 7 | 7 | ||
| 8 | - let { permission, agentTitle }: { permission: Permission; agentTitle: string } = $props() | 8 | + let { permission }: { permission: Permission } = $props() |
| 9 | + | ||
| 10 | + const agentTitle = $derived(titleOf(permission.agent)) | ||
| 9 | 11 | ||
| 10 | // ACP option kinds in words a citizen user understands; the agent's own | 12 | // ACP option kinds in words a citizen user understands; the agent's own |
| 11 | // label is kept as a tooltip. | 13 | // label is kept as a tooltip. |
| @@ -34,7 +36,7 @@ | |||
| 34 | {label(option.kind, option.name)} | 36 | {label(option.kind, option.name)} |
| 35 | </button> | 37 | </button> |
| 36 | {/each} | 38 | {/each} |
| 37 | - <button class="stop" onclick={() => cancel(permission.agent)}>{t('conversation.stop', { agent: agentTitle })}</button> | 39 | + <button class="stop" onclick={() => cancel(permission.channel)}>{t('conversation.stop', { agent: agentTitle })}</button> |
| 38 | </div> | 40 | </div> |
| 39 | <details> | 41 | <details> |
| 40 | <summary>{t('permission.details')}</summary> | 42 | <summary>{t('permission.details')}</summary> |
added
frontend/src/lib/chat/Spinner.svelte +22 -0 | new file mode 100644 | ||
| @@ -0,0 +1,22 @@ | ||
| 1 | +<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> | |
| 2 | +<!-- A small turning circle: something is under way (an agent thinks, starts…). --> | |
| 3 | +<span class="spinner" aria-hidden="true"></span> | |
| 4 | + | |
| 5 | +<style> | |
| 6 | + .spinner { | |
| 7 | + display: inline-block; | |
| 8 | + width: 11px; | |
| 9 | + height: 11px; | |
| 10 | + border: 2px solid var(--border); | |
| 11 | + border-top-color: var(--accent); | |
| 12 | + border-radius: 50%; | |
| 13 | + animation: turn 0.8s linear infinite; | |
| 14 | + vertical-align: -1px; | |
| 15 | + } | |
| 16 | + @keyframes turn { | |
| 17 | + to { transform: rotate(360deg); } | |
| 18 | + } | |
| 19 | + @media (prefers-reduced-motion: reduce) { | |
| 20 | + .spinner { animation-duration: 2.4s; } | |
| 21 | + } | |
| 22 | +</style> | |
| new file mode 100644 | |||
| @@ -0,0 +1,22 @@ | |||
| 1 | +<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> | ||
| 2 | +<!-- A small turning circle: something is under way (an agent thinks, starts…). --> | ||
| 3 | +<span class="spinner" aria-hidden="true"></span> | ||
| 4 | + | ||
| 5 | +<style> | ||
| 6 | + .spinner { | ||
| 7 | + display: inline-block; | ||
| 8 | + width: 11px; | ||
| 9 | + height: 11px; | ||
| 10 | + border: 2px solid var(--border); | ||
| 11 | + border-top-color: var(--accent); | ||
| 12 | + border-radius: 50%; | ||
| 13 | + animation: turn 0.8s linear infinite; | ||
| 14 | + vertical-align: -1px; | ||
| 15 | + } | ||
| 16 | + @keyframes turn { | ||
| 17 | + to { transform: rotate(360deg); } | ||
| 18 | + } | ||
| 19 | + @media (prefers-reduced-motion: reduce) { | ||
| 20 | + .spinner { animation-duration: 2.4s; } | ||
| 21 | + } | ||
| 22 | +</style> | ||
modified
frontend/src/lib/conversation.test.ts +45 -18 | @@ -1,17 +1,19 @@ | ||
| 1 | 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE |
| 2 | 2 | |
| 3 | 3 | import { describe, expect, it } from 'vitest' |
| 4 | -import type { Message } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | |
| 5 | -import { Role } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | |
| 6 | -import { addPermission, applyDelta, applyMessage, emptyConversation, removePermission, usagePercent } from './conversation' | |
| 4 | +import type { Channel, Message } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' | |
| 5 | +import { Role } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' | |
| 6 | +import { activityOf, addPermission, applyDelta, applyMessage, emptyConversation, prependMessages, removePermission, usagePercent } from './conversation' | |
| 7 | + | |
| 8 | +const general: Channel = { id: 'c1', name: 'general', description: '', direct: '', members: ['alice'] } | |
| 7 | 9 | |
| 8 | 10 | function message(id: string, done = false): Message { |
| 9 | - return { id, agent: 'alice', role: Role.RoleAgent, parts: [], done, queued: false, createdAt: '2026-10-29T10:00:00Z' } | |
| 11 | + return { id, channel: 'c1', agent: 'alice', role: Role.RoleAgent, parts: [], done, queued: false, createdAt: '2026-10-29T10:00:00Z' } | |
| 10 | 12 | } |
| 11 | 13 | |
| 12 | 14 | describe('conversation events', () => { |
| 13 | 15 | it('adds a message, then replaces it when it finishes', () => { |
| 14 | - const conversation = emptyConversation('alice') | |
| 16 | + const conversation = emptyConversation(general) | |
| 15 | 17 | applyMessage(conversation, message('m-1')) |
| 16 | 18 | applyMessage(conversation, { ...message('m-1', true), stopReason: 'end_turn' }) |
| 17 | 19 | expect(conversation.messages).toHaveLength(1) |
| @@ -19,26 +21,26 @@ describe('conversation events', () => { | ||
| 19 | 21 | }) |
| 20 | 22 | |
| 21 | 23 | it('appends streamed text and replaces tool parts', () => { |
| 22 | - const conversation = emptyConversation('alice') | |
| 24 | + const conversation = emptyConversation(general) | |
| 23 | 25 | applyMessage(conversation, message('m-1')) |
| 24 | - applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 0, kind: 'text', text: 'Bon' }) | |
| 25 | - applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 0, kind: 'text', text: 'jour' }) | |
| 26 | - applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'pending', input: '', output: '', locations: [], diffs: [] } } }) | |
| 27 | - applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'completed', input: '', output: 'a', locations: [], diffs: [] } } }) | |
| 26 | + applyDelta(conversation, { channel: 'c1', messageId: 'm-1', index: 0, kind: 'text', text: 'Bon' }) | |
| 27 | + applyDelta(conversation, { channel: 'c1', messageId: 'm-1', index: 0, kind: 'text', text: 'jour' }) | |
| 28 | + applyDelta(conversation, { channel: 'c1', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'pending', input: '', output: '', locations: [], diffs: [] } } }) | |
| 29 | + applyDelta(conversation, { channel: 'c1', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'completed', input: '', output: 'a', locations: [], diffs: [] } } }) | |
| 28 | 30 | const parts = conversation.messages![0].parts! |
| 29 | 31 | expect(parts[0].text).toBe('Bonjour') |
| 30 | 32 | expect(parts[1].tool!.status).toBe('completed') |
| 31 | 33 | }) |
| 32 | 34 | |
| 33 | 35 | it('ignores a delta for an unknown message', () => { |
| 34 | - const conversation = emptyConversation('alice') | |
| 35 | - applyDelta(conversation, { agent: 'alice', messageId: 'nope', index: 0, kind: 'text', text: 'x' }) | |
| 36 | + const conversation = emptyConversation(general) | |
| 37 | + applyDelta(conversation, { channel: 'c1', messageId: 'nope', index: 0, kind: 'text', text: 'x' }) | |
| 36 | 38 | expect(conversation.messages).toHaveLength(0) |
| 37 | 39 | }) |
| 38 | 40 | |
| 39 | 41 | it('keeps each permission request once until it is resolved', () => { |
| 40 | - const conversation = emptyConversation('alice') | |
| 41 | - const permission = { id: 'p-1', agent: 'alice', messageId: 'm-1', toolCall: { id: 'c', title: '', kind: '', status: '', input: '', output: '', locations: [], diffs: [] }, options: [], rejections: 0 } | |
| 42 | + const conversation = emptyConversation(general) | |
| 43 | + const permission = { id: 'p-1', channel: 'c1', agent: 'alice', messageId: 'm-1', toolCall: { id: 'c', title: '', kind: '', status: '', input: '', output: '', locations: [], diffs: [] }, options: [], rejections: 0 } | |
| 42 | 44 | addPermission(conversation, permission) |
| 43 | 45 | addPermission(conversation, permission) |
| 44 | 46 | expect(conversation.permissions).toHaveLength(1) |
| @@ -47,9 +49,34 @@ describe('conversation events', () => { | ||
| 47 | 49 | }) |
| 48 | 50 | |
| 49 | 51 | it('computes the context gauge', () => { |
| 50 | - const conversation = emptyConversation('alice') | |
| 51 | - expect(usagePercent(conversation)).toBeNull() | |
| 52 | - conversation.usage = { used: 410, size: 1000 } | |
| 53 | - expect(usagePercent(conversation)).toBe(41) | |
| 52 | + const conversation = emptyConversation(general) | |
| 53 | + expect(usagePercent(conversation, 'alice')).toBeNull() | |
| 54 | + conversation.usage = { alice: { used: 410, size: 1000 } } | |
| 55 | + expect(usagePercent(conversation, 'alice')).toBe(41) | |
| 56 | + expect(usagePercent(conversation, 'bob')).toBeNull() | |
| 57 | + }) | |
| 58 | + | |
| 59 | + it('puts older messages first, once', () => { | |
| 60 | + const conversation = emptyConversation(general) | |
| 61 | + applyMessage(conversation, message('m-3', true)) | |
| 62 | + prependMessages(conversation, [message('m-1', true), message('m-2', true), message('m-3', true)], false) | |
| 63 | + expect(conversation.messages!.map((m) => m.id)).toEqual(['m-1', 'm-2', 'm-3']) | |
| 64 | + expect(conversation.more).toBe(false) | |
| 65 | + }) | |
| 66 | + | |
| 67 | + it('tells what a streaming answer is doing', () => { | |
| 68 | + const streaming = message('m-1') | |
| 69 | + expect(activityOf(streaming)).toBe('thinking') | |
| 70 | + streaming.parts = [{ kind: 'thought', text: 'hmm' }] | |
| 71 | + expect(activityOf(streaming)).toBe('thinking') | |
| 72 | + const tool = { id: 'c', title: 'ls', kind: 'execute', status: 'in_progress', input: '', output: '', locations: [], diffs: [] } | |
| 73 | + streaming.parts = [{ kind: 'tool', tool }] | |
| 74 | + expect(activityOf(streaming)).toBe('usingTool') | |
| 75 | + streaming.parts = [{ kind: 'tool', tool: { ...tool, status: 'completed' } }] | |
| 76 | + expect(activityOf(streaming)).toBe('thinking') | |
| 77 | + streaming.parts = [{ kind: 'text', text: 'Bon' }] | |
| 78 | + expect(activityOf(streaming)).toBe('typing') | |
| 79 | + expect(activityOf(message('m-2', true))).toBeNull() | |
| 80 | + expect(activityOf({ ...message('m-3'), role: Role.RoleUser })).toBeNull() | |
| 54 | 81 | }) |
| 55 | 82 | }) |
| @@ -1,17 +1,19 @@ | |||
| 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE |
| 2 | 2 | ||
| 3 | import { describe, expect, it } from 'vitest' | 3 | import { describe, expect, it } from 'vitest' |
| 4 | -import type { Message } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | 4 | +import type { Channel, Message } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' |
| 5 | -import { Role } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | 5 | +import { Role } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' |
| 6 | -import { addPermission, applyDelta, applyMessage, emptyConversation, removePermission, usagePercent } from './conversation' | 6 | +import { activityOf, addPermission, applyDelta, applyMessage, emptyConversation, prependMessages, removePermission, usagePercent } from './conversation' |
| 7 | + | ||
| 8 | +const general: Channel = { id: 'c1', name: 'general', description: '', direct: '', members: ['alice'] } | ||
| 7 | 9 | ||
| 8 | function message(id: string, done = false): Message { | 10 | function message(id: string, done = false): Message { |
| 9 | - return { id, agent: 'alice', role: Role.RoleAgent, parts: [], done, queued: false, createdAt: '2026-10-29T10:00:00Z' } | 11 | + return { id, channel: 'c1', agent: 'alice', role: Role.RoleAgent, parts: [], done, queued: false, createdAt: '2026-10-29T10:00:00Z' } |
| 10 | } | 12 | } |
| 11 | 13 | ||
| 12 | describe('conversation events', () => { | 14 | describe('conversation events', () => { |
| 13 | it('adds a message, then replaces it when it finishes', () => { | 15 | it('adds a message, then replaces it when it finishes', () => { |
| 14 | - const conversation = emptyConversation('alice') | 16 | + const conversation = emptyConversation(general) |
| 15 | applyMessage(conversation, message('m-1')) | 17 | applyMessage(conversation, message('m-1')) |
| 16 | applyMessage(conversation, { ...message('m-1', true), stopReason: 'end_turn' }) | 18 | applyMessage(conversation, { ...message('m-1', true), stopReason: 'end_turn' }) |
| 17 | expect(conversation.messages).toHaveLength(1) | 19 | expect(conversation.messages).toHaveLength(1) |
| @@ -19,26 +21,26 @@ describe('conversation events', () => { | |||
| 19 | }) | 21 | }) |
| 20 | 22 | ||
| 21 | it('appends streamed text and replaces tool parts', () => { | 23 | it('appends streamed text and replaces tool parts', () => { |
| 22 | - const conversation = emptyConversation('alice') | 24 | + const conversation = emptyConversation(general) |
| 23 | applyMessage(conversation, message('m-1')) | 25 | applyMessage(conversation, message('m-1')) |
| 24 | - applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 0, kind: 'text', text: 'Bon' }) | 26 | + applyDelta(conversation, { channel: 'c1', messageId: 'm-1', index: 0, kind: 'text', text: 'Bon' }) |
| 25 | - applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 0, kind: 'text', text: 'jour' }) | 27 | + applyDelta(conversation, { channel: 'c1', messageId: 'm-1', index: 0, kind: 'text', text: 'jour' }) |
| 26 | - applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'pending', input: '', output: '', locations: [], diffs: [] } } }) | 28 | + applyDelta(conversation, { channel: 'c1', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'pending', input: '', output: '', locations: [], diffs: [] } } }) |
| 27 | - applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'completed', input: '', output: 'a', locations: [], diffs: [] } } }) | 29 | + applyDelta(conversation, { channel: 'c1', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'completed', input: '', output: 'a', locations: [], diffs: [] } } }) |
| 28 | const parts = conversation.messages![0].parts! | 30 | const parts = conversation.messages![0].parts! |
| 29 | expect(parts[0].text).toBe('Bonjour') | 31 | expect(parts[0].text).toBe('Bonjour') |
| 30 | expect(parts[1].tool!.status).toBe('completed') | 32 | expect(parts[1].tool!.status).toBe('completed') |
| 31 | }) | 33 | }) |
| 32 | 34 | ||
| 33 | it('ignores a delta for an unknown message', () => { | 35 | it('ignores a delta for an unknown message', () => { |
| 34 | - const conversation = emptyConversation('alice') | 36 | + const conversation = emptyConversation(general) |
| 35 | - applyDelta(conversation, { agent: 'alice', messageId: 'nope', index: 0, kind: 'text', text: 'x' }) | 37 | + applyDelta(conversation, { channel: 'c1', messageId: 'nope', index: 0, kind: 'text', text: 'x' }) |
| 36 | expect(conversation.messages).toHaveLength(0) | 38 | expect(conversation.messages).toHaveLength(0) |
| 37 | }) | 39 | }) |
| 38 | 40 | ||
| 39 | it('keeps each permission request once until it is resolved', () => { | 41 | it('keeps each permission request once until it is resolved', () => { |
| 40 | - const conversation = emptyConversation('alice') | 42 | + const conversation = emptyConversation(general) |
| 41 | - const permission = { id: 'p-1', agent: 'alice', messageId: 'm-1', toolCall: { id: 'c', title: '', kind: '', status: '', input: '', output: '', locations: [], diffs: [] }, options: [], rejections: 0 } | 43 | + const permission = { id: 'p-1', channel: 'c1', agent: 'alice', messageId: 'm-1', toolCall: { id: 'c', title: '', kind: '', status: '', input: '', output: '', locations: [], diffs: [] }, options: [], rejections: 0 } |
| 42 | addPermission(conversation, permission) | 44 | addPermission(conversation, permission) |
| 43 | addPermission(conversation, permission) | 45 | addPermission(conversation, permission) |
| 44 | expect(conversation.permissions).toHaveLength(1) | 46 | expect(conversation.permissions).toHaveLength(1) |
| @@ -47,9 +49,34 @@ describe('conversation events', () => { | |||
| 47 | }) | 49 | }) |
| 48 | 50 | ||
| 49 | it('computes the context gauge', () => { | 51 | it('computes the context gauge', () => { |
| 50 | - const conversation = emptyConversation('alice') | 52 | + const conversation = emptyConversation(general) |
| 51 | - expect(usagePercent(conversation)).toBeNull() | 53 | + expect(usagePercent(conversation, 'alice')).toBeNull() |
| 52 | - conversation.usage = { used: 410, size: 1000 } | 54 | + conversation.usage = { alice: { used: 410, size: 1000 } } |
| 53 | - expect(usagePercent(conversation)).toBe(41) | 55 | + expect(usagePercent(conversation, 'alice')).toBe(41) |
| 56 | + expect(usagePercent(conversation, 'bob')).toBeNull() | ||
| 57 | + }) | ||
| 58 | + | ||
| 59 | + it('puts older messages first, once', () => { | ||
| 60 | + const conversation = emptyConversation(general) | ||
| 61 | + applyMessage(conversation, message('m-3', true)) | ||
| 62 | + prependMessages(conversation, [message('m-1', true), message('m-2', true), message('m-3', true)], false) | ||
| 63 | + expect(conversation.messages!.map((m) => m.id)).toEqual(['m-1', 'm-2', 'm-3']) | ||
| 64 | + expect(conversation.more).toBe(false) | ||
| 65 | + }) | ||
| 66 | + | ||
| 67 | + it('tells what a streaming answer is doing', () => { | ||
| 68 | + const streaming = message('m-1') | ||
| 69 | + expect(activityOf(streaming)).toBe('thinking') | ||
| 70 | + streaming.parts = [{ kind: 'thought', text: 'hmm' }] | ||
| 71 | + expect(activityOf(streaming)).toBe('thinking') | ||
| 72 | + const tool = { id: 'c', title: 'ls', kind: 'execute', status: 'in_progress', input: '', output: '', locations: [], diffs: [] } | ||
| 73 | + streaming.parts = [{ kind: 'tool', tool }] | ||
| 74 | + expect(activityOf(streaming)).toBe('usingTool') | ||
| 75 | + streaming.parts = [{ kind: 'tool', tool: { ...tool, status: 'completed' } }] | ||
| 76 | + expect(activityOf(streaming)).toBe('thinking') | ||
| 77 | + streaming.parts = [{ kind: 'text', text: 'Bon' }] | ||
| 78 | + expect(activityOf(streaming)).toBe('typing') | ||
| 79 | + expect(activityOf(message('m-2', true))).toBeNull() | ||
| 80 | + expect(activityOf({ ...message('m-3'), role: Role.RoleUser })).toBeNull() | ||
| 54 | }) | 81 | }) |
| 55 | }) | 82 | }) |
modified
frontend/src/lib/conversation.ts +44 -10 | @@ -1,13 +1,26 @@ | ||
| 1 | 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE |
| 2 | 2 | |
| 3 | -// Applies the conversation events of the Go side to a conversation held by | |
| 4 | -// the interface (issue #15). Pure functions: the Svelte state calls them. | |
| 3 | +// Applies the channel events of the Go side to a conversation held by the | |
| 4 | +// interface (issues #15 and #23). Pure functions: the Svelte state calls them. | |
| 5 | 5 | |
| 6 | -import type { Conversation, DeltaPayload, Message, Permission } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | |
| 6 | +import type { Channel, Conversation, DeltaPayload, Message, Permission } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' | |
| 7 | 7 | |
| 8 | -/** An empty conversation with agent, before the Go side has sent anything. */ | |
| 9 | -export function emptyConversation(agent: string): Conversation { | |
| 10 | - return { agent, messages: [], busy: false, queued: 0, usage: { used: 0, size: 0 }, commands: [], permissions: [] } | |
| 8 | +/** An empty conversation of channel, before the Go side has sent anything. */ | |
| 9 | +export function emptyConversation(channel: Channel): Conversation { | |
| 10 | + return { channel, messages: [], more: false, busy: false, queued: 0, answering: '', usage: {}, permissions: [] } | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Puts older messages, loaded when the reader scrolls up, before the ones | |
| 15 | + * already shown. | |
| 16 | + * | |
| 17 | + * @example | |
| 18 | + * prependMessages(conversation, page.messages, page.more) | |
| 19 | + */ | |
| 20 | +export function prependMessages(conversation: Conversation, older: Message[], more: boolean) { | |
| 21 | + const known = new Set((conversation.messages ?? []).map((message) => message.id)) | |
| 22 | + conversation.messages = [...older.filter((message) => !known.has(message.id)), ...(conversation.messages ?? [])] | |
| 23 | + conversation.more = more | |
| 11 | 24 | } |
| 12 | 25 | |
| 13 | 26 | /** |
| @@ -48,9 +61,30 @@ export function removePermission(conversation: Conversation, id: string) { | ||
| 48 | 61 | conversation.permissions = (conversation.permissions ?? []).filter((candidate) => candidate.id !== id) |
| 49 | 62 | } |
| 50 | 63 | |
| 51 | -/** Share of the context window used, from 0 to 100, or null when the size is unknown. */ | |
| 52 | -export function usagePercent(conversation: Conversation): number | null { | |
| 53 | - const { used, size } = conversation.usage | |
| 54 | - if (!size) return null | |
| 64 | +/** Share of agent's context window used, from 0 to 100, or null when unknown. */ | |
| 65 | +export function usagePercent(conversation: Conversation, agent: string): number | null { | |
| 66 | + const usage = conversation.usage?.[agent] | |
| 67 | + if (!usage?.size) return null | |
| 68 | + const { used, size } = usage | |
| 55 | 69 | return Math.min(100, Math.round((used / size) * 100)) |
| 56 | 70 | } |
| 71 | + | |
| 72 | +/** What a streaming agent message is doing, to tell the user. */ | |
| 73 | +export type Activity = 'thinking' | 'usingTool' | 'typing' | |
| 74 | + | |
| 75 | +/** | |
| 76 | + * What an agent message is doing while it streams, from its last part: | |
| 77 | + * nothing yet or reasoning → thinking, a tool call not finished → using a | |
| 78 | + * tool, text → typing. Null once the message is done. | |
| 79 | + * | |
| 80 | + * @example | |
| 81 | + * activityOf(message) // 'thinking' until the first words arrive | |
| 82 | + */ | |
| 83 | +export function activityOf(message: Message): Activity | null { | |
| 84 | + if (message.role !== 'agent' || message.done) return null | |
| 85 | + const last = message.parts?.at(-1) | |
| 86 | + if (!last || last.kind === 'thought') return 'thinking' | |
| 87 | + if (last.kind === 'tool') return last.tool?.status === 'completed' || last.tool?.status === 'failed' ? 'thinking' : 'usingTool' | |
| 88 | + if (last.kind === 'text') return 'typing' | |
| 89 | + return 'thinking' | |
| 90 | +} | |
| @@ -1,13 +1,26 @@ | |||
| 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE |
| 2 | 2 | ||
| 3 | -// Applies the conversation events of the Go side to a conversation held by | 3 | +// Applies the channel events of the Go side to a conversation held by the |
| 4 | -// the interface (issue #15). Pure functions: the Svelte state calls them. | 4 | +// interface (issues #15 and #23). Pure functions: the Svelte state calls them. |
| 5 | 5 | ||
| 6 | -import type { Conversation, DeltaPayload, Message, Permission } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | 6 | +import type { Channel, Conversation, DeltaPayload, Message, Permission } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' |
| 7 | 7 | ||
| 8 | -/** An empty conversation with agent, before the Go side has sent anything. */ | 8 | +/** An empty conversation of channel, before the Go side has sent anything. */ |
| 9 | -export function emptyConversation(agent: string): Conversation { | 9 | +export function emptyConversation(channel: Channel): Conversation { |
| 10 | - return { agent, messages: [], busy: false, queued: 0, usage: { used: 0, size: 0 }, commands: [], permissions: [] } | 10 | + return { channel, messages: [], more: false, busy: false, queued: 0, answering: '', usage: {}, permissions: [] } |
| 11 | +} | ||
| 12 | + | ||
| 13 | +/** | ||
| 14 | + * Puts older messages, loaded when the reader scrolls up, before the ones | ||
| 15 | + * already shown. | ||
| 16 | + * | ||
| 17 | + * @example | ||
| 18 | + * prependMessages(conversation, page.messages, page.more) | ||
| 19 | + */ | ||
| 20 | +export function prependMessages(conversation: Conversation, older: Message[], more: boolean) { | ||
| 21 | + const known = new Set((conversation.messages ?? []).map((message) => message.id)) | ||
| 22 | + conversation.messages = [...older.filter((message) => !known.has(message.id)), ...(conversation.messages ?? [])] | ||
| 23 | + conversation.more = more | ||
| 11 | } | 24 | } |
| 12 | 25 | ||
| 13 | /** | 26 | /** |
| @@ -48,9 +61,30 @@ export function removePermission(conversation: Conversation, id: string) { | |||
| 48 | conversation.permissions = (conversation.permissions ?? []).filter((candidate) => candidate.id !== id) | 61 | conversation.permissions = (conversation.permissions ?? []).filter((candidate) => candidate.id !== id) |
| 49 | } | 62 | } |
| 50 | 63 | ||
| 51 | -/** Share of the context window used, from 0 to 100, or null when the size is unknown. */ | 64 | +/** Share of agent's context window used, from 0 to 100, or null when unknown. */ |
| 52 | -export function usagePercent(conversation: Conversation): number | null { | 65 | +export function usagePercent(conversation: Conversation, agent: string): number | null { |
| 53 | - const { used, size } = conversation.usage | 66 | + const usage = conversation.usage?.[agent] |
| 54 | - if (!size) return null | 67 | + if (!usage?.size) return null |
| 68 | + const { used, size } = usage | ||
| 55 | return Math.min(100, Math.round((used / size) * 100)) | 69 | return Math.min(100, Math.round((used / size) * 100)) |
| 56 | } | 70 | } |
| 71 | + | ||
| 72 | +/** What a streaming agent message is doing, to tell the user. */ | ||
| 73 | +export type Activity = 'thinking' | 'usingTool' | 'typing' | ||
| 74 | + | ||
| 75 | +/** | ||
| 76 | + * What an agent message is doing while it streams, from its last part: | ||
| 77 | + * nothing yet or reasoning → thinking, a tool call not finished → using a | ||
| 78 | + * tool, text → typing. Null once the message is done. | ||
| 79 | + * | ||
| 80 | + * @example | ||
| 81 | + * activityOf(message) // 'thinking' until the first words arrive | ||
| 82 | + */ | ||
| 83 | +export function activityOf(message: Message): Activity | null { | ||
| 84 | + if (message.role !== 'agent' || message.done) return null | ||
| 85 | + const last = message.parts?.at(-1) | ||
| 86 | + if (!last || last.kind === 'thought') return 'thinking' | ||
| 87 | + if (last.kind === 'tool') return last.tool?.status === 'completed' || last.tool?.status === 'failed' ? 'thinking' : 'usingTool' | ||
| 88 | + if (last.kind === 'text') return 'typing' | ||
| 89 | + return 'thinking' | ||
| 90 | +} | ||
modified
frontend/src/lib/i18n/messages.ts +64 -6 | @@ -26,7 +26,19 @@ export const en = { | ||
| 26 | 26 | 'agent.open': 'Open the conversation with {agent}', |
| 27 | 27 | |
| 28 | 28 | // channels |
| 29 | - 'channels.empty': 'No channel declared.', | |
| 29 | + 'channels.empty': 'No channel yet.', | |
| 30 | + 'channels.section.direct': 'Direct messages', | |
| 31 | + 'channels.noDirect': 'Click an agent to talk with it directly.', | |
| 32 | + 'channels.new': 'New channel', | |
| 33 | + 'channels.namePlaceholder': 'Channel name', | |
| 34 | + 'channels.create': 'Create', | |
| 35 | + 'channels.cancel': 'Cancel', | |
| 36 | + 'channels.rename': 'Rename #{channel}', | |
| 37 | + 'channels.delete': 'Delete #{channel}', | |
| 38 | + 'channels.deleteConfirm': 'Delete #{channel} and its whole history?', | |
| 39 | + 'channels.deleteYes': 'Delete', | |
| 40 | + 'channels.unread': '{count} new answers', | |
| 41 | + 'channels.members': '{count} agents', | |
| 30 | 42 | |
| 31 | 43 | // log |
| 32 | 44 | 'logs.of': 'Log of', |
| @@ -48,11 +60,23 @@ export const en = { | ||
| 48 | 60 | 'conversation.stop': '■ Stop {agent}', |
| 49 | 61 | 'conversation.placeholder': 'Write to {agent}…', |
| 50 | 62 | 'conversation.noAgents': 'Start by adding an agent to the configuration (Settings panel).', |
| 51 | - 'conversation.pickAgent': 'Click an agent in the Agents panel to open the conversation.', | |
| 63 | + 'conversation.pickAgent': 'Pick a channel, or click an agent to talk with it directly.', | |
| 64 | + 'conversation.channelEmpty': 'Mention an agent with @ to bring it into #{channel}. {description}', | |
| 65 | + 'conversation.channelPlaceholder': 'Write in #{channel} — @ to mention an agent, / for commands', | |
| 66 | + 'conversation.answeringAgent': '{agent} is answering…', | |
| 67 | + 'conversation.older': 'Load earlier messages', | |
| 68 | + 'conversation.invite': '+ Invite', | |
| 69 | + 'conversation.inviteLabel': 'Invite an agent into the channel', | |
| 70 | + 'conversation.removeMember': 'Remove {agent} from the channel', | |
| 71 | + 'conversation.historyProblem': 'History unavailable ({problem}): this session will be forgotten when Sidekicks quits.', | |
| 52 | 72 | |
| 53 | 73 | // composer |
| 54 | 74 | 'composer.label': 'Your message', |
| 55 | 75 | 'composer.hint': 'Enter to send · Shift+Enter for a new line · ↑ previous messages', |
| 76 | + 'composer.suggestions': 'Suggestions', | |
| 77 | + 'composer.noCommands': 'The agents of this conversation offer no command.', | |
| 78 | + 'composer.askingCommands': 'Asking {agents} for their commands…', | |
| 79 | + 'composer.commandsError': 'Could not get the commands: {error}', | |
| 56 | 80 | 'composer.stop': '■ Stop', |
| 57 | 81 | 'composer.send': 'Send', |
| 58 | 82 | |
| @@ -60,6 +84,9 @@ export const en = { | ||
| 60 | 84 | 'message.you': 'You', |
| 61 | 85 | 'message.queued': 'waiting', |
| 62 | 86 | 'message.typing': 'writing…', |
| 87 | + 'message.thinking': 'thinking…', | |
| 88 | + 'message.usingTool': 'using a tool…', | |
| 89 | + 'message.preparing': '{agent} is getting ready…', | |
| 63 | 90 | 'message.thought': 'Reasoning', |
| 64 | 91 | 'message.stop.cancelled': 'Answer stopped.', |
| 65 | 92 | 'message.stop.max_tokens': 'Answer cut: the agent reached its length limit.', |
| @@ -67,7 +94,9 @@ export const en = { | ||
| 67 | 94 | 'message.stop.refusal': 'The agent refused to answer.', |
| 68 | 95 | 'message.error': 'The answer ended with an error: {error}', |
| 69 | 96 | 'message.notSent': 'Message not sent (answer stopped).', |
| 70 | - 'system.agent-restarted': '{agent} restarted and no longer remembers the beginning of the conversation.', | |
| 97 | + 'system.agent-joined': '{agent} joined the channel.', | |
| 98 | + 'system.agent-left': '{agent} left the channel.', | |
| 99 | + 'system.no-recipient': 'Nobody was addressed: mention an agent with @ (for example @{example}).', | |
| 71 | 100 | 'system.agent-unavailable': '{agent} could not answer.', |
| 72 | 101 | |
| 73 | 102 | // tool calls |
| @@ -144,7 +173,19 @@ export const fr: Record<MessageKey, string> = { | ||
| 144 | 173 | 'agent.stop': 'Arrêter {agent}', |
| 145 | 174 | 'agent.open': 'Ouvrir la conversation avec {agent}', |
| 146 | 175 | |
| 147 | - 'channels.empty': 'Aucun channel déclaré.', | |
| 176 | + 'channels.empty': 'Aucun channel pour le moment.', | |
| 177 | + 'channels.section.direct': 'Messages directs', | |
| 178 | + 'channels.noDirect': 'Cliquez sur un agent pour lui parler directement.', | |
| 179 | + 'channels.new': 'Nouveau channel', | |
| 180 | + 'channels.namePlaceholder': 'Nom du channel', | |
| 181 | + 'channels.create': 'Créer', | |
| 182 | + 'channels.cancel': 'Annuler', | |
| 183 | + 'channels.rename': 'Renommer #{channel}', | |
| 184 | + 'channels.delete': 'Supprimer #{channel}', | |
| 185 | + 'channels.deleteConfirm': 'Supprimer #{channel} et tout son historique ?', | |
| 186 | + 'channels.deleteYes': 'Supprimer', | |
| 187 | + 'channels.unread': '{count} nouvelles réponses', | |
| 188 | + 'channels.members': '{count} agents', | |
| 148 | 189 | |
| 149 | 190 | 'logs.of': 'Journal de', |
| 150 | 191 | 'logs.empty': "Rien pour l'instant : le journal se remplit quand l'agent démarre.", |
| @@ -163,16 +204,31 @@ export const fr: Record<MessageKey, string> = { | ||
| 163 | 204 | 'conversation.stop': '■ Arrêter {agent}', |
| 164 | 205 | 'conversation.placeholder': 'Écrivez à {agent}…', |
| 165 | 206 | 'conversation.noAgents': 'Commencez par ajouter un agent dans la configuration (panneau Settings).', |
| 166 | - 'conversation.pickAgent': 'Cliquez sur un agent dans le panneau Agents pour ouvrir la conversation.', | |
| 207 | + 'conversation.pickAgent': 'Choisissez un channel, ou cliquez sur un agent pour lui parler directement.', | |
| 208 | + 'conversation.channelEmpty': 'Mentionnez un agent avec @ pour le faire venir dans #{channel}. {description}', | |
| 209 | + 'conversation.channelPlaceholder': 'Écrivez dans #{channel} — @ pour mentionner un agent, / pour les commandes', | |
| 210 | + 'conversation.answeringAgent': '{agent} répond…', | |
| 211 | + 'conversation.older': 'Charger les messages précédents', | |
| 212 | + 'conversation.invite': '+ Inviter', | |
| 213 | + 'conversation.inviteLabel': 'Inviter un agent dans le channel', | |
| 214 | + 'conversation.removeMember': 'Retirer {agent} du channel', | |
| 215 | + 'conversation.historyProblem': "Historique indisponible ({problem}) : cette session sera oubliée à la fermeture de Sidekicks.", | |
| 167 | 216 | |
| 168 | 217 | 'composer.label': 'Votre message', |
| 169 | 218 | 'composer.hint': 'Entrée pour envoyer · Maj+Entrée pour une nouvelle ligne · ↑ messages précédents', |
| 219 | + 'composer.suggestions': 'Suggestions', | |
| 220 | + 'composer.noCommands': "Les agents de cette conversation ne proposent aucune commande.", | |
| 221 | + 'composer.askingCommands': 'Sidekicks demande ses commandes à {agents}…', | |
| 222 | + 'composer.commandsError': "Impossible d'obtenir les commandes : {error}", | |
| 170 | 223 | 'composer.stop': '■ Arrêter', |
| 171 | 224 | 'composer.send': 'Envoyer', |
| 172 | 225 | |
| 173 | 226 | 'message.you': 'Vous', |
| 174 | 227 | 'message.queued': 'en attente', |
| 175 | 228 | 'message.typing': 'écrit…', |
| 229 | + 'message.thinking': 'réfléchit…', | |
| 230 | + 'message.usingTool': 'utilise un outil…', | |
| 231 | + 'message.preparing': '{agent} se prépare…', | |
| 176 | 232 | 'message.thought': 'Réflexion', |
| 177 | 233 | 'message.stop.cancelled': 'Réponse arrêtée.', |
| 178 | 234 | 'message.stop.max_tokens': "Réponse coupée : l'agent a atteint sa limite de longueur.", |
| @@ -180,7 +236,9 @@ export const fr: Record<MessageKey, string> = { | ||
| 180 | 236 | 'message.stop.refusal': "L'agent a refusé de répondre.", |
| 181 | 237 | 'message.error': "La réponse s'est terminée sur une erreur : {error}", |
| 182 | 238 | 'message.notSent': 'Message non envoyé (réponse arrêtée).', |
| 183 | - 'system.agent-restarted': '{agent} a redémarré et ne se souvient plus du début de la conversation.', | |
| 239 | + 'system.agent-joined': '{agent} a rejoint le channel.', | |
| 240 | + 'system.agent-left': '{agent} a quitté le channel.', | |
| 241 | + 'system.no-recipient': "Personne n'est désigné : mentionnez un agent avec @ (par exemple @{example}).", | |
| 184 | 242 | 'system.agent-unavailable': "{agent} n'a pas pu répondre.", |
| 185 | 243 | |
| 186 | 244 | 'tool.status.pending': 'en attente', |
| @@ -26,7 +26,19 @@ export const en = { | |||
| 26 | 'agent.open': 'Open the conversation with {agent}', | 26 | 'agent.open': 'Open the conversation with {agent}', |
| 27 | 27 | ||
| 28 | // channels | 28 | // channels |
| 29 | - 'channels.empty': 'No channel declared.', | 29 | + 'channels.empty': 'No channel yet.', |
| 30 | + 'channels.section.direct': 'Direct messages', | ||
| 31 | + 'channels.noDirect': 'Click an agent to talk with it directly.', | ||
| 32 | + 'channels.new': 'New channel', | ||
| 33 | + 'channels.namePlaceholder': 'Channel name', | ||
| 34 | + 'channels.create': 'Create', | ||
| 35 | + 'channels.cancel': 'Cancel', | ||
| 36 | + 'channels.rename': 'Rename #{channel}', | ||
| 37 | + 'channels.delete': 'Delete #{channel}', | ||
| 38 | + 'channels.deleteConfirm': 'Delete #{channel} and its whole history?', | ||
| 39 | + 'channels.deleteYes': 'Delete', | ||
| 40 | + 'channels.unread': '{count} new answers', | ||
| 41 | + 'channels.members': '{count} agents', | ||
| 30 | 42 | ||
| 31 | // log | 43 | // log |
| 32 | 'logs.of': 'Log of', | 44 | 'logs.of': 'Log of', |
| @@ -48,11 +60,23 @@ export const en = { | |||
| 48 | 'conversation.stop': '■ Stop {agent}', | 60 | 'conversation.stop': '■ Stop {agent}', |
| 49 | 'conversation.placeholder': 'Write to {agent}…', | 61 | 'conversation.placeholder': 'Write to {agent}…', |
| 50 | 'conversation.noAgents': 'Start by adding an agent to the configuration (Settings panel).', | 62 | 'conversation.noAgents': 'Start by adding an agent to the configuration (Settings panel).', |
| 51 | - 'conversation.pickAgent': 'Click an agent in the Agents panel to open the conversation.', | 63 | + 'conversation.pickAgent': 'Pick a channel, or click an agent to talk with it directly.', |
| 64 | + 'conversation.channelEmpty': 'Mention an agent with @ to bring it into #{channel}. {description}', | ||
| 65 | + 'conversation.channelPlaceholder': 'Write in #{channel} — @ to mention an agent, / for commands', | ||
| 66 | + 'conversation.answeringAgent': '{agent} is answering…', | ||
| 67 | + 'conversation.older': 'Load earlier messages', | ||
| 68 | + 'conversation.invite': '+ Invite', | ||
| 69 | + 'conversation.inviteLabel': 'Invite an agent into the channel', | ||
| 70 | + 'conversation.removeMember': 'Remove {agent} from the channel', | ||
| 71 | + 'conversation.historyProblem': 'History unavailable ({problem}): this session will be forgotten when Sidekicks quits.', | ||
| 52 | 72 | ||
| 53 | // composer | 73 | // composer |
| 54 | 'composer.label': 'Your message', | 74 | 'composer.label': 'Your message', |
| 55 | 'composer.hint': 'Enter to send · Shift+Enter for a new line · ↑ previous messages', | 75 | 'composer.hint': 'Enter to send · Shift+Enter for a new line · ↑ previous messages', |
| 76 | + 'composer.suggestions': 'Suggestions', | ||
| 77 | + 'composer.noCommands': 'The agents of this conversation offer no command.', | ||
| 78 | + 'composer.askingCommands': 'Asking {agents} for their commands…', | ||
| 79 | + 'composer.commandsError': 'Could not get the commands: {error}', | ||
| 56 | 'composer.stop': '■ Stop', | 80 | 'composer.stop': '■ Stop', |
| 57 | 'composer.send': 'Send', | 81 | 'composer.send': 'Send', |
| 58 | 82 | ||
| @@ -60,6 +84,9 @@ export const en = { | |||
| 60 | 'message.you': 'You', | 84 | 'message.you': 'You', |
| 61 | 'message.queued': 'waiting', | 85 | 'message.queued': 'waiting', |
| 62 | 'message.typing': 'writing…', | 86 | 'message.typing': 'writing…', |
| 87 | + 'message.thinking': 'thinking…', | ||
| 88 | + 'message.usingTool': 'using a tool…', | ||
| 89 | + 'message.preparing': '{agent} is getting ready…', | ||
| 63 | 'message.thought': 'Reasoning', | 90 | 'message.thought': 'Reasoning', |
| 64 | 'message.stop.cancelled': 'Answer stopped.', | 91 | 'message.stop.cancelled': 'Answer stopped.', |
| 65 | 'message.stop.max_tokens': 'Answer cut: the agent reached its length limit.', | 92 | 'message.stop.max_tokens': 'Answer cut: the agent reached its length limit.', |
| @@ -67,7 +94,9 @@ export const en = { | |||
| 67 | 'message.stop.refusal': 'The agent refused to answer.', | 94 | 'message.stop.refusal': 'The agent refused to answer.', |
| 68 | 'message.error': 'The answer ended with an error: {error}', | 95 | 'message.error': 'The answer ended with an error: {error}', |
| 69 | 'message.notSent': 'Message not sent (answer stopped).', | 96 | 'message.notSent': 'Message not sent (answer stopped).', |
| 70 | - 'system.agent-restarted': '{agent} restarted and no longer remembers the beginning of the conversation.', | 97 | + 'system.agent-joined': '{agent} joined the channel.', |
| 98 | + 'system.agent-left': '{agent} left the channel.', | ||
| 99 | + 'system.no-recipient': 'Nobody was addressed: mention an agent with @ (for example @{example}).', | ||
| 71 | 'system.agent-unavailable': '{agent} could not answer.', | 100 | 'system.agent-unavailable': '{agent} could not answer.', |
| 72 | 101 | ||
| 73 | // tool calls | 102 | // tool calls |
| @@ -144,7 +173,19 @@ export const fr: Record<MessageKey, string> = { | |||
| 144 | 'agent.stop': 'Arrêter {agent}', | 173 | 'agent.stop': 'Arrêter {agent}', |
| 145 | 'agent.open': 'Ouvrir la conversation avec {agent}', | 174 | 'agent.open': 'Ouvrir la conversation avec {agent}', |
| 146 | 175 | ||
| 147 | - 'channels.empty': 'Aucun channel déclaré.', | 176 | + 'channels.empty': 'Aucun channel pour le moment.', |
| 177 | + 'channels.section.direct': 'Messages directs', | ||
| 178 | + 'channels.noDirect': 'Cliquez sur un agent pour lui parler directement.', | ||
| 179 | + 'channels.new': 'Nouveau channel', | ||
| 180 | + 'channels.namePlaceholder': 'Nom du channel', | ||
| 181 | + 'channels.create': 'Créer', | ||
| 182 | + 'channels.cancel': 'Annuler', | ||
| 183 | + 'channels.rename': 'Renommer #{channel}', | ||
| 184 | + 'channels.delete': 'Supprimer #{channel}', | ||
| 185 | + 'channels.deleteConfirm': 'Supprimer #{channel} et tout son historique ?', | ||
| 186 | + 'channels.deleteYes': 'Supprimer', | ||
| 187 | + 'channels.unread': '{count} nouvelles réponses', | ||
| 188 | + 'channels.members': '{count} agents', | ||
| 148 | 189 | ||
| 149 | 'logs.of': 'Journal de', | 190 | 'logs.of': 'Journal de', |
| 150 | 'logs.empty': "Rien pour l'instant : le journal se remplit quand l'agent démarre.", | 191 | 'logs.empty': "Rien pour l'instant : le journal se remplit quand l'agent démarre.", |
| @@ -163,16 +204,31 @@ export const fr: Record<MessageKey, string> = { | |||
| 163 | 'conversation.stop': '■ Arrêter {agent}', | 204 | 'conversation.stop': '■ Arrêter {agent}', |
| 164 | 'conversation.placeholder': 'Écrivez à {agent}…', | 205 | 'conversation.placeholder': 'Écrivez à {agent}…', |
| 165 | 'conversation.noAgents': 'Commencez par ajouter un agent dans la configuration (panneau Settings).', | 206 | 'conversation.noAgents': 'Commencez par ajouter un agent dans la configuration (panneau Settings).', |
| 166 | - 'conversation.pickAgent': 'Cliquez sur un agent dans le panneau Agents pour ouvrir la conversation.', | 207 | + 'conversation.pickAgent': 'Choisissez un channel, ou cliquez sur un agent pour lui parler directement.', |
| 208 | + 'conversation.channelEmpty': 'Mentionnez un agent avec @ pour le faire venir dans #{channel}. {description}', | ||
| 209 | + 'conversation.channelPlaceholder': 'Écrivez dans #{channel} — @ pour mentionner un agent, / pour les commandes', | ||
| 210 | + 'conversation.answeringAgent': '{agent} répond…', | ||
| 211 | + 'conversation.older': 'Charger les messages précédents', | ||
| 212 | + 'conversation.invite': '+ Inviter', | ||
| 213 | + 'conversation.inviteLabel': 'Inviter un agent dans le channel', | ||
| 214 | + 'conversation.removeMember': 'Retirer {agent} du channel', | ||
| 215 | + 'conversation.historyProblem': "Historique indisponible ({problem}) : cette session sera oubliée à la fermeture de Sidekicks.", | ||
| 167 | 216 | ||
| 168 | 'composer.label': 'Votre message', | 217 | 'composer.label': 'Votre message', |
| 169 | 'composer.hint': 'Entrée pour envoyer · Maj+Entrée pour une nouvelle ligne · ↑ messages précédents', | 218 | 'composer.hint': 'Entrée pour envoyer · Maj+Entrée pour une nouvelle ligne · ↑ messages précédents', |
| 219 | + 'composer.suggestions': 'Suggestions', | ||
| 220 | + 'composer.noCommands': "Les agents de cette conversation ne proposent aucune commande.", | ||
| 221 | + 'composer.askingCommands': 'Sidekicks demande ses commandes à {agents}…', | ||
| 222 | + 'composer.commandsError': "Impossible d'obtenir les commandes : {error}", | ||
| 170 | 'composer.stop': '■ Arrêter', | 223 | 'composer.stop': '■ Arrêter', |
| 171 | 'composer.send': 'Envoyer', | 224 | 'composer.send': 'Envoyer', |
| 172 | 225 | ||
| 173 | 'message.you': 'Vous', | 226 | 'message.you': 'Vous', |
| 174 | 'message.queued': 'en attente', | 227 | 'message.queued': 'en attente', |
| 175 | 'message.typing': 'écrit…', | 228 | 'message.typing': 'écrit…', |
| 229 | + 'message.thinking': 'réfléchit…', | ||
| 230 | + 'message.usingTool': 'utilise un outil…', | ||
| 231 | + 'message.preparing': '{agent} se prépare…', | ||
| 176 | 'message.thought': 'Réflexion', | 232 | 'message.thought': 'Réflexion', |
| 177 | 'message.stop.cancelled': 'Réponse arrêtée.', | 233 | 'message.stop.cancelled': 'Réponse arrêtée.', |
| 178 | 'message.stop.max_tokens': "Réponse coupée : l'agent a atteint sa limite de longueur.", | 234 | 'message.stop.max_tokens': "Réponse coupée : l'agent a atteint sa limite de longueur.", |
| @@ -180,7 +236,9 @@ export const fr: Record<MessageKey, string> = { | |||
| 180 | 'message.stop.refusal': "L'agent a refusé de répondre.", | 236 | 'message.stop.refusal': "L'agent a refusé de répondre.", |
| 181 | 'message.error': "La réponse s'est terminée sur une erreur : {error}", | 237 | 'message.error': "La réponse s'est terminée sur une erreur : {error}", |
| 182 | 'message.notSent': 'Message non envoyé (réponse arrêtée).', | 238 | 'message.notSent': 'Message non envoyé (réponse arrêtée).', |
| 183 | - 'system.agent-restarted': '{agent} a redémarré et ne se souvient plus du début de la conversation.', | 239 | + 'system.agent-joined': '{agent} a rejoint le channel.', |
| 240 | + 'system.agent-left': '{agent} a quitté le channel.', | ||
| 241 | + 'system.no-recipient': "Personne n'est désigné : mentionnez un agent avec @ (par exemple @{example}).", | ||
| 184 | 'system.agent-unavailable': "{agent} n'a pas pu répondre.", | 242 | 'system.agent-unavailable': "{agent} n'a pas pu répondre.", |
| 185 | 243 | ||
| 186 | 'tool.status.pending': 'en attente', | 244 | 'tool.status.pending': 'en attente', |
modified
frontend/src/lib/panels/AgentsPanel.svelte +5 -3 | @@ -2,8 +2,10 @@ | ||
| 2 | 2 | <script lang="ts"> |
| 3 | 3 | import { hasMessage, t, type MessageKey } from '../i18n/index.svelte' |
| 4 | 4 | import { AgentService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' |
| 5 | - import { act, appState, selectAgent } from '../state.svelte' | |
| 5 | + import { act, appState, channelOf, openDirect } from '../state.svelte' | |
| 6 | 6 | |
| 7 | + // The agent of the direct conversation shown, to highlight it. | |
| 8 | + const directAgent = $derived(channelOf(appState.selectedChannel)?.direct ?? '') | |
| 7 | 9 | </script> |
| 8 | 10 | |
| 9 | 11 | {#if appState.agents.length === 0} |
| @@ -11,9 +13,9 @@ | ||
| 11 | 13 | {:else} |
| 12 | 14 | <ul> |
| 13 | 15 | {#each appState.agents as agent (agent.name)} |
| 14 | - <li title={agent.description || agent.title} class:selected={appState.selectedAgent === agent.name}> | |
| 16 | + <li title={agent.description || agent.title} class:selected={directAgent === agent.name}> | |
| 15 | 17 | <span class="dot {agent.state}" aria-hidden="true"></span> |
| 16 | - <button class="name" onclick={() => selectAgent(agent.name)} aria-label={t('agent.open', { agent: agent.title })}>{agent.icon} {agent.title}</button> | |
| 18 | + <button class="name" onclick={() => openDirect(agent.name)} aria-label={t('agent.open', { agent: agent.title })}>{agent.icon} {agent.title}</button> | |
| 17 | 19 | <span class="state">{hasMessage(`agent.state.${agent.state}`) ? t(`agent.state.${agent.state}` as MessageKey) : agent.state}{agent.outdated ? t('agent.outdated') : ''}</span> |
| 18 | 20 | <span class="actions"> |
| 19 | 21 | {#if agent.state === 'stopped' || agent.state === 'error'} |
| @@ -2,8 +2,10 @@ | |||
| 2 | <script lang="ts"> | 2 | <script lang="ts"> |
| 3 | import { hasMessage, t, type MessageKey } from '../i18n/index.svelte' | 3 | import { hasMessage, t, type MessageKey } from '../i18n/index.svelte' |
| 4 | import { AgentService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' | 4 | import { AgentService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' |
| 5 | - import { act, appState, selectAgent } from '../state.svelte' | 5 | + import { act, appState, channelOf, openDirect } from '../state.svelte' |
| 6 | 6 | ||
| 7 | + // The agent of the direct conversation shown, to highlight it. | ||
| 8 | + const directAgent = $derived(channelOf(appState.selectedChannel)?.direct ?? '') | ||
| 7 | </script> | 9 | </script> |
| 8 | 10 | ||
| 9 | {#if appState.agents.length === 0} | 11 | {#if appState.agents.length === 0} |
| @@ -11,9 +13,9 @@ | |||
| 11 | {:else} | 13 | {:else} |
| 12 | <ul> | 14 | <ul> |
| 13 | {#each appState.agents as agent (agent.name)} | 15 | {#each appState.agents as agent (agent.name)} |
| 14 | - <li title={agent.description || agent.title} class:selected={appState.selectedAgent === agent.name}> | 16 | + <li title={agent.description || agent.title} class:selected={directAgent === agent.name}> |
| 15 | <span class="dot {agent.state}" aria-hidden="true"></span> | 17 | <span class="dot {agent.state}" aria-hidden="true"></span> |
| 16 | - <button class="name" onclick={() => selectAgent(agent.name)} aria-label={t('agent.open', { agent: agent.title })}>{agent.icon} {agent.title}</button> | 18 | + <button class="name" onclick={() => openDirect(agent.name)} aria-label={t('agent.open', { agent: agent.title })}>{agent.icon} {agent.title}</button> |
| 17 | <span class="state">{hasMessage(`agent.state.${agent.state}`) ? t(`agent.state.${agent.state}` as MessageKey) : agent.state}{agent.outdated ? t('agent.outdated') : ''}</span> | 19 | <span class="state">{hasMessage(`agent.state.${agent.state}`) ? t(`agent.state.${agent.state}` as MessageKey) : agent.state}{agent.outdated ? t('agent.outdated') : ''}</span> |
| 18 | <span class="actions"> | 20 | <span class="actions"> |
| 19 | {#if agent.state === 'stopped' || agent.state === 'error'} | 21 | {#if agent.state === 'stopped' || agent.state === 'error'} |
modified
frontend/src/lib/panels/ChannelsPanel.svelte +143 -16 | @@ -1,30 +1,157 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 2 | <script lang="ts"> |
| 3 | 3 | import { t } from '../i18n/index.svelte' |
| 4 | - import { appState } from '../state.svelte' | |
| 4 | + import { appState, createChannel, deleteChannel, renameChannel, selectChannel } from '../state.svelte' | |
| 5 | + import type { Channel } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' | |
| 5 | 6 | |
| 6 | - let selected = $state('') | |
| 7 | - const channels = $derived(appState.config?.channels ?? []) | |
| 7 | + // Issue #21: the channels, created, renamed and deleted here; the direct | |
| 8 | + // conversations below them. | |
| 9 | + const channels = $derived(appState.channels.filter((channel) => !channel.direct)) | |
| 10 | + const directs = $derived(appState.channels.filter((channel) => channel.direct)) | |
| 11 | + | |
| 12 | + let creating = $state(false) | |
| 13 | + let newName = $state('') | |
| 14 | + // The channel being renamed, and the one waiting for a delete confirmation. | |
| 15 | + let renaming = $state('') | |
| 16 | + let renameTo = $state('') | |
| 17 | + let confirming = $state('') | |
| 18 | + | |
| 19 | + async function create() { | |
| 20 | + const name = newName.trim() | |
| 21 | + if (!name) return | |
| 22 | + await createChannel(name) | |
| 23 | + if (!appState.actionError) { | |
| 24 | + creating = false | |
| 25 | + newName = '' | |
| 26 | + } | |
| 27 | + } | |
| 28 | + | |
| 29 | + function startRename(channel: Channel) { | |
| 30 | + appState.actionError = '' | |
| 31 | + renaming = channel.id | |
| 32 | + renameTo = channel.name | |
| 33 | + } | |
| 34 | + | |
| 35 | + async function rename(channel: Channel) { | |
| 36 | + const name = renameTo.trim() | |
| 37 | + if (name && name !== channel.name) await renameChannel(channel.id, name, channel.description) | |
| 38 | + if (!appState.actionError) renaming = '' | |
| 39 | + } | |
| 40 | + | |
| 41 | + function onKey(event: KeyboardEvent, enter: () => void, escape: () => void) { | |
| 42 | + if (event.key === 'Enter') enter() | |
| 43 | + else if (event.key === 'Escape') escape() | |
| 44 | + } | |
| 45 | + | |
| 46 | + function agentOf(name: string) { | |
| 47 | + return appState.agents.find((agent) => agent.name === name) | |
| 48 | + } | |
| 49 | + | |
| 50 | + // Focuses an input as soon as it appears. | |
| 51 | + function autofocus(element: HTMLInputElement) { | |
| 52 | + element.focus() | |
| 53 | + element.select() | |
| 54 | + } | |
| 8 | 55 | </script> |
| 9 | 56 | |
| 10 | -{#if channels.length === 0} | |
| 57 | +<!-- The panel's title already says "Channels": the list comes first, then | |
| 58 | + the button to add one. --> | |
| 59 | +{#if channels.length === 0 && !creating} | |
| 11 | 60 | <p class="empty">{t('channels.empty')}</p> |
| 12 | -{:else} | |
| 13 | - <ul> | |
| 14 | - {#each channels as channel (channel.name)} | |
| 15 | - <li> | |
| 16 | - <button class:active={selected === channel.name} onclick={() => (selected = channel.name)} title={channel.description}> | |
| 61 | +{/if} | |
| 62 | +<ul> | |
| 63 | + {#each channels as channel (channel.id)} | |
| 64 | + <li class:active={appState.selectedChannel === channel.id}> | |
| 65 | + {#if renaming === channel.id} | |
| 66 | + <input | |
| 67 | + use:autofocus | |
| 68 | + class="rename" | |
| 69 | + bind:value={renameTo} | |
| 70 | + maxlength="64" | |
| 71 | + onkeydown={(event) => onKey(event, () => rename(channel), () => (renaming = ''))} | |
| 72 | + onblur={() => rename(channel)} | |
| 73 | + /> | |
| 74 | + {#if appState.actionError}<p class="error" role="alert">{appState.actionError}</p>{/if} | |
| 75 | + {:else} | |
| 76 | + <button class="name" onclick={() => selectChannel(channel.id)} title={channel.description || t('channels.members', { count: channel.members?.length ?? 0 })}> | |
| 17 | 77 | # {channel.name} |
| 18 | 78 | </button> |
| 19 | - </li> | |
| 20 | - {/each} | |
| 21 | - </ul> | |
| 79 | + {#if appState.unread[channel.id]} | |
| 80 | + <span class="badge" title={t('channels.unread', { count: appState.unread[channel.id] })}>{appState.unread[channel.id]}</span> | |
| 81 | + {/if} | |
| 82 | + <span class="actions"> | |
| 83 | + <button onclick={() => startRename(channel)} aria-label={t('channels.rename', { channel: channel.name })} title={t('channels.rename', { channel: channel.name })}>✎</button> | |
| 84 | + <button onclick={() => (confirming = channel.id)} aria-label={t('channels.delete', { channel: channel.name })} title={t('channels.delete', { channel: channel.name })}>🗑</button> | |
| 85 | + </span> | |
| 86 | + {/if} | |
| 87 | + {#if confirming === channel.id} | |
| 88 | + <div class="confirm" role="alertdialog" aria-label={t('channels.deleteConfirm', { channel: channel.name })}> | |
| 89 | + <p>{t('channels.deleteConfirm', { channel: channel.name })}</p> | |
| 90 | + <button class="danger" onclick={() => { confirming = ''; void deleteChannel(channel.id) }}>{t('channels.deleteYes')}</button> | |
| 91 | + <button onclick={() => (confirming = '')}>{t('channels.cancel')}</button> | |
| 92 | + </div> | |
| 93 | + {/if} | |
| 94 | + </li> | |
| 95 | + {/each} | |
| 96 | +</ul> | |
| 97 | +{#if creating} | |
| 98 | + <div class="form"> | |
| 99 | + <input | |
| 100 | + use:autofocus | |
| 101 | + bind:value={newName} | |
| 102 | + maxlength="64" | |
| 103 | + placeholder={t('channels.namePlaceholder')} | |
| 104 | + onkeydown={(event) => onKey(event, create, () => (creating = false))} | |
| 105 | + /> | |
| 106 | + <button class="primary" onclick={create} disabled={!newName.trim()}>{t('channels.create')}</button> | |
| 107 | + </div> | |
| 108 | + {#if appState.actionError}<p class="error" role="alert">{appState.actionError}</p>{/if} | |
| 109 | +{:else} | |
| 110 | + <button class="add" onclick={() => { appState.actionError = ''; creating = true }}>+ {t('channels.new')}</button> | |
| 111 | +{/if} | |
| 112 | + | |
| 113 | +<div class="section"> | |
| 114 | + <span class="heading">{t('channels.section.direct')}</span> | |
| 115 | +</div> | |
| 116 | +{#if directs.length === 0} | |
| 117 | + <p class="empty">{t('channels.noDirect')}</p> | |
| 22 | 118 | {/if} |
| 119 | +<ul> | |
| 120 | + {#each directs as channel (channel.id)} | |
| 121 | + {@const agent = agentOf(channel.direct)} | |
| 122 | + <li class:active={appState.selectedChannel === channel.id}> | |
| 123 | + <button class="name" onclick={() => selectChannel(channel.id)}> | |
| 124 | + {agent?.icon ?? '🤖'} {agent?.title ?? channel.direct} | |
| 125 | + </button> | |
| 126 | + {#if appState.unread[channel.id]} | |
| 127 | + <span class="badge" title={t('channels.unread', { count: appState.unread[channel.id] })}>{appState.unread[channel.id]}</span> | |
| 128 | + {/if} | |
| 129 | + </li> | |
| 130 | + {/each} | |
| 131 | +</ul> | |
| 23 | 132 | |
| 24 | 133 | <style> |
| 134 | + .section { display: flex; align-items: center; justify-content: space-between; padding: 14px 8px 4px; } | |
| 135 | + .heading { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; } | |
| 136 | + .add { margin: 4px 8px 0; border: none; background: none; padding: 2px 0; color: var(--muted); font-size: 12px; } | |
| 137 | + .add:hover { color: var(--accent); } | |
| 138 | + .form { display: flex; gap: 4px; padding: 0 8px 6px; } | |
| 139 | + input { flex: 1; min-width: 0; font: inherit; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); } | |
| 140 | + .rename { margin: 2px 0; } | |
| 25 | 141 | ul { list-style: none; margin: 0; padding: 0; } |
| 26 | - button { display: block; width: 100%; text-align: left; border: none; background: none; padding: 5px 8px; border-radius: 6px; } | |
| 27 | - button:hover, button.active { background: var(--panel-2); } | |
| 28 | - button.active { font-weight: 600; } | |
| 29 | - .empty { color: var(--muted); padding: 0 8px; } | |
| 142 | + li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 0 4px 0 0; border-radius: 6px; } | |
| 143 | + li:hover, li.active { background: var(--panel-2); } | |
| 144 | + li.active .name { font-weight: 600; } | |
| 145 | + .name { flex: 1; min-width: 0; text-align: left; border: none; background: none; padding: 5px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 146 | + .badge { background: var(--accent); color: #fff; border-radius: 9px; font-size: 11px; padding: 0 6px; line-height: 17px; } | |
| 147 | + .actions { display: flex; gap: 2px; opacity: 0; } | |
| 148 | + li:hover .actions, li:focus-within .actions { opacity: 1; } | |
| 149 | + .actions button { padding: 0 5px; font-size: 11px; border: none; background: none; } | |
| 150 | + .confirm { flex-basis: 100%; margin: 2px 8px 6px; padding: 6px 8px; border: 1px solid var(--danger); border-radius: 6px; font-size: 12px; } | |
| 151 | + .confirm p { margin: 0 0 6px; } | |
| 152 | + .confirm button { font-size: 12px; margin-right: 4px; } | |
| 153 | + .danger { color: #fff; background: var(--danger); border-color: var(--danger); } | |
| 154 | + .primary { background: var(--accent); border-color: var(--accent); color: #fff; } | |
| 155 | + .empty { color: var(--muted); padding: 0 8px; margin: 2px 0 6px; font-size: 12px; } | |
| 156 | + .error { flex-basis: 100%; color: var(--danger); font-size: 12px; margin: 0 8px 6px; } | |
| 30 | 157 | </style> |
| @@ -1,30 +1,157 @@ | |||
| 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | <script lang="ts"> | 2 | <script lang="ts"> |
| 3 | import { t } from '../i18n/index.svelte' | 3 | import { t } from '../i18n/index.svelte' |
| 4 | - import { appState } from '../state.svelte' | 4 | + import { appState, createChannel, deleteChannel, renameChannel, selectChannel } from '../state.svelte' |
| 5 | + import type { Channel } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' | ||
| 5 | 6 | ||
| 6 | - let selected = $state('') | 7 | + // Issue #21: the channels, created, renamed and deleted here; the direct |
| 7 | - const channels = $derived(appState.config?.channels ?? []) | 8 | + // conversations below them. |
| 9 | + const channels = $derived(appState.channels.filter((channel) => !channel.direct)) | ||
| 10 | + const directs = $derived(appState.channels.filter((channel) => channel.direct)) | ||
| 11 | + | ||
| 12 | + let creating = $state(false) | ||
| 13 | + let newName = $state('') | ||
| 14 | + // The channel being renamed, and the one waiting for a delete confirmation. | ||
| 15 | + let renaming = $state('') | ||
| 16 | + let renameTo = $state('') | ||
| 17 | + let confirming = $state('') | ||
| 18 | + | ||
| 19 | + async function create() { | ||
| 20 | + const name = newName.trim() | ||
| 21 | + if (!name) return | ||
| 22 | + await createChannel(name) | ||
| 23 | + if (!appState.actionError) { | ||
| 24 | + creating = false | ||
| 25 | + newName = '' | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + function startRename(channel: Channel) { | ||
| 30 | + appState.actionError = '' | ||
| 31 | + renaming = channel.id | ||
| 32 | + renameTo = channel.name | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + async function rename(channel: Channel) { | ||
| 36 | + const name = renameTo.trim() | ||
| 37 | + if (name && name !== channel.name) await renameChannel(channel.id, name, channel.description) | ||
| 38 | + if (!appState.actionError) renaming = '' | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + function onKey(event: KeyboardEvent, enter: () => void, escape: () => void) { | ||
| 42 | + if (event.key === 'Enter') enter() | ||
| 43 | + else if (event.key === 'Escape') escape() | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + function agentOf(name: string) { | ||
| 47 | + return appState.agents.find((agent) => agent.name === name) | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + // Focuses an input as soon as it appears. | ||
| 51 | + function autofocus(element: HTMLInputElement) { | ||
| 52 | + element.focus() | ||
| 53 | + element.select() | ||
| 54 | + } | ||
| 8 | </script> | 55 | </script> |
| 9 | 56 | ||
| 10 | -{#if channels.length === 0} | 57 | +<!-- The panel's title already says "Channels": the list comes first, then |
| 58 | + the button to add one. --> | ||
| 59 | +{#if channels.length === 0 && !creating} | ||
| 11 | <p class="empty">{t('channels.empty')}</p> | 60 | <p class="empty">{t('channels.empty')}</p> |
| 12 | -{:else} | 61 | +{/if} |
| 13 | - <ul> | 62 | +<ul> |
| 14 | - {#each channels as channel (channel.name)} | 63 | + {#each channels as channel (channel.id)} |
| 15 | - <li> | 64 | + <li class:active={appState.selectedChannel === channel.id}> |
| 16 | - <button class:active={selected === channel.name} onclick={() => (selected = channel.name)} title={channel.description}> | 65 | + {#if renaming === channel.id} |
| 66 | + <input | ||
| 67 | + use:autofocus | ||
| 68 | + class="rename" | ||
| 69 | + bind:value={renameTo} | ||
| 70 | + maxlength="64" | ||
| 71 | + onkeydown={(event) => onKey(event, () => rename(channel), () => (renaming = ''))} | ||
| 72 | + onblur={() => rename(channel)} | ||
| 73 | + /> | ||
| 74 | + {#if appState.actionError}<p class="error" role="alert">{appState.actionError}</p>{/if} | ||
| 75 | + {:else} | ||
| 76 | + <button class="name" onclick={() => selectChannel(channel.id)} title={channel.description || t('channels.members', { count: channel.members?.length ?? 0 })}> | ||
| 17 | # {channel.name} | 77 | # {channel.name} |
| 18 | </button> | 78 | </button> |
| 19 | - </li> | 79 | + {#if appState.unread[channel.id]} |
| 20 | - {/each} | 80 | + <span class="badge" title={t('channels.unread', { count: appState.unread[channel.id] })}>{appState.unread[channel.id]}</span> |
| 21 | - </ul> | 81 | + {/if} |
| 82 | + <span class="actions"> | ||
| 83 | + <button onclick={() => startRename(channel)} aria-label={t('channels.rename', { channel: channel.name })} title={t('channels.rename', { channel: channel.name })}>✎</button> | ||
| 84 | + <button onclick={() => (confirming = channel.id)} aria-label={t('channels.delete', { channel: channel.name })} title={t('channels.delete', { channel: channel.name })}>🗑</button> | ||
| 85 | + </span> | ||
| 86 | + {/if} | ||
| 87 | + {#if confirming === channel.id} | ||
| 88 | + <div class="confirm" role="alertdialog" aria-label={t('channels.deleteConfirm', { channel: channel.name })}> | ||
| 89 | + <p>{t('channels.deleteConfirm', { channel: channel.name })}</p> | ||
| 90 | + <button class="danger" onclick={() => { confirming = ''; void deleteChannel(channel.id) }}>{t('channels.deleteYes')}</button> | ||
| 91 | + <button onclick={() => (confirming = '')}>{t('channels.cancel')}</button> | ||
| 92 | + </div> | ||
| 93 | + {/if} | ||
| 94 | + </li> | ||
| 95 | + {/each} | ||
| 96 | +</ul> | ||
| 97 | +{#if creating} | ||
| 98 | + <div class="form"> | ||
| 99 | + <input | ||
| 100 | + use:autofocus | ||
| 101 | + bind:value={newName} | ||
| 102 | + maxlength="64" | ||
| 103 | + placeholder={t('channels.namePlaceholder')} | ||
| 104 | + onkeydown={(event) => onKey(event, create, () => (creating = false))} | ||
| 105 | + /> | ||
| 106 | + <button class="primary" onclick={create} disabled={!newName.trim()}>{t('channels.create')}</button> | ||
| 107 | + </div> | ||
| 108 | + {#if appState.actionError}<p class="error" role="alert">{appState.actionError}</p>{/if} | ||
| 109 | +{:else} | ||
| 110 | + <button class="add" onclick={() => { appState.actionError = ''; creating = true }}>+ {t('channels.new')}</button> | ||
| 111 | +{/if} | ||
| 112 | + | ||
| 113 | +<div class="section"> | ||
| 114 | + <span class="heading">{t('channels.section.direct')}</span> | ||
| 115 | +</div> | ||
| 116 | +{#if directs.length === 0} | ||
| 117 | + <p class="empty">{t('channels.noDirect')}</p> | ||
| 22 | {/if} | 118 | {/if} |
| 119 | +<ul> | ||
| 120 | + {#each directs as channel (channel.id)} | ||
| 121 | + {@const agent = agentOf(channel.direct)} | ||
| 122 | + <li class:active={appState.selectedChannel === channel.id}> | ||
| 123 | + <button class="name" onclick={() => selectChannel(channel.id)}> | ||
| 124 | + {agent?.icon ?? '🤖'} {agent?.title ?? channel.direct} | ||
| 125 | + </button> | ||
| 126 | + {#if appState.unread[channel.id]} | ||
| 127 | + <span class="badge" title={t('channels.unread', { count: appState.unread[channel.id] })}>{appState.unread[channel.id]}</span> | ||
| 128 | + {/if} | ||
| 129 | + </li> | ||
| 130 | + {/each} | ||
| 131 | +</ul> | ||
| 23 | 132 | ||
| 24 | <style> | 133 | <style> |
| 134 | + .section { display: flex; align-items: center; justify-content: space-between; padding: 14px 8px 4px; } | ||
| 135 | + .heading { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; } | ||
| 136 | + .add { margin: 4px 8px 0; border: none; background: none; padding: 2px 0; color: var(--muted); font-size: 12px; } | ||
| 137 | + .add:hover { color: var(--accent); } | ||
| 138 | + .form { display: flex; gap: 4px; padding: 0 8px 6px; } | ||
| 139 | + input { flex: 1; min-width: 0; font: inherit; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); } | ||
| 140 | + .rename { margin: 2px 0; } | ||
| 25 | ul { list-style: none; margin: 0; padding: 0; } | 141 | ul { list-style: none; margin: 0; padding: 0; } |
| 26 | - button { display: block; width: 100%; text-align: left; border: none; background: none; padding: 5px 8px; border-radius: 6px; } | 142 | + li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 0 4px 0 0; border-radius: 6px; } |
| 27 | - button:hover, button.active { background: var(--panel-2); } | 143 | + li:hover, li.active { background: var(--panel-2); } |
| 28 | - button.active { font-weight: 600; } | 144 | + li.active .name { font-weight: 600; } |
| 29 | - .empty { color: var(--muted); padding: 0 8px; } | 145 | + .name { flex: 1; min-width: 0; text-align: left; border: none; background: none; padding: 5px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 146 | + .badge { background: var(--accent); color: #fff; border-radius: 9px; font-size: 11px; padding: 0 6px; line-height: 17px; } | ||
| 147 | + .actions { display: flex; gap: 2px; opacity: 0; } | ||
| 148 | + li:hover .actions, li:focus-within .actions { opacity: 1; } | ||
| 149 | + .actions button { padding: 0 5px; font-size: 11px; border: none; background: none; } | ||
| 150 | + .confirm { flex-basis: 100%; margin: 2px 8px 6px; padding: 6px 8px; border: 1px solid var(--danger); border-radius: 6px; font-size: 12px; } | ||
| 151 | + .confirm p { margin: 0 0 6px; } | ||
| 152 | + .confirm button { font-size: 12px; margin-right: 4px; } | ||
| 153 | + .danger { color: #fff; background: var(--danger); border-color: var(--danger); } | ||
| 154 | + .primary { background: var(--accent); border-color: var(--accent); color: #fff; } | ||
| 155 | + .empty { color: var(--muted); padding: 0 8px; margin: 2px 0 6px; font-size: 12px; } | ||
| 156 | + .error { flex-basis: 100%; color: var(--danger); font-size: 12px; margin: 0 8px 6px; } | ||
| 30 | </style> | 157 | </style> |
modified
frontend/src/lib/panels/ConversationPanel.svelte +143 -24 | @@ -5,20 +5,72 @@ | ||
| 5 | 5 | import Composer from '../chat/Composer.svelte' |
| 6 | 6 | import MessageView from '../chat/MessageView.svelte' |
| 7 | 7 | import PermissionCard from '../chat/PermissionCard.svelte' |
| 8 | - import { isNearBottom } from '../composer' | |
| 8 | + import Spinner from '../chat/Spinner.svelte' | |
| 9 | + import { isNearBottom, type Trigger } from '../composer' | |
| 9 | 10 | import { usagePercent } from '../conversation' |
| 10 | - import { appState, cancel, conversationOf, send } from '../state.svelte' | |
| 11 | + import { addMember, agentTitle, appState, cancel, channelOf, loadOlder, prepareCommands, removeMember, send } from '../state.svelte' | |
| 12 | + import { commandSuggestions, isCommandPosition, mentionSuggestions, type AgentInfo, type Suggestions } from '../suggestions' | |
| 11 | 13 | import { stuckAfter, trailingFailures } from '../tools' |
| 12 | 14 | |
| 13 | - const agent = $derived(appState.agents.find((candidate) => candidate.name === appState.selectedAgent)) | |
| 14 | - const conversation = $derived(agent ? conversationOf(agent.name) : null) | |
| 15 | - const title = $derived(agent ? `${agent.icon ? agent.icon + ' ' : ''}${agent.title}` : '') | |
| 16 | - const percent = $derived(conversation ? usagePercent(conversation) : null) | |
| 15 | + const channel = $derived(channelOf(appState.selectedChannel)) | |
| 16 | + // Read only: selectChannel creates the conversation (a $derived must not write state). | |
| 17 | + const conversation = $derived(channel ? (appState.conversations[channel.id] ?? null) : null) | |
| 18 | + const direct = $derived(channel?.direct ? appState.agents.find((agent) => agent.name === channel.direct) : undefined) | |
| 19 | + const title = $derived(channel ? (channel.direct ? `${direct?.icon ? direct.icon + ' ' : ''}${agentTitle(channel.direct)}` : `# ${channel.name}`) : '') | |
| 20 | + const members = $derived(channel?.members ?? []) | |
| 21 | + const invitable = $derived(appState.agents.filter((agent) => !members.includes(agent.name))) | |
| 22 | + // The gauge of the agent answering, or of the one of a direct conversation. | |
| 23 | + const gaugeAgent = $derived(conversation?.answering || channel?.direct || '') | |
| 24 | + const percent = $derived(conversation && gaugeAgent ? usagePercent(conversation, gaugeAgent) : null) | |
| 17 | 25 | // An answer whose last tool calls all failed: the agent is probably looping. |
| 18 | 26 | const failures = $derived.by(() => { |
| 19 | 27 | const last = conversation?.messages?.at(-1) |
| 20 | 28 | return conversation?.busy && last?.role === 'agent' && !last.done ? trailingFailures(last.parts ?? []) : 0 |
| 21 | 29 | }) |
| 30 | + const stuckAgent = $derived(agentTitle(conversation?.answering ?? '')) | |
| 31 | + | |
| 32 | + function infoOf(name: string): AgentInfo { | |
| 33 | + const agent = appState.agents.find((candidate) => candidate.name === name) | |
| 34 | + return { name, title: agent?.title || name, icon: agent?.icon ?? '' } | |
| 35 | + } | |
| 36 | + | |
| 37 | + // The composer's popup: agents after @ (not in a direct conversation), | |
| 38 | + // the members' commands after / at the start of the message. | |
| 39 | + function suggest(trigger: Trigger, text: string): Suggestions | null { | |
| 40 | + if (!channel) return null | |
| 41 | + if (trigger.char === '@' && !channel.direct) { | |
| 42 | + return { items: mentionSuggestions(trigger.query, appState.agents.map((agent) => infoOf(agent.name))) } | |
| 43 | + } | |
| 44 | + if (trigger.char === '/' && isCommandPosition(text, trigger.start)) { | |
| 45 | + const items = commandSuggestions(trigger.query, members.map(infoOf), appState.commands) | |
| 46 | + return { items, empty: commandsHint() } | |
| 47 | + } | |
| 48 | + return null | |
| 49 | + } | |
| 50 | + | |
| 51 | + // Why the "/" list is empty: commands being asked for, an error, or none. | |
| 52 | + function commandsHint(): string { | |
| 53 | + if (!channel) return '' | |
| 54 | + const unknown = members.filter((member) => !appState.commands[member]?.length) | |
| 55 | + if (appState.preparing[channel.id] && unknown.length > 0) { | |
| 56 | + return t('composer.askingCommands', { agents: unknown.map(agentTitle).join(', ') }) | |
| 57 | + } | |
| 58 | + if (appState.prepareError[channel.id]) return t('composer.commandsError', { error: appState.prepareError[channel.id] }) | |
| 59 | + return t('composer.noCommands') | |
| 60 | + } | |
| 61 | + | |
| 62 | + // The first "/" asks the members that never announced their commands. | |
| 63 | + function onTrigger(trigger: Trigger, text: string) { | |
| 64 | + if (channel && trigger.char === '/' && isCommandPosition(text, trigger.start)) void prepareCommands(channel.id) | |
| 65 | + } | |
| 66 | + | |
| 67 | + // Between the user's message and the first words of the answer: the agent | |
| 68 | + // starts, loads its model, or waits for its turn elsewhere. | |
| 69 | + const gettingReady = $derived.by(() => { | |
| 70 | + if (!conversation?.busy || !conversation.answering) return false | |
| 71 | + const last = conversation.messages?.at(-1) | |
| 72 | + return !(last?.role === 'agent' && !last.done && last.agent === conversation.answering) | |
| 73 | + }) | |
| 22 | 74 | |
| 23 | 75 | // Smart scrolling: follow new content only when the reader is at the bottom. |
| 24 | 76 | let list: HTMLElement | undefined = $state() |
| @@ -28,14 +80,24 @@ | ||
| 28 | 80 | } |
| 29 | 81 | $effect(() => { |
| 30 | 82 | // Re-run on every change of the conversation's content. |
| 31 | - JSON.stringify(conversation?.messages) | |
| 83 | + JSON.stringify(conversation?.messages?.at(-1)) | |
| 84 | + void conversation?.messages?.length | |
| 32 | 85 | void conversation?.permissions?.length |
| 33 | 86 | if (following && list) tick().then(() => list && (list.scrollTop = list.scrollHeight)) |
| 34 | 87 | }) |
| 35 | 88 | $effect(() => { |
| 36 | - void appState.selectedAgent | |
| 89 | + void appState.selectedChannel | |
| 37 | 90 | following = true |
| 38 | 91 | }) |
| 92 | + | |
| 93 | + async function older() { | |
| 94 | + if (!channel || !list) return | |
| 95 | + const height = list.scrollHeight | |
| 96 | + await loadOlder(channel.id) | |
| 97 | + await tick() | |
| 98 | + // Keep the messages the reader was looking at in place. | |
| 99 | + if (list) list.scrollTop += list.scrollHeight - height | |
| 100 | + } | |
| 39 | 101 | </script> |
| 40 | 102 | |
| 41 | 103 | <section class="conversation"> |
| @@ -45,42 +107,91 @@ | ||
| 45 | 107 | {t('conversation.invalidConfigHint')} |
| 46 | 108 | </div> |
| 47 | 109 | {/if} |
| 110 | + {#if appState.config?.historyProblem} | |
| 111 | + <div class="banner warning" role="alert">{t('conversation.historyProblem', { problem: appState.config.historyProblem })}</div> | |
| 112 | + {/if} | |
| 48 | 113 | |
| 49 | - {#if agent && conversation} | |
| 114 | + {#if channel && conversation} | |
| 50 | 115 | <header> |
| 51 | - <span class="title">{title}</span> | |
| 52 | - {#if conversation.busy}<span class="busy">{t('conversation.answering')}{conversation.queued ? t('conversation.queued', { count: conversation.queued }) : ''}</span>{/if} | |
| 116 | + <span class="title" title={channel.description}>{title}</span> | |
| 117 | + {#if !channel.direct} | |
| 118 | + <span class="members"> | |
| 119 | + {#each members as member (member)} | |
| 120 | + <span class="member"> | |
| 121 | + {infoOf(member).icon} {agentTitle(member)} | |
| 122 | + <button onclick={() => removeMember(channel.id, member)} aria-label={t('conversation.removeMember', { agent: agentTitle(member) })} title={t('conversation.removeMember', { agent: agentTitle(member) })}>×</button> | |
| 123 | + </span> | |
| 124 | + {/each} | |
| 125 | + {#if invitable.length > 0} | |
| 126 | + <select | |
| 127 | + aria-label={t('conversation.inviteLabel')} | |
| 128 | + onchange={(event) => { | |
| 129 | + const select = event.currentTarget | |
| 130 | + if (select.value) void addMember(channel.id, select.value) | |
| 131 | + select.value = '' | |
| 132 | + }} | |
| 133 | + > | |
| 134 | + <option value="">{t('conversation.invite')}</option> | |
| 135 | + {#each invitable as agent (agent.name)} | |
| 136 | + <option value={agent.name}>{agent.icon} {agent.title}</option> | |
| 137 | + {/each} | |
| 138 | + </select> | |
| 139 | + {/if} | |
| 140 | + </span> | |
| 141 | + {/if} | |
| 142 | + {#if conversation.busy} | |
| 143 | + <span class="busy"><Spinner /> | |
| 144 | + {conversation.answering ? t('conversation.answeringAgent', { agent: agentTitle(conversation.answering) }) : t('conversation.answering')}{conversation.queued ? t('conversation.queued', { count: conversation.queued }) : ''} | |
| 145 | + </span> | |
| 146 | + {/if} | |
| 53 | 147 | {#if percent !== null} |
| 54 | 148 | <span class="usage" title={t('conversation.usageHint')}>{t('conversation.usage', { percent })}</span> |
| 55 | 149 | {/if} |
| 56 | 150 | </header> |
| 57 | 151 | <div class="messages" bind:this={list} onscroll={onScroll}> |
| 152 | + {#if conversation.more} | |
| 153 | + <button class="older" onclick={older}>{t('conversation.older')}</button> | |
| 154 | + {/if} | |
| 58 | 155 | {#if (conversation.messages ?? []).length === 0} |
| 59 | - <p class="empty">{t('conversation.empty', { agent: agent.title, description: agent.description })}</p> | |
| 156 | + <p class="empty"> | |
| 157 | + {#if channel.direct} | |
| 158 | + {t('conversation.empty', { agent: agentTitle(channel.direct), description: direct?.description ?? '' })} | |
| 159 | + {:else} | |
| 160 | + {t('conversation.channelEmpty', { channel: channel.name, description: channel.description })} | |
| 161 | + {/if} | |
| 162 | + </p> | |
| 60 | 163 | {/if} |
| 61 | 164 | {#each conversation.messages ?? [] as message (message.id)} |
| 62 | - <MessageView {message} agentTitle={agent.title} /> | |
| 165 | + <MessageView {message} /> | |
| 63 | 166 | {/each} |
| 167 | + {#if gettingReady} | |
| 168 | + <p class="pending"><Spinner /> {t('message.preparing', { agent: agentTitle(conversation.answering) })}</p> | |
| 169 | + {/if} | |
| 64 | 170 | {#each conversation.permissions ?? [] as permission (permission.id)} |
| 65 | - <PermissionCard {permission} agentTitle={agent.title} /> | |
| 171 | + <PermissionCard {permission} /> | |
| 66 | 172 | {/each} |
| 67 | 173 | </div> |
| 68 | 174 | {#if failures >= stuckAfter} |
| 69 | 175 | <div class="stuck" role="alert"> |
| 70 | - <span>{t('conversation.stuck', { agent: agent.title, count: failures })}</span> | |
| 71 | - <button onclick={() => cancel(agent.name)}>{t('conversation.stop', { agent: agent.title })}</button> | |
| 176 | + <span>{t('conversation.stuck', { agent: stuckAgent, count: failures })}</span> | |
| 177 | + <button onclick={() => cancel(channel.id)}>{t('conversation.stop', { agent: stuckAgent })}</button> | |
| 72 | 178 | </div> |
| 73 | 179 | {/if} |
| 74 | 180 | {#if appState.actionError} |
| 75 | 181 | <p class="action-error" role="alert">{appState.actionError}</p> |
| 76 | 182 | {/if} |
| 77 | 183 | <div class="composer"> |
| 78 | - <Composer | |
| 79 | - placeholder={t('conversation.placeholder', { agent: agent.title })} | |
| 80 | - busy={conversation.busy} | |
| 81 | - onSend={(text) => send(agent.name, text)} | |
| 82 | - onStop={() => cancel(agent.name)} | |
| 83 | - /> | |
| 184 | + {#key channel.id} | |
| 185 | + <Composer | |
| 186 | + placeholder={channel.direct ? t('conversation.placeholder', { agent: agentTitle(channel.direct) }) : t('conversation.channelPlaceholder', { channel: channel.name })} | |
| 187 | + busy={conversation.busy} | |
| 188 | + triggers={['@', '/']} | |
| 189 | + {suggest} | |
| 190 | + {onTrigger} | |
| 191 | + onSend={(text) => send(channel.id, text)} | |
| 192 | + onStop={() => cancel(channel.id)} | |
| 193 | + /> | |
| 194 | + {/key} | |
| 84 | 195 | </div> |
| 85 | 196 | {:else} |
| 86 | 197 | <div class="welcome"> |
| @@ -97,11 +208,19 @@ | ||
| 97 | 208 | <style> |
| 98 | 209 | .conversation { height: 100%; display: flex; flex-direction: column; background: var(--bg); } |
| 99 | 210 | .banner { margin: 12px; padding: 10px 14px; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); } |
| 100 | - header { display: flex; align-items: baseline; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--border); } | |
| 211 | + .banner.warning { border-color: var(--warn); color: var(--warn); } | |
| 212 | + header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 10px 18px; border-bottom: 1px solid var(--border); } | |
| 101 | 213 | .title { font-weight: 600; font-size: 15px; } |
| 102 | - .busy { color: var(--warn); font-size: 12px; font-style: italic; } | |
| 214 | + .members { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } | |
| 215 | + .member { font-size: 12px; border: 1px solid var(--border); border-radius: 10px; padding: 0 2px 0 8px; display: inline-flex; align-items: center; gap: 2px; } | |
| 216 | + .member button { border: none; background: none; padding: 0 5px; color: var(--muted); font-size: 12px; } | |
| 217 | + .member button:hover { color: var(--danger); } | |
| 218 | + select { font: inherit; font-size: 12px; border: 1px dashed var(--border); border-radius: 10px; padding: 0 6px; background: var(--bg); color: var(--muted); } | |
| 219 | + .busy { color: var(--warn); font-size: 12px; font-style: italic; display: inline-flex; align-items: center; gap: 6px; } | |
| 220 | + .pending { color: var(--muted); font-size: 13px; font-style: italic; display: flex; align-items: center; gap: 8px; margin: 0; } | |
| 103 | 221 | .usage { margin-left: auto; color: var(--muted); font-size: 12px; border: 1px solid var(--border); border-radius: 10px; padding: 1px 10px; } |
| 104 | 222 | .messages { flex: 1; overflow-y: auto; padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; } |
| 223 | + .older { align-self: center; font-size: 12px; } | |
| 105 | 224 | .empty { color: var(--muted); margin: auto; text-align: center; max-width: 420px; } |
| 106 | 225 | .stuck { display: flex; align-items: center; gap: 10px; margin: 0 18px 8px; padding: 8px 12px; border: 1px solid var(--warn); border-radius: var(--radius); color: var(--warn); font-size: 13px; } |
| 107 | 226 | .stuck button { margin-left: auto; color: var(--danger); border-color: var(--danger); } |
| @@ -5,20 +5,72 @@ | |||
| 5 | import Composer from '../chat/Composer.svelte' | 5 | import Composer from '../chat/Composer.svelte' |
| 6 | import MessageView from '../chat/MessageView.svelte' | 6 | import MessageView from '../chat/MessageView.svelte' |
| 7 | import PermissionCard from '../chat/PermissionCard.svelte' | 7 | import PermissionCard from '../chat/PermissionCard.svelte' |
| 8 | - import { isNearBottom } from '../composer' | 8 | + import Spinner from '../chat/Spinner.svelte' |
| 9 | + import { isNearBottom, type Trigger } from '../composer' | ||
| 9 | import { usagePercent } from '../conversation' | 10 | import { usagePercent } from '../conversation' |
| 10 | - import { appState, cancel, conversationOf, send } from '../state.svelte' | 11 | + import { addMember, agentTitle, appState, cancel, channelOf, loadOlder, prepareCommands, removeMember, send } from '../state.svelte' |
| 12 | + import { commandSuggestions, isCommandPosition, mentionSuggestions, type AgentInfo, type Suggestions } from '../suggestions' | ||
| 11 | import { stuckAfter, trailingFailures } from '../tools' | 13 | import { stuckAfter, trailingFailures } from '../tools' |
| 12 | 14 | ||
| 13 | - const agent = $derived(appState.agents.find((candidate) => candidate.name === appState.selectedAgent)) | 15 | + const channel = $derived(channelOf(appState.selectedChannel)) |
| 14 | - const conversation = $derived(agent ? conversationOf(agent.name) : null) | 16 | + // Read only: selectChannel creates the conversation (a $derived must not write state). |
| 15 | - const title = $derived(agent ? `${agent.icon ? agent.icon + ' ' : ''}${agent.title}` : '') | 17 | + const conversation = $derived(channel ? (appState.conversations[channel.id] ?? null) : null) |
| 16 | - const percent = $derived(conversation ? usagePercent(conversation) : null) | 18 | + const direct = $derived(channel?.direct ? appState.agents.find((agent) => agent.name === channel.direct) : undefined) |
| 19 | + const title = $derived(channel ? (channel.direct ? `${direct?.icon ? direct.icon + ' ' : ''}${agentTitle(channel.direct)}` : `# ${channel.name}`) : '') | ||
| 20 | + const members = $derived(channel?.members ?? []) | ||
| 21 | + const invitable = $derived(appState.agents.filter((agent) => !members.includes(agent.name))) | ||
| 22 | + // The gauge of the agent answering, or of the one of a direct conversation. | ||
| 23 | + const gaugeAgent = $derived(conversation?.answering || channel?.direct || '') | ||
| 24 | + const percent = $derived(conversation && gaugeAgent ? usagePercent(conversation, gaugeAgent) : null) | ||
| 17 | // An answer whose last tool calls all failed: the agent is probably looping. | 25 | // An answer whose last tool calls all failed: the agent is probably looping. |
| 18 | const failures = $derived.by(() => { | 26 | const failures = $derived.by(() => { |
| 19 | const last = conversation?.messages?.at(-1) | 27 | const last = conversation?.messages?.at(-1) |
| 20 | return conversation?.busy && last?.role === 'agent' && !last.done ? trailingFailures(last.parts ?? []) : 0 | 28 | return conversation?.busy && last?.role === 'agent' && !last.done ? trailingFailures(last.parts ?? []) : 0 |
| 21 | }) | 29 | }) |
| 30 | + const stuckAgent = $derived(agentTitle(conversation?.answering ?? '')) | ||
| 31 | + | ||
| 32 | + function infoOf(name: string): AgentInfo { | ||
| 33 | + const agent = appState.agents.find((candidate) => candidate.name === name) | ||
| 34 | + return { name, title: agent?.title || name, icon: agent?.icon ?? '' } | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + // The composer's popup: agents after @ (not in a direct conversation), | ||
| 38 | + // the members' commands after / at the start of the message. | ||
| 39 | + function suggest(trigger: Trigger, text: string): Suggestions | null { | ||
| 40 | + if (!channel) return null | ||
| 41 | + if (trigger.char === '@' && !channel.direct) { | ||
| 42 | + return { items: mentionSuggestions(trigger.query, appState.agents.map((agent) => infoOf(agent.name))) } | ||
| 43 | + } | ||
| 44 | + if (trigger.char === '/' && isCommandPosition(text, trigger.start)) { | ||
| 45 | + const items = commandSuggestions(trigger.query, members.map(infoOf), appState.commands) | ||
| 46 | + return { items, empty: commandsHint() } | ||
| 47 | + } | ||
| 48 | + return null | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + // Why the "/" list is empty: commands being asked for, an error, or none. | ||
| 52 | + function commandsHint(): string { | ||
| 53 | + if (!channel) return '' | ||
| 54 | + const unknown = members.filter((member) => !appState.commands[member]?.length) | ||
| 55 | + if (appState.preparing[channel.id] && unknown.length > 0) { | ||
| 56 | + return t('composer.askingCommands', { agents: unknown.map(agentTitle).join(', ') }) | ||
| 57 | + } | ||
| 58 | + if (appState.prepareError[channel.id]) return t('composer.commandsError', { error: appState.prepareError[channel.id] }) | ||
| 59 | + return t('composer.noCommands') | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + // The first "/" asks the members that never announced their commands. | ||
| 63 | + function onTrigger(trigger: Trigger, text: string) { | ||
| 64 | + if (channel && trigger.char === '/' && isCommandPosition(text, trigger.start)) void prepareCommands(channel.id) | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + // Between the user's message and the first words of the answer: the agent | ||
| 68 | + // starts, loads its model, or waits for its turn elsewhere. | ||
| 69 | + const gettingReady = $derived.by(() => { | ||
| 70 | + if (!conversation?.busy || !conversation.answering) return false | ||
| 71 | + const last = conversation.messages?.at(-1) | ||
| 72 | + return !(last?.role === 'agent' && !last.done && last.agent === conversation.answering) | ||
| 73 | + }) | ||
| 22 | 74 | ||
| 23 | // Smart scrolling: follow new content only when the reader is at the bottom. | 75 | // Smart scrolling: follow new content only when the reader is at the bottom. |
| 24 | let list: HTMLElement | undefined = $state() | 76 | let list: HTMLElement | undefined = $state() |
| @@ -28,14 +80,24 @@ | |||
| 28 | } | 80 | } |
| 29 | $effect(() => { | 81 | $effect(() => { |
| 30 | // Re-run on every change of the conversation's content. | 82 | // Re-run on every change of the conversation's content. |
| 31 | - JSON.stringify(conversation?.messages) | 83 | + JSON.stringify(conversation?.messages?.at(-1)) |
| 84 | + void conversation?.messages?.length | ||
| 32 | void conversation?.permissions?.length | 85 | void conversation?.permissions?.length |
| 33 | if (following && list) tick().then(() => list && (list.scrollTop = list.scrollHeight)) | 86 | if (following && list) tick().then(() => list && (list.scrollTop = list.scrollHeight)) |
| 34 | }) | 87 | }) |
| 35 | $effect(() => { | 88 | $effect(() => { |
| 36 | - void appState.selectedAgent | 89 | + void appState.selectedChannel |
| 37 | following = true | 90 | following = true |
| 38 | }) | 91 | }) |
| 92 | + | ||
| 93 | + async function older() { | ||
| 94 | + if (!channel || !list) return | ||
| 95 | + const height = list.scrollHeight | ||
| 96 | + await loadOlder(channel.id) | ||
| 97 | + await tick() | ||
| 98 | + // Keep the messages the reader was looking at in place. | ||
| 99 | + if (list) list.scrollTop += list.scrollHeight - height | ||
| 100 | + } | ||
| 39 | </script> | 101 | </script> |
| 40 | 102 | ||
| 41 | <section class="conversation"> | 103 | <section class="conversation"> |
| @@ -45,42 +107,91 @@ | |||
| 45 | {t('conversation.invalidConfigHint')} | 107 | {t('conversation.invalidConfigHint')} |
| 46 | </div> | 108 | </div> |
| 47 | {/if} | 109 | {/if} |
| 110 | + {#if appState.config?.historyProblem} | ||
| 111 | + <div class="banner warning" role="alert">{t('conversation.historyProblem', { problem: appState.config.historyProblem })}</div> | ||
| 112 | + {/if} | ||
| 48 | 113 | ||
| 49 | - {#if agent && conversation} | 114 | + {#if channel && conversation} |
| 50 | <header> | 115 | <header> |
| 51 | - <span class="title">{title}</span> | 116 | + <span class="title" title={channel.description}>{title}</span> |
| 52 | - {#if conversation.busy}<span class="busy">{t('conversation.answering')}{conversation.queued ? t('conversation.queued', { count: conversation.queued }) : ''}</span>{/if} | 117 | + {#if !channel.direct} |
| 118 | + <span class="members"> | ||
| 119 | + {#each members as member (member)} | ||
| 120 | + <span class="member"> | ||
| 121 | + {infoOf(member).icon} {agentTitle(member)} | ||
| 122 | + <button onclick={() => removeMember(channel.id, member)} aria-label={t('conversation.removeMember', { agent: agentTitle(member) })} title={t('conversation.removeMember', { agent: agentTitle(member) })}>×</button> | ||
| 123 | + </span> | ||
| 124 | + {/each} | ||
| 125 | + {#if invitable.length > 0} | ||
| 126 | + <select | ||
| 127 | + aria-label={t('conversation.inviteLabel')} | ||
| 128 | + onchange={(event) => { | ||
| 129 | + const select = event.currentTarget | ||
| 130 | + if (select.value) void addMember(channel.id, select.value) | ||
| 131 | + select.value = '' | ||
| 132 | + }} | ||
| 133 | + > | ||
| 134 | + <option value="">{t('conversation.invite')}</option> | ||
| 135 | + {#each invitable as agent (agent.name)} | ||
| 136 | + <option value={agent.name}>{agent.icon} {agent.title}</option> | ||
| 137 | + {/each} | ||
| 138 | + </select> | ||
| 139 | + {/if} | ||
| 140 | + </span> | ||
| 141 | + {/if} | ||
| 142 | + {#if conversation.busy} | ||
| 143 | + <span class="busy"><Spinner /> | ||
| 144 | + {conversation.answering ? t('conversation.answeringAgent', { agent: agentTitle(conversation.answering) }) : t('conversation.answering')}{conversation.queued ? t('conversation.queued', { count: conversation.queued }) : ''} | ||
| 145 | + </span> | ||
| 146 | + {/if} | ||
| 53 | {#if percent !== null} | 147 | {#if percent !== null} |
| 54 | <span class="usage" title={t('conversation.usageHint')}>{t('conversation.usage', { percent })}</span> | 148 | <span class="usage" title={t('conversation.usageHint')}>{t('conversation.usage', { percent })}</span> |
| 55 | {/if} | 149 | {/if} |
| 56 | </header> | 150 | </header> |
| 57 | <div class="messages" bind:this={list} onscroll={onScroll}> | 151 | <div class="messages" bind:this={list} onscroll={onScroll}> |
| 152 | + {#if conversation.more} | ||
| 153 | + <button class="older" onclick={older}>{t('conversation.older')}</button> | ||
| 154 | + {/if} | ||
| 58 | {#if (conversation.messages ?? []).length === 0} | 155 | {#if (conversation.messages ?? []).length === 0} |
| 59 | - <p class="empty">{t('conversation.empty', { agent: agent.title, description: agent.description })}</p> | 156 | + <p class="empty"> |
| 157 | + {#if channel.direct} | ||
| 158 | + {t('conversation.empty', { agent: agentTitle(channel.direct), description: direct?.description ?? '' })} | ||
| 159 | + {:else} | ||
| 160 | + {t('conversation.channelEmpty', { channel: channel.name, description: channel.description })} | ||
| 161 | + {/if} | ||
| 162 | + </p> | ||
| 60 | {/if} | 163 | {/if} |
| 61 | {#each conversation.messages ?? [] as message (message.id)} | 164 | {#each conversation.messages ?? [] as message (message.id)} |
| 62 | - <MessageView {message} agentTitle={agent.title} /> | 165 | + <MessageView {message} /> |
| 63 | {/each} | 166 | {/each} |
| 167 | + {#if gettingReady} | ||
| 168 | + <p class="pending"><Spinner /> {t('message.preparing', { agent: agentTitle(conversation.answering) })}</p> | ||
| 169 | + {/if} | ||
| 64 | {#each conversation.permissions ?? [] as permission (permission.id)} | 170 | {#each conversation.permissions ?? [] as permission (permission.id)} |
| 65 | - <PermissionCard {permission} agentTitle={agent.title} /> | 171 | + <PermissionCard {permission} /> |
| 66 | {/each} | 172 | {/each} |
| 67 | </div> | 173 | </div> |
| 68 | {#if failures >= stuckAfter} | 174 | {#if failures >= stuckAfter} |
| 69 | <div class="stuck" role="alert"> | 175 | <div class="stuck" role="alert"> |
| 70 | - <span>{t('conversation.stuck', { agent: agent.title, count: failures })}</span> | 176 | + <span>{t('conversation.stuck', { agent: stuckAgent, count: failures })}</span> |
| 71 | - <button onclick={() => cancel(agent.name)}>{t('conversation.stop', { agent: agent.title })}</button> | 177 | + <button onclick={() => cancel(channel.id)}>{t('conversation.stop', { agent: stuckAgent })}</button> |
| 72 | </div> | 178 | </div> |
| 73 | {/if} | 179 | {/if} |
| 74 | {#if appState.actionError} | 180 | {#if appState.actionError} |
| 75 | <p class="action-error" role="alert">{appState.actionError}</p> | 181 | <p class="action-error" role="alert">{appState.actionError}</p> |
| 76 | {/if} | 182 | {/if} |
| 77 | <div class="composer"> | 183 | <div class="composer"> |
| 78 | - <Composer | 184 | + {#key channel.id} |
| 79 | - placeholder={t('conversation.placeholder', { agent: agent.title })} | 185 | + <Composer |
| 80 | - busy={conversation.busy} | 186 | + placeholder={channel.direct ? t('conversation.placeholder', { agent: agentTitle(channel.direct) }) : t('conversation.channelPlaceholder', { channel: channel.name })} |
| 81 | - onSend={(text) => send(agent.name, text)} | 187 | + busy={conversation.busy} |
| 82 | - onStop={() => cancel(agent.name)} | 188 | + triggers={['@', '/']} |
| 83 | - /> | 189 | + {suggest} |
| 190 | + {onTrigger} | ||
| 191 | + onSend={(text) => send(channel.id, text)} | ||
| 192 | + onStop={() => cancel(channel.id)} | ||
| 193 | + /> | ||
| 194 | + {/key} | ||
| 84 | </div> | 195 | </div> |
| 85 | {:else} | 196 | {:else} |
| 86 | <div class="welcome"> | 197 | <div class="welcome"> |
| @@ -97,11 +208,19 @@ | |||
| 97 | <style> | 208 | <style> |
| 98 | .conversation { height: 100%; display: flex; flex-direction: column; background: var(--bg); } | 209 | .conversation { height: 100%; display: flex; flex-direction: column; background: var(--bg); } |
| 99 | .banner { margin: 12px; padding: 10px 14px; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); } | 210 | .banner { margin: 12px; padding: 10px 14px; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); } |
| 100 | - header { display: flex; align-items: baseline; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--border); } | 211 | + .banner.warning { border-color: var(--warn); color: var(--warn); } |
| 212 | + header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 10px 18px; border-bottom: 1px solid var(--border); } | ||
| 101 | .title { font-weight: 600; font-size: 15px; } | 213 | .title { font-weight: 600; font-size: 15px; } |
| 102 | - .busy { color: var(--warn); font-size: 12px; font-style: italic; } | 214 | + .members { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } |
| 215 | + .member { font-size: 12px; border: 1px solid var(--border); border-radius: 10px; padding: 0 2px 0 8px; display: inline-flex; align-items: center; gap: 2px; } | ||
| 216 | + .member button { border: none; background: none; padding: 0 5px; color: var(--muted); font-size: 12px; } | ||
| 217 | + .member button:hover { color: var(--danger); } | ||
| 218 | + select { font: inherit; font-size: 12px; border: 1px dashed var(--border); border-radius: 10px; padding: 0 6px; background: var(--bg); color: var(--muted); } | ||
| 219 | + .busy { color: var(--warn); font-size: 12px; font-style: italic; display: inline-flex; align-items: center; gap: 6px; } | ||
| 220 | + .pending { color: var(--muted); font-size: 13px; font-style: italic; display: flex; align-items: center; gap: 8px; margin: 0; } | ||
| 103 | .usage { margin-left: auto; color: var(--muted); font-size: 12px; border: 1px solid var(--border); border-radius: 10px; padding: 1px 10px; } | 221 | .usage { margin-left: auto; color: var(--muted); font-size: 12px; border: 1px solid var(--border); border-radius: 10px; padding: 1px 10px; } |
| 104 | .messages { flex: 1; overflow-y: auto; padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; } | 222 | .messages { flex: 1; overflow-y: auto; padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; } |
| 223 | + .older { align-self: center; font-size: 12px; } | ||
| 105 | .empty { color: var(--muted); margin: auto; text-align: center; max-width: 420px; } | 224 | .empty { color: var(--muted); margin: auto; text-align: center; max-width: 420px; } |
| 106 | .stuck { display: flex; align-items: center; gap: 10px; margin: 0 18px 8px; padding: 8px 12px; border: 1px solid var(--warn); border-radius: var(--radius); color: var(--warn); font-size: 13px; } | 225 | .stuck { display: flex; align-items: center; gap: 10px; margin: 0 18px 8px; padding: 8px 12px; border: 1px solid var(--warn); border-radius: var(--radius); color: var(--warn); font-size: 13px; } |
| 107 | .stuck button { margin-left: auto; color: var(--danger); border-color: var(--danger); } | 226 | .stuck button { margin-left: auto; color: var(--danger); border-color: var(--danger); } |
modified
frontend/src/lib/state.svelte.ts +183 -34 | @@ -1,14 +1,18 @@ | ||
| 1 | 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE |
| 2 | 2 | |
| 3 | -// The state shared by the panels: the configuration, the agents and the | |
| 4 | -// conversations, loaded from the Go services and kept up to date by the Go | |
| 5 | -// events. | |
| 3 | +// The state shared by the panels: the configuration, the agents, the | |
| 4 | +// channels and their conversations, loaded from the Go services and kept up | |
| 5 | +// to date by the Go events. | |
| 6 | 6 | |
| 7 | -import { AgentService, ChatService, ConfigService } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' | |
| 7 | +import { AgentService, ChannelService, ConfigService } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' | |
| 8 | 8 | import type { ConfigView } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' |
| 9 | 9 | import type { Status } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/agents' |
| 10 | +import type { Command } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/acp' | |
| 10 | 11 | import type { AgentLogPayload, AgentStatePayload, ConfigChangedPayload } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/events' |
| 11 | 12 | import type { |
| 13 | + Channel, | |
| 14 | + ListPayload, | |
| 15 | + CommandsPayload, | |
| 12 | 16 | Conversation, |
| 13 | 17 | DeltaPayload, |
| 14 | 18 | MessagePayload, |
| @@ -16,10 +20,12 @@ import type { | ||
| 16 | 20 | PermissionResolvedPayload, |
| 17 | 21 | TurnPayload, |
| 18 | 22 | UsagePayload, |
| 19 | -} from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | |
| 23 | +} from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' | |
| 20 | 24 | import { |
| 21 | 25 | AgentLog, |
| 22 | 26 | AgentState, |
| 27 | + ChannelsChanged, | |
| 28 | + CommandsChanged, | |
| 23 | 29 | ConfigChanged, |
| 24 | 30 | MessageDelta, |
| 25 | 31 | MessageUpdated, |
| @@ -28,7 +34,7 @@ import { | ||
| 28 | 34 | TurnChanged, |
| 29 | 35 | UsageChanged, |
| 30 | 36 | } from '../../bindings/events' |
| 31 | -import { addPermission, applyDelta, applyMessage, emptyConversation, removePermission } from './conversation' | |
| 37 | +import { addPermission, applyDelta, applyMessage, emptyConversation, prependMessages, removePermission } from './conversation' | |
| 32 | 38 | import { createFrameBatcher, subscribe } from './events' |
| 33 | 39 | import { applyTheme } from './theme' |
| 34 | 40 | import { setLanguage } from './i18n/index.svelte' |
| @@ -40,14 +46,29 @@ export const appState = $state({ | ||
| 40 | 46 | agents: [] as Status[], |
| 41 | 47 | /** The last stderr lines of each agent, by agent name. */ |
| 42 | 48 | logs: {} as Record<string, string[]>, |
| 43 | - /** The conversation with each agent, by agent name. */ | |
| 49 | + /** Every channel and direct conversation, oldest first. */ | |
| 50 | + channels: [] as Channel[], | |
| 51 | + /** The conversation of each channel opened so far, by channel id. */ | |
| 44 | 52 | conversations: {} as Record<string, Conversation>, |
| 45 | - /** The agent whose conversation is shown in the centre. */ | |
| 46 | - selectedAgent: '', | |
| 53 | + /** Answers finished in channels other than the one shown, by channel id. */ | |
| 54 | + unread: {} as Record<string, number>, | |
| 55 | + /** The slash commands of each agent, by agent name. */ | |
| 56 | + commands: {} as Record<string, Command[]>, | |
| 57 | + /** Channels whose members are being asked for their commands. */ | |
| 58 | + preparing: {} as Record<string, boolean>, | |
| 59 | + /** Why asking a channel's members for their commands failed. */ | |
| 60 | + prepareError: {} as Record<string, string>, | |
| 61 | + /** The channel shown in the centre. */ | |
| 62 | + selectedChannel: '', | |
| 47 | 63 | /** The last error returned by an action, shown to the user. */ |
| 48 | 64 | actionError: '', |
| 49 | 65 | }) |
| 50 | 66 | |
| 67 | +// The channels whose conversation was loaded from Go: from then on, the | |
| 68 | +// events keep it complete. Loading it again while an answer streams would | |
| 69 | +// apply fragments twice. | |
| 70 | +const loaded = new Set<string>() | |
| 71 | + | |
| 51 | 72 | async function refreshConfig() { |
| 52 | 73 | appState.config = await ConfigService.Current() |
| 53 | 74 | applyTheme(document.documentElement, appState.config.theme) |
| @@ -56,14 +77,36 @@ async function refreshConfig() { | ||
| 56 | 77 | |
| 57 | 78 | async function refreshAgents() { |
| 58 | 79 | appState.agents = (await AgentService.List()) ?? [] |
| 59 | - if (appState.selectedAgent && !appState.agents.some((agent) => agent.name === appState.selectedAgent)) { | |
| 60 | - appState.selectedAgent = '' | |
| 80 | +} | |
| 81 | + | |
| 82 | +async function refreshChannels() { | |
| 83 | + setChannels((await ChannelService.List()) ?? []) | |
| 84 | +} | |
| 85 | + | |
| 86 | +function setChannels(channels: Channel[]) { | |
| 87 | + appState.channels = channels | |
| 88 | + for (const channel of channels) { | |
| 89 | + const conversation = appState.conversations[channel.id] | |
| 90 | + if (conversation) conversation.channel = channel | |
| 91 | + } | |
| 92 | + if (appState.selectedChannel && !channels.some((channel) => channel.id === appState.selectedChannel)) { | |
| 93 | + appState.selectedChannel = '' | |
| 61 | 94 | } |
| 62 | 95 | } |
| 63 | 96 | |
| 64 | -/** The conversation with agent, created empty when it does not exist yet. */ | |
| 65 | -export function conversationOf(agent: string): Conversation { | |
| 66 | - return (appState.conversations[agent] ??= emptyConversation(agent)) | |
| 97 | +/** The channel with id, if it exists. */ | |
| 98 | +export function channelOf(id: string): Channel | undefined { | |
| 99 | + return appState.channels.find((channel) => channel.id === id) | |
| 100 | +} | |
| 101 | + | |
| 102 | +/** The conversation of channel id, created empty when it does not exist yet. */ | |
| 103 | +export function conversationOf(id: string): Conversation { | |
| 104 | + return (appState.conversations[id] ??= emptyConversation(channelOf(id) ?? { id, name: '', description: '', direct: '', members: [] })) | |
| 105 | +} | |
| 106 | + | |
| 107 | +/** The title of an agent, or its name when it is no longer configured. */ | |
| 108 | +export function agentTitle(name: string): string { | |
| 109 | + return appState.agents.find((agent) => agent.name === name)?.title || name | |
| 67 | 110 | } |
| 68 | 111 | |
| 69 | 112 | /** Runs an action and shows its error instead of throwing. */ |
| @@ -76,32 +119,121 @@ export async function act(action: () => Promise<unknown>) { | ||
| 76 | 119 | } |
| 77 | 120 | } |
| 78 | 121 | |
| 122 | +/** Shows a channel in the centre, loading its conversation the first time. */ | |
| 123 | +export async function selectChannel(id: string) { | |
| 124 | + conversationOf(id) // shown empty until loaded | |
| 125 | + appState.selectedChannel = id | |
| 126 | + appState.unread[id] = 0 | |
| 127 | + const channel = channelOf(id) | |
| 128 | + for (const member of channel?.members ?? []) void loadCommands(member) | |
| 129 | + if (loaded.has(id)) return | |
| 130 | + loaded.add(id) | |
| 131 | + await act(async () => { | |
| 132 | + const conversation = await ChannelService.Conversation(id) | |
| 133 | + if (conversation) appState.conversations[id] = conversation | |
| 134 | + }) | |
| 135 | +} | |
| 136 | + | |
| 137 | +/** Opens the direct conversation with agent, created the first time. */ | |
| 138 | +export function openDirect(agent: string) { | |
| 139 | + return act(async () => { | |
| 140 | + const channel = await ChannelService.Direct(agent) | |
| 141 | + if (!channel) return | |
| 142 | + if (!channelOf(channel.id)) appState.channels = [...appState.channels, channel] | |
| 143 | + await selectChannel(channel.id) | |
| 144 | + }) | |
| 145 | +} | |
| 146 | + | |
| 147 | +// Channels whose members were already asked for their commands. | |
| 148 | +const prepared = new Set<string>() | |
| 149 | + | |
| 79 | 150 | /** |
| 80 | - * Shows the conversation with agent in the centre. The interface follows the | |
| 81 | - * events of every conversation from the start, so its copy is complete: | |
| 82 | - * reloading it from Go while an answer streams would apply fragments twice. | |
| 151 | + * Asks the members of a channel that never announced their commands for | |
| 152 | + * them, once: the agents start and open their session, without loading a | |
| 153 | + * model. Called when the user types "/". | |
| 83 | 154 | */ |
| 84 | -export function selectAgent(agent: string) { | |
| 85 | - appState.selectedAgent = agent | |
| 86 | - conversationOf(agent) | |
| 155 | +export async function prepareCommands(id: string) { | |
| 156 | + const channel = channelOf(id) | |
| 157 | + if (prepared.has(id) || !channel?.members?.some((member) => !appState.commands[member]?.length)) return | |
| 158 | + prepared.add(id) | |
| 159 | + appState.preparing[id] = true | |
| 160 | + appState.prepareError[id] = '' | |
| 161 | + try { | |
| 162 | + await ChannelService.Prepare(id) | |
| 163 | + } catch (error) { | |
| 164 | + appState.prepareError[id] = error instanceof Error ? error.message : String(error) | |
| 165 | + prepared.delete(id) // the next "/" tries again | |
| 166 | + } finally { | |
| 167 | + appState.preparing[id] = false | |
| 168 | + } | |
| 87 | 169 | } |
| 88 | 170 | |
| 89 | -export function send(agent: string, text: string) { | |
| 90 | - return act(() => ChatService.Send(agent, text)) | |
| 171 | +async function loadCommands(agent: string) { | |
| 172 | + if (appState.commands[agent]) return | |
| 173 | + appState.commands[agent] = (await ChannelService.Commands(agent)) ?? [] | |
| 91 | 174 | } |
| 92 | 175 | |
| 93 | -export function cancel(agent: string) { | |
| 94 | - return act(() => ChatService.Cancel(agent)) | |
| 176 | +/** Loads the messages before the first one shown. */ | |
| 177 | +export function loadOlder(id: string) { | |
| 178 | + return act(async () => { | |
| 179 | + const conversation = conversationOf(id) | |
| 180 | + const first = conversation.messages?.[0] | |
| 181 | + if (!first) return | |
| 182 | + const page = await ChannelService.Older(id, first.id) | |
| 183 | + prependMessages(conversation, page?.messages ?? [], page?.more ?? false) | |
| 184 | + }) | |
| 185 | +} | |
| 186 | + | |
| 187 | +export function send(channel: string, text: string) { | |
| 188 | + return act(() => ChannelService.Send(channel, text)) | |
| 189 | +} | |
| 190 | + | |
| 191 | +export function cancel(channel: string) { | |
| 192 | + return act(() => ChannelService.Cancel(channel)) | |
| 95 | 193 | } |
| 96 | 194 | |
| 97 | 195 | export function answerPermission(permission: Permission, optionId: string) { |
| 98 | - return act(() => ChatService.AnswerPermission(permission.id, optionId)) | |
| 196 | + return act(() => ChannelService.AnswerPermission(permission.id, optionId)) | |
| 197 | +} | |
| 198 | + | |
| 199 | +export function createChannel(name: string) { | |
| 200 | + return act(async () => { | |
| 201 | + const channel = await ChannelService.Create(name, '') | |
| 202 | + if (channel) { | |
| 203 | + if (!channelOf(channel.id)) appState.channels = [...appState.channels, channel] | |
| 204 | + await selectChannel(channel.id) | |
| 205 | + } | |
| 206 | + }) | |
| 207 | +} | |
| 208 | + | |
| 209 | +export function renameChannel(id: string, name: string, description: string) { | |
| 210 | + return act(() => ChannelService.Rename(id, name, description)) | |
| 211 | +} | |
| 212 | + | |
| 213 | +export function deleteChannel(id: string) { | |
| 214 | + return act(async () => { | |
| 215 | + await ChannelService.Delete(id) | |
| 216 | + loaded.delete(id) | |
| 217 | + delete appState.conversations[id] | |
| 218 | + }) | |
| 219 | +} | |
| 220 | + | |
| 221 | +export function addMember(id: string, agent: string) { | |
| 222 | + return act(async () => { | |
| 223 | + await ChannelService.AddMember(id, agent) | |
| 224 | + void loadCommands(agent) | |
| 225 | + }) | |
| 226 | +} | |
| 227 | + | |
| 228 | +export function removeMember(id: string, agent: string) { | |
| 229 | + return act(() => ChannelService.RemoveMember(id, agent)) | |
| 99 | 230 | } |
| 100 | 231 | |
| 101 | 232 | /** Loads the state and follows the Go events; returns the clean-up function. */ |
| 102 | 233 | export function connect(): () => void { |
| 103 | 234 | void refreshConfig() |
| 104 | 235 | void refreshAgents() |
| 236 | + void refreshChannels() | |
| 105 | 237 | |
| 106 | 238 | const logBatcher = createFrameBatcher<AgentLogPayload>((payloads) => { |
| 107 | 239 | for (const { agent, line } of payloads) { |
| @@ -115,19 +247,31 @@ export function connect(): () => void { | ||
| 115 | 247 | type MessageEvent = { message: MessagePayload } | { delta: DeltaPayload } |
| 116 | 248 | const messageBatcher = createFrameBatcher<MessageEvent>((items) => { |
| 117 | 249 | for (const item of items) { |
| 118 | - if ('message' in item) applyMessage(conversationOf(item.message.agent), item.message.message) | |
| 119 | - else applyDelta(conversationOf(item.delta.agent), item.delta) | |
| 250 | + if ('message' in item) { | |
| 251 | + const { channel, message } = item.message | |
| 252 | + applyMessage(conversationOf(channel), message) | |
| 253 | + if (message.role === 'agent' && message.done && channel !== appState.selectedChannel) { | |
| 254 | + appState.unread[channel] = (appState.unread[channel] ?? 0) + 1 | |
| 255 | + } | |
| 256 | + } else applyDelta(conversationOf(item.delta.channel), item.delta) | |
| 120 | 257 | } |
| 121 | 258 | }) |
| 122 | 259 | |
| 123 | 260 | const stops = [ |
| 124 | 261 | subscribe<ConfigChangedPayload>(ConfigChanged, (payload) => { |
| 125 | - // Another file was opened: the Go side forgot the conversations too. | |
| 126 | - // A mere reload of the same file keeps them. | |
| 127 | - if (appState.config && payload.path !== appState.config.path) appState.conversations = {} | |
| 262 | + // Another file was opened: it has its own history. | |
| 263 | + if (appState.config && payload.path !== appState.config.path) { | |
| 264 | + appState.conversations = {} | |
| 265 | + appState.unread = {} | |
| 266 | + appState.selectedChannel = '' | |
| 267 | + loaded.clear() | |
| 268 | + prepared.clear() | |
| 269 | + } | |
| 128 | 270 | void refreshConfig() |
| 129 | 271 | void refreshAgents() |
| 272 | + void refreshChannels() | |
| 130 | 273 | }), |
| 274 | + subscribe<ListPayload>(ChannelsChanged, (payload) => setChannels(payload.channels ?? [])), | |
| 131 | 275 | subscribe<AgentStatePayload>(AgentState, (payload) => { |
| 132 | 276 | const agent = appState.agents.find((candidate) => candidate.name === payload.agent) |
| 133 | 277 | if (!agent) { |
| @@ -141,14 +285,19 @@ export function connect(): () => void { | ||
| 141 | 285 | subscribe<MessagePayload>(MessageUpdated, (payload) => messageBatcher.push({ message: payload })), |
| 142 | 286 | subscribe<DeltaPayload>(MessageDelta, (payload) => messageBatcher.push({ delta: payload })), |
| 143 | 287 | subscribe<TurnPayload>(TurnChanged, (payload) => { |
| 144 | - const conversation = conversationOf(payload.agent) | |
| 288 | + const conversation = conversationOf(payload.channel) | |
| 145 | 289 | conversation.busy = payload.busy |
| 146 | 290 | conversation.queued = payload.queued |
| 291 | + conversation.answering = payload.answering | |
| 147 | 292 | }), |
| 148 | - subscribe<Permission>(PermissionRequested, (payload) => addPermission(conversationOf(payload.agent), payload)), | |
| 149 | - subscribe<PermissionResolvedPayload>(PermissionResolved, (payload) => removePermission(conversationOf(payload.agent), payload.id)), | |
| 293 | + subscribe<Permission>(PermissionRequested, (payload) => addPermission(conversationOf(payload.channel), payload)), | |
| 294 | + subscribe<PermissionResolvedPayload>(PermissionResolved, (payload) => removePermission(conversationOf(payload.channel), payload.id)), | |
| 150 | 295 | subscribe<UsagePayload>(UsageChanged, (payload) => { |
| 151 | - conversationOf(payload.agent).usage = payload.usage | |
| 296 | + const conversation = conversationOf(payload.channel) | |
| 297 | + conversation.usage = { ...(conversation.usage ?? {}), [payload.agent]: payload.usage } | |
| 298 | + }), | |
| 299 | + subscribe<CommandsPayload>(CommandsChanged, (payload) => { | |
| 300 | + appState.commands[payload.agent] = payload.commands ?? [] | |
| 152 | 301 | }), |
| 153 | 302 | ] |
| 154 | 303 | return () => stops.forEach((stop) => stop()) |
| @@ -1,14 +1,18 @@ | |||
| 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE |
| 2 | 2 | ||
| 3 | -// The state shared by the panels: the configuration, the agents and the | 3 | +// The state shared by the panels: the configuration, the agents, the |
| 4 | -// conversations, loaded from the Go services and kept up to date by the Go | 4 | +// channels and their conversations, loaded from the Go services and kept up |
| 5 | -// events. | 5 | +// to date by the Go events. |
| 6 | 6 | ||
| 7 | -import { AgentService, ChatService, ConfigService } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' | 7 | +import { AgentService, ChannelService, ConfigService } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' |
| 8 | import type { ConfigView } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' | 8 | import type { ConfigView } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' |
| 9 | import type { Status } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/agents' | 9 | import type { Status } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/agents' |
| 10 | +import type { Command } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/acp' | ||
| 10 | import type { AgentLogPayload, AgentStatePayload, ConfigChangedPayload } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/events' | 11 | import type { AgentLogPayload, AgentStatePayload, ConfigChangedPayload } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/events' |
| 11 | import type { | 12 | import type { |
| 13 | + Channel, | ||
| 14 | + ListPayload, | ||
| 15 | + CommandsPayload, | ||
| 12 | Conversation, | 16 | Conversation, |
| 13 | DeltaPayload, | 17 | DeltaPayload, |
| 14 | MessagePayload, | 18 | MessagePayload, |
| @@ -16,10 +20,12 @@ import type { | |||
| 16 | PermissionResolvedPayload, | 20 | PermissionResolvedPayload, |
| 17 | TurnPayload, | 21 | TurnPayload, |
| 18 | UsagePayload, | 22 | UsagePayload, |
| 19 | -} from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations' | 23 | +} from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels' |
| 20 | import { | 24 | import { |
| 21 | AgentLog, | 25 | AgentLog, |
| 22 | AgentState, | 26 | AgentState, |
| 27 | + ChannelsChanged, | ||
| 28 | + CommandsChanged, | ||
| 23 | ConfigChanged, | 29 | ConfigChanged, |
| 24 | MessageDelta, | 30 | MessageDelta, |
| 25 | MessageUpdated, | 31 | MessageUpdated, |
| @@ -28,7 +34,7 @@ import { | |||
| 28 | TurnChanged, | 34 | TurnChanged, |
| 29 | UsageChanged, | 35 | UsageChanged, |
| 30 | } from '../../bindings/events' | 36 | } from '../../bindings/events' |
| 31 | -import { addPermission, applyDelta, applyMessage, emptyConversation, removePermission } from './conversation' | 37 | +import { addPermission, applyDelta, applyMessage, emptyConversation, prependMessages, removePermission } from './conversation' |
| 32 | import { createFrameBatcher, subscribe } from './events' | 38 | import { createFrameBatcher, subscribe } from './events' |
| 33 | import { applyTheme } from './theme' | 39 | import { applyTheme } from './theme' |
| 34 | import { setLanguage } from './i18n/index.svelte' | 40 | import { setLanguage } from './i18n/index.svelte' |
| @@ -40,14 +46,29 @@ export const appState = $state({ | |||
| 40 | agents: [] as Status[], | 46 | agents: [] as Status[], |
| 41 | /** The last stderr lines of each agent, by agent name. */ | 47 | /** The last stderr lines of each agent, by agent name. */ |
| 42 | logs: {} as Record<string, string[]>, | 48 | logs: {} as Record<string, string[]>, |
| 43 | - /** The conversation with each agent, by agent name. */ | 49 | + /** Every channel and direct conversation, oldest first. */ |
| 50 | + channels: [] as Channel[], | ||
| 51 | + /** The conversation of each channel opened so far, by channel id. */ | ||
| 44 | conversations: {} as Record<string, Conversation>, | 52 | conversations: {} as Record<string, Conversation>, |
| 45 | - /** The agent whose conversation is shown in the centre. */ | 53 | + /** Answers finished in channels other than the one shown, by channel id. */ |
| 46 | - selectedAgent: '', | 54 | + unread: {} as Record<string, number>, |
| 55 | + /** The slash commands of each agent, by agent name. */ | ||
| 56 | + commands: {} as Record<string, Command[]>, | ||
| 57 | + /** Channels whose members are being asked for their commands. */ | ||
| 58 | + preparing: {} as Record<string, boolean>, | ||
| 59 | + /** Why asking a channel's members for their commands failed. */ | ||
| 60 | + prepareError: {} as Record<string, string>, | ||
| 61 | + /** The channel shown in the centre. */ | ||
| 62 | + selectedChannel: '', | ||
| 47 | /** The last error returned by an action, shown to the user. */ | 63 | /** The last error returned by an action, shown to the user. */ |
| 48 | actionError: '', | 64 | actionError: '', |
| 49 | }) | 65 | }) |
| 50 | 66 | ||
| 67 | +// The channels whose conversation was loaded from Go: from then on, the | ||
| 68 | +// events keep it complete. Loading it again while an answer streams would | ||
| 69 | +// apply fragments twice. | ||
| 70 | +const loaded = new Set<string>() | ||
| 71 | + | ||
| 51 | async function refreshConfig() { | 72 | async function refreshConfig() { |
| 52 | appState.config = await ConfigService.Current() | 73 | appState.config = await ConfigService.Current() |
| 53 | applyTheme(document.documentElement, appState.config.theme) | 74 | applyTheme(document.documentElement, appState.config.theme) |
| @@ -56,14 +77,36 @@ async function refreshConfig() { | |||
| 56 | 77 | ||
| 57 | async function refreshAgents() { | 78 | async function refreshAgents() { |
| 58 | appState.agents = (await AgentService.List()) ?? [] | 79 | appState.agents = (await AgentService.List()) ?? [] |
| 59 | - if (appState.selectedAgent && !appState.agents.some((agent) => agent.name === appState.selectedAgent)) { | 80 | +} |
| 60 | - appState.selectedAgent = '' | 81 | + |
| 82 | +async function refreshChannels() { | ||
| 83 | + setChannels((await ChannelService.List()) ?? []) | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +function setChannels(channels: Channel[]) { | ||
| 87 | + appState.channels = channels | ||
| 88 | + for (const channel of channels) { | ||
| 89 | + const conversation = appState.conversations[channel.id] | ||
| 90 | + if (conversation) conversation.channel = channel | ||
| 91 | + } | ||
| 92 | + if (appState.selectedChannel && !channels.some((channel) => channel.id === appState.selectedChannel)) { | ||
| 93 | + appState.selectedChannel = '' | ||
| 61 | } | 94 | } |
| 62 | } | 95 | } |
| 63 | 96 | ||
| 64 | -/** The conversation with agent, created empty when it does not exist yet. */ | 97 | +/** The channel with id, if it exists. */ |
| 65 | -export function conversationOf(agent: string): Conversation { | 98 | +export function channelOf(id: string): Channel | undefined { |
| 66 | - return (appState.conversations[agent] ??= emptyConversation(agent)) | 99 | + return appState.channels.find((channel) => channel.id === id) |
| 100 | +} | ||
| 101 | + | ||
| 102 | +/** The conversation of channel id, created empty when it does not exist yet. */ | ||
| 103 | +export function conversationOf(id: string): Conversation { | ||
| 104 | + return (appState.conversations[id] ??= emptyConversation(channelOf(id) ?? { id, name: '', description: '', direct: '', members: [] })) | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +/** The title of an agent, or its name when it is no longer configured. */ | ||
| 108 | +export function agentTitle(name: string): string { | ||
| 109 | + return appState.agents.find((agent) => agent.name === name)?.title || name | ||
| 67 | } | 110 | } |
| 68 | 111 | ||
| 69 | /** Runs an action and shows its error instead of throwing. */ | 112 | /** Runs an action and shows its error instead of throwing. */ |
| @@ -76,32 +119,121 @@ export async function act(action: () => Promise<unknown>) { | |||
| 76 | } | 119 | } |
| 77 | } | 120 | } |
| 78 | 121 | ||
| 122 | +/** Shows a channel in the centre, loading its conversation the first time. */ | ||
| 123 | +export async function selectChannel(id: string) { | ||
| 124 | + conversationOf(id) // shown empty until loaded | ||
| 125 | + appState.selectedChannel = id | ||
| 126 | + appState.unread[id] = 0 | ||
| 127 | + const channel = channelOf(id) | ||
| 128 | + for (const member of channel?.members ?? []) void loadCommands(member) | ||
| 129 | + if (loaded.has(id)) return | ||
| 130 | + loaded.add(id) | ||
| 131 | + await act(async () => { | ||
| 132 | + const conversation = await ChannelService.Conversation(id) | ||
| 133 | + if (conversation) appState.conversations[id] = conversation | ||
| 134 | + }) | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +/** Opens the direct conversation with agent, created the first time. */ | ||
| 138 | +export function openDirect(agent: string) { | ||
| 139 | + return act(async () => { | ||
| 140 | + const channel = await ChannelService.Direct(agent) | ||
| 141 | + if (!channel) return | ||
| 142 | + if (!channelOf(channel.id)) appState.channels = [...appState.channels, channel] | ||
| 143 | + await selectChannel(channel.id) | ||
| 144 | + }) | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +// Channels whose members were already asked for their commands. | ||
| 148 | +const prepared = new Set<string>() | ||
| 149 | + | ||
| 79 | /** | 150 | /** |
| 80 | - * Shows the conversation with agent in the centre. The interface follows the | 151 | + * Asks the members of a channel that never announced their commands for |
| 81 | - * events of every conversation from the start, so its copy is complete: | 152 | + * them, once: the agents start and open their session, without loading a |
| 82 | - * reloading it from Go while an answer streams would apply fragments twice. | 153 | + * model. Called when the user types "/". |
| 83 | */ | 154 | */ |
| 84 | -export function selectAgent(agent: string) { | 155 | +export async function prepareCommands(id: string) { |
| 85 | - appState.selectedAgent = agent | 156 | + const channel = channelOf(id) |
| 86 | - conversationOf(agent) | 157 | + if (prepared.has(id) || !channel?.members?.some((member) => !appState.commands[member]?.length)) return |
| 158 | + prepared.add(id) | ||
| 159 | + appState.preparing[id] = true | ||
| 160 | + appState.prepareError[id] = '' | ||
| 161 | + try { | ||
| 162 | + await ChannelService.Prepare(id) | ||
| 163 | + } catch (error) { | ||
| 164 | + appState.prepareError[id] = error instanceof Error ? error.message : String(error) | ||
| 165 | + prepared.delete(id) // the next "/" tries again | ||
| 166 | + } finally { | ||
| 167 | + appState.preparing[id] = false | ||
| 168 | + } | ||
| 87 | } | 169 | } |
| 88 | 170 | ||
| 89 | -export function send(agent: string, text: string) { | 171 | +async function loadCommands(agent: string) { |
| 90 | - return act(() => ChatService.Send(agent, text)) | 172 | + if (appState.commands[agent]) return |
| 173 | + appState.commands[agent] = (await ChannelService.Commands(agent)) ?? [] | ||
| 91 | } | 174 | } |
| 92 | 175 | ||
| 93 | -export function cancel(agent: string) { | 176 | +/** Loads the messages before the first one shown. */ |
| 94 | - return act(() => ChatService.Cancel(agent)) | 177 | +export function loadOlder(id: string) { |
| 178 | + return act(async () => { | ||
| 179 | + const conversation = conversationOf(id) | ||
| 180 | + const first = conversation.messages?.[0] | ||
| 181 | + if (!first) return | ||
| 182 | + const page = await ChannelService.Older(id, first.id) | ||
| 183 | + prependMessages(conversation, page?.messages ?? [], page?.more ?? false) | ||
| 184 | + }) | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +export function send(channel: string, text: string) { | ||
| 188 | + return act(() => ChannelService.Send(channel, text)) | ||
| 189 | +} | ||
| 190 | + | ||
| 191 | +export function cancel(channel: string) { | ||
| 192 | + return act(() => ChannelService.Cancel(channel)) | ||
| 95 | } | 193 | } |
| 96 | 194 | ||
| 97 | export function answerPermission(permission: Permission, optionId: string) { | 195 | export function answerPermission(permission: Permission, optionId: string) { |
| 98 | - return act(() => ChatService.AnswerPermission(permission.id, optionId)) | 196 | + return act(() => ChannelService.AnswerPermission(permission.id, optionId)) |
| 197 | +} | ||
| 198 | + | ||
| 199 | +export function createChannel(name: string) { | ||
| 200 | + return act(async () => { | ||
| 201 | + const channel = await ChannelService.Create(name, '') | ||
| 202 | + if (channel) { | ||
| 203 | + if (!channelOf(channel.id)) appState.channels = [...appState.channels, channel] | ||
| 204 | + await selectChannel(channel.id) | ||
| 205 | + } | ||
| 206 | + }) | ||
| 207 | +} | ||
| 208 | + | ||
| 209 | +export function renameChannel(id: string, name: string, description: string) { | ||
| 210 | + return act(() => ChannelService.Rename(id, name, description)) | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +export function deleteChannel(id: string) { | ||
| 214 | + return act(async () => { | ||
| 215 | + await ChannelService.Delete(id) | ||
| 216 | + loaded.delete(id) | ||
| 217 | + delete appState.conversations[id] | ||
| 218 | + }) | ||
| 219 | +} | ||
| 220 | + | ||
| 221 | +export function addMember(id: string, agent: string) { | ||
| 222 | + return act(async () => { | ||
| 223 | + await ChannelService.AddMember(id, agent) | ||
| 224 | + void loadCommands(agent) | ||
| 225 | + }) | ||
| 226 | +} | ||
| 227 | + | ||
| 228 | +export function removeMember(id: string, agent: string) { | ||
| 229 | + return act(() => ChannelService.RemoveMember(id, agent)) | ||
| 99 | } | 230 | } |
| 100 | 231 | ||
| 101 | /** Loads the state and follows the Go events; returns the clean-up function. */ | 232 | /** Loads the state and follows the Go events; returns the clean-up function. */ |
| 102 | export function connect(): () => void { | 233 | export function connect(): () => void { |
| 103 | void refreshConfig() | 234 | void refreshConfig() |
| 104 | void refreshAgents() | 235 | void refreshAgents() |
| 236 | + void refreshChannels() | ||
| 105 | 237 | ||
| 106 | const logBatcher = createFrameBatcher<AgentLogPayload>((payloads) => { | 238 | const logBatcher = createFrameBatcher<AgentLogPayload>((payloads) => { |
| 107 | for (const { agent, line } of payloads) { | 239 | for (const { agent, line } of payloads) { |
| @@ -115,19 +247,31 @@ export function connect(): () => void { | |||
| 115 | type MessageEvent = { message: MessagePayload } | { delta: DeltaPayload } | 247 | type MessageEvent = { message: MessagePayload } | { delta: DeltaPayload } |
| 116 | const messageBatcher = createFrameBatcher<MessageEvent>((items) => { | 248 | const messageBatcher = createFrameBatcher<MessageEvent>((items) => { |
| 117 | for (const item of items) { | 249 | for (const item of items) { |
| 118 | - if ('message' in item) applyMessage(conversationOf(item.message.agent), item.message.message) | 250 | + if ('message' in item) { |
| 119 | - else applyDelta(conversationOf(item.delta.agent), item.delta) | 251 | + const { channel, message } = item.message |
| 252 | + applyMessage(conversationOf(channel), message) | ||
| 253 | + if (message.role === 'agent' && message.done && channel !== appState.selectedChannel) { | ||
| 254 | + appState.unread[channel] = (appState.unread[channel] ?? 0) + 1 | ||
| 255 | + } | ||
| 256 | + } else applyDelta(conversationOf(item.delta.channel), item.delta) | ||
| 120 | } | 257 | } |
| 121 | }) | 258 | }) |
| 122 | 259 | ||
| 123 | const stops = [ | 260 | const stops = [ |
| 124 | subscribe<ConfigChangedPayload>(ConfigChanged, (payload) => { | 261 | subscribe<ConfigChangedPayload>(ConfigChanged, (payload) => { |
| 125 | - // Another file was opened: the Go side forgot the conversations too. | 262 | + // Another file was opened: it has its own history. |
| 126 | - // A mere reload of the same file keeps them. | 263 | + if (appState.config && payload.path !== appState.config.path) { |
| 127 | - if (appState.config && payload.path !== appState.config.path) appState.conversations = {} | 264 | + appState.conversations = {} |
| 265 | + appState.unread = {} | ||
| 266 | + appState.selectedChannel = '' | ||
| 267 | + loaded.clear() | ||
| 268 | + prepared.clear() | ||
| 269 | + } | ||
| 128 | void refreshConfig() | 270 | void refreshConfig() |
| 129 | void refreshAgents() | 271 | void refreshAgents() |
| 272 | + void refreshChannels() | ||
| 130 | }), | 273 | }), |
| 274 | + subscribe<ListPayload>(ChannelsChanged, (payload) => setChannels(payload.channels ?? [])), | ||
| 131 | subscribe<AgentStatePayload>(AgentState, (payload) => { | 275 | subscribe<AgentStatePayload>(AgentState, (payload) => { |
| 132 | const agent = appState.agents.find((candidate) => candidate.name === payload.agent) | 276 | const agent = appState.agents.find((candidate) => candidate.name === payload.agent) |
| 133 | if (!agent) { | 277 | if (!agent) { |
| @@ -141,14 +285,19 @@ export function connect(): () => void { | |||
| 141 | subscribe<MessagePayload>(MessageUpdated, (payload) => messageBatcher.push({ message: payload })), | 285 | subscribe<MessagePayload>(MessageUpdated, (payload) => messageBatcher.push({ message: payload })), |
| 142 | subscribe<DeltaPayload>(MessageDelta, (payload) => messageBatcher.push({ delta: payload })), | 286 | subscribe<DeltaPayload>(MessageDelta, (payload) => messageBatcher.push({ delta: payload })), |
| 143 | subscribe<TurnPayload>(TurnChanged, (payload) => { | 287 | subscribe<TurnPayload>(TurnChanged, (payload) => { |
| 144 | - const conversation = conversationOf(payload.agent) | 288 | + const conversation = conversationOf(payload.channel) |
| 145 | conversation.busy = payload.busy | 289 | conversation.busy = payload.busy |
| 146 | conversation.queued = payload.queued | 290 | conversation.queued = payload.queued |
| 291 | + conversation.answering = payload.answering | ||
| 147 | }), | 292 | }), |
| 148 | - subscribe<Permission>(PermissionRequested, (payload) => addPermission(conversationOf(payload.agent), payload)), | 293 | + subscribe<Permission>(PermissionRequested, (payload) => addPermission(conversationOf(payload.channel), payload)), |
| 149 | - subscribe<PermissionResolvedPayload>(PermissionResolved, (payload) => removePermission(conversationOf(payload.agent), payload.id)), | 294 | + subscribe<PermissionResolvedPayload>(PermissionResolved, (payload) => removePermission(conversationOf(payload.channel), payload.id)), |
| 150 | subscribe<UsagePayload>(UsageChanged, (payload) => { | 295 | subscribe<UsagePayload>(UsageChanged, (payload) => { |
| 151 | - conversationOf(payload.agent).usage = payload.usage | 296 | + const conversation = conversationOf(payload.channel) |
| 297 | + conversation.usage = { ...(conversation.usage ?? {}), [payload.agent]: payload.usage } | ||
| 298 | + }), | ||
| 299 | + subscribe<CommandsPayload>(CommandsChanged, (payload) => { | ||
| 300 | + appState.commands[payload.agent] = payload.commands ?? [] | ||
| 152 | }), | 301 | }), |
| 153 | ] | 302 | ] |
| 154 | return () => stops.forEach((stop) => stop()) | 303 | return () => stops.forEach((stop) => stop()) |
added
frontend/src/lib/suggestions.test.ts +64 -0 | new file mode 100644 | ||
| @@ -0,0 +1,64 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +import { describe, expect, it } from 'vitest' | |
| 4 | +import { commandSuggestions, isCommandPosition, matchScore, mentionSuggestions, splitMentions, type AgentInfo } from './suggestions' | |
| 5 | + | |
| 6 | +const alice: AgentInfo = { name: 'alice', title: 'Alice (assistant)', icon: '👩' } | |
| 7 | +const bob: AgentInfo = { name: 'bob', title: 'Bob (assistant)', icon: '🧔' } | |
| 8 | +const garfield: AgentInfo = { name: 'garfield', title: 'Garfield (translator)', icon: '🐮' } | |
| 9 | +const commands = { | |
| 10 | + alice: [{ name: 'compact', description: 'Compress the history' }], | |
| 11 | + garfield: [ | |
| 12 | + { name: 'idioms', description: 'Translate idioms', hint: 'text' }, | |
| 13 | + { name: 'compact', description: 'Compress the history' }, | |
| 14 | + ], | |
| 15 | +} | |
| 16 | + | |
| 17 | +describe('matching', () => { | |
| 18 | + it('prefers a prefix, then a word, then letters in order', () => { | |
| 19 | + expect(matchScore('gar', 'garfield')).toBe(0) | |
| 20 | + expect(matchScore('tra', 'Garfield (translator)')).toBe(1) | |
| 21 | + expect(matchScore('gfd', 'garfield')).toBe(2) | |
| 22 | + expect(matchScore('xyz', 'garfield')).toBeNull() | |
| 23 | + expect(matchScore('', 'anything')).toBe(0) | |
| 24 | + }) | |
| 25 | +}) | |
| 26 | + | |
| 27 | +describe('mentions', () => { | |
| 28 | + it('filters the agents on their name and title', () => { | |
| 29 | + expect(mentionSuggestions('gar', [alice, bob, garfield]).map((s) => s.value)).toEqual(['@garfield']) | |
| 30 | + expect(mentionSuggestions('assist', [alice, bob, garfield]).map((s) => s.value)).toEqual(['@alice', '@bob']) | |
| 31 | + expect(mentionSuggestions('', [alice, bob]).map((s) => s.label)).toEqual(['👩 Alice (assistant)', '🧔 Bob (assistant)']) | |
| 32 | + }) | |
| 33 | + | |
| 34 | + it('cuts a message into text and mentions of known agents', () => { | |
| 35 | + const known = (name: string) => name === 'alice' || name === 'bob' | |
| 36 | + expect(splitMentions('@alice ask @bob, not @nobody or me@alice.fr', known)).toEqual([ | |
| 37 | + { text: '@alice', agent: 'alice' }, | |
| 38 | + { text: ' ask ' }, | |
| 39 | + { text: '@bob', agent: 'bob' }, | |
| 40 | + { text: ', not @nobody or me@alice.fr' }, | |
| 41 | + ]) | |
| 42 | + expect(splitMentions('no mention', known)).toEqual([{ text: 'no mention' }]) | |
| 43 | + }) | |
| 44 | +}) | |
| 45 | + | |
| 46 | +describe('commands', () => { | |
| 47 | + it('offers /command with one agent', () => { | |
| 48 | + const found = commandSuggestions('id', [garfield], commands) | |
| 49 | + expect(found).toEqual([{ value: '/idioms', label: '/idioms ‹text›', detail: 'Translate idioms' }]) | |
| 50 | + }) | |
| 51 | + | |
| 52 | + it('offers /agent:command with several agents', () => { | |
| 53 | + expect(commandSuggestions('', [alice, garfield], commands).map((s) => s.value)).toEqual(['/alice:compact', '/garfield:idioms', '/garfield:compact']) | |
| 54 | + expect(commandSuggestions('gar', [alice, garfield], commands).map((s) => s.value)).toEqual(['/garfield:idioms', '/garfield:compact']) | |
| 55 | + expect(commandSuggestions('garfield:id', [alice, garfield], commands).map((s) => s.value)).toEqual(['/garfield:idioms']) | |
| 56 | + expect(commandSuggestions('', [bob], commands)).toEqual([]) | |
| 57 | + }) | |
| 58 | + | |
| 59 | + it('knows where a command can start', () => { | |
| 60 | + expect(isCommandPosition('/id', 0)).toBe(true) | |
| 61 | + expect(isCommandPosition('@garfield /id', 10)).toBe(true) | |
| 62 | + expect(isCommandPosition('look at /etc', 8)).toBe(false) | |
| 63 | + }) | |
| 64 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +import { describe, expect, it } from 'vitest' | ||
| 4 | +import { commandSuggestions, isCommandPosition, matchScore, mentionSuggestions, splitMentions, type AgentInfo } from './suggestions' | ||
| 5 | + | ||
| 6 | +const alice: AgentInfo = { name: 'alice', title: 'Alice (assistant)', icon: '👩' } | ||
| 7 | +const bob: AgentInfo = { name: 'bob', title: 'Bob (assistant)', icon: '🧔' } | ||
| 8 | +const garfield: AgentInfo = { name: 'garfield', title: 'Garfield (translator)', icon: '🐮' } | ||
| 9 | +const commands = { | ||
| 10 | + alice: [{ name: 'compact', description: 'Compress the history' }], | ||
| 11 | + garfield: [ | ||
| 12 | + { name: 'idioms', description: 'Translate idioms', hint: 'text' }, | ||
| 13 | + { name: 'compact', description: 'Compress the history' }, | ||
| 14 | + ], | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +describe('matching', () => { | ||
| 18 | + it('prefers a prefix, then a word, then letters in order', () => { | ||
| 19 | + expect(matchScore('gar', 'garfield')).toBe(0) | ||
| 20 | + expect(matchScore('tra', 'Garfield (translator)')).toBe(1) | ||
| 21 | + expect(matchScore('gfd', 'garfield')).toBe(2) | ||
| 22 | + expect(matchScore('xyz', 'garfield')).toBeNull() | ||
| 23 | + expect(matchScore('', 'anything')).toBe(0) | ||
| 24 | + }) | ||
| 25 | +}) | ||
| 26 | + | ||
| 27 | +describe('mentions', () => { | ||
| 28 | + it('filters the agents on their name and title', () => { | ||
| 29 | + expect(mentionSuggestions('gar', [alice, bob, garfield]).map((s) => s.value)).toEqual(['@garfield']) | ||
| 30 | + expect(mentionSuggestions('assist', [alice, bob, garfield]).map((s) => s.value)).toEqual(['@alice', '@bob']) | ||
| 31 | + expect(mentionSuggestions('', [alice, bob]).map((s) => s.label)).toEqual(['👩 Alice (assistant)', '🧔 Bob (assistant)']) | ||
| 32 | + }) | ||
| 33 | + | ||
| 34 | + it('cuts a message into text and mentions of known agents', () => { | ||
| 35 | + const known = (name: string) => name === 'alice' || name === 'bob' | ||
| 36 | + expect(splitMentions('@alice ask @bob, not @nobody or me@alice.fr', known)).toEqual([ | ||
| 37 | + { text: '@alice', agent: 'alice' }, | ||
| 38 | + { text: ' ask ' }, | ||
| 39 | + { text: '@bob', agent: 'bob' }, | ||
| 40 | + { text: ', not @nobody or me@alice.fr' }, | ||
| 41 | + ]) | ||
| 42 | + expect(splitMentions('no mention', known)).toEqual([{ text: 'no mention' }]) | ||
| 43 | + }) | ||
| 44 | +}) | ||
| 45 | + | ||
| 46 | +describe('commands', () => { | ||
| 47 | + it('offers /command with one agent', () => { | ||
| 48 | + const found = commandSuggestions('id', [garfield], commands) | ||
| 49 | + expect(found).toEqual([{ value: '/idioms', label: '/idioms ‹text›', detail: 'Translate idioms' }]) | ||
| 50 | + }) | ||
| 51 | + | ||
| 52 | + it('offers /agent:command with several agents', () => { | ||
| 53 | + expect(commandSuggestions('', [alice, garfield], commands).map((s) => s.value)).toEqual(['/alice:compact', '/garfield:idioms', '/garfield:compact']) | ||
| 54 | + expect(commandSuggestions('gar', [alice, garfield], commands).map((s) => s.value)).toEqual(['/garfield:idioms', '/garfield:compact']) | ||
| 55 | + expect(commandSuggestions('garfield:id', [alice, garfield], commands).map((s) => s.value)).toEqual(['/garfield:idioms']) | ||
| 56 | + expect(commandSuggestions('', [bob], commands)).toEqual([]) | ||
| 57 | + }) | ||
| 58 | + | ||
| 59 | + it('knows where a command can start', () => { | ||
| 60 | + expect(isCommandPosition('/id', 0)).toBe(true) | ||
| 61 | + expect(isCommandPosition('@garfield /id', 10)).toBe(true) | ||
| 62 | + expect(isCommandPosition('look at /etc', 8)).toBe(false) | ||
| 63 | + }) | ||
| 64 | +}) | ||
added
frontend/src/lib/suggestions.ts +145 -0 | new file mode 100644 | ||
| @@ -0,0 +1,145 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +// What the composer suggests after @ and / (issues #22 and #24), and how | |
| 4 | +// mentions show in a message. Pure functions: the composer and the message | |
| 5 | +// view call them. | |
| 6 | + | |
| 7 | +/** An agent as the suggestions need it. */ | |
| 8 | +export interface AgentInfo { | |
| 9 | + name: string | |
| 10 | + title: string | |
| 11 | + icon: string | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** A command an agent offers (from ACP available_commands_update). */ | |
| 15 | +export interface CommandInfo { | |
| 16 | + name: string | |
| 17 | + description: string | |
| 18 | + hint?: string | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** One line of the suggestion popup. */ | |
| 22 | +export interface Suggestion { | |
| 23 | + /** What replaces the trigger and the typed query, e.g. "@garfield". */ | |
| 24 | + value: string | |
| 25 | + label: string | |
| 26 | + detail: string | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** What the composer popup shows for a trigger; empty explains an empty list. */ | |
| 30 | +export interface Suggestions { | |
| 31 | + items: Suggestion[] | |
| 32 | + empty?: string | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** | |
| 36 | + * How well query matches text: 0 for a prefix, 1 for a prefix of a word, 2 | |
| 37 | + * for letters in order ("gfd" in "garfield"), null for no match. | |
| 38 | + */ | |
| 39 | +export function matchScore(query: string, text: string): number | null { | |
| 40 | + const q = query.toLowerCase() | |
| 41 | + const t = text.toLowerCase() | |
| 42 | + if (q === '' || t.startsWith(q)) return 0 | |
| 43 | + if (t.split(/[\s(_-]+/).some((word) => word.startsWith(q))) return 1 | |
| 44 | + let position = 0 | |
| 45 | + for (const char of t) { | |
| 46 | + if (char === q[position]) position++ | |
| 47 | + if (position === q.length) return 2 | |
| 48 | + } | |
| 49 | + return null | |
| 50 | +} | |
| 51 | + | |
| 52 | +function best<T>(items: T[], score: (item: T) => number | null): T[] { | |
| 53 | + return items | |
| 54 | + .map((item, index) => ({ item, index, score: score(item) })) | |
| 55 | + .filter((entry) => entry.score !== null) | |
| 56 | + .sort((a, b) => a.score! - b.score! || a.index - b.index) | |
| 57 | + .map((entry) => entry.item) | |
| 58 | +} | |
| 59 | + | |
| 60 | +function lowest(...scores: (number | null)[]): number | null { | |
| 61 | + const found = scores.filter((score): score is number => score !== null) | |
| 62 | + return found.length ? Math.min(...found) : null | |
| 63 | +} | |
| 64 | + | |
| 65 | +/** | |
| 66 | + * The agents to suggest after "@": filtered on their name and title, best | |
| 67 | + * matches first. | |
| 68 | + * | |
| 69 | + * @example | |
| 70 | + * mentionSuggestions('gar', agents) // [{ value: '@garfield', label: '🐮 Garfield (translator)', … }] | |
| 71 | + */ | |
| 72 | +export function mentionSuggestions(query: string, agents: AgentInfo[]): Suggestion[] { | |
| 73 | + return best(agents, (agent) => lowest(matchScore(query, agent.name), matchScore(query, agent.title))).map((agent) => ({ | |
| 74 | + value: '@' + agent.name, | |
| 75 | + label: `${agent.icon ? agent.icon + ' ' : ''}${agent.title}`, | |
| 76 | + detail: '@' + agent.name, | |
| 77 | + })) | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * The commands to suggest after "/". With one agent, "/command"; with | |
| 82 | + * several, "/agent:command" — "/gar" narrows to Garfield's commands, | |
| 83 | + * "/garfield:id" to the ones starting with "id". | |
| 84 | + * | |
| 85 | + * @example | |
| 86 | + * commandSuggestions('id', [garfield], { garfield: [{ name: 'idioms', description: 'Idioms' }] }) | |
| 87 | + * // [{ value: '/idioms', label: '/idioms', detail: 'Idioms' }] | |
| 88 | + */ | |
| 89 | +export function commandSuggestions(query: string, members: AgentInfo[], commands: Record<string, CommandInfo[]>): Suggestion[] { | |
| 90 | + if (members.length === 1) { | |
| 91 | + const own = commands[members[0].name] ?? [] | |
| 92 | + return best(own, (command) => matchScore(query, command.name)).map((command) => ({ | |
| 93 | + value: '/' + command.name, | |
| 94 | + label: '/' + command.name + (command.hint ? ` ‹${command.hint}›` : ''), | |
| 95 | + detail: command.description, | |
| 96 | + })) | |
| 97 | + } | |
| 98 | + const colon = query.indexOf(':') | |
| 99 | + const agentQuery = colon >= 0 ? query.slice(0, colon) : query | |
| 100 | + const commandQuery = colon >= 0 ? query.slice(colon + 1) : '' | |
| 101 | + const entries = members.flatMap((agent) => (commands[agent.name] ?? []).map((command) => ({ agent, command }))) | |
| 102 | + return best(entries, ({ agent, command }) => { | |
| 103 | + if (colon >= 0) return agent.name === agentQuery ? matchScore(commandQuery, command.name) : null | |
| 104 | + return lowest(matchScore(agentQuery, agent.name), matchScore(agentQuery, command.name)) | |
| 105 | + }).map(({ agent, command }) => ({ | |
| 106 | + value: `/${agent.name}:${command.name}`, | |
| 107 | + label: `/${agent.name}:${command.name}` + (command.hint ? ` ‹${command.hint}›` : ''), | |
| 108 | + detail: command.description, | |
| 109 | + })) | |
| 110 | +} | |
| 111 | + | |
| 112 | +/** | |
| 113 | + * Whether a "/" at start begins a command: only at the start of the message, | |
| 114 | + * possibly after mentions ("@garfield /idioms …"). | |
| 115 | + */ | |
| 116 | +export function isCommandPosition(text: string, start: number): boolean { | |
| 117 | + return /^(@\S+\s+)*$/.test(text.slice(0, start)) | |
| 118 | +} | |
| 119 | + | |
| 120 | +/** A piece of a user message: plain text, or a mention of an agent. */ | |
| 121 | +export interface Segment { | |
| 122 | + text: string | |
| 123 | + agent?: string | |
| 124 | +} | |
| 125 | + | |
| 126 | +/** | |
| 127 | + * Cuts text into plain pieces and mentions of known agents, to show the | |
| 128 | + * mentions differently. | |
| 129 | + * | |
| 130 | + * @example | |
| 131 | + * splitMentions('@alice hello', (name) => name === 'alice') // [{ text: '@alice', agent: 'alice' }, { text: ' hello' }] | |
| 132 | + */ | |
| 133 | +export function splitMentions(text: string, known: (name: string) => boolean): Segment[] { | |
| 134 | + const segments: Segment[] = [] | |
| 135 | + let last = 0 | |
| 136 | + for (const match of text.matchAll(/(^|\s)@([a-z][a-z0-9_-]*)/g)) { | |
| 137 | + const start = match.index! + match[1].length | |
| 138 | + if (!known(match[2])) continue | |
| 139 | + if (start > last) segments.push({ text: text.slice(last, start) }) | |
| 140 | + segments.push({ text: '@' + match[2], agent: match[2] }) | |
| 141 | + last = start + 1 + match[2].length | |
| 142 | + } | |
| 143 | + if (last < text.length) segments.push({ text: text.slice(last) }) | |
| 144 | + return segments | |
| 145 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,145 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +// What the composer suggests after @ and / (issues #22 and #24), and how | ||
| 4 | +// mentions show in a message. Pure functions: the composer and the message | ||
| 5 | +// view call them. | ||
| 6 | + | ||
| 7 | +/** An agent as the suggestions need it. */ | ||
| 8 | +export interface AgentInfo { | ||
| 9 | + name: string | ||
| 10 | + title: string | ||
| 11 | + icon: string | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +/** A command an agent offers (from ACP available_commands_update). */ | ||
| 15 | +export interface CommandInfo { | ||
| 16 | + name: string | ||
| 17 | + description: string | ||
| 18 | + hint?: string | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +/** One line of the suggestion popup. */ | ||
| 22 | +export interface Suggestion { | ||
| 23 | + /** What replaces the trigger and the typed query, e.g. "@garfield". */ | ||
| 24 | + value: string | ||
| 25 | + label: string | ||
| 26 | + detail: string | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +/** What the composer popup shows for a trigger; empty explains an empty list. */ | ||
| 30 | +export interface Suggestions { | ||
| 31 | + items: Suggestion[] | ||
| 32 | + empty?: string | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +/** | ||
| 36 | + * How well query matches text: 0 for a prefix, 1 for a prefix of a word, 2 | ||
| 37 | + * for letters in order ("gfd" in "garfield"), null for no match. | ||
| 38 | + */ | ||
| 39 | +export function matchScore(query: string, text: string): number | null { | ||
| 40 | + const q = query.toLowerCase() | ||
| 41 | + const t = text.toLowerCase() | ||
| 42 | + if (q === '' || t.startsWith(q)) return 0 | ||
| 43 | + if (t.split(/[\s(_-]+/).some((word) => word.startsWith(q))) return 1 | ||
| 44 | + let position = 0 | ||
| 45 | + for (const char of t) { | ||
| 46 | + if (char === q[position]) position++ | ||
| 47 | + if (position === q.length) return 2 | ||
| 48 | + } | ||
| 49 | + return null | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +function best<T>(items: T[], score: (item: T) => number | null): T[] { | ||
| 53 | + return items | ||
| 54 | + .map((item, index) => ({ item, index, score: score(item) })) | ||
| 55 | + .filter((entry) => entry.score !== null) | ||
| 56 | + .sort((a, b) => a.score! - b.score! || a.index - b.index) | ||
| 57 | + .map((entry) => entry.item) | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +function lowest(...scores: (number | null)[]): number | null { | ||
| 61 | + const found = scores.filter((score): score is number => score !== null) | ||
| 62 | + return found.length ? Math.min(...found) : null | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +/** | ||
| 66 | + * The agents to suggest after "@": filtered on their name and title, best | ||
| 67 | + * matches first. | ||
| 68 | + * | ||
| 69 | + * @example | ||
| 70 | + * mentionSuggestions('gar', agents) // [{ value: '@garfield', label: '🐮 Garfield (translator)', … }] | ||
| 71 | + */ | ||
| 72 | +export function mentionSuggestions(query: string, agents: AgentInfo[]): Suggestion[] { | ||
| 73 | + return best(agents, (agent) => lowest(matchScore(query, agent.name), matchScore(query, agent.title))).map((agent) => ({ | ||
| 74 | + value: '@' + agent.name, | ||
| 75 | + label: `${agent.icon ? agent.icon + ' ' : ''}${agent.title}`, | ||
| 76 | + detail: '@' + agent.name, | ||
| 77 | + })) | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +/** | ||
| 81 | + * The commands to suggest after "/". With one agent, "/command"; with | ||
| 82 | + * several, "/agent:command" — "/gar" narrows to Garfield's commands, | ||
| 83 | + * "/garfield:id" to the ones starting with "id". | ||
| 84 | + * | ||
| 85 | + * @example | ||
| 86 | + * commandSuggestions('id', [garfield], { garfield: [{ name: 'idioms', description: 'Idioms' }] }) | ||
| 87 | + * // [{ value: '/idioms', label: '/idioms', detail: 'Idioms' }] | ||
| 88 | + */ | ||
| 89 | +export function commandSuggestions(query: string, members: AgentInfo[], commands: Record<string, CommandInfo[]>): Suggestion[] { | ||
| 90 | + if (members.length === 1) { | ||
| 91 | + const own = commands[members[0].name] ?? [] | ||
| 92 | + return best(own, (command) => matchScore(query, command.name)).map((command) => ({ | ||
| 93 | + value: '/' + command.name, | ||
| 94 | + label: '/' + command.name + (command.hint ? ` ‹${command.hint}›` : ''), | ||
| 95 | + detail: command.description, | ||
| 96 | + })) | ||
| 97 | + } | ||
| 98 | + const colon = query.indexOf(':') | ||
| 99 | + const agentQuery = colon >= 0 ? query.slice(0, colon) : query | ||
| 100 | + const commandQuery = colon >= 0 ? query.slice(colon + 1) : '' | ||
| 101 | + const entries = members.flatMap((agent) => (commands[agent.name] ?? []).map((command) => ({ agent, command }))) | ||
| 102 | + return best(entries, ({ agent, command }) => { | ||
| 103 | + if (colon >= 0) return agent.name === agentQuery ? matchScore(commandQuery, command.name) : null | ||
| 104 | + return lowest(matchScore(agentQuery, agent.name), matchScore(agentQuery, command.name)) | ||
| 105 | + }).map(({ agent, command }) => ({ | ||
| 106 | + value: `/${agent.name}:${command.name}`, | ||
| 107 | + label: `/${agent.name}:${command.name}` + (command.hint ? ` ‹${command.hint}›` : ''), | ||
| 108 | + detail: command.description, | ||
| 109 | + })) | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +/** | ||
| 113 | + * Whether a "/" at start begins a command: only at the start of the message, | ||
| 114 | + * possibly after mentions ("@garfield /idioms …"). | ||
| 115 | + */ | ||
| 116 | +export function isCommandPosition(text: string, start: number): boolean { | ||
| 117 | + return /^(@\S+\s+)*$/.test(text.slice(0, start)) | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +/** A piece of a user message: plain text, or a mention of an agent. */ | ||
| 121 | +export interface Segment { | ||
| 122 | + text: string | ||
| 123 | + agent?: string | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +/** | ||
| 127 | + * Cuts text into plain pieces and mentions of known agents, to show the | ||
| 128 | + * mentions differently. | ||
| 129 | + * | ||
| 130 | + * @example | ||
| 131 | + * splitMentions('@alice hello', (name) => name === 'alice') // [{ text: '@alice', agent: 'alice' }, { text: ' hello' }] | ||
| 132 | + */ | ||
| 133 | +export function splitMentions(text: string, known: (name: string) => boolean): Segment[] { | ||
| 134 | + const segments: Segment[] = [] | ||
| 135 | + let last = 0 | ||
| 136 | + for (const match of text.matchAll(/(^|\s)@([a-z][a-z0-9_-]*)/g)) { | ||
| 137 | + const start = match.index! + match[1].length | ||
| 138 | + if (!known(match[2])) continue | ||
| 139 | + if (start > last) segments.push({ text: text.slice(last, start) }) | ||
| 140 | + segments.push({ text: '@' + match[2], agent: match[2] }) | ||
| 141 | + last = start + 1 + match[2].length | ||
| 142 | + } | ||
| 143 | + if (last < text.length) segments.push({ text: text.slice(last) }) | ||
| 144 | + return segments | ||
| 145 | +} | ||
modified
go.mod +29 -0 | @@ -3,6 +3,7 @@ module git.rickub.com/bots-garden/sidekicks | ||
| 3 | 3 | go 1.26.0 |
| 4 | 4 | |
| 5 | 5 | require ( |
| 6 | + github.com/cockroachdb/pebble/v2 v2.1.7 | |
| 6 | 7 | github.com/coder/acp-go-sdk v0.13.5 |
| 7 | 8 | github.com/fsnotify/fsnotify v1.10.1 |
| 8 | 9 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 |
| @@ -12,11 +13,39 @@ require ( | ||
| 12 | 13 | ) |
| 13 | 14 | |
| 14 | 15 | require ( |
| 16 | + github.com/DataDog/zstd v1.5.7 // indirect | |
| 17 | + github.com/RaduBerinde/axisds v0.1.0 // indirect | |
| 18 | + github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 // indirect | |
| 15 | 19 | github.com/adrg/xdg v0.5.3 // indirect |
| 20 | + github.com/beorn7/perks v1.0.1 // indirect | |
| 21 | + github.com/cespare/xxhash/v2 v2.2.0 // indirect | |
| 22 | + github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b // indirect | |
| 23 | + github.com/cockroachdb/errors v1.11.3 // indirect | |
| 24 | + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect | |
| 25 | + github.com/cockroachdb/redact v1.1.5 // indirect | |
| 26 | + github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 // indirect | |
| 27 | + github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect | |
| 16 | 28 | github.com/coder/websocket v1.8.14 // indirect |
| 29 | + github.com/getsentry/sentry-go v0.27.0 // indirect | |
| 17 | 30 | github.com/go-ole/go-ole v1.3.0 // indirect |
| 18 | 31 | github.com/godbus/dbus/v5 v5.2.2 // indirect |
| 32 | + github.com/gogo/protobuf v1.3.2 // indirect | |
| 33 | + github.com/golang/protobuf v1.5.3 // indirect | |
| 34 | + github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect | |
| 35 | + github.com/klauspost/compress v1.18.7 // indirect | |
| 36 | + github.com/kr/pretty v0.3.1 // indirect | |
| 37 | + github.com/kr/text v0.2.0 // indirect | |
| 19 | 38 | github.com/mattn/go-colorable v0.1.14 // indirect |
| 20 | 39 | github.com/mattn/go-isatty v0.0.20 // indirect |
| 40 | + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect | |
| 41 | + github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 // indirect | |
| 42 | + github.com/pkg/errors v0.9.1 // indirect | |
| 43 | + github.com/prometheus/client_golang v1.16.0 // indirect | |
| 44 | + github.com/prometheus/client_model v0.3.0 // indirect | |
| 45 | + github.com/prometheus/common v0.42.0 // indirect | |
| 46 | + github.com/prometheus/procfs v0.10.1 // indirect | |
| 47 | + github.com/rogpeppe/go-internal v1.9.0 // indirect | |
| 48 | + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect | |
| 21 | 49 | golang.org/x/sys v0.48.0 // indirect |
| 50 | + google.golang.org/protobuf v1.33.0 // indirect | |
| 22 | 51 | ) |
| @@ -3,6 +3,7 @@ module git.rickub.com/bots-garden/sidekicks | |||
| 3 | go 1.26.0 | 3 | go 1.26.0 |
| 4 | 4 | ||
| 5 | require ( | 5 | require ( |
| 6 | + github.com/cockroachdb/pebble/v2 v2.1.7 | ||
| 6 | github.com/coder/acp-go-sdk v0.13.5 | 7 | github.com/coder/acp-go-sdk v0.13.5 |
| 7 | github.com/fsnotify/fsnotify v1.10.1 | 8 | github.com/fsnotify/fsnotify v1.10.1 |
| 8 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 | 9 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 |
| @@ -12,11 +13,39 @@ require ( | |||
| 12 | ) | 13 | ) |
| 13 | 14 | ||
| 14 | require ( | 15 | require ( |
| 16 | + github.com/DataDog/zstd v1.5.7 // indirect | ||
| 17 | + github.com/RaduBerinde/axisds v0.1.0 // indirect | ||
| 18 | + github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 // indirect | ||
| 15 | github.com/adrg/xdg v0.5.3 // indirect | 19 | github.com/adrg/xdg v0.5.3 // indirect |
| 20 | + github.com/beorn7/perks v1.0.1 // indirect | ||
| 21 | + github.com/cespare/xxhash/v2 v2.2.0 // indirect | ||
| 22 | + github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b // indirect | ||
| 23 | + github.com/cockroachdb/errors v1.11.3 // indirect | ||
| 24 | + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect | ||
| 25 | + github.com/cockroachdb/redact v1.1.5 // indirect | ||
| 26 | + github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 // indirect | ||
| 27 | + github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect | ||
| 16 | github.com/coder/websocket v1.8.14 // indirect | 28 | github.com/coder/websocket v1.8.14 // indirect |
| 29 | + github.com/getsentry/sentry-go v0.27.0 // indirect | ||
| 17 | github.com/go-ole/go-ole v1.3.0 // indirect | 30 | github.com/go-ole/go-ole v1.3.0 // indirect |
| 18 | github.com/godbus/dbus/v5 v5.2.2 // indirect | 31 | github.com/godbus/dbus/v5 v5.2.2 // indirect |
| 32 | + github.com/gogo/protobuf v1.3.2 // indirect | ||
| 33 | + github.com/golang/protobuf v1.5.3 // indirect | ||
| 34 | + github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect | ||
| 35 | + github.com/klauspost/compress v1.18.7 // indirect | ||
| 36 | + github.com/kr/pretty v0.3.1 // indirect | ||
| 37 | + github.com/kr/text v0.2.0 // indirect | ||
| 19 | github.com/mattn/go-colorable v0.1.14 // indirect | 38 | github.com/mattn/go-colorable v0.1.14 // indirect |
| 20 | github.com/mattn/go-isatty v0.0.20 // indirect | 39 | github.com/mattn/go-isatty v0.0.20 // indirect |
| 40 | + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect | ||
| 41 | + github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 // indirect | ||
| 42 | + github.com/pkg/errors v0.9.1 // indirect | ||
| 43 | + github.com/prometheus/client_golang v1.16.0 // indirect | ||
| 44 | + github.com/prometheus/client_model v0.3.0 // indirect | ||
| 45 | + github.com/prometheus/common v0.42.0 // indirect | ||
| 46 | + github.com/prometheus/procfs v0.10.1 // indirect | ||
| 47 | + github.com/rogpeppe/go-internal v1.9.0 // indirect | ||
| 48 | + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect | ||
| 21 | golang.org/x/sys v0.48.0 // indirect | 49 | golang.org/x/sys v0.48.0 // indirect |
| 50 | + google.golang.org/protobuf v1.33.0 // indirect | ||
| 22 | ) | 51 | ) |
modified
go.sum +110 -0 | @@ -1,44 +1,154 @@ | ||
| 1 | +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= | |
| 2 | +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= | |
| 3 | +github.com/RaduBerinde/axisds v0.1.0 h1:YItk/RmU5nvlsv/awo2Fjx97Mfpt4JfgtEVAGPrLdz8= | |
| 4 | +github.com/RaduBerinde/axisds v0.1.0/go.mod h1:UHGJonU9z4YYGKJxSaC6/TNcLOBptpmM5m2Cksbnw0Y= | |
| 5 | +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 h1:bsU8Tzxr/PNz75ayvCnxKZWEYdLMPDkUgticP4a4Bvk= | |
| 6 | +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54/go.mod h1:0tr7FllbE9gJkHq7CVeeDDFAFKQVy5RnCSSNBOvdqbc= | |
| 7 | +github.com/aclements/go-perfevent v0.0.0-20240301234650-f7843625020f h1:JjxwchlOepwsUWcQwD2mLUAGE9aCp0/ehy6yCHFBOvo= | |
| 8 | +github.com/aclements/go-perfevent v0.0.0-20240301234650-f7843625020f/go.mod h1:tMDTce/yLLN/SK8gMOxQfnyeMeCg8KGzp0D1cbECEeo= | |
| 1 | 9 | github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= |
| 2 | 10 | github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= |
| 11 | +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= | |
| 12 | +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= | |
| 13 | +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= | |
| 14 | +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | |
| 15 | +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b h1:SHlYZ/bMx7frnmeqCu+xm0TCxXLzX3jQIVuFbnFGtFU= | |
| 16 | +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b/go.mod h1:Gq51ZeKaFCXk6QwuGM0w1dnaOqc/F5zKT2zA9D6Xeac= | |
| 17 | +github.com/cockroachdb/datadriven v1.0.3-0.20250407164829-2945557346d5 h1:UycK/E0TkisVrQbSoxvU827FwgBBcZ95nRRmpj/12QI= | |
| 18 | +github.com/cockroachdb/datadriven v1.0.3-0.20250407164829-2945557346d5/go.mod h1:jsaKMvD3RBCATk1/jbUZM8C9idWBJME9+VRZ5+Liq1g= | |
| 19 | +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= | |
| 20 | +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= | |
| 21 | +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= | |
| 22 | +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= | |
| 23 | +github.com/cockroachdb/metamorphic v0.0.0-20231108215700-4ba948b56895 h1:XANOgPYtvELQ/h4IrmPAohXqe2pWA8Bwhejr3VQoZsA= | |
| 24 | +github.com/cockroachdb/metamorphic v0.0.0-20231108215700-4ba948b56895/go.mod h1:aPd7gM9ov9M8v32Yy5NJrDyOcD8z642dqs+F0CeNXfA= | |
| 25 | +github.com/cockroachdb/pebble/v2 v2.1.7 h1:hFQnbsniSWg9BVcNKMuaUufYPiVXY6uJvaY9grbQ9+U= | |
| 26 | +github.com/cockroachdb/pebble/v2 v2.1.7/go.mod h1:JhU5cqqYkr2BdsBHbZhRZOryAtfhcV3eNI/oBcbrxWc= | |
| 27 | +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= | |
| 28 | +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= | |
| 29 | +github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 h1:IJ+uNItEm0qx9FE2AgIc1PMsCUtk8nbSIzhQE1t5GWw= | |
| 30 | +github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= | |
| 31 | +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= | |
| 32 | +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= | |
| 3 | 33 | github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk= |
| 4 | 34 | github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= |
| 5 | 35 | github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= |
| 6 | 36 | github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= |
| 37 | +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | |
| 7 | 38 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| 8 | 39 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| 9 | 40 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= |
| 10 | 41 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= |
| 11 | 42 | github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= |
| 12 | 43 | github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= |
| 44 | +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= | |
| 45 | +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= | |
| 46 | +github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9 h1:r5GgOLGbza2wVHRzK7aAj6lWZjfbAwiu/RDCVOKjRyM= | |
| 47 | +github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs= | |
| 48 | +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= | |
| 49 | +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= | |
| 13 | 50 | github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= |
| 14 | 51 | github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= |
| 15 | 52 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= |
| 16 | 53 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= |
| 17 | 54 | github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= |
| 18 | 55 | github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= |
| 56 | +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= | |
| 57 | +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= | |
| 58 | +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | |
| 59 | +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= | |
| 60 | +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= | |
| 61 | +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= | |
| 62 | +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= | |
| 63 | +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e h1:4bw4WeyTYPp0smaXiJZCNnLrvVBqirQVreixayXezGc= | |
| 64 | +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= | |
| 65 | +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | |
| 19 | 66 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
| 20 | 67 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= |
| 68 | +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= | |
| 69 | +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= | |
| 70 | +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= | |
| 71 | +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= | |
| 72 | +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | |
| 73 | +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | |
| 74 | +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | |
| 75 | +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | |
| 21 | 76 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= |
| 22 | 77 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= |
| 23 | 78 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= |
| 24 | 79 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= |
| 25 | 80 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| 26 | 81 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| 82 | +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= | |
| 83 | +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= | |
| 84 | +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 h1:0lgqHvJWHLGW5TuObJrfyEi6+ASTKDBWikGvPqy9Yiw= | |
| 85 | +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= | |
| 86 | +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= | |
| 87 | +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= | |
| 88 | +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= | |
| 89 | +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | |
| 90 | +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | |
| 27 | 91 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| 28 | 92 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| 93 | +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= | |
| 94 | +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= | |
| 95 | +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= | |
| 96 | +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= | |
| 97 | +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= | |
| 98 | +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= | |
| 99 | +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= | |
| 100 | +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= | |
| 101 | +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= | |
| 102 | +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= | |
| 29 | 103 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= |
| 30 | 104 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= |
| 31 | 105 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= |
| 32 | 106 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= |
| 33 | 107 | github.com/wailsapp/wails/v3 v3.0.0 h1:lBe89kFu23bVz1dc1H9v0srkHQgWsy7BAKpH4sLQuDw= |
| 34 | 108 | github.com/wailsapp/wails/v3 v3.0.0/go.mod h1:U3p3/QfSer2XxXM3XWhwis9O7LvvuNKN/AcGoEFD+K8= |
| 109 | +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | |
| 110 | +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | |
| 35 | 111 | go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= |
| 36 | 112 | go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= |
| 113 | +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | |
| 114 | +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | |
| 115 | +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | |
| 116 | +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= | |
| 117 | +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= | |
| 118 | +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | |
| 119 | +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | |
| 120 | +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | |
| 121 | +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | |
| 122 | +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | |
| 123 | +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= | |
| 124 | +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | |
| 125 | +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | |
| 126 | +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | |
| 127 | +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | |
| 128 | +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= | |
| 129 | +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= | |
| 130 | +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | |
| 131 | +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |
| 132 | +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | |
| 37 | 133 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 38 | 134 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 39 | 135 | golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= |
| 40 | 136 | golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= |
| 137 | +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | |
| 138 | +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | |
| 41 | 139 | golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= |
| 42 | 140 | golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= |
| 141 | +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | |
| 142 | +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | |
| 143 | +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= | |
| 144 | +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= | |
| 145 | +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | |
| 146 | +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | |
| 147 | +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | |
| 148 | +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | |
| 149 | +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= | |
| 150 | +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= | |
| 151 | +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= | |
| 152 | +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= | |
| 43 | 153 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| 44 | 154 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| @@ -1,44 +1,154 @@ | |||
| 1 | +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= | ||
| 2 | +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= | ||
| 3 | +github.com/RaduBerinde/axisds v0.1.0 h1:YItk/RmU5nvlsv/awo2Fjx97Mfpt4JfgtEVAGPrLdz8= | ||
| 4 | +github.com/RaduBerinde/axisds v0.1.0/go.mod h1:UHGJonU9z4YYGKJxSaC6/TNcLOBptpmM5m2Cksbnw0Y= | ||
| 5 | +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 h1:bsU8Tzxr/PNz75ayvCnxKZWEYdLMPDkUgticP4a4Bvk= | ||
| 6 | +github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54/go.mod h1:0tr7FllbE9gJkHq7CVeeDDFAFKQVy5RnCSSNBOvdqbc= | ||
| 7 | +github.com/aclements/go-perfevent v0.0.0-20240301234650-f7843625020f h1:JjxwchlOepwsUWcQwD2mLUAGE9aCp0/ehy6yCHFBOvo= | ||
| 8 | +github.com/aclements/go-perfevent v0.0.0-20240301234650-f7843625020f/go.mod h1:tMDTce/yLLN/SK8gMOxQfnyeMeCg8KGzp0D1cbECEeo= | ||
| 1 | github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= | 9 | github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= |
| 2 | github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= | 10 | github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= |
| 11 | +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= | ||
| 12 | +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= | ||
| 13 | +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= | ||
| 14 | +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||
| 15 | +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b h1:SHlYZ/bMx7frnmeqCu+xm0TCxXLzX3jQIVuFbnFGtFU= | ||
| 16 | +github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b/go.mod h1:Gq51ZeKaFCXk6QwuGM0w1dnaOqc/F5zKT2zA9D6Xeac= | ||
| 17 | +github.com/cockroachdb/datadriven v1.0.3-0.20250407164829-2945557346d5 h1:UycK/E0TkisVrQbSoxvU827FwgBBcZ95nRRmpj/12QI= | ||
| 18 | +github.com/cockroachdb/datadriven v1.0.3-0.20250407164829-2945557346d5/go.mod h1:jsaKMvD3RBCATk1/jbUZM8C9idWBJME9+VRZ5+Liq1g= | ||
| 19 | +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= | ||
| 20 | +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= | ||
| 21 | +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= | ||
| 22 | +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= | ||
| 23 | +github.com/cockroachdb/metamorphic v0.0.0-20231108215700-4ba948b56895 h1:XANOgPYtvELQ/h4IrmPAohXqe2pWA8Bwhejr3VQoZsA= | ||
| 24 | +github.com/cockroachdb/metamorphic v0.0.0-20231108215700-4ba948b56895/go.mod h1:aPd7gM9ov9M8v32Yy5NJrDyOcD8z642dqs+F0CeNXfA= | ||
| 25 | +github.com/cockroachdb/pebble/v2 v2.1.7 h1:hFQnbsniSWg9BVcNKMuaUufYPiVXY6uJvaY9grbQ9+U= | ||
| 26 | +github.com/cockroachdb/pebble/v2 v2.1.7/go.mod h1:JhU5cqqYkr2BdsBHbZhRZOryAtfhcV3eNI/oBcbrxWc= | ||
| 27 | +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= | ||
| 28 | +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= | ||
| 29 | +github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 h1:IJ+uNItEm0qx9FE2AgIc1PMsCUtk8nbSIzhQE1t5GWw= | ||
| 30 | +github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= | ||
| 31 | +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= | ||
| 32 | +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= | ||
| 3 | github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk= | 33 | github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk= |
| 4 | github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= | 34 | github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= |
| 5 | github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= | 35 | github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= |
| 6 | github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= | 36 | github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= |
| 37 | +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | ||
| 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | 38 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
| 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | 39 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| 9 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= | 40 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= |
| 10 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= | 41 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= |
| 11 | github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= | 42 | github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= |
| 12 | github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= | 43 | github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= |
| 44 | +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= | ||
| 45 | +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= | ||
| 46 | +github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9 h1:r5GgOLGbza2wVHRzK7aAj6lWZjfbAwiu/RDCVOKjRyM= | ||
| 47 | +github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs= | ||
| 48 | +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= | ||
| 49 | +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= | ||
| 13 | github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= | 50 | github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= |
| 14 | github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= | 51 | github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= |
| 15 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= | 52 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= |
| 16 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= | 53 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= |
| 17 | github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= | 54 | github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= |
| 18 | github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= | 55 | github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= |
| 56 | +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= | ||
| 57 | +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= | ||
| 58 | +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= | ||
| 59 | +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= | ||
| 60 | +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= | ||
| 61 | +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= | ||
| 62 | +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= | ||
| 63 | +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e h1:4bw4WeyTYPp0smaXiJZCNnLrvVBqirQVreixayXezGc= | ||
| 64 | +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= | ||
| 65 | +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||
| 19 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | 66 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
| 20 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | 67 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= |
| 68 | +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= | ||
| 69 | +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= | ||
| 70 | +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= | ||
| 71 | +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= | ||
| 72 | +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||
| 73 | +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||
| 74 | +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||
| 75 | +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | ||
| 21 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= | 76 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= |
| 22 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= | 77 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= |
| 23 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= | 78 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= |
| 24 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | 79 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= |
| 25 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | 80 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| 26 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | 81 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| 82 | +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= | ||
| 83 | +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= | ||
| 84 | +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 h1:0lgqHvJWHLGW5TuObJrfyEi6+ASTKDBWikGvPqy9Yiw= | ||
| 85 | +github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= | ||
| 86 | +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= | ||
| 87 | +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= | ||
| 88 | +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= | ||
| 89 | +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | ||
| 90 | +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||
| 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | 91 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | 92 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| 93 | +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= | ||
| 94 | +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= | ||
| 95 | +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= | ||
| 96 | +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= | ||
| 97 | +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= | ||
| 98 | +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= | ||
| 99 | +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= | ||
| 100 | +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= | ||
| 101 | +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= | ||
| 102 | +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= | ||
| 29 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= | 103 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= |
| 30 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= | 104 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= |
| 31 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | 105 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= |
| 32 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | 106 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= |
| 33 | github.com/wailsapp/wails/v3 v3.0.0 h1:lBe89kFu23bVz1dc1H9v0srkHQgWsy7BAKpH4sLQuDw= | 107 | github.com/wailsapp/wails/v3 v3.0.0 h1:lBe89kFu23bVz1dc1H9v0srkHQgWsy7BAKpH4sLQuDw= |
| 34 | github.com/wailsapp/wails/v3 v3.0.0/go.mod h1:U3p3/QfSer2XxXM3XWhwis9O7LvvuNKN/AcGoEFD+K8= | 108 | github.com/wailsapp/wails/v3 v3.0.0/go.mod h1:U3p3/QfSer2XxXM3XWhwis9O7LvvuNKN/AcGoEFD+K8= |
| 109 | +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | ||
| 110 | +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= | ||
| 35 | go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= | 111 | go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= |
| 36 | go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= | 112 | go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= |
| 113 | +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||
| 114 | +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||
| 115 | +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | ||
| 116 | +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= | ||
| 117 | +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= | ||
| 118 | +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||
| 119 | +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||
| 120 | +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||
| 121 | +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||
| 122 | +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||
| 123 | +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= | ||
| 124 | +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
| 125 | +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
| 126 | +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
| 127 | +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
| 128 | +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= | ||
| 129 | +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= | ||
| 130 | +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||
| 131 | +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||
| 132 | +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||
| 37 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 133 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 38 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 134 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 39 | golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= | 135 | golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= |
| 40 | golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= | 136 | golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= |
| 137 | +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||
| 138 | +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||
| 41 | golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= | 139 | golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= |
| 42 | golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= | 140 | golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= |
| 141 | +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||
| 142 | +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | ||
| 143 | +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= | ||
| 144 | +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= | ||
| 145 | +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||
| 146 | +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||
| 147 | +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||
| 148 | +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||
| 149 | +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= | ||
| 150 | +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= | ||
| 151 | +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= | ||
| 152 | +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= | ||
| 43 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | 153 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
| 44 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | 154 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
modified
internal/acp/conn_test.go +11 -0 | @@ -331,6 +331,17 @@ func (a *fakeAgent) Prompt(ctx context.Context, request sdk.PromptRequest) (sdk. | ||
| 331 | 331 | } |
| 332 | 332 | return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": "exceeded maximum tool call iterations (40)"}) |
| 333 | 333 | } |
| 334 | + | |
| 335 | +// LoadSession resumes "s-1" and, as ACP requires, replays its history before | |
| 336 | +// answering; any other id is unknown. | |
| 337 | +func (a *fakeAgent) LoadSession(ctx context.Context, request sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) { | |
| 338 | + if request.SessionId != "s-1" { | |
| 339 | + return sdk.LoadSessionResponse{}, sdk.NewInvalidParams(map[string]any{"error": "unknown session"}) | |
| 340 | + } | |
| 341 | + _ = a.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: "s-1", Update: sdk.UpdateAgentMessageText("earlier answer")}) | |
| 342 | + return sdk.LoadSessionResponse{}, nil | |
| 343 | +} | |
| 344 | + | |
| 334 | 345 | func (*fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { |
| 335 | 346 | return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound("session/resume") |
| 336 | 347 | } |
| @@ -331,6 +331,17 @@ func (a *fakeAgent) Prompt(ctx context.Context, request sdk.PromptRequest) (sdk. | |||
| 331 | } | 331 | } |
| 332 | return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": "exceeded maximum tool call iterations (40)"}) | 332 | return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": "exceeded maximum tool call iterations (40)"}) |
| 333 | } | 333 | } |
| 334 | + | ||
| 335 | +// LoadSession resumes "s-1" and, as ACP requires, replays its history before | ||
| 336 | +// answering; any other id is unknown. | ||
| 337 | +func (a *fakeAgent) LoadSession(ctx context.Context, request sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) { | ||
| 338 | + if request.SessionId != "s-1" { | ||
| 339 | + return sdk.LoadSessionResponse{}, sdk.NewInvalidParams(map[string]any{"error": "unknown session"}) | ||
| 340 | + } | ||
| 341 | + _ = a.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: "s-1", Update: sdk.UpdateAgentMessageText("earlier answer")}) | ||
| 342 | + return sdk.LoadSessionResponse{}, nil | ||
| 343 | +} | ||
| 344 | + | ||
| 334 | func (*fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { | 345 | func (*fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { |
| 335 | return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound("session/resume") | 346 | return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound("session/resume") |
| 336 | } | 347 | } |
modified
internal/acp/session.go +14 -0 | @@ -118,6 +118,20 @@ type TurnResult struct { | ||
| 118 | 118 | OutputTokens int |
| 119 | 119 | } |
| 120 | 120 | |
| 121 | +// LoadSession resumes the session id of an earlier connection, for agents | |
| 122 | +// whose Info().LoadSession is true. While loading, the agent replays the | |
| 123 | +// session's history as updates; handler receives them and should ignore what | |
| 124 | +// arrives outside a turn. | |
| 125 | +// | |
| 126 | +// if conn.Info().LoadSession { | |
| 127 | +// err = conn.LoadSession(ctx, storedID, workspace, handler) | |
| 128 | +// } | |
| 129 | +func (c *Conn) LoadSession(ctx context.Context, id, cwd string, handler SessionHandler) error { | |
| 130 | + c.sessions.register(id, handler) | |
| 131 | + _, err := c.connection.LoadSession(ctx, sdk.LoadSessionRequest{SessionId: sdk.SessionId(id), Cwd: cwd, McpServers: []sdk.McpServer{}}) | |
| 132 | + return readable(err) | |
| 133 | +} | |
| 134 | + | |
| 121 | 135 | // NewSession opens a session whose working directory is cwd and sends what |
| 122 | 136 | // the agent says about it to handler. |
| 123 | 137 | // |
| @@ -118,6 +118,20 @@ type TurnResult struct { | |||
| 118 | OutputTokens int | 118 | OutputTokens int |
| 119 | } | 119 | } |
| 120 | 120 | ||
| 121 | +// LoadSession resumes the session id of an earlier connection, for agents | ||
| 122 | +// whose Info().LoadSession is true. While loading, the agent replays the | ||
| 123 | +// session's history as updates; handler receives them and should ignore what | ||
| 124 | +// arrives outside a turn. | ||
| 125 | +// | ||
| 126 | +// if conn.Info().LoadSession { | ||
| 127 | +// err = conn.LoadSession(ctx, storedID, workspace, handler) | ||
| 128 | +// } | ||
| 129 | +func (c *Conn) LoadSession(ctx context.Context, id, cwd string, handler SessionHandler) error { | ||
| 130 | + c.sessions.register(id, handler) | ||
| 131 | + _, err := c.connection.LoadSession(ctx, sdk.LoadSessionRequest{SessionId: sdk.SessionId(id), Cwd: cwd, McpServers: []sdk.McpServer{}}) | ||
| 132 | + return readable(err) | ||
| 133 | +} | ||
| 134 | + | ||
| 121 | // NewSession opens a session whose working directory is cwd and sends what | 135 | // NewSession opens a session whose working directory is cwd and sends what |
| 122 | // the agent says about it to handler. | 136 | // the agent says about it to handler. |
| 123 | // | 137 | // |
modified
internal/acp/session_test.go +18 -0 | @@ -155,3 +155,21 @@ func TestAnAgentErrorInsteadOfAStopReasonIsReturned(t *testing.T) { | ||
| 155 | 155 | t.Fatalf("want the agent's own explanation, got %v", err) |
| 156 | 156 | } |
| 157 | 157 | } |
| 158 | + | |
| 159 | +func TestALoadedSessionReplaysItsHistoryToTheHandler(t *testing.T) { | |
| 160 | + conn, err := acp.Start(context.Background(), fakeSpec(t, "ready"), acp.Options{}) | |
| 161 | + if err != nil { | |
| 162 | + t.Fatal(err) | |
| 163 | + } | |
| 164 | + t.Cleanup(func() { _ = conn.Close(context.Background()) }) | |
| 165 | + handler := &recordingHandler{} | |
| 166 | + if err := conn.LoadSession(context.Background(), "s-1", t.TempDir(), handler); err != nil { | |
| 167 | + t.Fatal(err) | |
| 168 | + } | |
| 169 | + if replayed := handler.ofKind(acp.UpdateMessage); len(replayed) != 1 || replayed[0].Text != "earlier answer" { | |
| 170 | + t.Fatalf("replayed: %+v", replayed) | |
| 171 | + } | |
| 172 | + if err := conn.LoadSession(context.Background(), "s-404", t.TempDir(), &recordingHandler{}); err == nil || !strings.Contains(err.Error(), "unknown session") { | |
| 173 | + t.Fatalf("an unknown session is an error: %v", err) | |
| 174 | + } | |
| 175 | +} | |
| @@ -155,3 +155,21 @@ func TestAnAgentErrorInsteadOfAStopReasonIsReturned(t *testing.T) { | |||
| 155 | t.Fatalf("want the agent's own explanation, got %v", err) | 155 | t.Fatalf("want the agent's own explanation, got %v", err) |
| 156 | } | 156 | } |
| 157 | } | 157 | } |
| 158 | + | ||
| 159 | +func TestALoadedSessionReplaysItsHistoryToTheHandler(t *testing.T) { | ||
| 160 | + conn, err := acp.Start(context.Background(), fakeSpec(t, "ready"), acp.Options{}) | ||
| 161 | + if err != nil { | ||
| 162 | + t.Fatal(err) | ||
| 163 | + } | ||
| 164 | + t.Cleanup(func() { _ = conn.Close(context.Background()) }) | ||
| 165 | + handler := &recordingHandler{} | ||
| 166 | + if err := conn.LoadSession(context.Background(), "s-1", t.TempDir(), handler); err != nil { | ||
| 167 | + t.Fatal(err) | ||
| 168 | + } | ||
| 169 | + if replayed := handler.ofKind(acp.UpdateMessage); len(replayed) != 1 || replayed[0].Text != "earlier answer" { | ||
| 170 | + t.Fatalf("replayed: %+v", replayed) | ||
| 171 | + } | ||
| 172 | + if err := conn.LoadSession(context.Background(), "s-404", t.TempDir(), &recordingHandler{}); err == nil || !strings.Contains(err.Error(), "unknown session") { | ||
| 173 | + t.Fatalf("an unknown session is an error: %v", err) | ||
| 174 | + } | ||
| 175 | +} | ||
modified
internal/agents/manager.go +1 -0 | @@ -60,6 +60,7 @@ type Conn interface { | ||
| 60 | 60 | Err() error |
| 61 | 61 | Close(ctx context.Context) error |
| 62 | 62 | NewSession(ctx context.Context, cwd string, handler acp.SessionHandler) (string, error) |
| 63 | + LoadSession(ctx context.Context, id, cwd string, handler acp.SessionHandler) error | |
| 63 | 64 | Prompt(ctx context.Context, sessionID, text string) (acp.TurnResult, error) |
| 64 | 65 | Cancel(ctx context.Context, sessionID string) error |
| 65 | 66 | } |
| @@ -60,6 +60,7 @@ type Conn interface { | |||
| 60 | Err() error | 60 | Err() error |
| 61 | Close(ctx context.Context) error | 61 | Close(ctx context.Context) error |
| 62 | NewSession(ctx context.Context, cwd string, handler acp.SessionHandler) (string, error) | 62 | NewSession(ctx context.Context, cwd string, handler acp.SessionHandler) (string, error) |
| 63 | + LoadSession(ctx context.Context, id, cwd string, handler acp.SessionHandler) error | ||
| 63 | Prompt(ctx context.Context, sessionID, text string) (acp.TurnResult, error) | 64 | Prompt(ctx context.Context, sessionID, text string) (acp.TurnResult, error) |
| 64 | Cancel(ctx context.Context, sessionID string) error | 65 | Cancel(ctx context.Context, sessionID string) error |
| 65 | } | 66 | } |
modified
internal/agents/manager_test.go +1 -0 | @@ -52,6 +52,7 @@ func (c *fakeConn) crash(err error) { | ||
| 52 | 52 | func (c *fakeConn) NewSession(context.Context, string, acp.SessionHandler) (string, error) { |
| 53 | 53 | return "s-1", nil |
| 54 | 54 | } |
| 55 | +func (c *fakeConn) LoadSession(context.Context, string, string, acp.SessionHandler) error { return nil } | |
| 55 | 56 | func (c *fakeConn) Prompt(context.Context, string, string) (acp.TurnResult, error) { |
| 56 | 57 | return acp.TurnResult{StopReason: "end_turn"}, nil |
| 57 | 58 | } |
| @@ -52,6 +52,7 @@ func (c *fakeConn) crash(err error) { | |||
| 52 | func (c *fakeConn) NewSession(context.Context, string, acp.SessionHandler) (string, error) { | 52 | func (c *fakeConn) NewSession(context.Context, string, acp.SessionHandler) (string, error) { |
| 53 | return "s-1", nil | 53 | return "s-1", nil |
| 54 | } | 54 | } |
| 55 | +func (c *fakeConn) LoadSession(context.Context, string, string, acp.SessionHandler) error { return nil } | ||
| 55 | func (c *fakeConn) Prompt(context.Context, string, string) (acp.TurnResult, error) { | 56 | func (c *fakeConn) Prompt(context.Context, string, string) (acp.TurnResult, error) { |
| 56 | return acp.TurnResult{StopReason: "end_turn"}, nil | 57 | return acp.TurnResult{StopReason: "end_turn"}, nil |
| 57 | } | 58 | } |
modified
internal/app/app.go +85 -17 | @@ -1,8 +1,8 @@ | ||
| 1 | 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE |
| 2 | 2 | |
| 3 | 3 | // Package app is what the interface talks to (ADR 0003): the services bound to |
| 4 | -// the frontend, and the glue between the configuration, the agents and the | |
| 5 | -// conversations. | |
| 4 | +// the frontend, and the glue between the configuration, the agents, the | |
| 5 | +// channels and their store. | |
| 6 | 6 | // |
| 7 | 7 | // It does not import Wails: main.go binds the services, plugs the Wails event |
| 8 | 8 | // manager in as the events.Emitter and the native file dialog in as |
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | // core := app.New(location, app.Options{Emitter: emitter}) |
| 12 | 12 | // go core.Run(ctx) |
| 13 | 13 | // defer core.Shutdown(context.Background()) |
| 14 | -// services := []any{&app.AgentService{Core: core}, &app.ConfigService{Core: core}, &app.ChatService{Core: core}} | |
| 14 | +// services := []any{&app.AgentService{Core: core}, &app.ConfigService{Core: core}, &app.ChannelService{Core: core}} | |
| 15 | 15 | package app |
| 16 | 16 | |
| 17 | 17 | import ( |
| @@ -21,10 +21,11 @@ import ( | ||
| 21 | 21 | "sync" |
| 22 | 22 | |
| 23 | 23 | "git.rickub.com/bots-garden/sidekicks/internal/agents" |
| 24 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | |
| 24 | 25 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 25 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | |
| 26 | 26 | "git.rickub.com/bots-garden/sidekicks/internal/events" |
| 27 | 27 | "git.rickub.com/bots-garden/sidekicks/internal/prefs" |
| 28 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | |
| 28 | 29 | ) |
| 29 | 30 | |
| 30 | 31 | // Options configure a Core. Every field is optional. |
| @@ -37,21 +38,29 @@ type Options struct { | ||
| 37 | 38 | // PickFile asks the user for a configuration file (native dialog); it |
| 38 | 39 | // returns "" when the user cancels. |
| 39 | 40 | PickFile func() (string, error) |
| 41 | + // OpenStore opens the history of the configuration file at path; nil | |
| 42 | + // opens it in the user's configuration folder (store.DirFor). | |
| 43 | + OpenStore func(configPath string) (*store.Store, error) | |
| 40 | 44 | } |
| 41 | 45 | |
| 42 | -// Core holds the loaded configuration, the agent manager and the conversations. | |
| 46 | +// Core holds the loaded configuration, the agent manager, and the channels | |
| 47 | +// with their store. | |
| 43 | 48 | type Core struct { |
| 44 | - options Options | |
| 45 | - manager *agents.Manager | |
| 46 | - conversations *conversations.Manager | |
| 49 | + options Options | |
| 50 | + manager *agents.Manager | |
| 47 | 51 | |
| 48 | 52 | mu sync.Mutex |
| 49 | 53 | location config.Location |
| 50 | 54 | cfg *config.Config |
| 51 | 55 | problems []config.Problem |
| 52 | 56 | created bool |
| 53 | - runCtx context.Context | |
| 54 | - stopWait context.CancelFunc | |
| 57 | + db *store.Store | |
| 58 | + channels *channels.Manager | |
| 59 | + // historyProblem says why the history could not be opened: it then lives | |
| 60 | + // in memory and is lost at exit. | |
| 61 | + historyProblem string | |
| 62 | + runCtx context.Context | |
| 63 | + stopWait context.CancelFunc | |
| 55 | 64 | } |
| 56 | 65 | |
| 57 | 66 | // New loads the configuration at location — creating the default file first |
| @@ -69,10 +78,66 @@ func New(location config.Location, options Options) *Core { | ||
| 69 | 78 | MaxRunning: cfg.Limits.MaxRunningAgents, IdleTimeout: cfg.Limits.StopIdleAgentsAfter, |
| 70 | 79 | Emitter: options.Emitter, Starter: options.Starter, |
| 71 | 80 | }) |
| 72 | - core.conversations = conversations.NewManager(core.manager, conversations.Options{Emitter: options.Emitter}) | |
| 81 | + core.openChannels() | |
| 73 | 82 | return core |
| 74 | 83 | } |
| 75 | 84 | |
| 85 | +// openChannels opens the history of the current configuration file and the | |
| 86 | +// channels it holds, then creates the channels the file declares. A history | |
| 87 | +// that cannot be opened — another Sidekicks uses it, the disk is full — is | |
| 88 | +// replaced by one in memory, and the reason is reported. The caller does not | |
| 89 | +// hold c.mu. | |
| 90 | +func (c *Core) openChannels() { | |
| 91 | + c.mu.Lock() | |
| 92 | + path, declared := c.location.Path, c.cfg.Channels | |
| 93 | + c.mu.Unlock() | |
| 94 | + open := c.options.OpenStore | |
| 95 | + if open == nil { | |
| 96 | + open = openStore | |
| 97 | + } | |
| 98 | + problem := "" | |
| 99 | + db, err := open(path) | |
| 100 | + if err != nil { | |
| 101 | + problem = err.Error() | |
| 102 | + db, _ = store.OpenInMemory() | |
| 103 | + } | |
| 104 | + manager, err := channels.NewManager(c.manager, db, channels.Options{Emitter: c.options.Emitter}) | |
| 105 | + if err != nil { | |
| 106 | + problem = err.Error() | |
| 107 | + _ = db.Close() | |
| 108 | + db, _ = store.OpenInMemory() | |
| 109 | + manager, _ = channels.NewManager(c.manager, db, channels.Options{Emitter: c.options.Emitter}) | |
| 110 | + } | |
| 111 | + _ = manager.Declare(declared) | |
| 112 | + c.mu.Lock() | |
| 113 | + c.db, c.channels, c.historyProblem = db, manager, problem | |
| 114 | + c.mu.Unlock() | |
| 115 | +} | |
| 116 | + | |
| 117 | +func openStore(configPath string) (*store.Store, error) { | |
| 118 | + dir, err := store.DirFor(configPath) | |
| 119 | + if err != nil { | |
| 120 | + return nil, err | |
| 121 | + } | |
| 122 | + return store.Open(dir) | |
| 123 | +} | |
| 124 | + | |
| 125 | +// closeChannels stops the answers and closes the history. | |
| 126 | +func (c *Core) closeChannels() { | |
| 127 | + c.mu.Lock() | |
| 128 | + manager, db := c.channels, c.db | |
| 129 | + c.mu.Unlock() | |
| 130 | + manager.Close() | |
| 131 | + _ = db.Close() | |
| 132 | +} | |
| 133 | + | |
| 134 | +// Channels gives access to the channels, for the services. | |
| 135 | +func (c *Core) Channels() *channels.Manager { | |
| 136 | + c.mu.Lock() | |
| 137 | + defer c.mu.Unlock() | |
| 138 | + return c.channels | |
| 139 | +} | |
| 140 | + | |
| 76 | 141 | // load reads the configuration at location, creating the default file when |
| 77 | 142 | // that is where it should be. A file with problems gives an empty configuration. |
| 78 | 143 | func load(location config.Location) (*config.Config, []config.Problem, bool) { |
| @@ -157,20 +222,22 @@ func (c *Core) apply(ctx context.Context, cfg *config.Config, err error) { | ||
| 157 | 222 | return |
| 158 | 223 | } |
| 159 | 224 | c.cfg, c.problems = cfg, nil |
| 225 | + manager := c.channels | |
| 160 | 226 | c.mu.Unlock() |
| 161 | 227 | c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter) |
| 228 | + _ = manager.Declare(cfg.Channels) | |
| 162 | 229 | c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: path, Valid: true, Problems: []string{}}) |
| 163 | 230 | } |
| 164 | 231 | |
| 165 | -// Open switches to another configuration file: the conversations are | |
| 166 | -// forgotten, every agent is stopped, the file is loaded (with its problems, | |
| 167 | -// if any), watched, and remembered as the last one opened. | |
| 232 | +// Open switches to another configuration file: the answers are stopped, | |
| 233 | +// every agent is stopped, the file is loaded (with its problems, if any) | |
| 234 | +// with its own history, watched, and remembered as the last one opened. | |
| 168 | 235 | func (c *Core) Open(ctx context.Context, path string) error { |
| 169 | 236 | absolute, err := filepath.Abs(path) |
| 170 | 237 | if err != nil { |
| 171 | 238 | return err |
| 172 | 239 | } |
| 173 | - c.conversations.Reset() | |
| 240 | + c.closeChannels() | |
| 174 | 241 | c.manager.StopAll(ctx) |
| 175 | 242 | |
| 176 | 243 | location := config.Location{Path: absolute, Source: config.SourceLastOpened} |
| @@ -179,6 +246,7 @@ func (c *Core) Open(ctx context.Context, path string) error { | ||
| 179 | 246 | c.location, c.cfg, c.problems, c.created = location, cfg, problems, false |
| 180 | 247 | c.mu.Unlock() |
| 181 | 248 | c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter) |
| 249 | + c.openChannels() | |
| 182 | 250 | c.watch() |
| 183 | 251 | if c.options.Preferences != nil { |
| 184 | 252 | _ = c.options.Preferences.Remember(absolute) |
| @@ -187,9 +255,9 @@ func (c *Core) Open(ctx context.Context, path string) error { | ||
| 187 | 255 | return nil |
| 188 | 256 | } |
| 189 | 257 | |
| 190 | -// Shutdown cancels the answers and stops every agent. | |
| 258 | +// Shutdown cancels the answers, stops every agent and closes the history. | |
| 191 | 259 | func (c *Core) Shutdown(ctx context.Context) { |
| 192 | - c.conversations.Reset() | |
| 260 | + c.closeChannels() | |
| 193 | 261 | c.manager.StopAll(ctx) |
| 194 | 262 | } |
| 195 | 263 | |
| @@ -1,8 +1,8 @@ | |||
| 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE |
| 2 | 2 | ||
| 3 | // Package app is what the interface talks to (ADR 0003): the services bound to | 3 | // Package app is what the interface talks to (ADR 0003): the services bound to |
| 4 | -// the frontend, and the glue between the configuration, the agents and the | 4 | +// the frontend, and the glue between the configuration, the agents, the |
| 5 | -// conversations. | 5 | +// channels and their store. |
| 6 | // | 6 | // |
| 7 | // It does not import Wails: main.go binds the services, plugs the Wails event | 7 | // It does not import Wails: main.go binds the services, plugs the Wails event |
| 8 | // manager in as the events.Emitter and the native file dialog in as | 8 | // manager in as the events.Emitter and the native file dialog in as |
| @@ -11,7 +11,7 @@ | |||
| 11 | // core := app.New(location, app.Options{Emitter: emitter}) | 11 | // core := app.New(location, app.Options{Emitter: emitter}) |
| 12 | // go core.Run(ctx) | 12 | // go core.Run(ctx) |
| 13 | // defer core.Shutdown(context.Background()) | 13 | // defer core.Shutdown(context.Background()) |
| 14 | -// services := []any{&app.AgentService{Core: core}, &app.ConfigService{Core: core}, &app.ChatService{Core: core}} | 14 | +// services := []any{&app.AgentService{Core: core}, &app.ConfigService{Core: core}, &app.ChannelService{Core: core}} |
| 15 | package app | 15 | package app |
| 16 | 16 | ||
| 17 | import ( | 17 | import ( |
| @@ -21,10 +21,11 @@ import ( | |||
| 21 | "sync" | 21 | "sync" |
| 22 | 22 | ||
| 23 | "git.rickub.com/bots-garden/sidekicks/internal/agents" | 23 | "git.rickub.com/bots-garden/sidekicks/internal/agents" |
| 24 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | ||
| 24 | "git.rickub.com/bots-garden/sidekicks/internal/config" | 25 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 25 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | ||
| 26 | "git.rickub.com/bots-garden/sidekicks/internal/events" | 26 | "git.rickub.com/bots-garden/sidekicks/internal/events" |
| 27 | "git.rickub.com/bots-garden/sidekicks/internal/prefs" | 27 | "git.rickub.com/bots-garden/sidekicks/internal/prefs" |
| 28 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | ||
| 28 | ) | 29 | ) |
| 29 | 30 | ||
| 30 | // Options configure a Core. Every field is optional. | 31 | // Options configure a Core. Every field is optional. |
| @@ -37,21 +38,29 @@ type Options struct { | |||
| 37 | // PickFile asks the user for a configuration file (native dialog); it | 38 | // PickFile asks the user for a configuration file (native dialog); it |
| 38 | // returns "" when the user cancels. | 39 | // returns "" when the user cancels. |
| 39 | PickFile func() (string, error) | 40 | PickFile func() (string, error) |
| 41 | + // OpenStore opens the history of the configuration file at path; nil | ||
| 42 | + // opens it in the user's configuration folder (store.DirFor). | ||
| 43 | + OpenStore func(configPath string) (*store.Store, error) | ||
| 40 | } | 44 | } |
| 41 | 45 | ||
| 42 | -// Core holds the loaded configuration, the agent manager and the conversations. | 46 | +// Core holds the loaded configuration, the agent manager, and the channels |
| 47 | +// with their store. | ||
| 43 | type Core struct { | 48 | type Core struct { |
| 44 | - options Options | 49 | + options Options |
| 45 | - manager *agents.Manager | 50 | + manager *agents.Manager |
| 46 | - conversations *conversations.Manager | ||
| 47 | 51 | ||
| 48 | mu sync.Mutex | 52 | mu sync.Mutex |
| 49 | location config.Location | 53 | location config.Location |
| 50 | cfg *config.Config | 54 | cfg *config.Config |
| 51 | problems []config.Problem | 55 | problems []config.Problem |
| 52 | created bool | 56 | created bool |
| 53 | - runCtx context.Context | 57 | + db *store.Store |
| 54 | - stopWait context.CancelFunc | 58 | + channels *channels.Manager |
| 59 | + // historyProblem says why the history could not be opened: it then lives | ||
| 60 | + // in memory and is lost at exit. | ||
| 61 | + historyProblem string | ||
| 62 | + runCtx context.Context | ||
| 63 | + stopWait context.CancelFunc | ||
| 55 | } | 64 | } |
| 56 | 65 | ||
| 57 | // New loads the configuration at location — creating the default file first | 66 | // New loads the configuration at location — creating the default file first |
| @@ -69,10 +78,66 @@ func New(location config.Location, options Options) *Core { | |||
| 69 | MaxRunning: cfg.Limits.MaxRunningAgents, IdleTimeout: cfg.Limits.StopIdleAgentsAfter, | 78 | MaxRunning: cfg.Limits.MaxRunningAgents, IdleTimeout: cfg.Limits.StopIdleAgentsAfter, |
| 70 | Emitter: options.Emitter, Starter: options.Starter, | 79 | Emitter: options.Emitter, Starter: options.Starter, |
| 71 | }) | 80 | }) |
| 72 | - core.conversations = conversations.NewManager(core.manager, conversations.Options{Emitter: options.Emitter}) | 81 | + core.openChannels() |
| 73 | return core | 82 | return core |
| 74 | } | 83 | } |
| 75 | 84 | ||
| 85 | +// openChannels opens the history of the current configuration file and the | ||
| 86 | +// channels it holds, then creates the channels the file declares. A history | ||
| 87 | +// that cannot be opened — another Sidekicks uses it, the disk is full — is | ||
| 88 | +// replaced by one in memory, and the reason is reported. The caller does not | ||
| 89 | +// hold c.mu. | ||
| 90 | +func (c *Core) openChannels() { | ||
| 91 | + c.mu.Lock() | ||
| 92 | + path, declared := c.location.Path, c.cfg.Channels | ||
| 93 | + c.mu.Unlock() | ||
| 94 | + open := c.options.OpenStore | ||
| 95 | + if open == nil { | ||
| 96 | + open = openStore | ||
| 97 | + } | ||
| 98 | + problem := "" | ||
| 99 | + db, err := open(path) | ||
| 100 | + if err != nil { | ||
| 101 | + problem = err.Error() | ||
| 102 | + db, _ = store.OpenInMemory() | ||
| 103 | + } | ||
| 104 | + manager, err := channels.NewManager(c.manager, db, channels.Options{Emitter: c.options.Emitter}) | ||
| 105 | + if err != nil { | ||
| 106 | + problem = err.Error() | ||
| 107 | + _ = db.Close() | ||
| 108 | + db, _ = store.OpenInMemory() | ||
| 109 | + manager, _ = channels.NewManager(c.manager, db, channels.Options{Emitter: c.options.Emitter}) | ||
| 110 | + } | ||
| 111 | + _ = manager.Declare(declared) | ||
| 112 | + c.mu.Lock() | ||
| 113 | + c.db, c.channels, c.historyProblem = db, manager, problem | ||
| 114 | + c.mu.Unlock() | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +func openStore(configPath string) (*store.Store, error) { | ||
| 118 | + dir, err := store.DirFor(configPath) | ||
| 119 | + if err != nil { | ||
| 120 | + return nil, err | ||
| 121 | + } | ||
| 122 | + return store.Open(dir) | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +// closeChannels stops the answers and closes the history. | ||
| 126 | +func (c *Core) closeChannels() { | ||
| 127 | + c.mu.Lock() | ||
| 128 | + manager, db := c.channels, c.db | ||
| 129 | + c.mu.Unlock() | ||
| 130 | + manager.Close() | ||
| 131 | + _ = db.Close() | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +// Channels gives access to the channels, for the services. | ||
| 135 | +func (c *Core) Channels() *channels.Manager { | ||
| 136 | + c.mu.Lock() | ||
| 137 | + defer c.mu.Unlock() | ||
| 138 | + return c.channels | ||
| 139 | +} | ||
| 140 | + | ||
| 76 | // load reads the configuration at location, creating the default file when | 141 | // load reads the configuration at location, creating the default file when |
| 77 | // that is where it should be. A file with problems gives an empty configuration. | 142 | // that is where it should be. A file with problems gives an empty configuration. |
| 78 | func load(location config.Location) (*config.Config, []config.Problem, bool) { | 143 | func load(location config.Location) (*config.Config, []config.Problem, bool) { |
| @@ -157,20 +222,22 @@ func (c *Core) apply(ctx context.Context, cfg *config.Config, err error) { | |||
| 157 | return | 222 | return |
| 158 | } | 223 | } |
| 159 | c.cfg, c.problems = cfg, nil | 224 | c.cfg, c.problems = cfg, nil |
| 225 | + manager := c.channels | ||
| 160 | c.mu.Unlock() | 226 | c.mu.Unlock() |
| 161 | c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter) | 227 | c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter) |
| 228 | + _ = manager.Declare(cfg.Channels) | ||
| 162 | c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: path, Valid: true, Problems: []string{}}) | 229 | c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: path, Valid: true, Problems: []string{}}) |
| 163 | } | 230 | } |
| 164 | 231 | ||
| 165 | -// Open switches to another configuration file: the conversations are | 232 | +// Open switches to another configuration file: the answers are stopped, |
| 166 | -// forgotten, every agent is stopped, the file is loaded (with its problems, | 233 | +// every agent is stopped, the file is loaded (with its problems, if any) |
| 167 | -// if any), watched, and remembered as the last one opened. | 234 | +// with its own history, watched, and remembered as the last one opened. |
| 168 | func (c *Core) Open(ctx context.Context, path string) error { | 235 | func (c *Core) Open(ctx context.Context, path string) error { |
| 169 | absolute, err := filepath.Abs(path) | 236 | absolute, err := filepath.Abs(path) |
| 170 | if err != nil { | 237 | if err != nil { |
| 171 | return err | 238 | return err |
| 172 | } | 239 | } |
| 173 | - c.conversations.Reset() | 240 | + c.closeChannels() |
| 174 | c.manager.StopAll(ctx) | 241 | c.manager.StopAll(ctx) |
| 175 | 242 | ||
| 176 | location := config.Location{Path: absolute, Source: config.SourceLastOpened} | 243 | location := config.Location{Path: absolute, Source: config.SourceLastOpened} |
| @@ -179,6 +246,7 @@ func (c *Core) Open(ctx context.Context, path string) error { | |||
| 179 | c.location, c.cfg, c.problems, c.created = location, cfg, problems, false | 246 | c.location, c.cfg, c.problems, c.created = location, cfg, problems, false |
| 180 | c.mu.Unlock() | 247 | c.mu.Unlock() |
| 181 | c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter) | 248 | c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter) |
| 249 | + c.openChannels() | ||
| 182 | c.watch() | 250 | c.watch() |
| 183 | if c.options.Preferences != nil { | 251 | if c.options.Preferences != nil { |
| 184 | _ = c.options.Preferences.Remember(absolute) | 252 | _ = c.options.Preferences.Remember(absolute) |
| @@ -187,9 +255,9 @@ func (c *Core) Open(ctx context.Context, path string) error { | |||
| 187 | return nil | 255 | return nil |
| 188 | } | 256 | } |
| 189 | 257 | ||
| 190 | -// Shutdown cancels the answers and stops every agent. | 258 | +// Shutdown cancels the answers, stops every agent and closes the history. |
| 191 | func (c *Core) Shutdown(ctx context.Context) { | 259 | func (c *Core) Shutdown(ctx context.Context) { |
| 192 | - c.conversations.Reset() | 260 | + c.closeChannels() |
| 193 | c.manager.StopAll(ctx) | 261 | c.manager.StopAll(ctx) |
| 194 | } | 262 | } |
| 195 | 263 | ||
modified
internal/app/app_test.go +79 -11 | @@ -13,21 +13,42 @@ import ( | ||
| 13 | 13 | "git.rickub.com/bots-garden/sidekicks/internal/acp" |
| 14 | 14 | "git.rickub.com/bots-garden/sidekicks/internal/agents" |
| 15 | 15 | "git.rickub.com/bots-garden/sidekicks/internal/app" |
| 16 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | |
| 16 | 17 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 17 | 18 | "git.rickub.com/bots-garden/sidekicks/internal/events" |
| 18 | 19 | "git.rickub.com/bots-garden/sidekicks/internal/prefs" |
| 19 | 20 | ) |
| 20 | 21 | |
| 21 | -type conn struct{ done chan struct{} } | |
| 22 | +// TestMain points the user's configuration folder, where the history is | |
| 23 | +// kept, to a temporary folder: tests never touch the real one. | |
| 24 | +func TestMain(m *testing.M) { | |
| 25 | + home, err := os.MkdirTemp("", "sidekicks-app-test") | |
| 26 | + if err != nil { | |
| 27 | + panic(err) | |
| 28 | + } | |
| 29 | + _ = os.Setenv("HOME", home) | |
| 30 | + _ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 31 | + code := m.Run() | |
| 32 | + _ = os.RemoveAll(home) | |
| 33 | + os.Exit(code) | |
| 34 | +} | |
| 35 | + | |
| 36 | +type conn struct { | |
| 37 | + done chan struct{} | |
| 38 | + handler acp.SessionHandler | |
| 39 | +} | |
| 22 | 40 | |
| 23 | 41 | func (c *conn) Info() acp.Info { return acp.Info{} } |
| 24 | 42 | func (c *conn) Done() <-chan struct{} { return c.done } |
| 25 | 43 | func (c *conn) Err() error { return nil } |
| 26 | 44 | func (c *conn) Close(context.Context) error { close(c.done); return nil } |
| 27 | -func (c *conn) NewSession(context.Context, string, acp.SessionHandler) (string, error) { | |
| 45 | +func (c *conn) NewSession(_ context.Context, _ string, handler acp.SessionHandler) (string, error) { | |
| 46 | + c.handler = handler | |
| 28 | 47 | return "s-1", nil |
| 29 | 48 | } |
| 49 | +func (c *conn) LoadSession(context.Context, string, string, acp.SessionHandler) error { return nil } | |
| 30 | 50 | func (c *conn) Prompt(context.Context, string, string) (acp.TurnResult, error) { |
| 51 | + c.handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: "ok"}) | |
| 31 | 52 | return acp.TurnResult{StopReason: "end_turn"}, nil |
| 32 | 53 | } |
| 33 | 54 | func (c *conn) Cancel(context.Context, string) error { return nil } |
| @@ -47,10 +68,14 @@ const twoAgents = "version: 1\nui:\n theme: dark\nagents:\n - name: alice\n | ||
| 47 | 68 | func TestTheDefaultFileIsCreatedAtTheDefaultLocation(t *testing.T) { |
| 48 | 69 | path := filepath.Join(t.TempDir(), "sidekicks", "config.yaml") |
| 49 | 70 | core := app.New(config.Location{Path: path, Source: config.SourceDefault}, app.Options{Starter: starter}) |
| 71 | + t.Cleanup(func() { core.Shutdown(context.Background()) }) | |
| 50 | 72 | view := (&app.ConfigService{Core: core}).Current() |
| 51 | - if !view.Created || !view.Valid || view.Source != "default" || view.Channels[0].Name != "general" { | |
| 73 | + if !view.Created || !view.Valid || view.Source != "default" || view.HistoryProblem != "" { | |
| 52 | 74 | t.Fatalf("got %+v", view) |
| 53 | 75 | } |
| 76 | + if list := (&app.ChannelService{Core: core}).List(); len(list) != 1 || list[0].Name != "general" { | |
| 77 | + t.Fatalf("the declared channel is created: %+v", list) | |
| 78 | + } | |
| 54 | 79 | } |
| 55 | 80 | |
| 56 | 81 | func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) { |
| @@ -69,10 +94,14 @@ func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) { | ||
| 69 | 94 | path := filepath.Join(t.TempDir(), "config.yaml") |
| 70 | 95 | write(t, path, twoAgents) |
| 71 | 96 | core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter}) |
| 97 | + t.Cleanup(func() { core.Shutdown(context.Background()) }) | |
| 72 | 98 | view := (&app.ConfigService{Core: core}).Current() |
| 73 | - if !view.Valid || view.Theme != "dark" || view.Language != "en" || len(view.Channels) != 1 { | |
| 99 | + if !view.Valid || view.Theme != "dark" || view.Language != "en" { | |
| 74 | 100 | t.Fatalf("got %+v", view) |
| 75 | 101 | } |
| 102 | + if list := (&app.ChannelService{Core: core}).List(); len(list) != 1 || list[0].Name != "général" || list[0].Members[0] != "alice" { | |
| 103 | + t.Fatalf("channels: %+v", list) | |
| 104 | + } | |
| 76 | 105 | service := &app.AgentService{Core: core} |
| 77 | 106 | if list := service.List(); len(list) != 2 || list[0].State != agents.StateStopped { |
| 78 | 107 | t.Fatalf("got %+v", list) |
| @@ -202,18 +231,50 @@ func newSwitchFixture(t *testing.T) *switchFixture { | ||
| 202 | 231 | PickFile: func() (string, error) { return f.picked, nil }, |
| 203 | 232 | }) |
| 204 | 233 | ctx, cancel := context.WithCancel(context.Background()) |
| 205 | - t.Cleanup(cancel) | |
| 234 | + t.Cleanup(func() { | |
| 235 | + cancel() | |
| 236 | + f.core.Shutdown(context.Background()) | |
| 237 | + }) | |
| 206 | 238 | go f.core.Run(ctx) |
| 207 | 239 | return f |
| 208 | 240 | } |
| 209 | 241 | |
| 210 | -func TestOpenSwitchesConfigurationAndForgetsConversations(t *testing.T) { | |
| 242 | +func TestAHistoryInUseIsReplacedByOneInMemory(t *testing.T) { | |
| 243 | + path := filepath.Join(t.TempDir(), "config.yaml") | |
| 244 | + write(t, path, twoAgents) | |
| 245 | + first := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter}) | |
| 246 | + t.Cleanup(func() { first.Shutdown(context.Background()) }) | |
| 247 | + second := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter}) | |
| 248 | + t.Cleanup(func() { second.Shutdown(context.Background()) }) | |
| 249 | + if view := (&app.ConfigService{Core: second}).Current(); view.HistoryProblem == "" { | |
| 250 | + t.Fatal("a second Sidekicks on the same file cannot open its history, and says so") | |
| 251 | + } | |
| 252 | + if list := (&app.ChannelService{Core: second}).List(); len(list) != 1 { | |
| 253 | + t.Fatalf("it still works, in memory: %+v", list) | |
| 254 | + } | |
| 255 | +} | |
| 256 | + | |
| 257 | +func conversation(t *testing.T, service *app.ChannelService, id string) channels.Conversation { | |
| 258 | + t.Helper() | |
| 259 | + view, err := service.Conversation(id) | |
| 260 | + if err != nil { | |
| 261 | + t.Fatal(err) | |
| 262 | + } | |
| 263 | + return view | |
| 264 | +} | |
| 265 | + | |
| 266 | +func TestOpenSwitchesConfigurationAndItsHistory(t *testing.T) { | |
| 211 | 267 | f := newSwitchFixture(t) |
| 212 | - chat := &app.ChatService{Core: f.core} | |
| 213 | - if _, err := chat.Send("alice", "hello"); err != nil { | |
| 268 | + service := &app.ChannelService{Core: f.core} | |
| 269 | + direct, err := service.Direct("alice") | |
| 270 | + if err != nil { | |
| 214 | 271 | t.Fatal(err) |
| 215 | 272 | } |
| 216 | - waitFor(t, func() bool { return len(chat.Conversation("alice").Messages) == 2 && !chat.Conversation("alice").Busy }) | |
| 273 | + if _, err := service.Send(direct.ID, "hello"); err != nil { | |
| 274 | + t.Fatal(err) | |
| 275 | + } | |
| 276 | + waitFor(t, func() bool { view := conversation(t, service, direct.ID); return len(view.Messages) == 2 && !view.Busy }) | |
| 277 | + first := (&app.ConfigService{Core: f.core}).Current().Path | |
| 217 | 278 | |
| 218 | 279 | f.picked = f.second |
| 219 | 280 | if path, err := (&app.ConfigService{Core: f.core}).Choose(context.Background()); err != nil || path != f.second { |
| @@ -222,8 +283,8 @@ func TestOpenSwitchesConfigurationAndForgetsConversations(t *testing.T) { | ||
| 222 | 283 | if list := (&app.AgentService{Core: f.core}).List(); len(list) != 1 || list[0].Name != "garfield" { |
| 223 | 284 | t.Fatalf("agents of the new file: %+v", list) |
| 224 | 285 | } |
| 225 | - if len(chat.Conversation("alice").Messages) != 0 { | |
| 226 | - t.Fatal("conversations are forgotten when switching") | |
| 286 | + if list := service.List(); len(list) != 0 { | |
| 287 | + t.Fatalf("the new file has its own history: %+v", list) | |
| 227 | 288 | } |
| 228 | 289 | if view := (&app.ConfigService{Core: f.core}).Current(); view.Path != f.second || view.Source != "last-opened" { |
| 229 | 290 | t.Fatalf("view: %+v", view) |
| @@ -231,6 +292,13 @@ func TestOpenSwitchesConfigurationAndForgetsConversations(t *testing.T) { | ||
| 231 | 292 | if len(f.recorder.Named(events.ConfigChanged)) == 0 { |
| 232 | 293 | t.Fatal("config:changed expected after opening") |
| 233 | 294 | } |
| 295 | + | |
| 296 | + if err := (&app.ConfigService{Core: f.core}).Open(context.Background(), first); err != nil { | |
| 297 | + t.Fatal(err) | |
| 298 | + } | |
| 299 | + if view := conversation(t, service, direct.ID); len(view.Messages) != 2 { | |
| 300 | + t.Fatalf("the first file's history is back: %+v", view.Messages) | |
| 301 | + } | |
| 234 | 302 | } |
| 235 | 303 | |
| 236 | 304 | func TestOpenRemembersTheFileAndWatchesIt(t *testing.T) { |
| @@ -13,21 +13,42 @@ import ( | |||
| 13 | "git.rickub.com/bots-garden/sidekicks/internal/acp" | 13 | "git.rickub.com/bots-garden/sidekicks/internal/acp" |
| 14 | "git.rickub.com/bots-garden/sidekicks/internal/agents" | 14 | "git.rickub.com/bots-garden/sidekicks/internal/agents" |
| 15 | "git.rickub.com/bots-garden/sidekicks/internal/app" | 15 | "git.rickub.com/bots-garden/sidekicks/internal/app" |
| 16 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | ||
| 16 | "git.rickub.com/bots-garden/sidekicks/internal/config" | 17 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 17 | "git.rickub.com/bots-garden/sidekicks/internal/events" | 18 | "git.rickub.com/bots-garden/sidekicks/internal/events" |
| 18 | "git.rickub.com/bots-garden/sidekicks/internal/prefs" | 19 | "git.rickub.com/bots-garden/sidekicks/internal/prefs" |
| 19 | ) | 20 | ) |
| 20 | 21 | ||
| 21 | -type conn struct{ done chan struct{} } | 22 | +// TestMain points the user's configuration folder, where the history is |
| 23 | +// kept, to a temporary folder: tests never touch the real one. | ||
| 24 | +func TestMain(m *testing.M) { | ||
| 25 | + home, err := os.MkdirTemp("", "sidekicks-app-test") | ||
| 26 | + if err != nil { | ||
| 27 | + panic(err) | ||
| 28 | + } | ||
| 29 | + _ = os.Setenv("HOME", home) | ||
| 30 | + _ = os.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | ||
| 31 | + code := m.Run() | ||
| 32 | + _ = os.RemoveAll(home) | ||
| 33 | + os.Exit(code) | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +type conn struct { | ||
| 37 | + done chan struct{} | ||
| 38 | + handler acp.SessionHandler | ||
| 39 | +} | ||
| 22 | 40 | ||
| 23 | func (c *conn) Info() acp.Info { return acp.Info{} } | 41 | func (c *conn) Info() acp.Info { return acp.Info{} } |
| 24 | func (c *conn) Done() <-chan struct{} { return c.done } | 42 | func (c *conn) Done() <-chan struct{} { return c.done } |
| 25 | func (c *conn) Err() error { return nil } | 43 | func (c *conn) Err() error { return nil } |
| 26 | func (c *conn) Close(context.Context) error { close(c.done); return nil } | 44 | func (c *conn) Close(context.Context) error { close(c.done); return nil } |
| 27 | -func (c *conn) NewSession(context.Context, string, acp.SessionHandler) (string, error) { | 45 | +func (c *conn) NewSession(_ context.Context, _ string, handler acp.SessionHandler) (string, error) { |
| 46 | + c.handler = handler | ||
| 28 | return "s-1", nil | 47 | return "s-1", nil |
| 29 | } | 48 | } |
| 49 | +func (c *conn) LoadSession(context.Context, string, string, acp.SessionHandler) error { return nil } | ||
| 30 | func (c *conn) Prompt(context.Context, string, string) (acp.TurnResult, error) { | 50 | func (c *conn) Prompt(context.Context, string, string) (acp.TurnResult, error) { |
| 51 | + c.handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: "ok"}) | ||
| 31 | return acp.TurnResult{StopReason: "end_turn"}, nil | 52 | return acp.TurnResult{StopReason: "end_turn"}, nil |
| 32 | } | 53 | } |
| 33 | func (c *conn) Cancel(context.Context, string) error { return nil } | 54 | func (c *conn) Cancel(context.Context, string) error { return nil } |
| @@ -47,10 +68,14 @@ const twoAgents = "version: 1\nui:\n theme: dark\nagents:\n - name: alice\n | |||
| 47 | func TestTheDefaultFileIsCreatedAtTheDefaultLocation(t *testing.T) { | 68 | func TestTheDefaultFileIsCreatedAtTheDefaultLocation(t *testing.T) { |
| 48 | path := filepath.Join(t.TempDir(), "sidekicks", "config.yaml") | 69 | path := filepath.Join(t.TempDir(), "sidekicks", "config.yaml") |
| 49 | core := app.New(config.Location{Path: path, Source: config.SourceDefault}, app.Options{Starter: starter}) | 70 | core := app.New(config.Location{Path: path, Source: config.SourceDefault}, app.Options{Starter: starter}) |
| 71 | + t.Cleanup(func() { core.Shutdown(context.Background()) }) | ||
| 50 | view := (&app.ConfigService{Core: core}).Current() | 72 | view := (&app.ConfigService{Core: core}).Current() |
| 51 | - if !view.Created || !view.Valid || view.Source != "default" || view.Channels[0].Name != "general" { | 73 | + if !view.Created || !view.Valid || view.Source != "default" || view.HistoryProblem != "" { |
| 52 | t.Fatalf("got %+v", view) | 74 | t.Fatalf("got %+v", view) |
| 53 | } | 75 | } |
| 76 | + if list := (&app.ChannelService{Core: core}).List(); len(list) != 1 || list[0].Name != "general" { | ||
| 77 | + t.Fatalf("the declared channel is created: %+v", list) | ||
| 78 | + } | ||
| 54 | } | 79 | } |
| 55 | 80 | ||
| 56 | func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) { | 81 | func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) { |
| @@ -69,10 +94,14 @@ func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) { | |||
| 69 | path := filepath.Join(t.TempDir(), "config.yaml") | 94 | path := filepath.Join(t.TempDir(), "config.yaml") |
| 70 | write(t, path, twoAgents) | 95 | write(t, path, twoAgents) |
| 71 | core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter}) | 96 | core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter}) |
| 97 | + t.Cleanup(func() { core.Shutdown(context.Background()) }) | ||
| 72 | view := (&app.ConfigService{Core: core}).Current() | 98 | view := (&app.ConfigService{Core: core}).Current() |
| 73 | - if !view.Valid || view.Theme != "dark" || view.Language != "en" || len(view.Channels) != 1 { | 99 | + if !view.Valid || view.Theme != "dark" || view.Language != "en" { |
| 74 | t.Fatalf("got %+v", view) | 100 | t.Fatalf("got %+v", view) |
| 75 | } | 101 | } |
| 102 | + if list := (&app.ChannelService{Core: core}).List(); len(list) != 1 || list[0].Name != "général" || list[0].Members[0] != "alice" { | ||
| 103 | + t.Fatalf("channels: %+v", list) | ||
| 104 | + } | ||
| 76 | service := &app.AgentService{Core: core} | 105 | service := &app.AgentService{Core: core} |
| 77 | if list := service.List(); len(list) != 2 || list[0].State != agents.StateStopped { | 106 | if list := service.List(); len(list) != 2 || list[0].State != agents.StateStopped { |
| 78 | t.Fatalf("got %+v", list) | 107 | t.Fatalf("got %+v", list) |
| @@ -202,18 +231,50 @@ func newSwitchFixture(t *testing.T) *switchFixture { | |||
| 202 | PickFile: func() (string, error) { return f.picked, nil }, | 231 | PickFile: func() (string, error) { return f.picked, nil }, |
| 203 | }) | 232 | }) |
| 204 | ctx, cancel := context.WithCancel(context.Background()) | 233 | ctx, cancel := context.WithCancel(context.Background()) |
| 205 | - t.Cleanup(cancel) | 234 | + t.Cleanup(func() { |
| 235 | + cancel() | ||
| 236 | + f.core.Shutdown(context.Background()) | ||
| 237 | + }) | ||
| 206 | go f.core.Run(ctx) | 238 | go f.core.Run(ctx) |
| 207 | return f | 239 | return f |
| 208 | } | 240 | } |
| 209 | 241 | ||
| 210 | -func TestOpenSwitchesConfigurationAndForgetsConversations(t *testing.T) { | 242 | +func TestAHistoryInUseIsReplacedByOneInMemory(t *testing.T) { |
| 243 | + path := filepath.Join(t.TempDir(), "config.yaml") | ||
| 244 | + write(t, path, twoAgents) | ||
| 245 | + first := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter}) | ||
| 246 | + t.Cleanup(func() { first.Shutdown(context.Background()) }) | ||
| 247 | + second := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter}) | ||
| 248 | + t.Cleanup(func() { second.Shutdown(context.Background()) }) | ||
| 249 | + if view := (&app.ConfigService{Core: second}).Current(); view.HistoryProblem == "" { | ||
| 250 | + t.Fatal("a second Sidekicks on the same file cannot open its history, and says so") | ||
| 251 | + } | ||
| 252 | + if list := (&app.ChannelService{Core: second}).List(); len(list) != 1 { | ||
| 253 | + t.Fatalf("it still works, in memory: %+v", list) | ||
| 254 | + } | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +func conversation(t *testing.T, service *app.ChannelService, id string) channels.Conversation { | ||
| 258 | + t.Helper() | ||
| 259 | + view, err := service.Conversation(id) | ||
| 260 | + if err != nil { | ||
| 261 | + t.Fatal(err) | ||
| 262 | + } | ||
| 263 | + return view | ||
| 264 | +} | ||
| 265 | + | ||
| 266 | +func TestOpenSwitchesConfigurationAndItsHistory(t *testing.T) { | ||
| 211 | f := newSwitchFixture(t) | 267 | f := newSwitchFixture(t) |
| 212 | - chat := &app.ChatService{Core: f.core} | 268 | + service := &app.ChannelService{Core: f.core} |
| 213 | - if _, err := chat.Send("alice", "hello"); err != nil { | 269 | + direct, err := service.Direct("alice") |
| 270 | + if err != nil { | ||
| 214 | t.Fatal(err) | 271 | t.Fatal(err) |
| 215 | } | 272 | } |
| 216 | - waitFor(t, func() bool { return len(chat.Conversation("alice").Messages) == 2 && !chat.Conversation("alice").Busy }) | 273 | + if _, err := service.Send(direct.ID, "hello"); err != nil { |
| 274 | + t.Fatal(err) | ||
| 275 | + } | ||
| 276 | + waitFor(t, func() bool { view := conversation(t, service, direct.ID); return len(view.Messages) == 2 && !view.Busy }) | ||
| 277 | + first := (&app.ConfigService{Core: f.core}).Current().Path | ||
| 217 | 278 | ||
| 218 | f.picked = f.second | 279 | f.picked = f.second |
| 219 | if path, err := (&app.ConfigService{Core: f.core}).Choose(context.Background()); err != nil || path != f.second { | 280 | if path, err := (&app.ConfigService{Core: f.core}).Choose(context.Background()); err != nil || path != f.second { |
| @@ -222,8 +283,8 @@ func TestOpenSwitchesConfigurationAndForgetsConversations(t *testing.T) { | |||
| 222 | if list := (&app.AgentService{Core: f.core}).List(); len(list) != 1 || list[0].Name != "garfield" { | 283 | if list := (&app.AgentService{Core: f.core}).List(); len(list) != 1 || list[0].Name != "garfield" { |
| 223 | t.Fatalf("agents of the new file: %+v", list) | 284 | t.Fatalf("agents of the new file: %+v", list) |
| 224 | } | 285 | } |
| 225 | - if len(chat.Conversation("alice").Messages) != 0 { | 286 | + if list := service.List(); len(list) != 0 { |
| 226 | - t.Fatal("conversations are forgotten when switching") | 287 | + t.Fatalf("the new file has its own history: %+v", list) |
| 227 | } | 288 | } |
| 228 | if view := (&app.ConfigService{Core: f.core}).Current(); view.Path != f.second || view.Source != "last-opened" { | 289 | if view := (&app.ConfigService{Core: f.core}).Current(); view.Path != f.second || view.Source != "last-opened" { |
| 229 | t.Fatalf("view: %+v", view) | 290 | t.Fatalf("view: %+v", view) |
| @@ -231,6 +292,13 @@ func TestOpenSwitchesConfigurationAndForgetsConversations(t *testing.T) { | |||
| 231 | if len(f.recorder.Named(events.ConfigChanged)) == 0 { | 292 | if len(f.recorder.Named(events.ConfigChanged)) == 0 { |
| 232 | t.Fatal("config:changed expected after opening") | 293 | t.Fatal("config:changed expected after opening") |
| 233 | } | 294 | } |
| 295 | + | ||
| 296 | + if err := (&app.ConfigService{Core: f.core}).Open(context.Background(), first); err != nil { | ||
| 297 | + t.Fatal(err) | ||
| 298 | + } | ||
| 299 | + if view := conversation(t, service, direct.ID); len(view.Messages) != 2 { | ||
| 300 | + t.Fatalf("the first file's history is back: %+v", view.Messages) | ||
| 301 | + } | ||
| 234 | } | 302 | } |
| 235 | 303 | ||
| 236 | func TestOpenRemembersTheFileAndWatchesIt(t *testing.T) { | 304 | func TestOpenRemembersTheFileAndWatchesIt(t *testing.T) { |
modified
internal/app/services.go +88 -39 | @@ -7,9 +7,10 @@ import ( | ||
| 7 | 7 | "errors" |
| 8 | 8 | "os" |
| 9 | 9 | |
| 10 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | |
| 10 | 11 | "git.rickub.com/bots-garden/sidekicks/internal/agents" |
| 12 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | |
| 11 | 13 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 12 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | |
| 13 | 14 | "git.rickub.com/bots-garden/sidekicks/schema" |
| 14 | 15 | ) |
| 15 | 16 | |
| @@ -44,33 +45,93 @@ func (s *AgentService) Logs(name string) ([]string, error) { | ||
| 44 | 45 | return s.Core.manager.Logs(name) |
| 45 | 46 | } |
| 46 | 47 | |
| 47 | -// ChatService is the direct conversation with each agent (milestone M2). | |
| 48 | -// Answers arrive as message:updated, message:delta, turn:changed, | |
| 49 | -// permission:requested, permission:resolved and usage:changed events. | |
| 50 | -type ChatService struct { | |
| 48 | +// ChannelService is the channels and direct conversations (milestone M3). | |
| 49 | +// Changes arrive as channels:changed events; answers as message:updated, | |
| 50 | +// message:delta, turn:changed, permission:requested, permission:resolved, | |
| 51 | +// usage:changed and commands:changed events. | |
| 52 | +type ChannelService struct { | |
| 51 | 53 | Core *Core |
| 52 | 54 | } |
| 53 | 55 | |
| 54 | -// Send sends a message to the agent and returns the message id; the agent | |
| 55 | -// is started if needed. A message sent while the agent answers is queued. | |
| 56 | -func (s *ChatService) Send(agent, text string) (string, error) { | |
| 57 | - return s.Core.conversations.Send(agent, text) | |
| 56 | +// List returns every channel and direct conversation, oldest first. | |
| 57 | +func (s *ChannelService) List() []channels.Channel { | |
| 58 | + return s.Core.Channels().Channels() | |
| 58 | 59 | } |
| 59 | 60 | |
| 60 | -// Cancel stops the agent's current answer and drops the queued messages. | |
| 61 | -func (s *ChatService) Cancel(agent string) { | |
| 62 | - s.Core.conversations.Cancel(agent) | |
| 61 | +// Create creates a channel. | |
| 62 | +func (s *ChannelService) Create(name, description string) (channels.Channel, error) { | |
| 63 | + return s.Core.Channels().Create(name, description) | |
| 64 | +} | |
| 65 | + | |
| 66 | +// Rename changes the name and description of a channel. | |
| 67 | +func (s *ChannelService) Rename(id, name, description string) (channels.Channel, error) { | |
| 68 | + return s.Core.Channels().Rename(id, name, description) | |
| 69 | +} | |
| 70 | + | |
| 71 | +// Delete deletes a channel and its whole history. | |
| 72 | +func (s *ChannelService) Delete(id string) error { | |
| 73 | + return s.Core.Channels().Delete(id) | |
| 74 | +} | |
| 75 | + | |
| 76 | +// AddMember invites an agent into a channel. | |
| 77 | +func (s *ChannelService) AddMember(id, agent string) error { | |
| 78 | + return s.Core.Channels().AddMember(id, agent) | |
| 79 | +} | |
| 80 | + | |
| 81 | +// RemoveMember removes an agent from a channel. | |
| 82 | +func (s *ChannelService) RemoveMember(id, agent string) error { | |
| 83 | + return s.Core.Channels().RemoveMember(id, agent) | |
| 84 | +} | |
| 85 | + | |
| 86 | +// Direct returns the direct conversation with an agent, created the first time. | |
| 87 | +func (s *ChannelService) Direct(agent string) (channels.Channel, error) { | |
| 88 | + return s.Core.Channels().Direct(agent) | |
| 89 | +} | |
| 90 | + | |
| 91 | +// Send sends a message to a channel and returns its id. The agents it | |
| 92 | +// addresses are started if needed; a message sent during an answer is queued. | |
| 93 | +func (s *ChannelService) Send(id, text string) (string, error) { | |
| 94 | + return s.Core.Channels().Send(id, text) | |
| 95 | +} | |
| 96 | + | |
| 97 | +// Cancel stops the current answer of a channel, the agents still to answer | |
| 98 | +// and the queued messages. | |
| 99 | +func (s *ChannelService) Cancel(id string) { | |
| 100 | + s.Core.Channels().Cancel(id) | |
| 63 | 101 | } |
| 64 | 102 | |
| 65 | 103 | // AnswerPermission gives the user's choice for a permission request. |
| 66 | -func (s *ChatService) AnswerPermission(id, optionID string) error { | |
| 67 | - return s.Core.conversations.AnswerPermission(id, optionID) | |
| 104 | +func (s *ChannelService) AnswerPermission(id, optionID string) error { | |
| 105 | + return s.Core.Channels().AnswerPermission(id, optionID) | |
| 106 | +} | |
| 107 | + | |
| 108 | +// Conversation returns the last messages and the state of a channel. | |
| 109 | +func (s *ChannelService) Conversation(id string) (channels.Conversation, error) { | |
| 110 | + return s.Core.Channels().Conversation(id) | |
| 111 | +} | |
| 112 | + | |
| 113 | +// Older returns the messages before beforeID and whether older ones exist. | |
| 114 | +func (s *ChannelService) Older(id, beforeID string) (OlderView, error) { | |
| 115 | + messages, more, err := s.Core.Channels().Older(id, beforeID) | |
| 116 | + return OlderView{Messages: messages, More: more}, err | |
| 117 | +} | |
| 118 | + | |
| 119 | +// Prepare asks the members of a channel whose commands are not known yet | |
| 120 | +// for them (the user typed "/"): they are started and their session opened, | |
| 121 | +// without loading a model; the commands arrive as commands:changed events. | |
| 122 | +func (s *ChannelService) Prepare(ctx context.Context, id string) error { | |
| 123 | + return s.Core.Channels().Prepare(ctx, id) | |
| 124 | +} | |
| 125 | + | |
| 126 | +// Commands returns the slash commands an agent offers. | |
| 127 | +func (s *ChannelService) Commands(agent string) []acp.Command { | |
| 128 | + return s.Core.Channels().Commands(agent) | |
| 68 | 129 | } |
| 69 | 130 | |
| 70 | -// Conversation returns the whole conversation with the agent, e.g. when the | |
| 71 | -// interface switches to it. | |
| 72 | -func (s *ChatService) Conversation(agent string) conversations.Conversation { | |
| 73 | - return s.Core.conversations.Conversation(agent) | |
| 131 | +// OlderView is a page of older messages. | |
| 132 | +type OlderView struct { | |
| 133 | + Messages []channels.Message `json:"messages"` | |
| 134 | + More bool `json:"more"` | |
| 74 | 135 | } |
| 75 | 136 | |
| 76 | 137 | // ConfigView is the configuration as the interface shows it. |
| @@ -82,19 +143,14 @@ type ConfigView struct { | ||
| 82 | 143 | Created bool `json:"created"` |
| 83 | 144 | // Valid is false when the file has problems; the last valid configuration |
| 84 | 145 | // (or an empty one) is then in use. |
| 85 | - Valid bool `json:"valid"` | |
| 86 | - Problems []string `json:"problems"` | |
| 87 | - Warnings []string `json:"warnings"` | |
| 88 | - Theme string `json:"theme"` | |
| 89 | - Language string `json:"language"` | |
| 90 | - Channels []ChannelView `json:"channels"` | |
| 91 | -} | |
| 92 | - | |
| 93 | -// ChannelView is a channel declared in the configuration. | |
| 94 | -type ChannelView struct { | |
| 95 | - Name string `json:"name"` | |
| 96 | - Description string `json:"description"` | |
| 97 | - Agents []string `json:"agents"` | |
| 146 | + Valid bool `json:"valid"` | |
| 147 | + Problems []string `json:"problems"` | |
| 148 | + Warnings []string `json:"warnings"` | |
| 149 | + Theme string `json:"theme"` | |
| 150 | + Language string `json:"language"` | |
| 151 | + // HistoryProblem says why the history of this file could not be opened: | |
| 152 | + // it is then kept in memory only, until Sidekicks quits. | |
| 153 | + HistoryProblem string `json:"historyProblem"` | |
| 98 | 154 | } |
| 99 | 155 | |
| 100 | 156 | // ProblemView is a problem of the text being edited, located for the editor. |
| @@ -120,14 +176,7 @@ func (s *ConfigService) Current() ConfigView { | ||
| 120 | 176 | view := ConfigView{ |
| 121 | 177 | Path: c.location.Path, Source: string(c.location.Source), Created: c.created, |
| 122 | 178 | Valid: len(c.problems) == 0, Problems: messages(c.problems), Warnings: messages(c.cfg.Warnings), |
| 123 | - Theme: c.cfg.UI.Theme, Language: c.cfg.UI.Language, Channels: []ChannelView{}, | |
| 124 | - } | |
| 125 | - for _, channel := range c.cfg.Channels { | |
| 126 | - members := channel.Agents | |
| 127 | - if members == nil { | |
| 128 | - members = []string{} | |
| 129 | - } | |
| 130 | - view.Channels = append(view.Channels, ChannelView{Name: channel.Name, Description: channel.Description, Agents: members}) | |
| 179 | + Theme: c.cfg.UI.Theme, Language: c.cfg.UI.Language, HistoryProblem: c.historyProblem, | |
| 131 | 180 | } |
| 132 | 181 | return view |
| 133 | 182 | } |
| @@ -7,9 +7,10 @@ import ( | |||
| 7 | "errors" | 7 | "errors" |
| 8 | "os" | 8 | "os" |
| 9 | 9 | ||
| 10 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | ||
| 10 | "git.rickub.com/bots-garden/sidekicks/internal/agents" | 11 | "git.rickub.com/bots-garden/sidekicks/internal/agents" |
| 12 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | ||
| 11 | "git.rickub.com/bots-garden/sidekicks/internal/config" | 13 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 12 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | ||
| 13 | "git.rickub.com/bots-garden/sidekicks/schema" | 14 | "git.rickub.com/bots-garden/sidekicks/schema" |
| 14 | ) | 15 | ) |
| 15 | 16 | ||
| @@ -44,33 +45,93 @@ func (s *AgentService) Logs(name string) ([]string, error) { | |||
| 44 | return s.Core.manager.Logs(name) | 45 | return s.Core.manager.Logs(name) |
| 45 | } | 46 | } |
| 46 | 47 | ||
| 47 | -// ChatService is the direct conversation with each agent (milestone M2). | 48 | +// ChannelService is the channels and direct conversations (milestone M3). |
| 48 | -// Answers arrive as message:updated, message:delta, turn:changed, | 49 | +// Changes arrive as channels:changed events; answers as message:updated, |
| 49 | -// permission:requested, permission:resolved and usage:changed events. | 50 | +// message:delta, turn:changed, permission:requested, permission:resolved, |
| 50 | -type ChatService struct { | 51 | +// usage:changed and commands:changed events. |
| 52 | +type ChannelService struct { | ||
| 51 | Core *Core | 53 | Core *Core |
| 52 | } | 54 | } |
| 53 | 55 | ||
| 54 | -// Send sends a message to the agent and returns the message id; the agent | 56 | +// List returns every channel and direct conversation, oldest first. |
| 55 | -// is started if needed. A message sent while the agent answers is queued. | 57 | +func (s *ChannelService) List() []channels.Channel { |
| 56 | -func (s *ChatService) Send(agent, text string) (string, error) { | 58 | + return s.Core.Channels().Channels() |
| 57 | - return s.Core.conversations.Send(agent, text) | ||
| 58 | } | 59 | } |
| 59 | 60 | ||
| 60 | -// Cancel stops the agent's current answer and drops the queued messages. | 61 | +// Create creates a channel. |
| 61 | -func (s *ChatService) Cancel(agent string) { | 62 | +func (s *ChannelService) Create(name, description string) (channels.Channel, error) { |
| 62 | - s.Core.conversations.Cancel(agent) | 63 | + return s.Core.Channels().Create(name, description) |
| 64 | +} | ||
| 65 | + | ||
| 66 | +// Rename changes the name and description of a channel. | ||
| 67 | +func (s *ChannelService) Rename(id, name, description string) (channels.Channel, error) { | ||
| 68 | + return s.Core.Channels().Rename(id, name, description) | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +// Delete deletes a channel and its whole history. | ||
| 72 | +func (s *ChannelService) Delete(id string) error { | ||
| 73 | + return s.Core.Channels().Delete(id) | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +// AddMember invites an agent into a channel. | ||
| 77 | +func (s *ChannelService) AddMember(id, agent string) error { | ||
| 78 | + return s.Core.Channels().AddMember(id, agent) | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +// RemoveMember removes an agent from a channel. | ||
| 82 | +func (s *ChannelService) RemoveMember(id, agent string) error { | ||
| 83 | + return s.Core.Channels().RemoveMember(id, agent) | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +// Direct returns the direct conversation with an agent, created the first time. | ||
| 87 | +func (s *ChannelService) Direct(agent string) (channels.Channel, error) { | ||
| 88 | + return s.Core.Channels().Direct(agent) | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +// Send sends a message to a channel and returns its id. The agents it | ||
| 92 | +// addresses are started if needed; a message sent during an answer is queued. | ||
| 93 | +func (s *ChannelService) Send(id, text string) (string, error) { | ||
| 94 | + return s.Core.Channels().Send(id, text) | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +// Cancel stops the current answer of a channel, the agents still to answer | ||
| 98 | +// and the queued messages. | ||
| 99 | +func (s *ChannelService) Cancel(id string) { | ||
| 100 | + s.Core.Channels().Cancel(id) | ||
| 63 | } | 101 | } |
| 64 | 102 | ||
| 65 | // AnswerPermission gives the user's choice for a permission request. | 103 | // AnswerPermission gives the user's choice for a permission request. |
| 66 | -func (s *ChatService) AnswerPermission(id, optionID string) error { | 104 | +func (s *ChannelService) AnswerPermission(id, optionID string) error { |
| 67 | - return s.Core.conversations.AnswerPermission(id, optionID) | 105 | + return s.Core.Channels().AnswerPermission(id, optionID) |
| 106 | +} | ||
| 107 | + | ||
| 108 | +// Conversation returns the last messages and the state of a channel. | ||
| 109 | +func (s *ChannelService) Conversation(id string) (channels.Conversation, error) { | ||
| 110 | + return s.Core.Channels().Conversation(id) | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +// Older returns the messages before beforeID and whether older ones exist. | ||
| 114 | +func (s *ChannelService) Older(id, beforeID string) (OlderView, error) { | ||
| 115 | + messages, more, err := s.Core.Channels().Older(id, beforeID) | ||
| 116 | + return OlderView{Messages: messages, More: more}, err | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +// Prepare asks the members of a channel whose commands are not known yet | ||
| 120 | +// for them (the user typed "/"): they are started and their session opened, | ||
| 121 | +// without loading a model; the commands arrive as commands:changed events. | ||
| 122 | +func (s *ChannelService) Prepare(ctx context.Context, id string) error { | ||
| 123 | + return s.Core.Channels().Prepare(ctx, id) | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +// Commands returns the slash commands an agent offers. | ||
| 127 | +func (s *ChannelService) Commands(agent string) []acp.Command { | ||
| 128 | + return s.Core.Channels().Commands(agent) | ||
| 68 | } | 129 | } |
| 69 | 130 | ||
| 70 | -// Conversation returns the whole conversation with the agent, e.g. when the | 131 | +// OlderView is a page of older messages. |
| 71 | -// interface switches to it. | 132 | +type OlderView struct { |
| 72 | -func (s *ChatService) Conversation(agent string) conversations.Conversation { | 133 | + Messages []channels.Message `json:"messages"` |
| 73 | - return s.Core.conversations.Conversation(agent) | 134 | + More bool `json:"more"` |
| 74 | } | 135 | } |
| 75 | 136 | ||
| 76 | // ConfigView is the configuration as the interface shows it. | 137 | // ConfigView is the configuration as the interface shows it. |
| @@ -82,19 +143,14 @@ type ConfigView struct { | |||
| 82 | Created bool `json:"created"` | 143 | Created bool `json:"created"` |
| 83 | // Valid is false when the file has problems; the last valid configuration | 144 | // Valid is false when the file has problems; the last valid configuration |
| 84 | // (or an empty one) is then in use. | 145 | // (or an empty one) is then in use. |
| 85 | - Valid bool `json:"valid"` | 146 | + Valid bool `json:"valid"` |
| 86 | - Problems []string `json:"problems"` | 147 | + Problems []string `json:"problems"` |
| 87 | - Warnings []string `json:"warnings"` | 148 | + Warnings []string `json:"warnings"` |
| 88 | - Theme string `json:"theme"` | 149 | + Theme string `json:"theme"` |
| 89 | - Language string `json:"language"` | 150 | + Language string `json:"language"` |
| 90 | - Channels []ChannelView `json:"channels"` | 151 | + // HistoryProblem says why the history of this file could not be opened: |
| 91 | -} | 152 | + // it is then kept in memory only, until Sidekicks quits. |
| 92 | - | 153 | + HistoryProblem string `json:"historyProblem"` |
| 93 | -// ChannelView is a channel declared in the configuration. | ||
| 94 | -type ChannelView struct { | ||
| 95 | - Name string `json:"name"` | ||
| 96 | - Description string `json:"description"` | ||
| 97 | - Agents []string `json:"agents"` | ||
| 98 | } | 154 | } |
| 99 | 155 | ||
| 100 | // ProblemView is a problem of the text being edited, located for the editor. | 156 | // ProblemView is a problem of the text being edited, located for the editor. |
| @@ -120,14 +176,7 @@ func (s *ConfigService) Current() ConfigView { | |||
| 120 | view := ConfigView{ | 176 | view := ConfigView{ |
| 121 | Path: c.location.Path, Source: string(c.location.Source), Created: c.created, | 177 | Path: c.location.Path, Source: string(c.location.Source), Created: c.created, |
| 122 | Valid: len(c.problems) == 0, Problems: messages(c.problems), Warnings: messages(c.cfg.Warnings), | 178 | Valid: len(c.problems) == 0, Problems: messages(c.problems), Warnings: messages(c.cfg.Warnings), |
| 123 | - Theme: c.cfg.UI.Theme, Language: c.cfg.UI.Language, Channels: []ChannelView{}, | 179 | + Theme: c.cfg.UI.Theme, Language: c.cfg.UI.Language, HistoryProblem: c.historyProblem, |
| 124 | - } | ||
| 125 | - for _, channel := range c.cfg.Channels { | ||
| 126 | - members := channel.Agents | ||
| 127 | - if members == nil { | ||
| 128 | - members = []string{} | ||
| 129 | - } | ||
| 130 | - view.Channels = append(view.Channels, ChannelView{Name: channel.Name, Description: channel.Description, Agents: members}) | ||
| 131 | } | 180 | } |
| 132 | return view | 181 | return view |
| 133 | } | 182 | } |
added
internal/channels/channels.go +729 -0 | new file mode 100644 | ||
| @@ -0,0 +1,729 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +// Package channels holds the conversations of Sidekicks (milestone M3, ADR | |
| 4 | +// 0003 and 0005): channels where the user talks with one or several agents, | |
| 5 | +// and direct conversations, which are channels with one agent. | |
| 6 | +// | |
| 7 | +// Channels, members and finished messages live in internal/store, so they are | |
| 8 | +// there again after a restart. Sending a message routes it to the agents it | |
| 9 | +// addresses (mentions, or the last agent who spoke), borrows each agent from | |
| 10 | +// the agent manager, opens or resumes its ACP session for the channel, gives | |
| 11 | +// it the channel messages it has not seen, and streams its answer to the | |
| 12 | +// interface as events. | |
| 13 | +// | |
| 14 | +// manager, _ := channels.NewManager(agentManager, db, channels.Options{Emitter: emitter}) | |
| 15 | +// general, _ := manager.Create("general", "") | |
| 16 | +// _, _ = manager.Send(general.ID, "@alice Bonjour !") | |
| 17 | +// … | |
| 18 | +// manager.Cancel(general.ID) | |
| 19 | +package channels | |
| 20 | + | |
| 21 | +import ( | |
| 22 | + "context" | |
| 23 | + "encoding/json" | |
| 24 | + "errors" | |
| 25 | + "fmt" | |
| 26 | + "strings" | |
| 27 | + "sync" | |
| 28 | + "time" | |
| 29 | + "unicode/utf8" | |
| 30 | + | |
| 31 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | |
| 32 | + "git.rickub.com/bots-garden/sidekicks/internal/agents" | |
| 33 | + "git.rickub.com/bots-garden/sidekicks/internal/config" | |
| 34 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | |
| 35 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | |
| 36 | +) | |
| 37 | + | |
| 38 | +// Role tells who wrote a message. | |
| 39 | +type Role string | |
| 40 | + | |
| 41 | +// The roles. | |
| 42 | +const ( | |
| 43 | + RoleUser Role = "user" | |
| 44 | + RoleAgent Role = "agent" | |
| 45 | + RoleSystem Role = "system" | |
| 46 | +) | |
| 47 | + | |
| 48 | +// The kinds of Part. | |
| 49 | +const ( | |
| 50 | + PartText = "text" | |
| 51 | + PartThought = "thought" | |
| 52 | + PartTool = "tool" | |
| 53 | + PartPlan = "plan" | |
| 54 | +) | |
| 55 | + | |
| 56 | +// System message codes; the interface turns them into sentences. | |
| 57 | +const ( | |
| 58 | + // CodeAgentUnavailable: the agent could not be started or reached; Error says why. | |
| 59 | + CodeAgentUnavailable = "agent-unavailable" | |
| 60 | + // CodeAgentJoined: the agent was invited into the channel (Agent names it). | |
| 61 | + CodeAgentJoined = "agent-joined" | |
| 62 | + // CodeAgentLeft: the agent was removed from the channel (Agent names it). | |
| 63 | + CodeAgentLeft = "agent-left" | |
| 64 | + // CodeNoRecipient: nobody can answer; the user should mention an agent. | |
| 65 | + CodeNoRecipient = "no-recipient" | |
| 66 | +) | |
| 67 | + | |
| 68 | +// How many messages a channel loads when it is opened; older ones are paged | |
| 69 | +// with Older. | |
| 70 | +const pageSize = 100 | |
| 71 | + | |
| 72 | +// Errors returned by the manager. | |
| 73 | +var ( | |
| 74 | + ErrUnknownAgent = errors.New("unknown agent") | |
| 75 | + ErrUnknownChannel = errors.New("unknown channel") | |
| 76 | + ErrInvalidName = errors.New("a channel name has 1 to 64 characters") | |
| 77 | +) | |
| 78 | + | |
| 79 | +// Part is a piece of a message: text, reasoning, a tool call or a plan. | |
| 80 | +type Part struct { | |
| 81 | + Kind string `json:"kind"` | |
| 82 | + Text string `json:"text,omitempty"` | |
| 83 | + Tool *acp.ToolCall `json:"tool,omitempty"` | |
| 84 | + Plan []acp.PlanEntry `json:"plan,omitempty"` | |
| 85 | +} | |
| 86 | + | |
| 87 | +// Message is one message of a channel. | |
| 88 | +type Message struct { | |
| 89 | + ID string `json:"id"` | |
| 90 | + Channel string `json:"channel"` | |
| 91 | + // Agent is the author of an agent message, or the agent a system message | |
| 92 | + // is about. | |
| 93 | + Agent string `json:"agent,omitempty"` | |
| 94 | + Role Role `json:"role"` | |
| 95 | + Parts []Part `json:"parts"` | |
| 96 | + // Done is false while an agent message streams. | |
| 97 | + Done bool `json:"done"` | |
| 98 | + // Queued: a user message waiting for the current answers to end. | |
| 99 | + Queued bool `json:"queued"` | |
| 100 | + // StopReason of an agent message: end_turn, cancelled, max_tokens… | |
| 101 | + StopReason string `json:"stopReason,omitempty"` | |
| 102 | + // Error explains why an agent message ended without a stop reason, or a system message. | |
| 103 | + Error string `json:"error,omitempty"` | |
| 104 | + // Code identifies a system message (CodeAgentJoined…). | |
| 105 | + Code string `json:"code,omitempty"` | |
| 106 | + CreatedAt time.Time `json:"createdAt"` | |
| 107 | +} | |
| 108 | + | |
| 109 | +// Permission is a permission request waiting for the user. | |
| 110 | +type Permission struct { | |
| 111 | + ID string `json:"id"` | |
| 112 | + Channel string `json:"channel"` | |
| 113 | + Agent string `json:"agent"` | |
| 114 | + MessageID string `json:"messageId"` | |
| 115 | + ToolCall acp.ToolCall `json:"toolCall"` | |
| 116 | + Options []acp.PermissionOption `json:"options"` | |
| 117 | + // Rejections counts the refusals already given during this turn: an agent | |
| 118 | + // that keeps retrying is better stopped (ADR 0001, fact 4). | |
| 119 | + Rejections int `json:"rejections"` | |
| 120 | +} | |
| 121 | + | |
| 122 | +// Channel is a channel as the interface lists it. | |
| 123 | +type Channel struct { | |
| 124 | + ID string `json:"id"` | |
| 125 | + Name string `json:"name"` | |
| 126 | + Description string `json:"description"` | |
| 127 | + // Direct names the agent of a direct conversation; empty for a channel. | |
| 128 | + Direct string `json:"direct"` | |
| 129 | + Members []string `json:"members"` | |
| 130 | +} | |
| 131 | + | |
| 132 | +// Conversation is what the interface shows of a channel. | |
| 133 | +type Conversation struct { | |
| 134 | + Channel Channel `json:"channel"` | |
| 135 | + Messages []Message `json:"messages"` | |
| 136 | + // More tells whether older messages can be loaded with Older. | |
| 137 | + More bool `json:"more"` | |
| 138 | + Busy bool `json:"busy"` | |
| 139 | + Queued int `json:"queued"` | |
| 140 | + // Answering is the agent answering now, if any. | |
| 141 | + Answering string `json:"answering"` | |
| 142 | + // Usage is the context window use of each member, by agent name. | |
| 143 | + Usage map[string]acp.Usage `json:"usage"` | |
| 144 | + Permissions []Permission `json:"permissions"` | |
| 145 | +} | |
| 146 | + | |
| 147 | +// Event payloads, registered with Wails in main.go. | |
| 148 | + | |
| 149 | +// ListPayload is the whole list of channels, after any change (channels:changed). | |
| 150 | +type ListPayload struct { | |
| 151 | + Channels []Channel `json:"channels"` | |
| 152 | +} | |
| 153 | + | |
| 154 | +// MessagePayload carries a whole message: added, or finished. | |
| 155 | +type MessagePayload struct { | |
| 156 | + Channel string `json:"channel"` | |
| 157 | + Message Message `json:"message"` | |
| 158 | +} | |
| 159 | + | |
| 160 | +// DeltaPayload changes one part of a streaming message: Text is appended to | |
| 161 | +// a text or thought part; Part replaces a tool or plan part. | |
| 162 | +type DeltaPayload struct { | |
| 163 | + Channel string `json:"channel"` | |
| 164 | + MessageID string `json:"messageId"` | |
| 165 | + Index int `json:"index"` | |
| 166 | + Kind string `json:"kind"` | |
| 167 | + Text string `json:"text,omitempty"` | |
| 168 | + Part *Part `json:"part,omitempty"` | |
| 169 | +} | |
| 170 | + | |
| 171 | +// TurnPayload tells whether a channel is answering, who answers and how many | |
| 172 | +// messages wait. | |
| 173 | +type TurnPayload struct { | |
| 174 | + Channel string `json:"channel"` | |
| 175 | + Busy bool `json:"busy"` | |
| 176 | + Queued int `json:"queued"` | |
| 177 | + Answering string `json:"answering"` | |
| 178 | +} | |
| 179 | + | |
| 180 | +// PermissionResolvedPayload says a permission request no longer waits. | |
| 181 | +type PermissionResolvedPayload struct { | |
| 182 | + ID string `json:"id"` | |
| 183 | + Channel string `json:"channel"` | |
| 184 | +} | |
| 185 | + | |
| 186 | +// UsagePayload is the context window use of an agent in a channel. | |
| 187 | +type UsagePayload struct { | |
| 188 | + Channel string `json:"channel"` | |
| 189 | + Agent string `json:"agent"` | |
| 190 | + Usage acp.Usage `json:"usage"` | |
| 191 | +} | |
| 192 | + | |
| 193 | +// CommandsPayload lists the slash commands an agent offers. | |
| 194 | +type CommandsPayload struct { | |
| 195 | + Agent string `json:"agent"` | |
| 196 | + Commands []acp.Command `json:"commands"` | |
| 197 | +} | |
| 198 | + | |
| 199 | +// AgentSource lends agents. *agents.Manager implements it. | |
| 200 | +type AgentSource interface { | |
| 201 | + Acquire(ctx context.Context, name string) (agents.Conn, func(), error) | |
| 202 | + Definition(name string) (config.Agent, bool) | |
| 203 | +} | |
| 204 | + | |
| 205 | +// Options configure a Manager. | |
| 206 | +type Options struct { | |
| 207 | + // Emitter receives the events, in order, under the manager's lock: it | |
| 208 | + // must not block nor call the Manager. | |
| 209 | + Emitter events.Emitter | |
| 210 | + // Now defaults to time.Now. | |
| 211 | + Now func() time.Time | |
| 212 | +} | |
| 213 | + | |
| 214 | +// Manager holds the channels. It is safe for concurrent use. | |
| 215 | +type Manager struct { | |
| 216 | + agents AgentSource | |
| 217 | + db *store.Store | |
| 218 | + emitter events.Emitter | |
| 219 | + now func() time.Time | |
| 220 | + | |
| 221 | + mu sync.Mutex | |
| 222 | + channels map[string]*channel | |
| 223 | + order []string // channel ids, oldest first | |
| 224 | + permissions map[string]*pendingPermission | |
| 225 | + commands map[string][]acp.Command | |
| 226 | + // turns lets one agent answer one channel at a time: a local model | |
| 227 | + // serving two prompts at once would only be slower, and use more memory. | |
| 228 | + turns map[string]chan struct{} | |
| 229 | + sequence int | |
| 230 | +} | |
| 231 | + | |
| 232 | +type channel struct { | |
| 233 | + info store.Channel | |
| 234 | + members []*member | |
| 235 | + // messages are loaded on first use (loaded), the last pageSize ones. | |
| 236 | + loaded bool | |
| 237 | + more bool | |
| 238 | + messages []*Message | |
| 239 | + busy bool | |
| 240 | + queue []*pending | |
| 241 | + // answering is the agent whose turn it is (waiting for it, or streaming | |
| 242 | + // current); stopped is set by Cancel until the next message. | |
| 243 | + answering string | |
| 244 | + stopped bool | |
| 245 | + current *Message | |
| 246 | + cancel context.CancelFunc | |
| 247 | + usage map[string]acp.Usage | |
| 248 | + // rejected counts the refusals given during the current turn. | |
| 249 | + rejected int | |
| 250 | +} | |
| 251 | + | |
| 252 | +type member struct { | |
| 253 | + store.Membership | |
| 254 | + conn agents.Conn // the connection the session lives on | |
| 255 | + // fresh: the session was just opened (by Prepare or a turn) and has not | |
| 256 | + // been told the channel's past yet; the next turn tells it. | |
| 257 | + fresh bool | |
| 258 | +} | |
| 259 | + | |
| 260 | +// pending is a user message and the route it takes. | |
| 261 | +type pending struct { | |
| 262 | + message *Message | |
| 263 | + route Route | |
| 264 | +} | |
| 265 | + | |
| 266 | +type pendingPermission struct { | |
| 267 | + permission Permission | |
| 268 | + channel *channel | |
| 269 | + answer chan acp.PermissionAnswer | |
| 270 | +} | |
| 271 | + | |
| 272 | +// NewManager loads the channels of db. | |
| 273 | +func NewManager(source AgentSource, db *store.Store, options Options) (*Manager, error) { | |
| 274 | + if options.Emitter == nil { | |
| 275 | + options.Emitter = events.Discard | |
| 276 | + } | |
| 277 | + if options.Now == nil { | |
| 278 | + options.Now = time.Now | |
| 279 | + } | |
| 280 | + m := &Manager{ | |
| 281 | + agents: source, db: db, emitter: options.Emitter, now: options.Now, | |
| 282 | + channels: map[string]*channel{}, permissions: map[string]*pendingPermission{}, | |
| 283 | + commands: map[string][]acp.Command{}, turns: map[string]chan struct{}{}, | |
| 284 | + } | |
| 285 | + stored, err := db.Channels() | |
| 286 | + if err != nil { | |
| 287 | + return nil, err | |
| 288 | + } | |
| 289 | + for _, info := range stored { | |
| 290 | + ch := &channel{info: info, usage: map[string]acp.Usage{}} | |
| 291 | + memberships, err := db.Members(info.ID) | |
| 292 | + if err != nil { | |
| 293 | + return nil, err | |
| 294 | + } | |
| 295 | + for _, membership := range memberships { | |
| 296 | + ch.members = append(ch.members, &member{Membership: membership}) | |
| 297 | + } | |
| 298 | + m.channels[info.ID] = ch | |
| 299 | + m.order = append(m.order, info.ID) | |
| 300 | + } | |
| 301 | + return m, nil | |
| 302 | +} | |
| 303 | + | |
| 304 | +// ---------------------------------------------------------------- channels | |
| 305 | + | |
| 306 | +// Channels returns every channel and direct conversation, oldest first. | |
| 307 | +func (m *Manager) Channels() []Channel { | |
| 308 | + m.mu.Lock() | |
| 309 | + defer m.mu.Unlock() | |
| 310 | + return m.list() | |
| 311 | +} | |
| 312 | + | |
| 313 | +// Declare creates the channels of the configuration that do not exist yet | |
| 314 | +// and adds their missing members (ADR 0003: the YAML describes, the store | |
| 315 | +// remembers; nothing is removed). | |
| 316 | +func (m *Manager) Declare(declared []config.Channel) error { | |
| 317 | + m.mu.Lock() | |
| 318 | + defer m.mu.Unlock() | |
| 319 | + changed := false | |
| 320 | + for _, entry := range declared { | |
| 321 | + ch := m.byName(entry.Name) | |
| 322 | + if ch == nil { | |
| 323 | + created, err := m.create(entry.Name, entry.Description, "") | |
| 324 | + if err != nil { | |
| 325 | + return err | |
| 326 | + } | |
| 327 | + ch, changed = created, true | |
| 328 | + } | |
| 329 | + for _, agent := range entry.Agents { | |
| 330 | + if ch.member(agent) == nil { | |
| 331 | + if err := m.addMember(ch, agent); err != nil { | |
| 332 | + return err | |
| 333 | + } | |
| 334 | + changed = true | |
| 335 | + } | |
| 336 | + } | |
| 337 | + } | |
| 338 | + if changed { | |
| 339 | + m.emitChannels() | |
| 340 | + } | |
| 341 | + return nil | |
| 342 | +} | |
| 343 | + | |
| 344 | +// Create creates a channel called name. | |
| 345 | +func (m *Manager) Create(name, description string) (Channel, error) { | |
| 346 | + m.mu.Lock() | |
| 347 | + defer m.mu.Unlock() | |
| 348 | + ch, err := m.create(name, description, "") | |
| 349 | + if err != nil { | |
| 350 | + return Channel{}, err | |
| 351 | + } | |
| 352 | + m.emitChannels() | |
| 353 | + return ch.view(), nil | |
| 354 | +} | |
| 355 | + | |
| 356 | +// Direct returns the direct conversation with agent, creating it the first time. | |
| 357 | +func (m *Manager) Direct(agent string) (Channel, error) { | |
| 358 | + if _, ok := m.agents.Definition(agent); !ok { | |
| 359 | + return Channel{}, fmt.Errorf("%w: %s", ErrUnknownAgent, agent) | |
| 360 | + } | |
| 361 | + m.mu.Lock() | |
| 362 | + defer m.mu.Unlock() | |
| 363 | + for _, id := range m.order { | |
| 364 | + if ch := m.channels[id]; ch.info.Direct == agent { | |
| 365 | + return ch.view(), nil | |
| 366 | + } | |
| 367 | + } | |
| 368 | + ch, err := m.create("", "", agent) | |
| 369 | + if err != nil { | |
| 370 | + return Channel{}, err | |
| 371 | + } | |
| 372 | + if err := m.addMember(ch, agent); err != nil { | |
| 373 | + return Channel{}, err | |
| 374 | + } | |
| 375 | + m.emitChannels() | |
| 376 | + return ch.view(), nil | |
| 377 | +} | |
| 378 | + | |
| 379 | +// Rename changes the name and description of a channel. | |
| 380 | +func (m *Manager) Rename(id, name, description string) (Channel, error) { | |
| 381 | + name, err := validName(name) | |
| 382 | + if err != nil { | |
| 383 | + return Channel{}, err | |
| 384 | + } | |
| 385 | + m.mu.Lock() | |
| 386 | + defer m.mu.Unlock() | |
| 387 | + ch, err := m.channel(id) | |
| 388 | + if err != nil { | |
| 389 | + return Channel{}, err | |
| 390 | + } | |
| 391 | + if ch.info.Direct != "" { | |
| 392 | + return Channel{}, errors.New("a direct conversation has no name") | |
| 393 | + } | |
| 394 | + info := ch.info | |
| 395 | + info.Name, info.Description, info.UpdatedAt = name, strings.TrimSpace(description), m.now() | |
| 396 | + if err := m.db.UpdateChannel(info); err != nil { | |
| 397 | + return Channel{}, err | |
| 398 | + } | |
| 399 | + ch.info = info | |
| 400 | + m.emitChannels() | |
| 401 | + return ch.view(), nil | |
| 402 | +} | |
| 403 | + | |
| 404 | +// Delete deletes a channel and its whole history, after stopping its answers. | |
| 405 | +func (m *Manager) Delete(id string) error { | |
| 406 | + m.Cancel(id) | |
| 407 | + m.mu.Lock() | |
| 408 | + defer m.mu.Unlock() | |
| 409 | + if _, err := m.channel(id); err != nil { | |
| 410 | + return err | |
| 411 | + } | |
| 412 | + if err := m.db.DeleteChannel(id); err != nil { | |
| 413 | + return err | |
| 414 | + } | |
| 415 | + delete(m.channels, id) | |
| 416 | + for i, other := range m.order { | |
| 417 | + if other == id { | |
| 418 | + m.order = append(m.order[:i], m.order[i+1:]...) | |
| 419 | + break | |
| 420 | + } | |
| 421 | + } | |
| 422 | + m.emitChannels() | |
| 423 | + return nil | |
| 424 | +} | |
| 425 | + | |
| 426 | +// AddMember invites agent into a channel; the channel says so. | |
| 427 | +func (m *Manager) AddMember(id, agent string) error { | |
| 428 | + if _, ok := m.agents.Definition(agent); !ok { | |
| 429 | + return fmt.Errorf("%w: %s", ErrUnknownAgent, agent) | |
| 430 | + } | |
| 431 | + m.mu.Lock() | |
| 432 | + defer m.mu.Unlock() | |
| 433 | + ch, err := m.channel(id) | |
| 434 | + if err != nil { | |
| 435 | + return err | |
| 436 | + } | |
| 437 | + if ch.member(agent) != nil { | |
| 438 | + return nil | |
| 439 | + } | |
| 440 | + if ch.info.Direct != "" { | |
| 441 | + return errors.New("a direct conversation has one agent") | |
| 442 | + } | |
| 443 | + if err := m.addMember(ch, agent); err != nil { | |
| 444 | + return err | |
| 445 | + } | |
| 446 | + m.systemMessage(ch, CodeAgentJoined, agent, "") | |
| 447 | + m.emitChannels() | |
| 448 | + return nil | |
| 449 | +} | |
| 450 | + | |
| 451 | +// RemoveMember removes agent from a channel; the channel says so. Its | |
| 452 | +// messages stay. | |
| 453 | +func (m *Manager) RemoveMember(id, agent string) error { | |
| 454 | + m.mu.Lock() | |
| 455 | + defer m.mu.Unlock() | |
| 456 | + ch, err := m.channel(id) | |
| 457 | + if err != nil { | |
| 458 | + return err | |
| 459 | + } | |
| 460 | + if ch.info.Direct != "" { | |
| 461 | + return errors.New("a direct conversation keeps its agent") | |
| 462 | + } | |
| 463 | + for i, candidate := range ch.members { | |
| 464 | + if candidate.Agent == agent { | |
| 465 | + if err := m.db.DeleteMember(id, agent); err != nil { | |
| 466 | + return err | |
| 467 | + } | |
| 468 | + ch.members = append(ch.members[:i], ch.members[i+1:]...) | |
| 469 | + m.systemMessage(ch, CodeAgentLeft, agent, "") | |
| 470 | + m.emitChannels() | |
| 471 | + return nil | |
| 472 | + } | |
| 473 | + } | |
| 474 | + return nil | |
| 475 | +} | |
| 476 | + | |
| 477 | +// Commands returns the slash commands agent offers: the ones it announced | |
| 478 | +// last, kept from one run to the next. Empty until the agent has announced | |
| 479 | +// them once (Prepare asks it). | |
| 480 | +func (m *Manager) Commands(agent string) []acp.Command { | |
| 481 | + m.mu.Lock() | |
| 482 | + defer m.mu.Unlock() | |
| 483 | + m.commandsKnown(agent) | |
| 484 | + return append([]acp.Command{}, m.commands[agent]...) | |
| 485 | +} | |
| 486 | + | |
| 487 | +// commandsKnown loads the stored commands of agent into the cache and tells | |
| 488 | +// whether it ever announced some. The caller holds m.mu. | |
| 489 | +func (m *Manager) commandsKnown(agent string) bool { | |
| 490 | + if _, cached := m.commands[agent]; !cached { | |
| 491 | + commands := []acp.Command{} | |
| 492 | + if data, found, err := m.db.Value("commands/" + agent); err == nil && found { | |
| 493 | + _ = json.Unmarshal(data, &commands) | |
| 494 | + } | |
| 495 | + m.commands[agent] = commands | |
| 496 | + } | |
| 497 | + return len(m.commands[agent]) > 0 | |
| 498 | +} | |
| 499 | + | |
| 500 | +// Conversation returns what the interface shows of a channel: its last | |
| 501 | +// messages, its state and its pending permission requests. | |
| 502 | +func (m *Manager) Conversation(id string) (Conversation, error) { | |
| 503 | + m.mu.Lock() | |
| 504 | + defer m.mu.Unlock() | |
| 505 | + ch, err := m.channel(id) | |
| 506 | + if err != nil { | |
| 507 | + return Conversation{}, err | |
| 508 | + } | |
| 509 | + if err := m.load(ch); err != nil { | |
| 510 | + return Conversation{}, err | |
| 511 | + } | |
| 512 | + view := Conversation{Channel: ch.view(), Messages: []Message{}, More: ch.more, Busy: ch.busy, Queued: len(ch.queue), Usage: map[string]acp.Usage{}, Permissions: []Permission{}} | |
| 513 | + for _, message := range ch.messages { | |
| 514 | + view.Messages = append(view.Messages, copyMessage(message)) | |
| 515 | + } | |
| 516 | + view.Answering = ch.answering | |
| 517 | + for agent, usage := range ch.usage { | |
| 518 | + view.Usage[agent] = usage | |
| 519 | + } | |
| 520 | + for _, pending := range m.permissions { | |
| 521 | + if pending.channel == ch { | |
| 522 | + view.Permissions = append(view.Permissions, pending.permission) | |
| 523 | + } | |
| 524 | + } | |
| 525 | + return view, nil | |
| 526 | +} | |
| 527 | + | |
| 528 | +// Older returns the messages before beforeID, oldest first, and whether even | |
| 529 | +// older ones exist (the user scrolls up). | |
| 530 | +func (m *Manager) Older(id, beforeID string) ([]Message, bool, error) { | |
| 531 | + m.mu.Lock() | |
| 532 | + _, err := m.channel(id) | |
| 533 | + m.mu.Unlock() | |
| 534 | + if err != nil { | |
| 535 | + return nil, false, err | |
| 536 | + } | |
| 537 | + stored, more, err := m.db.Messages(id, beforeID, pageSize) | |
| 538 | + if err != nil { | |
| 539 | + return nil, false, err | |
| 540 | + } | |
| 541 | + messages := make([]Message, 0, len(stored)) | |
| 542 | + for _, data := range stored { | |
| 543 | + var message Message | |
| 544 | + if err := json.Unmarshal(data, &message); err != nil { | |
| 545 | + return nil, false, err | |
| 546 | + } | |
| 547 | + messages = append(messages, message) | |
| 548 | + } | |
| 549 | + return messages, more, nil | |
| 550 | +} | |
| 551 | + | |
| 552 | +// Close stops every answer. The store stays open: its owner closes it. | |
| 553 | +func (m *Manager) Close() { | |
| 554 | + m.mu.Lock() | |
| 555 | + ids := append([]string(nil), m.order...) | |
| 556 | + m.mu.Unlock() | |
| 557 | + for _, id := range ids { | |
| 558 | + m.Cancel(id) | |
| 559 | + } | |
| 560 | +} | |
| 561 | + | |
| 562 | +// ---------------------------------------------------------------- helpers (callers hold m.mu) | |
| 563 | + | |
| 564 | +func validName(name string) (string, error) { | |
| 565 | + name = strings.TrimSpace(name) | |
| 566 | + if name == "" || utf8.RuneCountInString(name) > 64 { | |
| 567 | + return "", ErrInvalidName | |
| 568 | + } | |
| 569 | + return name, nil | |
| 570 | +} | |
| 571 | + | |
| 572 | +func (m *Manager) create(name, description, direct string) (*channel, error) { | |
| 573 | + if direct == "" { | |
| 574 | + var err error | |
| 575 | + if name, err = validName(name); err != nil { | |
| 576 | + return nil, err | |
| 577 | + } | |
| 578 | + } | |
| 579 | + now := m.now() | |
| 580 | + info := store.Channel{ID: store.NewID(), Name: name, Description: strings.TrimSpace(description), Direct: direct, CreatedAt: now, UpdatedAt: now} | |
| 581 | + if err := m.db.CreateChannel(info); err != nil { | |
| 582 | + return nil, err | |
| 583 | + } | |
| 584 | + ch := &channel{info: info, loaded: true, usage: map[string]acp.Usage{}} | |
| 585 | + m.channels[info.ID] = ch | |
| 586 | + m.order = append(m.order, info.ID) | |
| 587 | + return ch, nil | |
| 588 | +} | |
| 589 | + | |
| 590 | +func (m *Manager) addMember(ch *channel, agent string) error { | |
| 591 | + membership := store.Membership{ChannelID: ch.info.ID, Agent: agent, JoinedAt: m.now()} | |
| 592 | + if err := m.db.PutMember(membership); err != nil { | |
| 593 | + return err | |
| 594 | + } | |
| 595 | + ch.members = append(ch.members, &member{Membership: membership}) | |
| 596 | + return nil | |
| 597 | +} | |
| 598 | + | |
| 599 | +func (m *Manager) channel(id string) (*channel, error) { | |
| 600 | + ch, ok := m.channels[id] | |
| 601 | + if !ok { | |
| 602 | + return nil, fmt.Errorf("%w: %s", ErrUnknownChannel, id) | |
| 603 | + } | |
| 604 | + return ch, nil | |
| 605 | +} | |
| 606 | + | |
| 607 | +func (m *Manager) byName(name string) *channel { | |
| 608 | + for _, id := range m.order { | |
| 609 | + if ch := m.channels[id]; ch.info.Direct == "" && strings.EqualFold(strings.TrimSpace(ch.info.Name), strings.TrimSpace(name)) { | |
| 610 | + return ch | |
| 611 | + } | |
| 612 | + } | |
| 613 | + return nil | |
| 614 | +} | |
| 615 | + | |
| 616 | +// load reads the last messages of a channel the first time it is used. | |
| 617 | +func (m *Manager) load(ch *channel) error { | |
| 618 | + if ch.loaded { | |
| 619 | + return nil | |
| 620 | + } | |
| 621 | + stored, more, err := m.db.Messages(ch.info.ID, "", pageSize) | |
| 622 | + if err != nil { | |
| 623 | + return err | |
| 624 | + } | |
| 625 | + for _, data := range stored { | |
| 626 | + message := &Message{} | |
| 627 | + if err := json.Unmarshal(data, message); err != nil { | |
| 628 | + return err | |
| 629 | + } | |
| 630 | + ch.messages = append(ch.messages, message) | |
| 631 | + } | |
| 632 | + ch.loaded, ch.more = true, more | |
| 633 | + return nil | |
| 634 | +} | |
| 635 | + | |
| 636 | +func (m *Manager) list() []Channel { | |
| 637 | + list := make([]Channel, 0, len(m.order)) | |
| 638 | + for _, id := range m.order { | |
| 639 | + list = append(list, m.channels[id].view()) | |
| 640 | + } | |
| 641 | + return list | |
| 642 | +} | |
| 643 | + | |
| 644 | +func (m *Manager) emitChannels() { | |
| 645 | + m.emitter.Emit(events.ChannelsChanged, ListPayload{Channels: m.list()}) | |
| 646 | +} | |
| 647 | + | |
| 648 | +func (ch *channel) view() Channel { | |
| 649 | + view := Channel{ID: ch.info.ID, Name: ch.info.Name, Description: ch.info.Description, Direct: ch.info.Direct, Members: []string{}} | |
| 650 | + for _, member := range ch.members { | |
| 651 | + view.Members = append(view.Members, member.Agent) | |
| 652 | + } | |
| 653 | + return view | |
| 654 | +} | |
| 655 | + | |
| 656 | +func (ch *channel) member(agent string) *member { | |
| 657 | + for _, candidate := range ch.members { | |
| 658 | + if candidate.Agent == agent { | |
| 659 | + return candidate | |
| 660 | + } | |
| 661 | + } | |
| 662 | + return nil | |
| 663 | +} | |
| 664 | + | |
| 665 | +// lastSpeaker is the member who answered last, the one a message without | |
| 666 | +// mention goes to; for a direct conversation, its agent. | |
| 667 | +func (ch *channel) lastSpeaker() string { | |
| 668 | + if ch.info.Direct != "" { | |
| 669 | + return ch.info.Direct | |
| 670 | + } | |
| 671 | + for i := len(ch.messages) - 1; i >= 0; i-- { | |
| 672 | + if message := ch.messages[i]; message.Role == RoleAgent && ch.member(message.Agent) != nil { | |
| 673 | + return message.Agent | |
| 674 | + } | |
| 675 | + } | |
| 676 | + if len(ch.members) == 1 { | |
| 677 | + return ch.members[0].Agent | |
| 678 | + } | |
| 679 | + return "" | |
| 680 | +} | |
| 681 | + | |
| 682 | +func (m *Manager) newMessage(ch *channel, role Role, agent string) *Message { | |
| 683 | + return &Message{ID: store.NewID(), Channel: ch.info.ID, Agent: agent, Role: role, Parts: []Part{}, CreatedAt: m.now()} | |
| 684 | +} | |
| 685 | + | |
| 686 | +func (m *Manager) systemMessage(ch *channel, code, agent, reason string) { | |
| 687 | + message := m.newMessage(ch, RoleSystem, agent) | |
| 688 | + message.Code, message.Error, message.Done = code, reason, true | |
| 689 | + ch.messages = append(ch.messages, message) | |
| 690 | + m.emitMessage(message) | |
| 691 | +} | |
| 692 | + | |
| 693 | +// emitMessage sends a message to the interface, and keeps it once it is | |
| 694 | +// finished: a streaming answer is written when it ends (ADR 0003). | |
| 695 | +func (m *Manager) emitMessage(message *Message) { | |
| 696 | + if message.Done && !message.Queued { | |
| 697 | + if data, err := json.Marshal(message); err == nil { | |
| 698 | + _ = m.db.PutMessage(message.Channel, message.ID, data) | |
| 699 | + } | |
| 700 | + } | |
| 701 | + m.emitter.Emit(events.MessageUpdated, MessagePayload{Channel: message.Channel, Message: copyMessage(message)}) | |
| 702 | +} | |
| 703 | + | |
| 704 | +func (m *Manager) emitPart(message *Message, index int) { | |
| 705 | + part := copyPart(message.Parts[index]) | |
| 706 | + m.emitter.Emit(events.MessageDelta, DeltaPayload{Channel: message.Channel, MessageID: message.ID, Index: index, Kind: part.Kind, Part: &part}) | |
| 707 | +} | |
| 708 | + | |
| 709 | +func (m *Manager) emitTurn(ch *channel) { | |
| 710 | + m.emitter.Emit(events.TurnChanged, TurnPayload{Channel: ch.info.ID, Busy: ch.busy, Queued: len(ch.queue), Answering: ch.answering}) | |
| 711 | +} | |
| 712 | + | |
| 713 | +func copyMessage(message *Message) Message { | |
| 714 | + copied := *message | |
| 715 | + copied.Parts = make([]Part, len(message.Parts)) | |
| 716 | + for i, part := range message.Parts { | |
| 717 | + copied.Parts[i] = copyPart(part) | |
| 718 | + } | |
| 719 | + return copied | |
| 720 | +} | |
| 721 | + | |
| 722 | +func copyPart(part Part) Part { | |
| 723 | + if part.Tool != nil { | |
| 724 | + tool := *part.Tool | |
| 725 | + part.Tool = &tool | |
| 726 | + } | |
| 727 | + part.Plan = append([]acp.PlanEntry(nil), part.Plan...) | |
| 728 | + return part | |
| 729 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,729 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +// Package channels holds the conversations of Sidekicks (milestone M3, ADR | ||
| 4 | +// 0003 and 0005): channels where the user talks with one or several agents, | ||
| 5 | +// and direct conversations, which are channels with one agent. | ||
| 6 | +// | ||
| 7 | +// Channels, members and finished messages live in internal/store, so they are | ||
| 8 | +// there again after a restart. Sending a message routes it to the agents it | ||
| 9 | +// addresses (mentions, or the last agent who spoke), borrows each agent from | ||
| 10 | +// the agent manager, opens or resumes its ACP session for the channel, gives | ||
| 11 | +// it the channel messages it has not seen, and streams its answer to the | ||
| 12 | +// interface as events. | ||
| 13 | +// | ||
| 14 | +// manager, _ := channels.NewManager(agentManager, db, channels.Options{Emitter: emitter}) | ||
| 15 | +// general, _ := manager.Create("general", "") | ||
| 16 | +// _, _ = manager.Send(general.ID, "@alice Bonjour !") | ||
| 17 | +// … | ||
| 18 | +// manager.Cancel(general.ID) | ||
| 19 | +package channels | ||
| 20 | + | ||
| 21 | +import ( | ||
| 22 | + "context" | ||
| 23 | + "encoding/json" | ||
| 24 | + "errors" | ||
| 25 | + "fmt" | ||
| 26 | + "strings" | ||
| 27 | + "sync" | ||
| 28 | + "time" | ||
| 29 | + "unicode/utf8" | ||
| 30 | + | ||
| 31 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | ||
| 32 | + "git.rickub.com/bots-garden/sidekicks/internal/agents" | ||
| 33 | + "git.rickub.com/bots-garden/sidekicks/internal/config" | ||
| 34 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | ||
| 35 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | ||
| 36 | +) | ||
| 37 | + | ||
| 38 | +// Role tells who wrote a message. | ||
| 39 | +type Role string | ||
| 40 | + | ||
| 41 | +// The roles. | ||
| 42 | +const ( | ||
| 43 | + RoleUser Role = "user" | ||
| 44 | + RoleAgent Role = "agent" | ||
| 45 | + RoleSystem Role = "system" | ||
| 46 | +) | ||
| 47 | + | ||
| 48 | +// The kinds of Part. | ||
| 49 | +const ( | ||
| 50 | + PartText = "text" | ||
| 51 | + PartThought = "thought" | ||
| 52 | + PartTool = "tool" | ||
| 53 | + PartPlan = "plan" | ||
| 54 | +) | ||
| 55 | + | ||
| 56 | +// System message codes; the interface turns them into sentences. | ||
| 57 | +const ( | ||
| 58 | + // CodeAgentUnavailable: the agent could not be started or reached; Error says why. | ||
| 59 | + CodeAgentUnavailable = "agent-unavailable" | ||
| 60 | + // CodeAgentJoined: the agent was invited into the channel (Agent names it). | ||
| 61 | + CodeAgentJoined = "agent-joined" | ||
| 62 | + // CodeAgentLeft: the agent was removed from the channel (Agent names it). | ||
| 63 | + CodeAgentLeft = "agent-left" | ||
| 64 | + // CodeNoRecipient: nobody can answer; the user should mention an agent. | ||
| 65 | + CodeNoRecipient = "no-recipient" | ||
| 66 | +) | ||
| 67 | + | ||
| 68 | +// How many messages a channel loads when it is opened; older ones are paged | ||
| 69 | +// with Older. | ||
| 70 | +const pageSize = 100 | ||
| 71 | + | ||
| 72 | +// Errors returned by the manager. | ||
| 73 | +var ( | ||
| 74 | + ErrUnknownAgent = errors.New("unknown agent") | ||
| 75 | + ErrUnknownChannel = errors.New("unknown channel") | ||
| 76 | + ErrInvalidName = errors.New("a channel name has 1 to 64 characters") | ||
| 77 | +) | ||
| 78 | + | ||
| 79 | +// Part is a piece of a message: text, reasoning, a tool call or a plan. | ||
| 80 | +type Part struct { | ||
| 81 | + Kind string `json:"kind"` | ||
| 82 | + Text string `json:"text,omitempty"` | ||
| 83 | + Tool *acp.ToolCall `json:"tool,omitempty"` | ||
| 84 | + Plan []acp.PlanEntry `json:"plan,omitempty"` | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +// Message is one message of a channel. | ||
| 88 | +type Message struct { | ||
| 89 | + ID string `json:"id"` | ||
| 90 | + Channel string `json:"channel"` | ||
| 91 | + // Agent is the author of an agent message, or the agent a system message | ||
| 92 | + // is about. | ||
| 93 | + Agent string `json:"agent,omitempty"` | ||
| 94 | + Role Role `json:"role"` | ||
| 95 | + Parts []Part `json:"parts"` | ||
| 96 | + // Done is false while an agent message streams. | ||
| 97 | + Done bool `json:"done"` | ||
| 98 | + // Queued: a user message waiting for the current answers to end. | ||
| 99 | + Queued bool `json:"queued"` | ||
| 100 | + // StopReason of an agent message: end_turn, cancelled, max_tokens… | ||
| 101 | + StopReason string `json:"stopReason,omitempty"` | ||
| 102 | + // Error explains why an agent message ended without a stop reason, or a system message. | ||
| 103 | + Error string `json:"error,omitempty"` | ||
| 104 | + // Code identifies a system message (CodeAgentJoined…). | ||
| 105 | + Code string `json:"code,omitempty"` | ||
| 106 | + CreatedAt time.Time `json:"createdAt"` | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +// Permission is a permission request waiting for the user. | ||
| 110 | +type Permission struct { | ||
| 111 | + ID string `json:"id"` | ||
| 112 | + Channel string `json:"channel"` | ||
| 113 | + Agent string `json:"agent"` | ||
| 114 | + MessageID string `json:"messageId"` | ||
| 115 | + ToolCall acp.ToolCall `json:"toolCall"` | ||
| 116 | + Options []acp.PermissionOption `json:"options"` | ||
| 117 | + // Rejections counts the refusals already given during this turn: an agent | ||
| 118 | + // that keeps retrying is better stopped (ADR 0001, fact 4). | ||
| 119 | + Rejections int `json:"rejections"` | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +// Channel is a channel as the interface lists it. | ||
| 123 | +type Channel struct { | ||
| 124 | + ID string `json:"id"` | ||
| 125 | + Name string `json:"name"` | ||
| 126 | + Description string `json:"description"` | ||
| 127 | + // Direct names the agent of a direct conversation; empty for a channel. | ||
| 128 | + Direct string `json:"direct"` | ||
| 129 | + Members []string `json:"members"` | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +// Conversation is what the interface shows of a channel. | ||
| 133 | +type Conversation struct { | ||
| 134 | + Channel Channel `json:"channel"` | ||
| 135 | + Messages []Message `json:"messages"` | ||
| 136 | + // More tells whether older messages can be loaded with Older. | ||
| 137 | + More bool `json:"more"` | ||
| 138 | + Busy bool `json:"busy"` | ||
| 139 | + Queued int `json:"queued"` | ||
| 140 | + // Answering is the agent answering now, if any. | ||
| 141 | + Answering string `json:"answering"` | ||
| 142 | + // Usage is the context window use of each member, by agent name. | ||
| 143 | + Usage map[string]acp.Usage `json:"usage"` | ||
| 144 | + Permissions []Permission `json:"permissions"` | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +// Event payloads, registered with Wails in main.go. | ||
| 148 | + | ||
| 149 | +// ListPayload is the whole list of channels, after any change (channels:changed). | ||
| 150 | +type ListPayload struct { | ||
| 151 | + Channels []Channel `json:"channels"` | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +// MessagePayload carries a whole message: added, or finished. | ||
| 155 | +type MessagePayload struct { | ||
| 156 | + Channel string `json:"channel"` | ||
| 157 | + Message Message `json:"message"` | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +// DeltaPayload changes one part of a streaming message: Text is appended to | ||
| 161 | +// a text or thought part; Part replaces a tool or plan part. | ||
| 162 | +type DeltaPayload struct { | ||
| 163 | + Channel string `json:"channel"` | ||
| 164 | + MessageID string `json:"messageId"` | ||
| 165 | + Index int `json:"index"` | ||
| 166 | + Kind string `json:"kind"` | ||
| 167 | + Text string `json:"text,omitempty"` | ||
| 168 | + Part *Part `json:"part,omitempty"` | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +// TurnPayload tells whether a channel is answering, who answers and how many | ||
| 172 | +// messages wait. | ||
| 173 | +type TurnPayload struct { | ||
| 174 | + Channel string `json:"channel"` | ||
| 175 | + Busy bool `json:"busy"` | ||
| 176 | + Queued int `json:"queued"` | ||
| 177 | + Answering string `json:"answering"` | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +// PermissionResolvedPayload says a permission request no longer waits. | ||
| 181 | +type PermissionResolvedPayload struct { | ||
| 182 | + ID string `json:"id"` | ||
| 183 | + Channel string `json:"channel"` | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +// UsagePayload is the context window use of an agent in a channel. | ||
| 187 | +type UsagePayload struct { | ||
| 188 | + Channel string `json:"channel"` | ||
| 189 | + Agent string `json:"agent"` | ||
| 190 | + Usage acp.Usage `json:"usage"` | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +// CommandsPayload lists the slash commands an agent offers. | ||
| 194 | +type CommandsPayload struct { | ||
| 195 | + Agent string `json:"agent"` | ||
| 196 | + Commands []acp.Command `json:"commands"` | ||
| 197 | +} | ||
| 198 | + | ||
| 199 | +// AgentSource lends agents. *agents.Manager implements it. | ||
| 200 | +type AgentSource interface { | ||
| 201 | + Acquire(ctx context.Context, name string) (agents.Conn, func(), error) | ||
| 202 | + Definition(name string) (config.Agent, bool) | ||
| 203 | +} | ||
| 204 | + | ||
| 205 | +// Options configure a Manager. | ||
| 206 | +type Options struct { | ||
| 207 | + // Emitter receives the events, in order, under the manager's lock: it | ||
| 208 | + // must not block nor call the Manager. | ||
| 209 | + Emitter events.Emitter | ||
| 210 | + // Now defaults to time.Now. | ||
| 211 | + Now func() time.Time | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +// Manager holds the channels. It is safe for concurrent use. | ||
| 215 | +type Manager struct { | ||
| 216 | + agents AgentSource | ||
| 217 | + db *store.Store | ||
| 218 | + emitter events.Emitter | ||
| 219 | + now func() time.Time | ||
| 220 | + | ||
| 221 | + mu sync.Mutex | ||
| 222 | + channels map[string]*channel | ||
| 223 | + order []string // channel ids, oldest first | ||
| 224 | + permissions map[string]*pendingPermission | ||
| 225 | + commands map[string][]acp.Command | ||
| 226 | + // turns lets one agent answer one channel at a time: a local model | ||
| 227 | + // serving two prompts at once would only be slower, and use more memory. | ||
| 228 | + turns map[string]chan struct{} | ||
| 229 | + sequence int | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +type channel struct { | ||
| 233 | + info store.Channel | ||
| 234 | + members []*member | ||
| 235 | + // messages are loaded on first use (loaded), the last pageSize ones. | ||
| 236 | + loaded bool | ||
| 237 | + more bool | ||
| 238 | + messages []*Message | ||
| 239 | + busy bool | ||
| 240 | + queue []*pending | ||
| 241 | + // answering is the agent whose turn it is (waiting for it, or streaming | ||
| 242 | + // current); stopped is set by Cancel until the next message. | ||
| 243 | + answering string | ||
| 244 | + stopped bool | ||
| 245 | + current *Message | ||
| 246 | + cancel context.CancelFunc | ||
| 247 | + usage map[string]acp.Usage | ||
| 248 | + // rejected counts the refusals given during the current turn. | ||
| 249 | + rejected int | ||
| 250 | +} | ||
| 251 | + | ||
| 252 | +type member struct { | ||
| 253 | + store.Membership | ||
| 254 | + conn agents.Conn // the connection the session lives on | ||
| 255 | + // fresh: the session was just opened (by Prepare or a turn) and has not | ||
| 256 | + // been told the channel's past yet; the next turn tells it. | ||
| 257 | + fresh bool | ||
| 258 | +} | ||
| 259 | + | ||
| 260 | +// pending is a user message and the route it takes. | ||
| 261 | +type pending struct { | ||
| 262 | + message *Message | ||
| 263 | + route Route | ||
| 264 | +} | ||
| 265 | + | ||
| 266 | +type pendingPermission struct { | ||
| 267 | + permission Permission | ||
| 268 | + channel *channel | ||
| 269 | + answer chan acp.PermissionAnswer | ||
| 270 | +} | ||
| 271 | + | ||
| 272 | +// NewManager loads the channels of db. | ||
| 273 | +func NewManager(source AgentSource, db *store.Store, options Options) (*Manager, error) { | ||
| 274 | + if options.Emitter == nil { | ||
| 275 | + options.Emitter = events.Discard | ||
| 276 | + } | ||
| 277 | + if options.Now == nil { | ||
| 278 | + options.Now = time.Now | ||
| 279 | + } | ||
| 280 | + m := &Manager{ | ||
| 281 | + agents: source, db: db, emitter: options.Emitter, now: options.Now, | ||
| 282 | + channels: map[string]*channel{}, permissions: map[string]*pendingPermission{}, | ||
| 283 | + commands: map[string][]acp.Command{}, turns: map[string]chan struct{}{}, | ||
| 284 | + } | ||
| 285 | + stored, err := db.Channels() | ||
| 286 | + if err != nil { | ||
| 287 | + return nil, err | ||
| 288 | + } | ||
| 289 | + for _, info := range stored { | ||
| 290 | + ch := &channel{info: info, usage: map[string]acp.Usage{}} | ||
| 291 | + memberships, err := db.Members(info.ID) | ||
| 292 | + if err != nil { | ||
| 293 | + return nil, err | ||
| 294 | + } | ||
| 295 | + for _, membership := range memberships { | ||
| 296 | + ch.members = append(ch.members, &member{Membership: membership}) | ||
| 297 | + } | ||
| 298 | + m.channels[info.ID] = ch | ||
| 299 | + m.order = append(m.order, info.ID) | ||
| 300 | + } | ||
| 301 | + return m, nil | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +// ---------------------------------------------------------------- channels | ||
| 305 | + | ||
| 306 | +// Channels returns every channel and direct conversation, oldest first. | ||
| 307 | +func (m *Manager) Channels() []Channel { | ||
| 308 | + m.mu.Lock() | ||
| 309 | + defer m.mu.Unlock() | ||
| 310 | + return m.list() | ||
| 311 | +} | ||
| 312 | + | ||
| 313 | +// Declare creates the channels of the configuration that do not exist yet | ||
| 314 | +// and adds their missing members (ADR 0003: the YAML describes, the store | ||
| 315 | +// remembers; nothing is removed). | ||
| 316 | +func (m *Manager) Declare(declared []config.Channel) error { | ||
| 317 | + m.mu.Lock() | ||
| 318 | + defer m.mu.Unlock() | ||
| 319 | + changed := false | ||
| 320 | + for _, entry := range declared { | ||
| 321 | + ch := m.byName(entry.Name) | ||
| 322 | + if ch == nil { | ||
| 323 | + created, err := m.create(entry.Name, entry.Description, "") | ||
| 324 | + if err != nil { | ||
| 325 | + return err | ||
| 326 | + } | ||
| 327 | + ch, changed = created, true | ||
| 328 | + } | ||
| 329 | + for _, agent := range entry.Agents { | ||
| 330 | + if ch.member(agent) == nil { | ||
| 331 | + if err := m.addMember(ch, agent); err != nil { | ||
| 332 | + return err | ||
| 333 | + } | ||
| 334 | + changed = true | ||
| 335 | + } | ||
| 336 | + } | ||
| 337 | + } | ||
| 338 | + if changed { | ||
| 339 | + m.emitChannels() | ||
| 340 | + } | ||
| 341 | + return nil | ||
| 342 | +} | ||
| 343 | + | ||
| 344 | +// Create creates a channel called name. | ||
| 345 | +func (m *Manager) Create(name, description string) (Channel, error) { | ||
| 346 | + m.mu.Lock() | ||
| 347 | + defer m.mu.Unlock() | ||
| 348 | + ch, err := m.create(name, description, "") | ||
| 349 | + if err != nil { | ||
| 350 | + return Channel{}, err | ||
| 351 | + } | ||
| 352 | + m.emitChannels() | ||
| 353 | + return ch.view(), nil | ||
| 354 | +} | ||
| 355 | + | ||
| 356 | +// Direct returns the direct conversation with agent, creating it the first time. | ||
| 357 | +func (m *Manager) Direct(agent string) (Channel, error) { | ||
| 358 | + if _, ok := m.agents.Definition(agent); !ok { | ||
| 359 | + return Channel{}, fmt.Errorf("%w: %s", ErrUnknownAgent, agent) | ||
| 360 | + } | ||
| 361 | + m.mu.Lock() | ||
| 362 | + defer m.mu.Unlock() | ||
| 363 | + for _, id := range m.order { | ||
| 364 | + if ch := m.channels[id]; ch.info.Direct == agent { | ||
| 365 | + return ch.view(), nil | ||
| 366 | + } | ||
| 367 | + } | ||
| 368 | + ch, err := m.create("", "", agent) | ||
| 369 | + if err != nil { | ||
| 370 | + return Channel{}, err | ||
| 371 | + } | ||
| 372 | + if err := m.addMember(ch, agent); err != nil { | ||
| 373 | + return Channel{}, err | ||
| 374 | + } | ||
| 375 | + m.emitChannels() | ||
| 376 | + return ch.view(), nil | ||
| 377 | +} | ||
| 378 | + | ||
| 379 | +// Rename changes the name and description of a channel. | ||
| 380 | +func (m *Manager) Rename(id, name, description string) (Channel, error) { | ||
| 381 | + name, err := validName(name) | ||
| 382 | + if err != nil { | ||
| 383 | + return Channel{}, err | ||
| 384 | + } | ||
| 385 | + m.mu.Lock() | ||
| 386 | + defer m.mu.Unlock() | ||
| 387 | + ch, err := m.channel(id) | ||
| 388 | + if err != nil { | ||
| 389 | + return Channel{}, err | ||
| 390 | + } | ||
| 391 | + if ch.info.Direct != "" { | ||
| 392 | + return Channel{}, errors.New("a direct conversation has no name") | ||
| 393 | + } | ||
| 394 | + info := ch.info | ||
| 395 | + info.Name, info.Description, info.UpdatedAt = name, strings.TrimSpace(description), m.now() | ||
| 396 | + if err := m.db.UpdateChannel(info); err != nil { | ||
| 397 | + return Channel{}, err | ||
| 398 | + } | ||
| 399 | + ch.info = info | ||
| 400 | + m.emitChannels() | ||
| 401 | + return ch.view(), nil | ||
| 402 | +} | ||
| 403 | + | ||
| 404 | +// Delete deletes a channel and its whole history, after stopping its answers. | ||
| 405 | +func (m *Manager) Delete(id string) error { | ||
| 406 | + m.Cancel(id) | ||
| 407 | + m.mu.Lock() | ||
| 408 | + defer m.mu.Unlock() | ||
| 409 | + if _, err := m.channel(id); err != nil { | ||
| 410 | + return err | ||
| 411 | + } | ||
| 412 | + if err := m.db.DeleteChannel(id); err != nil { | ||
| 413 | + return err | ||
| 414 | + } | ||
| 415 | + delete(m.channels, id) | ||
| 416 | + for i, other := range m.order { | ||
| 417 | + if other == id { | ||
| 418 | + m.order = append(m.order[:i], m.order[i+1:]...) | ||
| 419 | + break | ||
| 420 | + } | ||
| 421 | + } | ||
| 422 | + m.emitChannels() | ||
| 423 | + return nil | ||
| 424 | +} | ||
| 425 | + | ||
| 426 | +// AddMember invites agent into a channel; the channel says so. | ||
| 427 | +func (m *Manager) AddMember(id, agent string) error { | ||
| 428 | + if _, ok := m.agents.Definition(agent); !ok { | ||
| 429 | + return fmt.Errorf("%w: %s", ErrUnknownAgent, agent) | ||
| 430 | + } | ||
| 431 | + m.mu.Lock() | ||
| 432 | + defer m.mu.Unlock() | ||
| 433 | + ch, err := m.channel(id) | ||
| 434 | + if err != nil { | ||
| 435 | + return err | ||
| 436 | + } | ||
| 437 | + if ch.member(agent) != nil { | ||
| 438 | + return nil | ||
| 439 | + } | ||
| 440 | + if ch.info.Direct != "" { | ||
| 441 | + return errors.New("a direct conversation has one agent") | ||
| 442 | + } | ||
| 443 | + if err := m.addMember(ch, agent); err != nil { | ||
| 444 | + return err | ||
| 445 | + } | ||
| 446 | + m.systemMessage(ch, CodeAgentJoined, agent, "") | ||
| 447 | + m.emitChannels() | ||
| 448 | + return nil | ||
| 449 | +} | ||
| 450 | + | ||
| 451 | +// RemoveMember removes agent from a channel; the channel says so. Its | ||
| 452 | +// messages stay. | ||
| 453 | +func (m *Manager) RemoveMember(id, agent string) error { | ||
| 454 | + m.mu.Lock() | ||
| 455 | + defer m.mu.Unlock() | ||
| 456 | + ch, err := m.channel(id) | ||
| 457 | + if err != nil { | ||
| 458 | + return err | ||
| 459 | + } | ||
| 460 | + if ch.info.Direct != "" { | ||
| 461 | + return errors.New("a direct conversation keeps its agent") | ||
| 462 | + } | ||
| 463 | + for i, candidate := range ch.members { | ||
| 464 | + if candidate.Agent == agent { | ||
| 465 | + if err := m.db.DeleteMember(id, agent); err != nil { | ||
| 466 | + return err | ||
| 467 | + } | ||
| 468 | + ch.members = append(ch.members[:i], ch.members[i+1:]...) | ||
| 469 | + m.systemMessage(ch, CodeAgentLeft, agent, "") | ||
| 470 | + m.emitChannels() | ||
| 471 | + return nil | ||
| 472 | + } | ||
| 473 | + } | ||
| 474 | + return nil | ||
| 475 | +} | ||
| 476 | + | ||
| 477 | +// Commands returns the slash commands agent offers: the ones it announced | ||
| 478 | +// last, kept from one run to the next. Empty until the agent has announced | ||
| 479 | +// them once (Prepare asks it). | ||
| 480 | +func (m *Manager) Commands(agent string) []acp.Command { | ||
| 481 | + m.mu.Lock() | ||
| 482 | + defer m.mu.Unlock() | ||
| 483 | + m.commandsKnown(agent) | ||
| 484 | + return append([]acp.Command{}, m.commands[agent]...) | ||
| 485 | +} | ||
| 486 | + | ||
| 487 | +// commandsKnown loads the stored commands of agent into the cache and tells | ||
| 488 | +// whether it ever announced some. The caller holds m.mu. | ||
| 489 | +func (m *Manager) commandsKnown(agent string) bool { | ||
| 490 | + if _, cached := m.commands[agent]; !cached { | ||
| 491 | + commands := []acp.Command{} | ||
| 492 | + if data, found, err := m.db.Value("commands/" + agent); err == nil && found { | ||
| 493 | + _ = json.Unmarshal(data, &commands) | ||
| 494 | + } | ||
| 495 | + m.commands[agent] = commands | ||
| 496 | + } | ||
| 497 | + return len(m.commands[agent]) > 0 | ||
| 498 | +} | ||
| 499 | + | ||
| 500 | +// Conversation returns what the interface shows of a channel: its last | ||
| 501 | +// messages, its state and its pending permission requests. | ||
| 502 | +func (m *Manager) Conversation(id string) (Conversation, error) { | ||
| 503 | + m.mu.Lock() | ||
| 504 | + defer m.mu.Unlock() | ||
| 505 | + ch, err := m.channel(id) | ||
| 506 | + if err != nil { | ||
| 507 | + return Conversation{}, err | ||
| 508 | + } | ||
| 509 | + if err := m.load(ch); err != nil { | ||
| 510 | + return Conversation{}, err | ||
| 511 | + } | ||
| 512 | + view := Conversation{Channel: ch.view(), Messages: []Message{}, More: ch.more, Busy: ch.busy, Queued: len(ch.queue), Usage: map[string]acp.Usage{}, Permissions: []Permission{}} | ||
| 513 | + for _, message := range ch.messages { | ||
| 514 | + view.Messages = append(view.Messages, copyMessage(message)) | ||
| 515 | + } | ||
| 516 | + view.Answering = ch.answering | ||
| 517 | + for agent, usage := range ch.usage { | ||
| 518 | + view.Usage[agent] = usage | ||
| 519 | + } | ||
| 520 | + for _, pending := range m.permissions { | ||
| 521 | + if pending.channel == ch { | ||
| 522 | + view.Permissions = append(view.Permissions, pending.permission) | ||
| 523 | + } | ||
| 524 | + } | ||
| 525 | + return view, nil | ||
| 526 | +} | ||
| 527 | + | ||
| 528 | +// Older returns the messages before beforeID, oldest first, and whether even | ||
| 529 | +// older ones exist (the user scrolls up). | ||
| 530 | +func (m *Manager) Older(id, beforeID string) ([]Message, bool, error) { | ||
| 531 | + m.mu.Lock() | ||
| 532 | + _, err := m.channel(id) | ||
| 533 | + m.mu.Unlock() | ||
| 534 | + if err != nil { | ||
| 535 | + return nil, false, err | ||
| 536 | + } | ||
| 537 | + stored, more, err := m.db.Messages(id, beforeID, pageSize) | ||
| 538 | + if err != nil { | ||
| 539 | + return nil, false, err | ||
| 540 | + } | ||
| 541 | + messages := make([]Message, 0, len(stored)) | ||
| 542 | + for _, data := range stored { | ||
| 543 | + var message Message | ||
| 544 | + if err := json.Unmarshal(data, &message); err != nil { | ||
| 545 | + return nil, false, err | ||
| 546 | + } | ||
| 547 | + messages = append(messages, message) | ||
| 548 | + } | ||
| 549 | + return messages, more, nil | ||
| 550 | +} | ||
| 551 | + | ||
| 552 | +// Close stops every answer. The store stays open: its owner closes it. | ||
| 553 | +func (m *Manager) Close() { | ||
| 554 | + m.mu.Lock() | ||
| 555 | + ids := append([]string(nil), m.order...) | ||
| 556 | + m.mu.Unlock() | ||
| 557 | + for _, id := range ids { | ||
| 558 | + m.Cancel(id) | ||
| 559 | + } | ||
| 560 | +} | ||
| 561 | + | ||
| 562 | +// ---------------------------------------------------------------- helpers (callers hold m.mu) | ||
| 563 | + | ||
| 564 | +func validName(name string) (string, error) { | ||
| 565 | + name = strings.TrimSpace(name) | ||
| 566 | + if name == "" || utf8.RuneCountInString(name) > 64 { | ||
| 567 | + return "", ErrInvalidName | ||
| 568 | + } | ||
| 569 | + return name, nil | ||
| 570 | +} | ||
| 571 | + | ||
| 572 | +func (m *Manager) create(name, description, direct string) (*channel, error) { | ||
| 573 | + if direct == "" { | ||
| 574 | + var err error | ||
| 575 | + if name, err = validName(name); err != nil { | ||
| 576 | + return nil, err | ||
| 577 | + } | ||
| 578 | + } | ||
| 579 | + now := m.now() | ||
| 580 | + info := store.Channel{ID: store.NewID(), Name: name, Description: strings.TrimSpace(description), Direct: direct, CreatedAt: now, UpdatedAt: now} | ||
| 581 | + if err := m.db.CreateChannel(info); err != nil { | ||
| 582 | + return nil, err | ||
| 583 | + } | ||
| 584 | + ch := &channel{info: info, loaded: true, usage: map[string]acp.Usage{}} | ||
| 585 | + m.channels[info.ID] = ch | ||
| 586 | + m.order = append(m.order, info.ID) | ||
| 587 | + return ch, nil | ||
| 588 | +} | ||
| 589 | + | ||
| 590 | +func (m *Manager) addMember(ch *channel, agent string) error { | ||
| 591 | + membership := store.Membership{ChannelID: ch.info.ID, Agent: agent, JoinedAt: m.now()} | ||
| 592 | + if err := m.db.PutMember(membership); err != nil { | ||
| 593 | + return err | ||
| 594 | + } | ||
| 595 | + ch.members = append(ch.members, &member{Membership: membership}) | ||
| 596 | + return nil | ||
| 597 | +} | ||
| 598 | + | ||
| 599 | +func (m *Manager) channel(id string) (*channel, error) { | ||
| 600 | + ch, ok := m.channels[id] | ||
| 601 | + if !ok { | ||
| 602 | + return nil, fmt.Errorf("%w: %s", ErrUnknownChannel, id) | ||
| 603 | + } | ||
| 604 | + return ch, nil | ||
| 605 | +} | ||
| 606 | + | ||
| 607 | +func (m *Manager) byName(name string) *channel { | ||
| 608 | + for _, id := range m.order { | ||
| 609 | + if ch := m.channels[id]; ch.info.Direct == "" && strings.EqualFold(strings.TrimSpace(ch.info.Name), strings.TrimSpace(name)) { | ||
| 610 | + return ch | ||
| 611 | + } | ||
| 612 | + } | ||
| 613 | + return nil | ||
| 614 | +} | ||
| 615 | + | ||
| 616 | +// load reads the last messages of a channel the first time it is used. | ||
| 617 | +func (m *Manager) load(ch *channel) error { | ||
| 618 | + if ch.loaded { | ||
| 619 | + return nil | ||
| 620 | + } | ||
| 621 | + stored, more, err := m.db.Messages(ch.info.ID, "", pageSize) | ||
| 622 | + if err != nil { | ||
| 623 | + return err | ||
| 624 | + } | ||
| 625 | + for _, data := range stored { | ||
| 626 | + message := &Message{} | ||
| 627 | + if err := json.Unmarshal(data, message); err != nil { | ||
| 628 | + return err | ||
| 629 | + } | ||
| 630 | + ch.messages = append(ch.messages, message) | ||
| 631 | + } | ||
| 632 | + ch.loaded, ch.more = true, more | ||
| 633 | + return nil | ||
| 634 | +} | ||
| 635 | + | ||
| 636 | +func (m *Manager) list() []Channel { | ||
| 637 | + list := make([]Channel, 0, len(m.order)) | ||
| 638 | + for _, id := range m.order { | ||
| 639 | + list = append(list, m.channels[id].view()) | ||
| 640 | + } | ||
| 641 | + return list | ||
| 642 | +} | ||
| 643 | + | ||
| 644 | +func (m *Manager) emitChannels() { | ||
| 645 | + m.emitter.Emit(events.ChannelsChanged, ListPayload{Channels: m.list()}) | ||
| 646 | +} | ||
| 647 | + | ||
| 648 | +func (ch *channel) view() Channel { | ||
| 649 | + view := Channel{ID: ch.info.ID, Name: ch.info.Name, Description: ch.info.Description, Direct: ch.info.Direct, Members: []string{}} | ||
| 650 | + for _, member := range ch.members { | ||
| 651 | + view.Members = append(view.Members, member.Agent) | ||
| 652 | + } | ||
| 653 | + return view | ||
| 654 | +} | ||
| 655 | + | ||
| 656 | +func (ch *channel) member(agent string) *member { | ||
| 657 | + for _, candidate := range ch.members { | ||
| 658 | + if candidate.Agent == agent { | ||
| 659 | + return candidate | ||
| 660 | + } | ||
| 661 | + } | ||
| 662 | + return nil | ||
| 663 | +} | ||
| 664 | + | ||
| 665 | +// lastSpeaker is the member who answered last, the one a message without | ||
| 666 | +// mention goes to; for a direct conversation, its agent. | ||
| 667 | +func (ch *channel) lastSpeaker() string { | ||
| 668 | + if ch.info.Direct != "" { | ||
| 669 | + return ch.info.Direct | ||
| 670 | + } | ||
| 671 | + for i := len(ch.messages) - 1; i >= 0; i-- { | ||
| 672 | + if message := ch.messages[i]; message.Role == RoleAgent && ch.member(message.Agent) != nil { | ||
| 673 | + return message.Agent | ||
| 674 | + } | ||
| 675 | + } | ||
| 676 | + if len(ch.members) == 1 { | ||
| 677 | + return ch.members[0].Agent | ||
| 678 | + } | ||
| 679 | + return "" | ||
| 680 | +} | ||
| 681 | + | ||
| 682 | +func (m *Manager) newMessage(ch *channel, role Role, agent string) *Message { | ||
| 683 | + return &Message{ID: store.NewID(), Channel: ch.info.ID, Agent: agent, Role: role, Parts: []Part{}, CreatedAt: m.now()} | ||
| 684 | +} | ||
| 685 | + | ||
| 686 | +func (m *Manager) systemMessage(ch *channel, code, agent, reason string) { | ||
| 687 | + message := m.newMessage(ch, RoleSystem, agent) | ||
| 688 | + message.Code, message.Error, message.Done = code, reason, true | ||
| 689 | + ch.messages = append(ch.messages, message) | ||
| 690 | + m.emitMessage(message) | ||
| 691 | +} | ||
| 692 | + | ||
| 693 | +// emitMessage sends a message to the interface, and keeps it once it is | ||
| 694 | +// finished: a streaming answer is written when it ends (ADR 0003). | ||
| 695 | +func (m *Manager) emitMessage(message *Message) { | ||
| 696 | + if message.Done && !message.Queued { | ||
| 697 | + if data, err := json.Marshal(message); err == nil { | ||
| 698 | + _ = m.db.PutMessage(message.Channel, message.ID, data) | ||
| 699 | + } | ||
| 700 | + } | ||
| 701 | + m.emitter.Emit(events.MessageUpdated, MessagePayload{Channel: message.Channel, Message: copyMessage(message)}) | ||
| 702 | +} | ||
| 703 | + | ||
| 704 | +func (m *Manager) emitPart(message *Message, index int) { | ||
| 705 | + part := copyPart(message.Parts[index]) | ||
| 706 | + m.emitter.Emit(events.MessageDelta, DeltaPayload{Channel: message.Channel, MessageID: message.ID, Index: index, Kind: part.Kind, Part: &part}) | ||
| 707 | +} | ||
| 708 | + | ||
| 709 | +func (m *Manager) emitTurn(ch *channel) { | ||
| 710 | + m.emitter.Emit(events.TurnChanged, TurnPayload{Channel: ch.info.ID, Busy: ch.busy, Queued: len(ch.queue), Answering: ch.answering}) | ||
| 711 | +} | ||
| 712 | + | ||
| 713 | +func copyMessage(message *Message) Message { | ||
| 714 | + copied := *message | ||
| 715 | + copied.Parts = make([]Part, len(message.Parts)) | ||
| 716 | + for i, part := range message.Parts { | ||
| 717 | + copied.Parts[i] = copyPart(part) | ||
| 718 | + } | ||
| 719 | + return copied | ||
| 720 | +} | ||
| 721 | + | ||
| 722 | +func copyPart(part Part) Part { | ||
| 723 | + if part.Tool != nil { | ||
| 724 | + tool := *part.Tool | ||
| 725 | + part.Tool = &tool | ||
| 726 | + } | ||
| 727 | + part.Plan = append([]acp.PlanEntry(nil), part.Plan...) | ||
| 728 | + return part | ||
| 729 | +} | ||
added
internal/channels/channels_test.go +575 -0 | new file mode 100644 | ||
| @@ -0,0 +1,575 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +package channels_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "strings" | |
| 10 | + "testing" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | |
| 14 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | |
| 15 | + "git.rickub.com/bots-garden/sidekicks/internal/config" | |
| 16 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | |
| 17 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | |
| 18 | +) | |
| 19 | + | |
| 20 | +// ---------------------------------------------------------------- channels and members | |
| 21 | + | |
| 22 | +func TestChannelsAreCreatedRenamedAndDeleted(t *testing.T) { | |
| 23 | + f := setup(t, nil) | |
| 24 | + general := f.channel(t, "general", "alice") | |
| 25 | + if _, err := f.manager.Create(" General ", ""); !errors.Is(err, store.ErrNameTaken) { | |
| 26 | + t.Fatalf("names are unique, whatever the case: %v", err) | |
| 27 | + } | |
| 28 | + if _, err := f.manager.Create(" ", ""); !errors.Is(err, channels.ErrInvalidName) { | |
| 29 | + t.Fatalf("a name is needed: %v", err) | |
| 30 | + } | |
| 31 | + renamed, err := f.manager.Rename(general.ID, "team", "the team") | |
| 32 | + if err != nil || renamed.Name != "team" || renamed.Description != "the team" || fmt.Sprint(renamed.Members) != "[alice]" { | |
| 33 | + t.Fatalf("%+v %v", renamed, err) | |
| 34 | + } | |
| 35 | + f.send(t, general.ID, "hello") | |
| 36 | + f.idle(t, general.ID, 3) | |
| 37 | + if err := f.manager.Delete(general.ID); err != nil { | |
| 38 | + t.Fatal(err) | |
| 39 | + } | |
| 40 | + if list := f.manager.Channels(); len(list) != 0 { | |
| 41 | + t.Fatalf("deleted: %+v", list) | |
| 42 | + } | |
| 43 | + if messages, _, _ := f.db.Messages(general.ID, "", 10); len(messages) != 0 { | |
| 44 | + t.Fatal("the history goes with the channel") | |
| 45 | + } | |
| 46 | + payloads := f.recorder.Named(events.ChannelsChanged) | |
| 47 | + if last := payloads[len(payloads)-1].(channels.ListPayload); len(last.Channels) != 0 { | |
| 48 | + t.Fatalf("the last channels:changed lists no channel: %+v", last) | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +func TestMembersJoinAndLeaveVisibly(t *testing.T) { | |
| 53 | + f := setup(t, nil) | |
| 54 | + general := f.channel(t, "general", "alice", "bob") | |
| 55 | + if err := f.manager.AddMember(general.ID, "nobody"); !errors.Is(err, channels.ErrUnknownAgent) { | |
| 56 | + t.Fatalf("only configured agents: %v", err) | |
| 57 | + } | |
| 58 | + _ = f.manager.AddMember(general.ID, "alice") // already there: nothing happens | |
| 59 | + _ = f.manager.RemoveMember(general.ID, "bob") | |
| 60 | + view, _ := f.manager.Conversation(general.ID) | |
| 61 | + if fmt.Sprint(view.Channel.Members) != "[alice]" { | |
| 62 | + t.Fatalf("members: %v", view.Channel.Members) | |
| 63 | + } | |
| 64 | + if got := describe(view.Messages); got != "system:alice:agent-joined: | system:bob:agent-joined: | system:bob:agent-left:" { | |
| 65 | + t.Fatalf("messages: %s", got) | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +func TestDeclaredChannelsAreCreatedOnceAndCompleted(t *testing.T) { | |
| 70 | + f := setup(t, nil) | |
| 71 | + declared := []config.Channel{{Name: "general", Description: "With Alice", Agents: []string{"alice"}}} | |
| 72 | + if err := f.manager.Declare(declared); err != nil { | |
| 73 | + t.Fatal(err) | |
| 74 | + } | |
| 75 | + first := f.manager.Channels() | |
| 76 | + if len(first) != 1 || first[0].Name != "general" || first[0].Description != "With Alice" || fmt.Sprint(first[0].Members) != "[alice]" { | |
| 77 | + t.Fatalf("%+v", first) | |
| 78 | + } | |
| 79 | + _, _ = f.manager.Rename(first[0].ID, "General", "") | |
| 80 | + declared[0].Agents = []string{"alice", "bob"} | |
| 81 | + _ = f.manager.Declare(declared) | |
| 82 | + second := f.manager.Channels() | |
| 83 | + if len(second) != 1 || second[0].ID != first[0].ID || fmt.Sprint(second[0].Members) != "[alice bob]" { | |
| 84 | + t.Fatalf("the same channel, completed: %+v", second) | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +func TestADirectConversationIsAChannelWithOneAgent(t *testing.T) { | |
| 89 | + f := setup(t, nil) | |
| 90 | + direct, err := f.manager.Direct("alice") | |
| 91 | + if err != nil || direct.Direct != "alice" || direct.Name != "" || fmt.Sprint(direct.Members) != "[alice]" { | |
| 92 | + t.Fatalf("%+v %v", direct, err) | |
| 93 | + } | |
| 94 | + if again, _ := f.manager.Direct("alice"); again.ID != direct.ID { | |
| 95 | + t.Fatal("the same direct conversation is returned") | |
| 96 | + } | |
| 97 | + if _, err := f.manager.Direct("nobody"); !errors.Is(err, channels.ErrUnknownAgent) { | |
| 98 | + t.Fatalf("%v", err) | |
| 99 | + } | |
| 100 | + if err := f.manager.AddMember(direct.ID, "bob"); err == nil { | |
| 101 | + t.Fatal("a direct conversation keeps one agent") | |
| 102 | + } | |
| 103 | + f.send(t, direct.ID, "@bob hello") | |
| 104 | + view := f.idle(t, direct.ID, 2) | |
| 105 | + if got := describe(view.Messages); got != "user:::@bob hello | agent:alice::alice ok" { | |
| 106 | + t.Fatalf("in a direct conversation, its agent answers: %s", got) | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +// ---------------------------------------------------------------- one agent | |
| 111 | + | |
| 112 | +// streamHello sends "hello" to a fake Alice that thinks, writes, plans and | |
| 113 | +// reports its usage and commands. | |
| 114 | +func streamHello(t *testing.T) (*fixture, channels.Conversation) { | |
| 115 | + t.Helper() | |
| 116 | + f := setup(t, map[string]script{ | |
| 117 | + "hello": func(_ context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | |
| 118 | + h.Update(acp.Update{Kind: acp.UpdateThought, Text: "hmm"}) | |
| 119 | + h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "Bon"}) | |
| 120 | + h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "jour"}) | |
| 121 | + h.Update(acp.Update{Kind: acp.UpdatePlan, Plan: []acp.PlanEntry{{Content: "greet", Status: "completed"}}}) | |
| 122 | + h.Update(acp.Update{Kind: acp.UpdateUsage, Usage: &acp.Usage{Used: 50, Size: 1000}}) | |
| 123 | + h.Update(acp.Update{Kind: acp.UpdateCommands, Commands: []acp.Command{{Name: "compact"}}}) | |
| 124 | + return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 125 | + }, | |
| 126 | + }) | |
| 127 | + direct, _ := f.manager.Direct("alice") | |
| 128 | + f.send(t, direct.ID, " hello \n") | |
| 129 | + return f, f.idle(t, direct.ID, 2) | |
| 130 | +} | |
| 131 | + | |
| 132 | +func TestAnAnswerIsStreamedIntoParts(t *testing.T) { | |
| 133 | + f, view := streamHello(t) | |
| 134 | + if view.Messages[0].Role != channels.RoleUser || view.Messages[0].Parts[0].Text != "hello" || f.source.conn("alice").sent()[0] != "hello" { | |
| 135 | + t.Fatalf("messages: %+v", view.Messages) | |
| 136 | + } | |
| 137 | + answer := view.Messages[1] | |
| 138 | + if !answer.Done || answer.Agent != "alice" || answer.StopReason != "end_turn" || len(answer.Parts) != 3 { | |
| 139 | + t.Fatalf("answer: %+v", answer) | |
| 140 | + } | |
| 141 | + if answer.Parts[0].Kind != "thought" || answer.Parts[1].Text != "Bonjour" || answer.Parts[2].Plan[0].Content != "greet" { | |
| 142 | + t.Fatalf("parts: %+v", answer.Parts) | |
| 143 | + } | |
| 144 | + deltas := f.recorder.Named(events.MessageDelta) | |
| 145 | + if third := deltas[2].(channels.DeltaPayload); third.Text != "jour" || third.Index != 1 || third.Part != nil { | |
| 146 | + t.Fatalf("a text fragment carries only the appended text: %+v", third) | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +func TestUsageAndCommandsAreKeptAndCommandsSurviveARestart(t *testing.T) { | |
| 151 | + f, view := streamHello(t) | |
| 152 | + if view.Usage["alice"].Used != 50 { | |
| 153 | + t.Fatalf("usage: %+v", view.Usage) | |
| 154 | + } | |
| 155 | + if payloads := f.recorder.Named(events.CommandsChanged); len(payloads) != 1 || payloads[0].(channels.CommandsPayload).Agent != "alice" { | |
| 156 | + t.Fatalf("commands:changed: %+v", payloads) | |
| 157 | + } | |
| 158 | + manager := f.reopen(t, false) | |
| 159 | + if commands := manager.Commands("alice"); len(commands) != 1 || commands[0].Name != "compact" { | |
| 160 | + t.Fatalf("the commands are remembered: %+v", commands) | |
| 161 | + } | |
| 162 | + if commands := manager.Commands("bob"); len(commands) != 0 { | |
| 163 | + t.Fatalf("bob announced nothing: %+v", commands) | |
| 164 | + } | |
| 165 | +} | |
| 166 | + | |
| 167 | +func TestMessagesSentDuringAnAnswerAreQueued(t *testing.T) { | |
| 168 | + release := make(chan struct{}) | |
| 169 | + f := setup(t, map[string]script{ | |
| 170 | + "first": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | |
| 171 | + <-release | |
| 172 | + return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 173 | + }, | |
| 174 | + }) | |
| 175 | + direct, _ := f.manager.Direct("alice") | |
| 176 | + alice := f.source.conn("alice") | |
| 177 | + f.send(t, direct.ID, "first") | |
| 178 | + waitFor(t, func() bool { return len(alice.sent()) == 1 }) | |
| 179 | + f.send(t, direct.ID, "second") | |
| 180 | + view, _ := f.manager.Conversation(direct.ID) | |
| 181 | + if !view.Busy || view.Queued != 1 || !view.Messages[2].Queued || view.Answering != "alice" { | |
| 182 | + t.Fatalf("second should be queued: %+v", view) | |
| 183 | + } | |
| 184 | + close(release) | |
| 185 | + f.idle(t, direct.ID, 4) | |
| 186 | + if fmt.Sprint(alice.sent()) != "[first second]" || alice.sessionCount() != 1 { | |
| 187 | + t.Fatalf("prompts %v, sessions %d", alice.sent(), alice.sessionCount()) | |
| 188 | + } | |
| 189 | + turns := f.recorder.Named(events.TurnChanged) | |
| 190 | + if last := turns[len(turns)-1].(channels.TurnPayload); last.Busy || last.Queued != 0 || last.Answering != "" { | |
| 191 | + t.Fatalf("last turn event: %+v", last) | |
| 192 | + } | |
| 193 | +} | |
| 194 | + | |
| 195 | +func TestAToolCallIsMergedAndItsPermissionAnswered(t *testing.T) { | |
| 196 | + f := setup(t, map[string]script{ | |
| 197 | + "tool": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | |
| 198 | + h.Update(acp.Update{Kind: acp.UpdateToolCall, ToolCall: &acp.ToolCall{ID: "c1", Title: "bash: ls", Kind: "execute", Status: "pending", Input: `{"command":"ls"}`}}) | |
| 199 | + answer := h.RequestPermission(ctx, acp.PermissionRequest{ | |
| 200 | + ToolCall: acp.ToolCall{ID: "c1"}, | |
| 201 | + Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}, {ID: "never", Kind: "reject_always"}}, | |
| 202 | + }) | |
| 203 | + if answer.OptionID != "allow" { | |
| 204 | + return acp.TurnResult{}, errors.New("refused") | |
| 205 | + } | |
| 206 | + h.Update(acp.Update{Kind: acp.UpdateToolCallUpdate, ToolCall: &acp.ToolCall{ID: "c1", Status: "completed", Output: "agent.yaml"}}) | |
| 207 | + return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 208 | + }, | |
| 209 | + }) | |
| 210 | + direct, _ := f.manager.Direct("alice") | |
| 211 | + f.send(t, direct.ID, "tool") | |
| 212 | + waitFor(t, func() bool { return len(f.recorder.Named(events.PermissionRequested)) == 1 }) | |
| 213 | + request := f.recorder.Named(events.PermissionRequested)[0].(channels.Permission) | |
| 214 | + if request.ToolCall.Title != "bash: ls" || request.Channel != direct.ID || request.Agent != "alice" || request.MessageID == "" { | |
| 215 | + t.Fatalf("the request should be completed with the known tool call: %+v", request) | |
| 216 | + } | |
| 217 | + if view, _ := f.manager.Conversation(direct.ID); len(view.Permissions) != 1 { | |
| 218 | + t.Fatal("the pending request should be in the conversation") | |
| 219 | + } | |
| 220 | + if err := f.manager.AnswerPermission(request.ID, "nope"); err == nil { | |
| 221 | + t.Fatal("an unknown option must be refused") | |
| 222 | + } | |
| 223 | + if err := f.manager.AnswerPermission(request.ID, "allow"); err != nil { | |
| 224 | + t.Fatal(err) | |
| 225 | + } | |
| 226 | + view := f.idle(t, direct.ID, 2) | |
| 227 | + if tool := view.Messages[1].Parts[0].Tool; tool.Status != "completed" || tool.Output != "agent.yaml" || tool.Title != "bash: ls" { | |
| 228 | + t.Fatalf("merged tool call: %+v", tool) | |
| 229 | + } | |
| 230 | + if len(f.recorder.Named(events.PermissionResolved)) != 1 || len(view.Permissions) != 0 { | |
| 231 | + t.Fatal("the request should be resolved") | |
| 232 | + } | |
| 233 | +} | |
| 234 | + | |
| 235 | +func TestRefusalsAreCountedWithinATurn(t *testing.T) { | |
| 236 | + f := setup(t, map[string]script{ | |
| 237 | + "stubborn": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | |
| 238 | + for i := 0; i < 3; i++ { | |
| 239 | + h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: fmt.Sprint(i)}, Options: []acp.PermissionOption{{ID: "reject", Kind: "reject_once"}}}) | |
| 240 | + } | |
| 241 | + return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 242 | + }, | |
| 243 | + }) | |
| 244 | + direct, _ := f.manager.Direct("alice") | |
| 245 | + f.send(t, direct.ID, "stubborn") | |
| 246 | + for i := 0; i < 3; i++ { | |
| 247 | + waitFor(t, func() bool { return len(f.recorder.Named(events.PermissionRequested)) == i+1 }) | |
| 248 | + request := f.recorder.Named(events.PermissionRequested)[i].(channels.Permission) | |
| 249 | + if request.Rejections != i { | |
| 250 | + t.Fatalf("request %d: want %d previous refusals, got %d", i, i, request.Rejections) | |
| 251 | + } | |
| 252 | + _ = f.manager.AnswerPermission(request.ID, "reject") | |
| 253 | + } | |
| 254 | + f.idle(t, direct.ID, 2) | |
| 255 | +} | |
| 256 | + | |
| 257 | +func TestCancelStopsTheAnswerThePermissionTheQueueAndTheNextAgents(t *testing.T) { | |
| 258 | + f := setup(t, map[string]script{ | |
| 259 | + "@alice @bob wait": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | |
| 260 | + h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "…"}) | |
| 261 | + answer := h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: "c"}, Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}}}) | |
| 262 | + if answer.OptionID != "" { | |
| 263 | + return acp.TurnResult{}, errors.New("a cancelled request must be answered empty") | |
| 264 | + } | |
| 265 | + <-ctx.Done() | |
| 266 | + return acp.TurnResult{StopReason: "cancelled"}, nil | |
| 267 | + }, | |
| 268 | + }) | |
| 269 | + general := f.channel(t, "general", "alice", "bob") | |
| 270 | + f.send(t, general.ID, "@alice @bob wait") | |
| 271 | + waitFor(t, func() bool { return len(f.recorder.Named(events.PermissionRequested)) == 1 }) | |
| 272 | + f.send(t, general.ID, "later") | |
| 273 | + f.manager.Cancel(general.ID) | |
| 274 | + view := f.idle(t, general.ID, 5) | |
| 275 | + // [joined alice, joined bob, user "wait", alice's answer, user "later" (queued, never sent)] | |
| 276 | + if answer := view.Messages[3]; answer.Agent != "alice" || answer.StopReason != "cancelled" { | |
| 277 | + t.Fatalf("the answer should end cancelled: %s", describe(view.Messages)) | |
| 278 | + } | |
| 279 | + if later := view.Messages[4]; later.Parts[0].Text != "later" || later.Queued || later.Error != "cancelled" { | |
| 280 | + t.Fatalf("the queued message should be cancelled: %+v", later) | |
| 281 | + } | |
| 282 | + if len(view.Messages) != 5 || len(f.source.conn("bob").sent()) != 0 { | |
| 283 | + t.Fatalf("bob should not answer after a stop: %s", describe(view.Messages)) | |
| 284 | + } | |
| 285 | + if len(f.recorder.Named(events.PermissionResolved)) != 1 { | |
| 286 | + t.Fatal("the pending permission should be resolved") | |
| 287 | + } | |
| 288 | +} | |
| 289 | + | |
| 290 | +func TestAnAgentErrorEndsTheAnswer(t *testing.T) { | |
| 291 | + f := setup(t, map[string]script{ | |
| 292 | + "loop": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | |
| 293 | + return acp.TurnResult{}, errors.New("exceeded maximum tool call iterations (40)") | |
| 294 | + }, | |
| 295 | + }) | |
| 296 | + direct, _ := f.manager.Direct("alice") | |
| 297 | + f.send(t, direct.ID, "loop") | |
| 298 | + view := f.idle(t, direct.ID, 2) | |
| 299 | + if answer := view.Messages[1]; !answer.Done || !strings.Contains(answer.Error, "maximum tool call") { | |
| 300 | + t.Fatalf("answer: %+v", answer) | |
| 301 | + } | |
| 302 | +} | |
| 303 | + | |
| 304 | +func TestAnUnavailableAgentGivesASystemMessage(t *testing.T) { | |
| 305 | + f := setup(t, nil) | |
| 306 | + f.source.fail = errors.New("too many agents running") | |
| 307 | + direct, _ := f.manager.Direct("alice") | |
| 308 | + f.send(t, direct.ID, "hello") | |
| 309 | + view := f.idle(t, direct.ID, 2) | |
| 310 | + if last := view.Messages[1]; last.Role != channels.RoleSystem || last.Code != channels.CodeAgentUnavailable || last.Agent != "alice" || last.Error != "too many agents running" { | |
| 311 | + t.Fatalf("system message: %+v", last) | |
| 312 | + } | |
| 313 | +} | |
| 314 | + | |
| 315 | +func TestUnknownChannelAndEmptyMessage(t *testing.T) { | |
| 316 | + f := setup(t, nil) | |
| 317 | + if _, err := f.manager.Send("nope", "hi"); !errors.Is(err, channels.ErrUnknownChannel) { | |
| 318 | + t.Fatalf("want ErrUnknownChannel, got %v", err) | |
| 319 | + } | |
| 320 | + direct, _ := f.manager.Direct("alice") | |
| 321 | + if _, err := f.manager.Send(direct.ID, " "); err == nil { | |
| 322 | + t.Fatal("an empty message must be refused") | |
| 323 | + } | |
| 324 | +} | |
| 325 | + | |
| 326 | +// ---------------------------------------------------------------- several agents | |
| 327 | + | |
| 328 | +func TestWithoutMentionTheLastAgentWhoSpokeAnswers(t *testing.T) { | |
| 329 | + f := setup(t, nil) | |
| 330 | + general := f.channel(t, "general", "alice", "bob") | |
| 331 | + f.send(t, general.ID, "anyone?") | |
| 332 | + view := f.idle(t, general.ID, 4) | |
| 333 | + if last := view.Messages[3]; last.Code != channels.CodeNoRecipient { | |
| 334 | + t.Fatalf("with two members and nobody who spoke, the user must mention: %s", describe(view.Messages)) | |
| 335 | + } | |
| 336 | + f.send(t, general.ID, "@bob hi") | |
| 337 | + f.idle(t, general.ID, 6) | |
| 338 | + f.send(t, general.ID, "and then?") | |
| 339 | + view = f.idle(t, general.ID, 8) | |
| 340 | + if last := view.Messages[7]; last.Agent != "bob" { | |
| 341 | + t.Fatalf("bob spoke last: %s", describe(view.Messages)) | |
| 342 | + } | |
| 343 | +} | |
| 344 | + | |
| 345 | +func TestAMentionedAgentIsInvitedAndAnswersAfterTheOther(t *testing.T) { | |
| 346 | + f := setup(t, nil) | |
| 347 | + general := f.channel(t, "general", "alice") | |
| 348 | + f.send(t, general.ID, "@alice @bob compare") | |
| 349 | + view := f.idle(t, general.ID, 5) | |
| 350 | + want := "system:alice:agent-joined: | user:::@alice @bob compare | system:bob:agent-joined: | agent:alice::alice ok | agent:bob::bob ok" | |
| 351 | + if got := describe(view.Messages); got != want { | |
| 352 | + t.Fatalf("got %s\nwant %s", got, want) | |
| 353 | + } | |
| 354 | + if fmt.Sprint(view.Channel.Members) != "[alice bob]" { | |
| 355 | + t.Fatalf("members: %v", view.Channel.Members) | |
| 356 | + } | |
| 357 | +} | |
| 358 | + | |
| 359 | +func TestAnAgentReadsWhatItHasNotSeen(t *testing.T) { | |
| 360 | + f := setup(t, nil) | |
| 361 | + general := f.channel(t, "general", "alice", "bob") | |
| 362 | + f.send(t, general.ID, "@alice explain goroutines") | |
| 363 | + f.idle(t, general.ID, 4) | |
| 364 | + f.send(t, general.ID, "@bob what do you think?") | |
| 365 | + f.idle(t, general.ID, 6) | |
| 366 | + want := "[Messages in #general you have not seen yet]\n" + | |
| 367 | + "user: @alice explain goroutines\n" + | |
| 368 | + "alice: alice ok\n" + | |
| 369 | + "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\n@bob what do you think?" | |
| 370 | + if got := f.source.conn("bob").sent()[0]; got != want { | |
| 371 | + t.Fatalf("bob read:\n%s", got) | |
| 372 | + } | |
| 373 | + f.send(t, general.ID, "@alice and you?") | |
| 374 | + f.idle(t, general.ID, 8) | |
| 375 | + want = "[Messages in #general you have not seen yet]\n" + | |
| 376 | + "user: @bob what do you think?\n" + | |
| 377 | + "bob: bob ok\n" + | |
| 378 | + "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\n@alice and you?" | |
| 379 | + if got := f.source.conn("alice").sent()[1]; got != want { | |
| 380 | + t.Fatalf("alice reads only what is new to her session, not her own answer:\n%s", got) | |
| 381 | + } | |
| 382 | +} | |
| 383 | + | |
| 384 | +func TestACommandIsSentAloneToItsAgent(t *testing.T) { | |
| 385 | + f := setup(t, nil) | |
| 386 | + general := f.channel(t, "general", "alice", "garfield") | |
| 387 | + f.send(t, general.ID, "@alice hello") | |
| 388 | + f.idle(t, general.ID, 4) | |
| 389 | + f.send(t, general.ID, "/garfield:idioms Il pleut des cordes") | |
| 390 | + f.idle(t, general.ID, 6) | |
| 391 | + if got := f.source.conn("garfield").sent(); fmt.Sprint(got) != "[/idioms Il pleut des cordes]" { | |
| 392 | + t.Fatalf("garfield received %q", got) | |
| 393 | + } | |
| 394 | +} | |
| 395 | + | |
| 396 | +func TestOneAgentAnswersOneChannelAtATime(t *testing.T) { | |
| 397 | + release := make(chan struct{}) | |
| 398 | + f := setup(t, map[string]script{ | |
| 399 | + "slow": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | |
| 400 | + <-release | |
| 401 | + return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 402 | + }, | |
| 403 | + }) | |
| 404 | + one, _ := f.manager.Direct("alice") | |
| 405 | + two := f.channel(t, "two", "alice") | |
| 406 | + alice := f.source.conn("alice") | |
| 407 | + f.send(t, one.ID, "slow") | |
| 408 | + waitFor(t, func() bool { return len(alice.sent()) == 1 }) | |
| 409 | + f.send(t, two.ID, "quick") | |
| 410 | + time.Sleep(30 * time.Millisecond) | |
| 411 | + if len(alice.sent()) != 1 { | |
| 412 | + t.Fatal("the second channel waits for alice") | |
| 413 | + } | |
| 414 | + if view, _ := f.manager.Conversation(two.ID); !view.Busy || view.Answering != "alice" { | |
| 415 | + t.Fatalf("the second channel shows alice's turn coming: %+v", view) | |
| 416 | + } | |
| 417 | + close(release) | |
| 418 | + f.idle(t, two.ID, 3) | |
| 419 | + if alice.sessionCount() != 2 { | |
| 420 | + t.Fatalf("one session per channel, got %d", alice.sessionCount()) | |
| 421 | + } | |
| 422 | +} | |
| 423 | + | |
| 424 | +// ---------------------------------------------------------------- commands before the first message | |
| 425 | + | |
| 426 | +func TestPrepareAsksAnAgentForItsCommandsOnce(t *testing.T) { | |
| 427 | + f := setup(t, nil) | |
| 428 | + garfield := f.source.conn("garfield") | |
| 429 | + garfield.commands = []acp.Command{{Name: "idioms", Description: "Translate idioms"}} | |
| 430 | + translations := f.channel(t, "translations", "garfield") | |
| 431 | + if err := f.manager.Prepare(context.Background(), translations.ID); err != nil { | |
| 432 | + t.Fatal(err) | |
| 433 | + } | |
| 434 | + if commands := f.manager.Commands("garfield"); len(commands) != 1 || commands[0].Name != "idioms" { | |
| 435 | + t.Fatalf("commands: %+v", commands) | |
| 436 | + } | |
| 437 | + _ = f.manager.Prepare(context.Background(), translations.ID) | |
| 438 | + f.send(t, translations.ID, "/idioms Il pleut") | |
| 439 | + f.idle(t, translations.ID, 3) | |
| 440 | + if garfield.sessionCount() != 1 || len(garfield.sent()) != 1 { | |
| 441 | + t.Fatalf("the prepared session serves the first message: %d sessions, prompts %q", garfield.sessionCount(), garfield.sent()) | |
| 442 | + } | |
| 443 | +} | |
| 444 | + | |
| 445 | +func TestAPreparedSessionIsStillToldThePast(t *testing.T) { | |
| 446 | + f := setup(t, nil) | |
| 447 | + direct, _ := f.manager.Direct("alice") | |
| 448 | + f.send(t, direct.ID, "I am Philippe") | |
| 449 | + f.idle(t, direct.ID, 2) | |
| 450 | + f.source.conn("alice").commands = []acp.Command{{Name: "compact"}} // announced from now on | |
| 451 | + manager := f.reopen(t, false) | |
| 452 | + if err := manager.Prepare(context.Background(), direct.ID); err != nil { | |
| 453 | + t.Fatal(err) | |
| 454 | + } | |
| 455 | + _, _ = manager.Send(direct.ID, "who am I?") | |
| 456 | + f.idle(t, direct.ID, 4) | |
| 457 | + if got := f.source.conn("alice").sent(); len(got) != 1 || !strings.Contains(got[0], "user: I am Philippe") { | |
| 458 | + t.Fatalf("the first prompt of a prepared session carries the past: %q", got) | |
| 459 | + } | |
| 460 | +} | |
| 461 | + | |
| 462 | +func TestPrepareLeavesABusyAgentAlone(t *testing.T) { | |
| 463 | + release := make(chan struct{}) | |
| 464 | + f := setup(t, map[string]script{ | |
| 465 | + "slow": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | |
| 466 | + <-release | |
| 467 | + return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 468 | + }, | |
| 469 | + }) | |
| 470 | + direct, _ := f.manager.Direct("alice") | |
| 471 | + other := f.channel(t, "other", "alice") | |
| 472 | + f.send(t, direct.ID, "slow") | |
| 473 | + waitFor(t, func() bool { return len(f.source.conn("alice").sent()) == 1 }) | |
| 474 | + if err := f.manager.Prepare(context.Background(), other.ID); err != nil { | |
| 475 | + t.Fatal(err) | |
| 476 | + } | |
| 477 | + if f.source.conn("alice").sessionCount() != 1 { | |
| 478 | + t.Fatal("no session is opened for a busy agent") | |
| 479 | + } | |
| 480 | + close(release) | |
| 481 | + f.idle(t, direct.ID, 2) | |
| 482 | +} | |
| 483 | + | |
| 484 | +// ---------------------------------------------------------------- after a restart | |
| 485 | + | |
| 486 | +func TestTheHistoryIsBackAfterARestart(t *testing.T) { | |
| 487 | + f := setup(t, nil) | |
| 488 | + general := f.channel(t, "general", "alice") | |
| 489 | + f.send(t, general.ID, "hello") | |
| 490 | + f.idle(t, general.ID, 3) | |
| 491 | + manager := f.reopen(t, false) | |
| 492 | + list := manager.Channels() | |
| 493 | + if len(list) != 1 || list[0].Name != "general" || fmt.Sprint(list[0].Members) != "[alice]" { | |
| 494 | + t.Fatalf("channels: %+v", list) | |
| 495 | + } | |
| 496 | + view, _ := manager.Conversation(general.ID) | |
| 497 | + if got := describe(view.Messages); got != "system:alice:agent-joined: | user:::hello | agent:alice::alice ok" { | |
| 498 | + t.Fatalf("messages: %s", got) | |
| 499 | + } | |
| 500 | +} | |
| 501 | + | |
| 502 | +func TestANewSessionIsToldTheLastMessages(t *testing.T) { | |
| 503 | + f := setup(t, nil) | |
| 504 | + direct, _ := f.manager.Direct("alice") | |
| 505 | + f.send(t, direct.ID, "I am Philippe") | |
| 506 | + f.idle(t, direct.ID, 2) | |
| 507 | + manager := f.reopen(t, false) | |
| 508 | + if _, err := manager.Send(direct.ID, "who am I?"); err != nil { | |
| 509 | + t.Fatal(err) | |
| 510 | + } | |
| 511 | + f.idle(t, direct.ID, 4) | |
| 512 | + alice := f.source.conn("alice") | |
| 513 | + want := "[Messages in this conversation you have not seen yet]\n" + | |
| 514 | + "user: I am Philippe\n" + | |
| 515 | + "you: alice ok\n" + | |
| 516 | + "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\nwho am I?" | |
| 517 | + if got := alice.sent(); len(got) != 1 || got[0] != want { | |
| 518 | + t.Fatalf("alice read:\n%q", got) | |
| 519 | + } | |
| 520 | +} | |
| 521 | + | |
| 522 | +func TestAnAgentThatCanLoadItsSessionResumesIt(t *testing.T) { | |
| 523 | + f := setup(t, nil) | |
| 524 | + direct, _ := f.manager.Direct("alice") | |
| 525 | + f.send(t, direct.ID, "I am Philippe") | |
| 526 | + f.idle(t, direct.ID, 2) | |
| 527 | + manager := f.reopen(t, true) | |
| 528 | + _, _ = manager.Send(direct.ID, "who am I?") | |
| 529 | + view := f.idle(t, direct.ID, 4) | |
| 530 | + alice := f.source.conn("alice") | |
| 531 | + if fmt.Sprint(alice.loaded) != "[alice-s1]" || alice.sessionCount() != 0 { | |
| 532 | + t.Fatalf("loaded %v, new sessions %d", alice.loaded, alice.sessionCount()) | |
| 533 | + } | |
| 534 | + if got := alice.sent(); fmt.Sprint(got) != "[who am I?]" { | |
| 535 | + t.Fatalf("a resumed session needs no reminder: %q", got) | |
| 536 | + } | |
| 537 | + if strings.Contains(describe(view.Messages), "replayed") { | |
| 538 | + t.Fatalf("the replayed history is not shown again: %s", describe(view.Messages)) | |
| 539 | + } | |
| 540 | +} | |
| 541 | + | |
| 542 | +func TestOlderMessagesArePaged(t *testing.T) { | |
| 543 | + f := setup(t, nil) | |
| 544 | + direct, _ := f.manager.Direct("alice") | |
| 545 | + for i := 0; i < 55; i++ { | |
| 546 | + f.send(t, direct.ID, fmt.Sprint("m", i)) | |
| 547 | + f.idle(t, direct.ID, 2*(i+1)) | |
| 548 | + } | |
| 549 | + manager := f.reopen(t, false) | |
| 550 | + view, _ := manager.Conversation(direct.ID) | |
| 551 | + if len(view.Messages) != 100 || !view.More { | |
| 552 | + t.Fatalf("the last 100 messages, and more: %d %v", len(view.Messages), view.More) | |
| 553 | + } | |
| 554 | + older, more, err := manager.Older(direct.ID, view.Messages[0].ID) | |
| 555 | + if err != nil || len(older) != 10 || more || older[0].Parts[0].Text != "m0" { | |
| 556 | + t.Fatalf("%d %v %v", len(older), more, err) | |
| 557 | + } | |
| 558 | +} | |
| 559 | + | |
| 560 | +func ExampleManager_Send() { | |
| 561 | + db, _ := store.OpenInMemory() | |
| 562 | + defer func() { _ = db.Close() }() | |
| 563 | + manager, _ := channels.NewManager(newFakeSource(nil), db, channels.Options{}) | |
| 564 | + general, _ := manager.Create("general", "") | |
| 565 | + _, _ = manager.Send(general.ID, "@alice @bob Bonjour") | |
| 566 | + for { | |
| 567 | + view, _ := manager.Conversation(general.ID) | |
| 568 | + if !view.Busy && len(view.Messages) == 5 { | |
| 569 | + fmt.Println(describe(view.Messages[3:])) | |
| 570 | + break | |
| 571 | + } | |
| 572 | + time.Sleep(time.Millisecond) | |
| 573 | + } | |
| 574 | + // Output: agent:alice::alice ok | agent:bob::bob ok | |
| 575 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,575 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +package channels_test | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "context" | ||
| 7 | + "errors" | ||
| 8 | + "fmt" | ||
| 9 | + "strings" | ||
| 10 | + "testing" | ||
| 11 | + "time" | ||
| 12 | + | ||
| 13 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | ||
| 14 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | ||
| 15 | + "git.rickub.com/bots-garden/sidekicks/internal/config" | ||
| 16 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | ||
| 17 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | ||
| 18 | +) | ||
| 19 | + | ||
| 20 | +// ---------------------------------------------------------------- channels and members | ||
| 21 | + | ||
| 22 | +func TestChannelsAreCreatedRenamedAndDeleted(t *testing.T) { | ||
| 23 | + f := setup(t, nil) | ||
| 24 | + general := f.channel(t, "general", "alice") | ||
| 25 | + if _, err := f.manager.Create(" General ", ""); !errors.Is(err, store.ErrNameTaken) { | ||
| 26 | + t.Fatalf("names are unique, whatever the case: %v", err) | ||
| 27 | + } | ||
| 28 | + if _, err := f.manager.Create(" ", ""); !errors.Is(err, channels.ErrInvalidName) { | ||
| 29 | + t.Fatalf("a name is needed: %v", err) | ||
| 30 | + } | ||
| 31 | + renamed, err := f.manager.Rename(general.ID, "team", "the team") | ||
| 32 | + if err != nil || renamed.Name != "team" || renamed.Description != "the team" || fmt.Sprint(renamed.Members) != "[alice]" { | ||
| 33 | + t.Fatalf("%+v %v", renamed, err) | ||
| 34 | + } | ||
| 35 | + f.send(t, general.ID, "hello") | ||
| 36 | + f.idle(t, general.ID, 3) | ||
| 37 | + if err := f.manager.Delete(general.ID); err != nil { | ||
| 38 | + t.Fatal(err) | ||
| 39 | + } | ||
| 40 | + if list := f.manager.Channels(); len(list) != 0 { | ||
| 41 | + t.Fatalf("deleted: %+v", list) | ||
| 42 | + } | ||
| 43 | + if messages, _, _ := f.db.Messages(general.ID, "", 10); len(messages) != 0 { | ||
| 44 | + t.Fatal("the history goes with the channel") | ||
| 45 | + } | ||
| 46 | + payloads := f.recorder.Named(events.ChannelsChanged) | ||
| 47 | + if last := payloads[len(payloads)-1].(channels.ListPayload); len(last.Channels) != 0 { | ||
| 48 | + t.Fatalf("the last channels:changed lists no channel: %+v", last) | ||
| 49 | + } | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +func TestMembersJoinAndLeaveVisibly(t *testing.T) { | ||
| 53 | + f := setup(t, nil) | ||
| 54 | + general := f.channel(t, "general", "alice", "bob") | ||
| 55 | + if err := f.manager.AddMember(general.ID, "nobody"); !errors.Is(err, channels.ErrUnknownAgent) { | ||
| 56 | + t.Fatalf("only configured agents: %v", err) | ||
| 57 | + } | ||
| 58 | + _ = f.manager.AddMember(general.ID, "alice") // already there: nothing happens | ||
| 59 | + _ = f.manager.RemoveMember(general.ID, "bob") | ||
| 60 | + view, _ := f.manager.Conversation(general.ID) | ||
| 61 | + if fmt.Sprint(view.Channel.Members) != "[alice]" { | ||
| 62 | + t.Fatalf("members: %v", view.Channel.Members) | ||
| 63 | + } | ||
| 64 | + if got := describe(view.Messages); got != "system:alice:agent-joined: | system:bob:agent-joined: | system:bob:agent-left:" { | ||
| 65 | + t.Fatalf("messages: %s", got) | ||
| 66 | + } | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +func TestDeclaredChannelsAreCreatedOnceAndCompleted(t *testing.T) { | ||
| 70 | + f := setup(t, nil) | ||
| 71 | + declared := []config.Channel{{Name: "general", Description: "With Alice", Agents: []string{"alice"}}} | ||
| 72 | + if err := f.manager.Declare(declared); err != nil { | ||
| 73 | + t.Fatal(err) | ||
| 74 | + } | ||
| 75 | + first := f.manager.Channels() | ||
| 76 | + if len(first) != 1 || first[0].Name != "general" || first[0].Description != "With Alice" || fmt.Sprint(first[0].Members) != "[alice]" { | ||
| 77 | + t.Fatalf("%+v", first) | ||
| 78 | + } | ||
| 79 | + _, _ = f.manager.Rename(first[0].ID, "General", "") | ||
| 80 | + declared[0].Agents = []string{"alice", "bob"} | ||
| 81 | + _ = f.manager.Declare(declared) | ||
| 82 | + second := f.manager.Channels() | ||
| 83 | + if len(second) != 1 || second[0].ID != first[0].ID || fmt.Sprint(second[0].Members) != "[alice bob]" { | ||
| 84 | + t.Fatalf("the same channel, completed: %+v", second) | ||
| 85 | + } | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +func TestADirectConversationIsAChannelWithOneAgent(t *testing.T) { | ||
| 89 | + f := setup(t, nil) | ||
| 90 | + direct, err := f.manager.Direct("alice") | ||
| 91 | + if err != nil || direct.Direct != "alice" || direct.Name != "" || fmt.Sprint(direct.Members) != "[alice]" { | ||
| 92 | + t.Fatalf("%+v %v", direct, err) | ||
| 93 | + } | ||
| 94 | + if again, _ := f.manager.Direct("alice"); again.ID != direct.ID { | ||
| 95 | + t.Fatal("the same direct conversation is returned") | ||
| 96 | + } | ||
| 97 | + if _, err := f.manager.Direct("nobody"); !errors.Is(err, channels.ErrUnknownAgent) { | ||
| 98 | + t.Fatalf("%v", err) | ||
| 99 | + } | ||
| 100 | + if err := f.manager.AddMember(direct.ID, "bob"); err == nil { | ||
| 101 | + t.Fatal("a direct conversation keeps one agent") | ||
| 102 | + } | ||
| 103 | + f.send(t, direct.ID, "@bob hello") | ||
| 104 | + view := f.idle(t, direct.ID, 2) | ||
| 105 | + if got := describe(view.Messages); got != "user:::@bob hello | agent:alice::alice ok" { | ||
| 106 | + t.Fatalf("in a direct conversation, its agent answers: %s", got) | ||
| 107 | + } | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +// ---------------------------------------------------------------- one agent | ||
| 111 | + | ||
| 112 | +// streamHello sends "hello" to a fake Alice that thinks, writes, plans and | ||
| 113 | +// reports its usage and commands. | ||
| 114 | +func streamHello(t *testing.T) (*fixture, channels.Conversation) { | ||
| 115 | + t.Helper() | ||
| 116 | + f := setup(t, map[string]script{ | ||
| 117 | + "hello": func(_ context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | ||
| 118 | + h.Update(acp.Update{Kind: acp.UpdateThought, Text: "hmm"}) | ||
| 119 | + h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "Bon"}) | ||
| 120 | + h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "jour"}) | ||
| 121 | + h.Update(acp.Update{Kind: acp.UpdatePlan, Plan: []acp.PlanEntry{{Content: "greet", Status: "completed"}}}) | ||
| 122 | + h.Update(acp.Update{Kind: acp.UpdateUsage, Usage: &acp.Usage{Used: 50, Size: 1000}}) | ||
| 123 | + h.Update(acp.Update{Kind: acp.UpdateCommands, Commands: []acp.Command{{Name: "compact"}}}) | ||
| 124 | + return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 125 | + }, | ||
| 126 | + }) | ||
| 127 | + direct, _ := f.manager.Direct("alice") | ||
| 128 | + f.send(t, direct.ID, " hello \n") | ||
| 129 | + return f, f.idle(t, direct.ID, 2) | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +func TestAnAnswerIsStreamedIntoParts(t *testing.T) { | ||
| 133 | + f, view := streamHello(t) | ||
| 134 | + if view.Messages[0].Role != channels.RoleUser || view.Messages[0].Parts[0].Text != "hello" || f.source.conn("alice").sent()[0] != "hello" { | ||
| 135 | + t.Fatalf("messages: %+v", view.Messages) | ||
| 136 | + } | ||
| 137 | + answer := view.Messages[1] | ||
| 138 | + if !answer.Done || answer.Agent != "alice" || answer.StopReason != "end_turn" || len(answer.Parts) != 3 { | ||
| 139 | + t.Fatalf("answer: %+v", answer) | ||
| 140 | + } | ||
| 141 | + if answer.Parts[0].Kind != "thought" || answer.Parts[1].Text != "Bonjour" || answer.Parts[2].Plan[0].Content != "greet" { | ||
| 142 | + t.Fatalf("parts: %+v", answer.Parts) | ||
| 143 | + } | ||
| 144 | + deltas := f.recorder.Named(events.MessageDelta) | ||
| 145 | + if third := deltas[2].(channels.DeltaPayload); third.Text != "jour" || third.Index != 1 || third.Part != nil { | ||
| 146 | + t.Fatalf("a text fragment carries only the appended text: %+v", third) | ||
| 147 | + } | ||
| 148 | +} | ||
| 149 | + | ||
| 150 | +func TestUsageAndCommandsAreKeptAndCommandsSurviveARestart(t *testing.T) { | ||
| 151 | + f, view := streamHello(t) | ||
| 152 | + if view.Usage["alice"].Used != 50 { | ||
| 153 | + t.Fatalf("usage: %+v", view.Usage) | ||
| 154 | + } | ||
| 155 | + if payloads := f.recorder.Named(events.CommandsChanged); len(payloads) != 1 || payloads[0].(channels.CommandsPayload).Agent != "alice" { | ||
| 156 | + t.Fatalf("commands:changed: %+v", payloads) | ||
| 157 | + } | ||
| 158 | + manager := f.reopen(t, false) | ||
| 159 | + if commands := manager.Commands("alice"); len(commands) != 1 || commands[0].Name != "compact" { | ||
| 160 | + t.Fatalf("the commands are remembered: %+v", commands) | ||
| 161 | + } | ||
| 162 | + if commands := manager.Commands("bob"); len(commands) != 0 { | ||
| 163 | + t.Fatalf("bob announced nothing: %+v", commands) | ||
| 164 | + } | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +func TestMessagesSentDuringAnAnswerAreQueued(t *testing.T) { | ||
| 168 | + release := make(chan struct{}) | ||
| 169 | + f := setup(t, map[string]script{ | ||
| 170 | + "first": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | ||
| 171 | + <-release | ||
| 172 | + return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 173 | + }, | ||
| 174 | + }) | ||
| 175 | + direct, _ := f.manager.Direct("alice") | ||
| 176 | + alice := f.source.conn("alice") | ||
| 177 | + f.send(t, direct.ID, "first") | ||
| 178 | + waitFor(t, func() bool { return len(alice.sent()) == 1 }) | ||
| 179 | + f.send(t, direct.ID, "second") | ||
| 180 | + view, _ := f.manager.Conversation(direct.ID) | ||
| 181 | + if !view.Busy || view.Queued != 1 || !view.Messages[2].Queued || view.Answering != "alice" { | ||
| 182 | + t.Fatalf("second should be queued: %+v", view) | ||
| 183 | + } | ||
| 184 | + close(release) | ||
| 185 | + f.idle(t, direct.ID, 4) | ||
| 186 | + if fmt.Sprint(alice.sent()) != "[first second]" || alice.sessionCount() != 1 { | ||
| 187 | + t.Fatalf("prompts %v, sessions %d", alice.sent(), alice.sessionCount()) | ||
| 188 | + } | ||
| 189 | + turns := f.recorder.Named(events.TurnChanged) | ||
| 190 | + if last := turns[len(turns)-1].(channels.TurnPayload); last.Busy || last.Queued != 0 || last.Answering != "" { | ||
| 191 | + t.Fatalf("last turn event: %+v", last) | ||
| 192 | + } | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +func TestAToolCallIsMergedAndItsPermissionAnswered(t *testing.T) { | ||
| 196 | + f := setup(t, map[string]script{ | ||
| 197 | + "tool": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | ||
| 198 | + h.Update(acp.Update{Kind: acp.UpdateToolCall, ToolCall: &acp.ToolCall{ID: "c1", Title: "bash: ls", Kind: "execute", Status: "pending", Input: `{"command":"ls"}`}}) | ||
| 199 | + answer := h.RequestPermission(ctx, acp.PermissionRequest{ | ||
| 200 | + ToolCall: acp.ToolCall{ID: "c1"}, | ||
| 201 | + Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}, {ID: "never", Kind: "reject_always"}}, | ||
| 202 | + }) | ||
| 203 | + if answer.OptionID != "allow" { | ||
| 204 | + return acp.TurnResult{}, errors.New("refused") | ||
| 205 | + } | ||
| 206 | + h.Update(acp.Update{Kind: acp.UpdateToolCallUpdate, ToolCall: &acp.ToolCall{ID: "c1", Status: "completed", Output: "agent.yaml"}}) | ||
| 207 | + return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 208 | + }, | ||
| 209 | + }) | ||
| 210 | + direct, _ := f.manager.Direct("alice") | ||
| 211 | + f.send(t, direct.ID, "tool") | ||
| 212 | + waitFor(t, func() bool { return len(f.recorder.Named(events.PermissionRequested)) == 1 }) | ||
| 213 | + request := f.recorder.Named(events.PermissionRequested)[0].(channels.Permission) | ||
| 214 | + if request.ToolCall.Title != "bash: ls" || request.Channel != direct.ID || request.Agent != "alice" || request.MessageID == "" { | ||
| 215 | + t.Fatalf("the request should be completed with the known tool call: %+v", request) | ||
| 216 | + } | ||
| 217 | + if view, _ := f.manager.Conversation(direct.ID); len(view.Permissions) != 1 { | ||
| 218 | + t.Fatal("the pending request should be in the conversation") | ||
| 219 | + } | ||
| 220 | + if err := f.manager.AnswerPermission(request.ID, "nope"); err == nil { | ||
| 221 | + t.Fatal("an unknown option must be refused") | ||
| 222 | + } | ||
| 223 | + if err := f.manager.AnswerPermission(request.ID, "allow"); err != nil { | ||
| 224 | + t.Fatal(err) | ||
| 225 | + } | ||
| 226 | + view := f.idle(t, direct.ID, 2) | ||
| 227 | + if tool := view.Messages[1].Parts[0].Tool; tool.Status != "completed" || tool.Output != "agent.yaml" || tool.Title != "bash: ls" { | ||
| 228 | + t.Fatalf("merged tool call: %+v", tool) | ||
| 229 | + } | ||
| 230 | + if len(f.recorder.Named(events.PermissionResolved)) != 1 || len(view.Permissions) != 0 { | ||
| 231 | + t.Fatal("the request should be resolved") | ||
| 232 | + } | ||
| 233 | +} | ||
| 234 | + | ||
| 235 | +func TestRefusalsAreCountedWithinATurn(t *testing.T) { | ||
| 236 | + f := setup(t, map[string]script{ | ||
| 237 | + "stubborn": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | ||
| 238 | + for i := 0; i < 3; i++ { | ||
| 239 | + h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: fmt.Sprint(i)}, Options: []acp.PermissionOption{{ID: "reject", Kind: "reject_once"}}}) | ||
| 240 | + } | ||
| 241 | + return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 242 | + }, | ||
| 243 | + }) | ||
| 244 | + direct, _ := f.manager.Direct("alice") | ||
| 245 | + f.send(t, direct.ID, "stubborn") | ||
| 246 | + for i := 0; i < 3; i++ { | ||
| 247 | + waitFor(t, func() bool { return len(f.recorder.Named(events.PermissionRequested)) == i+1 }) | ||
| 248 | + request := f.recorder.Named(events.PermissionRequested)[i].(channels.Permission) | ||
| 249 | + if request.Rejections != i { | ||
| 250 | + t.Fatalf("request %d: want %d previous refusals, got %d", i, i, request.Rejections) | ||
| 251 | + } | ||
| 252 | + _ = f.manager.AnswerPermission(request.ID, "reject") | ||
| 253 | + } | ||
| 254 | + f.idle(t, direct.ID, 2) | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +func TestCancelStopsTheAnswerThePermissionTheQueueAndTheNextAgents(t *testing.T) { | ||
| 258 | + f := setup(t, map[string]script{ | ||
| 259 | + "@alice @bob wait": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | ||
| 260 | + h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "…"}) | ||
| 261 | + answer := h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: "c"}, Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}}}) | ||
| 262 | + if answer.OptionID != "" { | ||
| 263 | + return acp.TurnResult{}, errors.New("a cancelled request must be answered empty") | ||
| 264 | + } | ||
| 265 | + <-ctx.Done() | ||
| 266 | + return acp.TurnResult{StopReason: "cancelled"}, nil | ||
| 267 | + }, | ||
| 268 | + }) | ||
| 269 | + general := f.channel(t, "general", "alice", "bob") | ||
| 270 | + f.send(t, general.ID, "@alice @bob wait") | ||
| 271 | + waitFor(t, func() bool { return len(f.recorder.Named(events.PermissionRequested)) == 1 }) | ||
| 272 | + f.send(t, general.ID, "later") | ||
| 273 | + f.manager.Cancel(general.ID) | ||
| 274 | + view := f.idle(t, general.ID, 5) | ||
| 275 | + // [joined alice, joined bob, user "wait", alice's answer, user "later" (queued, never sent)] | ||
| 276 | + if answer := view.Messages[3]; answer.Agent != "alice" || answer.StopReason != "cancelled" { | ||
| 277 | + t.Fatalf("the answer should end cancelled: %s", describe(view.Messages)) | ||
| 278 | + } | ||
| 279 | + if later := view.Messages[4]; later.Parts[0].Text != "later" || later.Queued || later.Error != "cancelled" { | ||
| 280 | + t.Fatalf("the queued message should be cancelled: %+v", later) | ||
| 281 | + } | ||
| 282 | + if len(view.Messages) != 5 || len(f.source.conn("bob").sent()) != 0 { | ||
| 283 | + t.Fatalf("bob should not answer after a stop: %s", describe(view.Messages)) | ||
| 284 | + } | ||
| 285 | + if len(f.recorder.Named(events.PermissionResolved)) != 1 { | ||
| 286 | + t.Fatal("the pending permission should be resolved") | ||
| 287 | + } | ||
| 288 | +} | ||
| 289 | + | ||
| 290 | +func TestAnAgentErrorEndsTheAnswer(t *testing.T) { | ||
| 291 | + f := setup(t, map[string]script{ | ||
| 292 | + "loop": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | ||
| 293 | + return acp.TurnResult{}, errors.New("exceeded maximum tool call iterations (40)") | ||
| 294 | + }, | ||
| 295 | + }) | ||
| 296 | + direct, _ := f.manager.Direct("alice") | ||
| 297 | + f.send(t, direct.ID, "loop") | ||
| 298 | + view := f.idle(t, direct.ID, 2) | ||
| 299 | + if answer := view.Messages[1]; !answer.Done || !strings.Contains(answer.Error, "maximum tool call") { | ||
| 300 | + t.Fatalf("answer: %+v", answer) | ||
| 301 | + } | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +func TestAnUnavailableAgentGivesASystemMessage(t *testing.T) { | ||
| 305 | + f := setup(t, nil) | ||
| 306 | + f.source.fail = errors.New("too many agents running") | ||
| 307 | + direct, _ := f.manager.Direct("alice") | ||
| 308 | + f.send(t, direct.ID, "hello") | ||
| 309 | + view := f.idle(t, direct.ID, 2) | ||
| 310 | + if last := view.Messages[1]; last.Role != channels.RoleSystem || last.Code != channels.CodeAgentUnavailable || last.Agent != "alice" || last.Error != "too many agents running" { | ||
| 311 | + t.Fatalf("system message: %+v", last) | ||
| 312 | + } | ||
| 313 | +} | ||
| 314 | + | ||
| 315 | +func TestUnknownChannelAndEmptyMessage(t *testing.T) { | ||
| 316 | + f := setup(t, nil) | ||
| 317 | + if _, err := f.manager.Send("nope", "hi"); !errors.Is(err, channels.ErrUnknownChannel) { | ||
| 318 | + t.Fatalf("want ErrUnknownChannel, got %v", err) | ||
| 319 | + } | ||
| 320 | + direct, _ := f.manager.Direct("alice") | ||
| 321 | + if _, err := f.manager.Send(direct.ID, " "); err == nil { | ||
| 322 | + t.Fatal("an empty message must be refused") | ||
| 323 | + } | ||
| 324 | +} | ||
| 325 | + | ||
| 326 | +// ---------------------------------------------------------------- several agents | ||
| 327 | + | ||
| 328 | +func TestWithoutMentionTheLastAgentWhoSpokeAnswers(t *testing.T) { | ||
| 329 | + f := setup(t, nil) | ||
| 330 | + general := f.channel(t, "general", "alice", "bob") | ||
| 331 | + f.send(t, general.ID, "anyone?") | ||
| 332 | + view := f.idle(t, general.ID, 4) | ||
| 333 | + if last := view.Messages[3]; last.Code != channels.CodeNoRecipient { | ||
| 334 | + t.Fatalf("with two members and nobody who spoke, the user must mention: %s", describe(view.Messages)) | ||
| 335 | + } | ||
| 336 | + f.send(t, general.ID, "@bob hi") | ||
| 337 | + f.idle(t, general.ID, 6) | ||
| 338 | + f.send(t, general.ID, "and then?") | ||
| 339 | + view = f.idle(t, general.ID, 8) | ||
| 340 | + if last := view.Messages[7]; last.Agent != "bob" { | ||
| 341 | + t.Fatalf("bob spoke last: %s", describe(view.Messages)) | ||
| 342 | + } | ||
| 343 | +} | ||
| 344 | + | ||
| 345 | +func TestAMentionedAgentIsInvitedAndAnswersAfterTheOther(t *testing.T) { | ||
| 346 | + f := setup(t, nil) | ||
| 347 | + general := f.channel(t, "general", "alice") | ||
| 348 | + f.send(t, general.ID, "@alice @bob compare") | ||
| 349 | + view := f.idle(t, general.ID, 5) | ||
| 350 | + want := "system:alice:agent-joined: | user:::@alice @bob compare | system:bob:agent-joined: | agent:alice::alice ok | agent:bob::bob ok" | ||
| 351 | + if got := describe(view.Messages); got != want { | ||
| 352 | + t.Fatalf("got %s\nwant %s", got, want) | ||
| 353 | + } | ||
| 354 | + if fmt.Sprint(view.Channel.Members) != "[alice bob]" { | ||
| 355 | + t.Fatalf("members: %v", view.Channel.Members) | ||
| 356 | + } | ||
| 357 | +} | ||
| 358 | + | ||
| 359 | +func TestAnAgentReadsWhatItHasNotSeen(t *testing.T) { | ||
| 360 | + f := setup(t, nil) | ||
| 361 | + general := f.channel(t, "general", "alice", "bob") | ||
| 362 | + f.send(t, general.ID, "@alice explain goroutines") | ||
| 363 | + f.idle(t, general.ID, 4) | ||
| 364 | + f.send(t, general.ID, "@bob what do you think?") | ||
| 365 | + f.idle(t, general.ID, 6) | ||
| 366 | + want := "[Messages in #general you have not seen yet]\n" + | ||
| 367 | + "user: @alice explain goroutines\n" + | ||
| 368 | + "alice: alice ok\n" + | ||
| 369 | + "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\n@bob what do you think?" | ||
| 370 | + if got := f.source.conn("bob").sent()[0]; got != want { | ||
| 371 | + t.Fatalf("bob read:\n%s", got) | ||
| 372 | + } | ||
| 373 | + f.send(t, general.ID, "@alice and you?") | ||
| 374 | + f.idle(t, general.ID, 8) | ||
| 375 | + want = "[Messages in #general you have not seen yet]\n" + | ||
| 376 | + "user: @bob what do you think?\n" + | ||
| 377 | + "bob: bob ok\n" + | ||
| 378 | + "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\n@alice and you?" | ||
| 379 | + if got := f.source.conn("alice").sent()[1]; got != want { | ||
| 380 | + t.Fatalf("alice reads only what is new to her session, not her own answer:\n%s", got) | ||
| 381 | + } | ||
| 382 | +} | ||
| 383 | + | ||
| 384 | +func TestACommandIsSentAloneToItsAgent(t *testing.T) { | ||
| 385 | + f := setup(t, nil) | ||
| 386 | + general := f.channel(t, "general", "alice", "garfield") | ||
| 387 | + f.send(t, general.ID, "@alice hello") | ||
| 388 | + f.idle(t, general.ID, 4) | ||
| 389 | + f.send(t, general.ID, "/garfield:idioms Il pleut des cordes") | ||
| 390 | + f.idle(t, general.ID, 6) | ||
| 391 | + if got := f.source.conn("garfield").sent(); fmt.Sprint(got) != "[/idioms Il pleut des cordes]" { | ||
| 392 | + t.Fatalf("garfield received %q", got) | ||
| 393 | + } | ||
| 394 | +} | ||
| 395 | + | ||
| 396 | +func TestOneAgentAnswersOneChannelAtATime(t *testing.T) { | ||
| 397 | + release := make(chan struct{}) | ||
| 398 | + f := setup(t, map[string]script{ | ||
| 399 | + "slow": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | ||
| 400 | + <-release | ||
| 401 | + return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 402 | + }, | ||
| 403 | + }) | ||
| 404 | + one, _ := f.manager.Direct("alice") | ||
| 405 | + two := f.channel(t, "two", "alice") | ||
| 406 | + alice := f.source.conn("alice") | ||
| 407 | + f.send(t, one.ID, "slow") | ||
| 408 | + waitFor(t, func() bool { return len(alice.sent()) == 1 }) | ||
| 409 | + f.send(t, two.ID, "quick") | ||
| 410 | + time.Sleep(30 * time.Millisecond) | ||
| 411 | + if len(alice.sent()) != 1 { | ||
| 412 | + t.Fatal("the second channel waits for alice") | ||
| 413 | + } | ||
| 414 | + if view, _ := f.manager.Conversation(two.ID); !view.Busy || view.Answering != "alice" { | ||
| 415 | + t.Fatalf("the second channel shows alice's turn coming: %+v", view) | ||
| 416 | + } | ||
| 417 | + close(release) | ||
| 418 | + f.idle(t, two.ID, 3) | ||
| 419 | + if alice.sessionCount() != 2 { | ||
| 420 | + t.Fatalf("one session per channel, got %d", alice.sessionCount()) | ||
| 421 | + } | ||
| 422 | +} | ||
| 423 | + | ||
| 424 | +// ---------------------------------------------------------------- commands before the first message | ||
| 425 | + | ||
| 426 | +func TestPrepareAsksAnAgentForItsCommandsOnce(t *testing.T) { | ||
| 427 | + f := setup(t, nil) | ||
| 428 | + garfield := f.source.conn("garfield") | ||
| 429 | + garfield.commands = []acp.Command{{Name: "idioms", Description: "Translate idioms"}} | ||
| 430 | + translations := f.channel(t, "translations", "garfield") | ||
| 431 | + if err := f.manager.Prepare(context.Background(), translations.ID); err != nil { | ||
| 432 | + t.Fatal(err) | ||
| 433 | + } | ||
| 434 | + if commands := f.manager.Commands("garfield"); len(commands) != 1 || commands[0].Name != "idioms" { | ||
| 435 | + t.Fatalf("commands: %+v", commands) | ||
| 436 | + } | ||
| 437 | + _ = f.manager.Prepare(context.Background(), translations.ID) | ||
| 438 | + f.send(t, translations.ID, "/idioms Il pleut") | ||
| 439 | + f.idle(t, translations.ID, 3) | ||
| 440 | + if garfield.sessionCount() != 1 || len(garfield.sent()) != 1 { | ||
| 441 | + t.Fatalf("the prepared session serves the first message: %d sessions, prompts %q", garfield.sessionCount(), garfield.sent()) | ||
| 442 | + } | ||
| 443 | +} | ||
| 444 | + | ||
| 445 | +func TestAPreparedSessionIsStillToldThePast(t *testing.T) { | ||
| 446 | + f := setup(t, nil) | ||
| 447 | + direct, _ := f.manager.Direct("alice") | ||
| 448 | + f.send(t, direct.ID, "I am Philippe") | ||
| 449 | + f.idle(t, direct.ID, 2) | ||
| 450 | + f.source.conn("alice").commands = []acp.Command{{Name: "compact"}} // announced from now on | ||
| 451 | + manager := f.reopen(t, false) | ||
| 452 | + if err := manager.Prepare(context.Background(), direct.ID); err != nil { | ||
| 453 | + t.Fatal(err) | ||
| 454 | + } | ||
| 455 | + _, _ = manager.Send(direct.ID, "who am I?") | ||
| 456 | + f.idle(t, direct.ID, 4) | ||
| 457 | + if got := f.source.conn("alice").sent(); len(got) != 1 || !strings.Contains(got[0], "user: I am Philippe") { | ||
| 458 | + t.Fatalf("the first prompt of a prepared session carries the past: %q", got) | ||
| 459 | + } | ||
| 460 | +} | ||
| 461 | + | ||
| 462 | +func TestPrepareLeavesABusyAgentAlone(t *testing.T) { | ||
| 463 | + release := make(chan struct{}) | ||
| 464 | + f := setup(t, map[string]script{ | ||
| 465 | + "slow": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | ||
| 466 | + <-release | ||
| 467 | + return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 468 | + }, | ||
| 469 | + }) | ||
| 470 | + direct, _ := f.manager.Direct("alice") | ||
| 471 | + other := f.channel(t, "other", "alice") | ||
| 472 | + f.send(t, direct.ID, "slow") | ||
| 473 | + waitFor(t, func() bool { return len(f.source.conn("alice").sent()) == 1 }) | ||
| 474 | + if err := f.manager.Prepare(context.Background(), other.ID); err != nil { | ||
| 475 | + t.Fatal(err) | ||
| 476 | + } | ||
| 477 | + if f.source.conn("alice").sessionCount() != 1 { | ||
| 478 | + t.Fatal("no session is opened for a busy agent") | ||
| 479 | + } | ||
| 480 | + close(release) | ||
| 481 | + f.idle(t, direct.ID, 2) | ||
| 482 | +} | ||
| 483 | + | ||
| 484 | +// ---------------------------------------------------------------- after a restart | ||
| 485 | + | ||
| 486 | +func TestTheHistoryIsBackAfterARestart(t *testing.T) { | ||
| 487 | + f := setup(t, nil) | ||
| 488 | + general := f.channel(t, "general", "alice") | ||
| 489 | + f.send(t, general.ID, "hello") | ||
| 490 | + f.idle(t, general.ID, 3) | ||
| 491 | + manager := f.reopen(t, false) | ||
| 492 | + list := manager.Channels() | ||
| 493 | + if len(list) != 1 || list[0].Name != "general" || fmt.Sprint(list[0].Members) != "[alice]" { | ||
| 494 | + t.Fatalf("channels: %+v", list) | ||
| 495 | + } | ||
| 496 | + view, _ := manager.Conversation(general.ID) | ||
| 497 | + if got := describe(view.Messages); got != "system:alice:agent-joined: | user:::hello | agent:alice::alice ok" { | ||
| 498 | + t.Fatalf("messages: %s", got) | ||
| 499 | + } | ||
| 500 | +} | ||
| 501 | + | ||
| 502 | +func TestANewSessionIsToldTheLastMessages(t *testing.T) { | ||
| 503 | + f := setup(t, nil) | ||
| 504 | + direct, _ := f.manager.Direct("alice") | ||
| 505 | + f.send(t, direct.ID, "I am Philippe") | ||
| 506 | + f.idle(t, direct.ID, 2) | ||
| 507 | + manager := f.reopen(t, false) | ||
| 508 | + if _, err := manager.Send(direct.ID, "who am I?"); err != nil { | ||
| 509 | + t.Fatal(err) | ||
| 510 | + } | ||
| 511 | + f.idle(t, direct.ID, 4) | ||
| 512 | + alice := f.source.conn("alice") | ||
| 513 | + want := "[Messages in this conversation you have not seen yet]\n" + | ||
| 514 | + "user: I am Philippe\n" + | ||
| 515 | + "you: alice ok\n" + | ||
| 516 | + "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\nwho am I?" | ||
| 517 | + if got := alice.sent(); len(got) != 1 || got[0] != want { | ||
| 518 | + t.Fatalf("alice read:\n%q", got) | ||
| 519 | + } | ||
| 520 | +} | ||
| 521 | + | ||
| 522 | +func TestAnAgentThatCanLoadItsSessionResumesIt(t *testing.T) { | ||
| 523 | + f := setup(t, nil) | ||
| 524 | + direct, _ := f.manager.Direct("alice") | ||
| 525 | + f.send(t, direct.ID, "I am Philippe") | ||
| 526 | + f.idle(t, direct.ID, 2) | ||
| 527 | + manager := f.reopen(t, true) | ||
| 528 | + _, _ = manager.Send(direct.ID, "who am I?") | ||
| 529 | + view := f.idle(t, direct.ID, 4) | ||
| 530 | + alice := f.source.conn("alice") | ||
| 531 | + if fmt.Sprint(alice.loaded) != "[alice-s1]" || alice.sessionCount() != 0 { | ||
| 532 | + t.Fatalf("loaded %v, new sessions %d", alice.loaded, alice.sessionCount()) | ||
| 533 | + } | ||
| 534 | + if got := alice.sent(); fmt.Sprint(got) != "[who am I?]" { | ||
| 535 | + t.Fatalf("a resumed session needs no reminder: %q", got) | ||
| 536 | + } | ||
| 537 | + if strings.Contains(describe(view.Messages), "replayed") { | ||
| 538 | + t.Fatalf("the replayed history is not shown again: %s", describe(view.Messages)) | ||
| 539 | + } | ||
| 540 | +} | ||
| 541 | + | ||
| 542 | +func TestOlderMessagesArePaged(t *testing.T) { | ||
| 543 | + f := setup(t, nil) | ||
| 544 | + direct, _ := f.manager.Direct("alice") | ||
| 545 | + for i := 0; i < 55; i++ { | ||
| 546 | + f.send(t, direct.ID, fmt.Sprint("m", i)) | ||
| 547 | + f.idle(t, direct.ID, 2*(i+1)) | ||
| 548 | + } | ||
| 549 | + manager := f.reopen(t, false) | ||
| 550 | + view, _ := manager.Conversation(direct.ID) | ||
| 551 | + if len(view.Messages) != 100 || !view.More { | ||
| 552 | + t.Fatalf("the last 100 messages, and more: %d %v", len(view.Messages), view.More) | ||
| 553 | + } | ||
| 554 | + older, more, err := manager.Older(direct.ID, view.Messages[0].ID) | ||
| 555 | + if err != nil || len(older) != 10 || more || older[0].Parts[0].Text != "m0" { | ||
| 556 | + t.Fatalf("%d %v %v", len(older), more, err) | ||
| 557 | + } | ||
| 558 | +} | ||
| 559 | + | ||
| 560 | +func ExampleManager_Send() { | ||
| 561 | + db, _ := store.OpenInMemory() | ||
| 562 | + defer func() { _ = db.Close() }() | ||
| 563 | + manager, _ := channels.NewManager(newFakeSource(nil), db, channels.Options{}) | ||
| 564 | + general, _ := manager.Create("general", "") | ||
| 565 | + _, _ = manager.Send(general.ID, "@alice @bob Bonjour") | ||
| 566 | + for { | ||
| 567 | + view, _ := manager.Conversation(general.ID) | ||
| 568 | + if !view.Busy && len(view.Messages) == 5 { | ||
| 569 | + fmt.Println(describe(view.Messages[3:])) | ||
| 570 | + break | ||
| 571 | + } | ||
| 572 | + time.Sleep(time.Millisecond) | ||
| 573 | + } | ||
| 574 | + // Output: agent:alice::alice ok | agent:bob::bob ok | ||
| 575 | +} | ||
added
internal/channels/fakes_test.go +269 -0 | new file mode 100644 | ||
| @@ -0,0 +1,269 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +package channels_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "fmt" | |
| 8 | + "strings" | |
| 9 | + "sync" | |
| 10 | + "testing" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | |
| 14 | + "git.rickub.com/bots-garden/sidekicks/internal/agents" | |
| 15 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | |
| 16 | + "git.rickub.com/bots-garden/sidekicks/internal/config" | |
| 17 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | |
| 18 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | |
| 19 | +) | |
| 20 | + | |
| 21 | +// script is what a fake agent does with a prompt, given the session handler. | |
| 22 | +type script func(ctx context.Context, handler acp.SessionHandler) (acp.TurnResult, error) | |
| 23 | + | |
| 24 | +// fakeConn is one running fake agent. Without a script for a prompt, it | |
| 25 | +// answers "<agent> ok". | |
| 26 | +type fakeConn struct { | |
| 27 | + agent string | |
| 28 | + canLoad bool | |
| 29 | + // commands, when set, are announced right after session/new, like mm. | |
| 30 | + commands []acp.Command | |
| 31 | + mu sync.Mutex | |
| 32 | + handlers map[string]acp.SessionHandler | |
| 33 | + sessions int | |
| 34 | + loaded []string | |
| 35 | + prompts []string | |
| 36 | + cancelled chan struct{} | |
| 37 | + scripts map[string]script | |
| 38 | +} | |
| 39 | + | |
| 40 | +func newFakeConn(agent string, scripts map[string]script) *fakeConn { | |
| 41 | + return &fakeConn{agent: agent, scripts: scripts, cancelled: make(chan struct{}, 10), handlers: map[string]acp.SessionHandler{}} | |
| 42 | +} | |
| 43 | + | |
| 44 | +func (c *fakeConn) Info() acp.Info { return acp.Info{LoadSession: c.canLoad} } | |
| 45 | +func (c *fakeConn) Done() <-chan struct{} { return make(chan struct{}) } | |
| 46 | +func (c *fakeConn) Err() error { return nil } | |
| 47 | +func (c *fakeConn) Close(context.Context) error { return nil } | |
| 48 | +func (c *fakeConn) Cancel(context.Context, string) error { | |
| 49 | + c.cancelled <- struct{}{} | |
| 50 | + return nil | |
| 51 | +} | |
| 52 | + | |
| 53 | +func (c *fakeConn) NewSession(_ context.Context, cwd string, handler acp.SessionHandler) (string, error) { | |
| 54 | + c.mu.Lock() | |
| 55 | + defer c.mu.Unlock() | |
| 56 | + if cwd != "/agents/"+c.agent { | |
| 57 | + return "", fmt.Errorf("unexpected cwd %s", cwd) | |
| 58 | + } | |
| 59 | + c.sessions++ | |
| 60 | + id := fmt.Sprintf("%s-s%d", c.agent, c.sessions) | |
| 61 | + c.handlers[id] = handler | |
| 62 | + if c.commands != nil { | |
| 63 | + handler.Update(acp.Update{Kind: acp.UpdateCommands, Commands: c.commands}) | |
| 64 | + } | |
| 65 | + return id, nil | |
| 66 | +} | |
| 67 | + | |
| 68 | +func (c *fakeConn) LoadSession(_ context.Context, id, _ string, handler acp.SessionHandler) error { | |
| 69 | + c.mu.Lock() | |
| 70 | + defer c.mu.Unlock() | |
| 71 | + c.loaded = append(c.loaded, id) | |
| 72 | + c.handlers[id] = handler | |
| 73 | + handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: "replayed history"}) | |
| 74 | + return nil | |
| 75 | +} | |
| 76 | + | |
| 77 | +func (c *fakeConn) Prompt(ctx context.Context, sessionID string, text string) (acp.TurnResult, error) { | |
| 78 | + c.mu.Lock() | |
| 79 | + c.prompts = append(c.prompts, text) | |
| 80 | + handler := c.handlers[sessionID] | |
| 81 | + play := c.scripts[text] | |
| 82 | + c.mu.Unlock() | |
| 83 | + if play == nil { | |
| 84 | + handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: c.agent + " ok"}) | |
| 85 | + return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 86 | + } | |
| 87 | + return play(withCancel(ctx, c.cancelled), handler) | |
| 88 | +} | |
| 89 | + | |
| 90 | +func (c *fakeConn) sent() []string { | |
| 91 | + c.mu.Lock() | |
| 92 | + defer c.mu.Unlock() | |
| 93 | + return append([]string(nil), c.prompts...) | |
| 94 | +} | |
| 95 | + | |
| 96 | +func (c *fakeConn) sessionCount() int { | |
| 97 | + c.mu.Lock() | |
| 98 | + defer c.mu.Unlock() | |
| 99 | + return c.sessions | |
| 100 | +} | |
| 101 | + | |
| 102 | +// withCancel ends ctx when the fake agent receives session/cancel. | |
| 103 | +func withCancel(parent context.Context, cancelled chan struct{}) context.Context { | |
| 104 | + ctx, cancel := context.WithCancel(parent) | |
| 105 | + go func() { | |
| 106 | + select { | |
| 107 | + case <-cancelled: | |
| 108 | + cancel() | |
| 109 | + case <-ctx.Done(): | |
| 110 | + } | |
| 111 | + }() | |
| 112 | + return ctx | |
| 113 | +} | |
| 114 | + | |
| 115 | +// fakeSource lends the fake agents alice, bob and garfield. | |
| 116 | +type fakeSource struct { | |
| 117 | + mu sync.Mutex | |
| 118 | + conns map[string]*fakeConn | |
| 119 | + fail error | |
| 120 | +} | |
| 121 | + | |
| 122 | +func newFakeSource(scripts map[string]script) *fakeSource { | |
| 123 | + source := &fakeSource{conns: map[string]*fakeConn{}} | |
| 124 | + for _, name := range []string{"alice", "bob", "garfield"} { | |
| 125 | + source.conns[name] = newFakeConn(name, scripts) | |
| 126 | + } | |
| 127 | + return source | |
| 128 | +} | |
| 129 | + | |
| 130 | +func (s *fakeSource) Acquire(ctx context.Context, name string) (agents.Conn, func(), error) { | |
| 131 | + s.mu.Lock() | |
| 132 | + defer s.mu.Unlock() | |
| 133 | + if s.fail != nil { | |
| 134 | + return nil, nil, s.fail | |
| 135 | + } | |
| 136 | + if err := ctx.Err(); err != nil { | |
| 137 | + return nil, nil, err | |
| 138 | + } | |
| 139 | + return s.conns[name], func() {}, nil | |
| 140 | +} | |
| 141 | + | |
| 142 | +func (s *fakeSource) Definition(name string) (config.Agent, bool) { | |
| 143 | + if name != "alice" && name != "bob" && name != "garfield" { | |
| 144 | + return config.Agent{}, false | |
| 145 | + } | |
| 146 | + return config.Agent{Name: name, Workspace: "/agents/" + name}, true | |
| 147 | +} | |
| 148 | + | |
| 149 | +func (s *fakeSource) conn(name string) *fakeConn { | |
| 150 | + s.mu.Lock() | |
| 151 | + defer s.mu.Unlock() | |
| 152 | + return s.conns[name] | |
| 153 | +} | |
| 154 | + | |
| 155 | +// restart replaces an agent's process by a new one, which has no session. | |
| 156 | +func (s *fakeSource) restart(name string, canLoad bool) *fakeConn { | |
| 157 | + s.mu.Lock() | |
| 158 | + defer s.mu.Unlock() | |
| 159 | + conn := newFakeConn(name, s.conns[name].scripts) | |
| 160 | + conn.canLoad, conn.commands = canLoad, s.conns[name].commands | |
| 161 | + s.conns[name] = conn | |
| 162 | + return conn | |
| 163 | +} | |
| 164 | + | |
| 165 | +type fixture struct { | |
| 166 | + manager *channels.Manager | |
| 167 | + source *fakeSource | |
| 168 | + db *store.Store | |
| 169 | + recorder *events.Recorder | |
| 170 | +} | |
| 171 | + | |
| 172 | +func setup(t *testing.T, scripts map[string]script) *fixture { | |
| 173 | + t.Helper() | |
| 174 | + db, err := store.OpenInMemory() | |
| 175 | + if err != nil { | |
| 176 | + t.Fatal(err) | |
| 177 | + } | |
| 178 | + t.Cleanup(func() { _ = db.Close() }) | |
| 179 | + source := newFakeSource(scripts) | |
| 180 | + recorder := &events.Recorder{} | |
| 181 | + manager, err := channels.NewManager(source, db, channels.Options{Emitter: recorder}) | |
| 182 | + if err != nil { | |
| 183 | + t.Fatal(err) | |
| 184 | + } | |
| 185 | + t.Cleanup(manager.Close) | |
| 186 | + return &fixture{manager: manager, source: source, db: db, recorder: recorder} | |
| 187 | +} | |
| 188 | + | |
| 189 | +// reopen gives a new manager on the same store, as after quitting and | |
| 190 | +// relaunching Sidekicks: the agents are new processes. | |
| 191 | +func (f *fixture) reopen(t *testing.T, canLoad bool) *channels.Manager { | |
| 192 | + t.Helper() | |
| 193 | + f.manager.Close() | |
| 194 | + for _, name := range []string{"alice", "bob", "garfield"} { | |
| 195 | + f.source.restart(name, canLoad) | |
| 196 | + } | |
| 197 | + manager, err := channels.NewManager(f.source, f.db, channels.Options{Emitter: f.recorder}) | |
| 198 | + if err != nil { | |
| 199 | + t.Fatal(err) | |
| 200 | + } | |
| 201 | + f.manager = manager | |
| 202 | + return manager | |
| 203 | +} | |
| 204 | + | |
| 205 | +func (f *fixture) channel(t *testing.T, name string, members ...string) channels.Channel { | |
| 206 | + t.Helper() | |
| 207 | + created, err := f.manager.Create(name, "") | |
| 208 | + if err != nil { | |
| 209 | + t.Fatal(err) | |
| 210 | + } | |
| 211 | + for _, member := range members { | |
| 212 | + if err := f.manager.AddMember(created.ID, member); err != nil { | |
| 213 | + t.Fatal(err) | |
| 214 | + } | |
| 215 | + } | |
| 216 | + return created | |
| 217 | +} | |
| 218 | + | |
| 219 | +func (f *fixture) send(t *testing.T, channelID, text string) { | |
| 220 | + t.Helper() | |
| 221 | + if _, err := f.manager.Send(channelID, text); err != nil { | |
| 222 | + t.Fatal(err) | |
| 223 | + } | |
| 224 | +} | |
| 225 | + | |
| 226 | +// idle waits until the channel answers nothing and has at least n messages. | |
| 227 | +func (f *fixture) idle(t *testing.T, channelID string, n int) channels.Conversation { | |
| 228 | + t.Helper() | |
| 229 | + deadline := time.Now().Add(3 * time.Second) | |
| 230 | + for { | |
| 231 | + view, err := f.manager.Conversation(channelID) | |
| 232 | + if err != nil { | |
| 233 | + t.Fatal(err) | |
| 234 | + } | |
| 235 | + if !view.Busy && len(view.Messages) >= n { | |
| 236 | + return view | |
| 237 | + } | |
| 238 | + if time.Now().After(deadline) { | |
| 239 | + t.Fatalf("still busy or too few messages: %s", describe(view.Messages)) | |
| 240 | + } | |
| 241 | + time.Sleep(5 * time.Millisecond) | |
| 242 | + } | |
| 243 | +} | |
| 244 | + | |
| 245 | +func waitFor(t *testing.T, condition func() bool) { | |
| 246 | + t.Helper() | |
| 247 | + deadline := time.Now().Add(3 * time.Second) | |
| 248 | + for !condition() { | |
| 249 | + if time.Now().After(deadline) { | |
| 250 | + t.Fatal("condition not met in time") | |
| 251 | + } | |
| 252 | + time.Sleep(5 * time.Millisecond) | |
| 253 | + } | |
| 254 | +} | |
| 255 | + | |
| 256 | +// describe summarises messages as "role:agent:code:text" lines, for failures and comparisons. | |
| 257 | +func describe(messages []channels.Message) string { | |
| 258 | + var lines []string | |
| 259 | + for _, message := range messages { | |
| 260 | + text := "" | |
| 261 | + for _, part := range message.Parts { | |
| 262 | + if part.Kind == channels.PartText { | |
| 263 | + text += part.Text | |
| 264 | + } | |
| 265 | + } | |
| 266 | + lines = append(lines, fmt.Sprintf("%s:%s:%s:%s", message.Role, message.Agent, message.Code, text)) | |
| 267 | + } | |
| 268 | + return strings.Join(lines, " | ") | |
| 269 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,269 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +package channels_test | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "context" | ||
| 7 | + "fmt" | ||
| 8 | + "strings" | ||
| 9 | + "sync" | ||
| 10 | + "testing" | ||
| 11 | + "time" | ||
| 12 | + | ||
| 13 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | ||
| 14 | + "git.rickub.com/bots-garden/sidekicks/internal/agents" | ||
| 15 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | ||
| 16 | + "git.rickub.com/bots-garden/sidekicks/internal/config" | ||
| 17 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | ||
| 18 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | +// script is what a fake agent does with a prompt, given the session handler. | ||
| 22 | +type script func(ctx context.Context, handler acp.SessionHandler) (acp.TurnResult, error) | ||
| 23 | + | ||
| 24 | +// fakeConn is one running fake agent. Without a script for a prompt, it | ||
| 25 | +// answers "<agent> ok". | ||
| 26 | +type fakeConn struct { | ||
| 27 | + agent string | ||
| 28 | + canLoad bool | ||
| 29 | + // commands, when set, are announced right after session/new, like mm. | ||
| 30 | + commands []acp.Command | ||
| 31 | + mu sync.Mutex | ||
| 32 | + handlers map[string]acp.SessionHandler | ||
| 33 | + sessions int | ||
| 34 | + loaded []string | ||
| 35 | + prompts []string | ||
| 36 | + cancelled chan struct{} | ||
| 37 | + scripts map[string]script | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +func newFakeConn(agent string, scripts map[string]script) *fakeConn { | ||
| 41 | + return &fakeConn{agent: agent, scripts: scripts, cancelled: make(chan struct{}, 10), handlers: map[string]acp.SessionHandler{}} | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +func (c *fakeConn) Info() acp.Info { return acp.Info{LoadSession: c.canLoad} } | ||
| 45 | +func (c *fakeConn) Done() <-chan struct{} { return make(chan struct{}) } | ||
| 46 | +func (c *fakeConn) Err() error { return nil } | ||
| 47 | +func (c *fakeConn) Close(context.Context) error { return nil } | ||
| 48 | +func (c *fakeConn) Cancel(context.Context, string) error { | ||
| 49 | + c.cancelled <- struct{}{} | ||
| 50 | + return nil | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +func (c *fakeConn) NewSession(_ context.Context, cwd string, handler acp.SessionHandler) (string, error) { | ||
| 54 | + c.mu.Lock() | ||
| 55 | + defer c.mu.Unlock() | ||
| 56 | + if cwd != "/agents/"+c.agent { | ||
| 57 | + return "", fmt.Errorf("unexpected cwd %s", cwd) | ||
| 58 | + } | ||
| 59 | + c.sessions++ | ||
| 60 | + id := fmt.Sprintf("%s-s%d", c.agent, c.sessions) | ||
| 61 | + c.handlers[id] = handler | ||
| 62 | + if c.commands != nil { | ||
| 63 | + handler.Update(acp.Update{Kind: acp.UpdateCommands, Commands: c.commands}) | ||
| 64 | + } | ||
| 65 | + return id, nil | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +func (c *fakeConn) LoadSession(_ context.Context, id, _ string, handler acp.SessionHandler) error { | ||
| 69 | + c.mu.Lock() | ||
| 70 | + defer c.mu.Unlock() | ||
| 71 | + c.loaded = append(c.loaded, id) | ||
| 72 | + c.handlers[id] = handler | ||
| 73 | + handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: "replayed history"}) | ||
| 74 | + return nil | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +func (c *fakeConn) Prompt(ctx context.Context, sessionID string, text string) (acp.TurnResult, error) { | ||
| 78 | + c.mu.Lock() | ||
| 79 | + c.prompts = append(c.prompts, text) | ||
| 80 | + handler := c.handlers[sessionID] | ||
| 81 | + play := c.scripts[text] | ||
| 82 | + c.mu.Unlock() | ||
| 83 | + if play == nil { | ||
| 84 | + handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: c.agent + " ok"}) | ||
| 85 | + return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 86 | + } | ||
| 87 | + return play(withCancel(ctx, c.cancelled), handler) | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +func (c *fakeConn) sent() []string { | ||
| 91 | + c.mu.Lock() | ||
| 92 | + defer c.mu.Unlock() | ||
| 93 | + return append([]string(nil), c.prompts...) | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +func (c *fakeConn) sessionCount() int { | ||
| 97 | + c.mu.Lock() | ||
| 98 | + defer c.mu.Unlock() | ||
| 99 | + return c.sessions | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +// withCancel ends ctx when the fake agent receives session/cancel. | ||
| 103 | +func withCancel(parent context.Context, cancelled chan struct{}) context.Context { | ||
| 104 | + ctx, cancel := context.WithCancel(parent) | ||
| 105 | + go func() { | ||
| 106 | + select { | ||
| 107 | + case <-cancelled: | ||
| 108 | + cancel() | ||
| 109 | + case <-ctx.Done(): | ||
| 110 | + } | ||
| 111 | + }() | ||
| 112 | + return ctx | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +// fakeSource lends the fake agents alice, bob and garfield. | ||
| 116 | +type fakeSource struct { | ||
| 117 | + mu sync.Mutex | ||
| 118 | + conns map[string]*fakeConn | ||
| 119 | + fail error | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +func newFakeSource(scripts map[string]script) *fakeSource { | ||
| 123 | + source := &fakeSource{conns: map[string]*fakeConn{}} | ||
| 124 | + for _, name := range []string{"alice", "bob", "garfield"} { | ||
| 125 | + source.conns[name] = newFakeConn(name, scripts) | ||
| 126 | + } | ||
| 127 | + return source | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +func (s *fakeSource) Acquire(ctx context.Context, name string) (agents.Conn, func(), error) { | ||
| 131 | + s.mu.Lock() | ||
| 132 | + defer s.mu.Unlock() | ||
| 133 | + if s.fail != nil { | ||
| 134 | + return nil, nil, s.fail | ||
| 135 | + } | ||
| 136 | + if err := ctx.Err(); err != nil { | ||
| 137 | + return nil, nil, err | ||
| 138 | + } | ||
| 139 | + return s.conns[name], func() {}, nil | ||
| 140 | +} | ||
| 141 | + | ||
| 142 | +func (s *fakeSource) Definition(name string) (config.Agent, bool) { | ||
| 143 | + if name != "alice" && name != "bob" && name != "garfield" { | ||
| 144 | + return config.Agent{}, false | ||
| 145 | + } | ||
| 146 | + return config.Agent{Name: name, Workspace: "/agents/" + name}, true | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +func (s *fakeSource) conn(name string) *fakeConn { | ||
| 150 | + s.mu.Lock() | ||
| 151 | + defer s.mu.Unlock() | ||
| 152 | + return s.conns[name] | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +// restart replaces an agent's process by a new one, which has no session. | ||
| 156 | +func (s *fakeSource) restart(name string, canLoad bool) *fakeConn { | ||
| 157 | + s.mu.Lock() | ||
| 158 | + defer s.mu.Unlock() | ||
| 159 | + conn := newFakeConn(name, s.conns[name].scripts) | ||
| 160 | + conn.canLoad, conn.commands = canLoad, s.conns[name].commands | ||
| 161 | + s.conns[name] = conn | ||
| 162 | + return conn | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +type fixture struct { | ||
| 166 | + manager *channels.Manager | ||
| 167 | + source *fakeSource | ||
| 168 | + db *store.Store | ||
| 169 | + recorder *events.Recorder | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +func setup(t *testing.T, scripts map[string]script) *fixture { | ||
| 173 | + t.Helper() | ||
| 174 | + db, err := store.OpenInMemory() | ||
| 175 | + if err != nil { | ||
| 176 | + t.Fatal(err) | ||
| 177 | + } | ||
| 178 | + t.Cleanup(func() { _ = db.Close() }) | ||
| 179 | + source := newFakeSource(scripts) | ||
| 180 | + recorder := &events.Recorder{} | ||
| 181 | + manager, err := channels.NewManager(source, db, channels.Options{Emitter: recorder}) | ||
| 182 | + if err != nil { | ||
| 183 | + t.Fatal(err) | ||
| 184 | + } | ||
| 185 | + t.Cleanup(manager.Close) | ||
| 186 | + return &fixture{manager: manager, source: source, db: db, recorder: recorder} | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +// reopen gives a new manager on the same store, as after quitting and | ||
| 190 | +// relaunching Sidekicks: the agents are new processes. | ||
| 191 | +func (f *fixture) reopen(t *testing.T, canLoad bool) *channels.Manager { | ||
| 192 | + t.Helper() | ||
| 193 | + f.manager.Close() | ||
| 194 | + for _, name := range []string{"alice", "bob", "garfield"} { | ||
| 195 | + f.source.restart(name, canLoad) | ||
| 196 | + } | ||
| 197 | + manager, err := channels.NewManager(f.source, f.db, channels.Options{Emitter: f.recorder}) | ||
| 198 | + if err != nil { | ||
| 199 | + t.Fatal(err) | ||
| 200 | + } | ||
| 201 | + f.manager = manager | ||
| 202 | + return manager | ||
| 203 | +} | ||
| 204 | + | ||
| 205 | +func (f *fixture) channel(t *testing.T, name string, members ...string) channels.Channel { | ||
| 206 | + t.Helper() | ||
| 207 | + created, err := f.manager.Create(name, "") | ||
| 208 | + if err != nil { | ||
| 209 | + t.Fatal(err) | ||
| 210 | + } | ||
| 211 | + for _, member := range members { | ||
| 212 | + if err := f.manager.AddMember(created.ID, member); err != nil { | ||
| 213 | + t.Fatal(err) | ||
| 214 | + } | ||
| 215 | + } | ||
| 216 | + return created | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +func (f *fixture) send(t *testing.T, channelID, text string) { | ||
| 220 | + t.Helper() | ||
| 221 | + if _, err := f.manager.Send(channelID, text); err != nil { | ||
| 222 | + t.Fatal(err) | ||
| 223 | + } | ||
| 224 | +} | ||
| 225 | + | ||
| 226 | +// idle waits until the channel answers nothing and has at least n messages. | ||
| 227 | +func (f *fixture) idle(t *testing.T, channelID string, n int) channels.Conversation { | ||
| 228 | + t.Helper() | ||
| 229 | + deadline := time.Now().Add(3 * time.Second) | ||
| 230 | + for { | ||
| 231 | + view, err := f.manager.Conversation(channelID) | ||
| 232 | + if err != nil { | ||
| 233 | + t.Fatal(err) | ||
| 234 | + } | ||
| 235 | + if !view.Busy && len(view.Messages) >= n { | ||
| 236 | + return view | ||
| 237 | + } | ||
| 238 | + if time.Now().After(deadline) { | ||
| 239 | + t.Fatalf("still busy or too few messages: %s", describe(view.Messages)) | ||
| 240 | + } | ||
| 241 | + time.Sleep(5 * time.Millisecond) | ||
| 242 | + } | ||
| 243 | +} | ||
| 244 | + | ||
| 245 | +func waitFor(t *testing.T, condition func() bool) { | ||
| 246 | + t.Helper() | ||
| 247 | + deadline := time.Now().Add(3 * time.Second) | ||
| 248 | + for !condition() { | ||
| 249 | + if time.Now().After(deadline) { | ||
| 250 | + t.Fatal("condition not met in time") | ||
| 251 | + } | ||
| 252 | + time.Sleep(5 * time.Millisecond) | ||
| 253 | + } | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +// describe summarises messages as "role:agent:code:text" lines, for failures and comparisons. | ||
| 257 | +func describe(messages []channels.Message) string { | ||
| 258 | + var lines []string | ||
| 259 | + for _, message := range messages { | ||
| 260 | + text := "" | ||
| 261 | + for _, part := range message.Parts { | ||
| 262 | + if part.Kind == channels.PartText { | ||
| 263 | + text += part.Text | ||
| 264 | + } | ||
| 265 | + } | ||
| 266 | + lines = append(lines, fmt.Sprintf("%s:%s:%s:%s", message.Role, message.Agent, message.Code, text)) | ||
| 267 | + } | ||
| 268 | + return strings.Join(lines, " | ") | ||
| 269 | +} | ||
added
internal/channels/realagents_test.go +136 -0 | new file mode 100644 | ||
| @@ -0,0 +1,136 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +//go:build realagents | |
| 4 | + | |
| 5 | +// Channels with the real mm test agents, started through scripts/agents.sh | |
| 6 | +// (two at most, model unloaded at exit). Run with: | |
| 7 | +// | |
| 8 | +// go test -tags realagents -count=1 ./internal/channels | |
| 9 | +package channels_test | |
| 10 | + | |
| 11 | +import ( | |
| 12 | + "context" | |
| 13 | + "path/filepath" | |
| 14 | + "strings" | |
| 15 | + "testing" | |
| 16 | + "time" | |
| 17 | + | |
| 18 | + "git.rickub.com/bots-garden/sidekicks/internal/agents" | |
| 19 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | |
| 20 | + "git.rickub.com/bots-garden/sidekicks/internal/config" | |
| 21 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | |
| 22 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | |
| 23 | +) | |
| 24 | + | |
| 25 | +func realAgents(t *testing.T, names ...string) *agents.Manager { | |
| 26 | + t.Helper() | |
| 27 | + root, _ := filepath.Abs("../..") | |
| 28 | + var definitions []config.Agent | |
| 29 | + for _, name := range names { | |
| 30 | + definitions = append(definitions, config.Agent{ | |
| 31 | + Name: name, Title: name, Command: filepath.Join(root, "scripts", "agents.sh"), | |
| 32 | + Args: []string{"run", name}, Workspace: filepath.Join(root, "agents", name), | |
| 33 | + }) | |
| 34 | + } | |
| 35 | + manager := agents.NewManager(definitions, agents.Options{MaxRunning: 2}) | |
| 36 | + t.Cleanup(func() { manager.StopAll(context.Background()) }) | |
| 37 | + return manager | |
| 38 | +} | |
| 39 | + | |
| 40 | +func realManager(t *testing.T, source channels.AgentSource, db *store.Store) (*channels.Manager, *events.Recorder) { | |
| 41 | + t.Helper() | |
| 42 | + recorder := &events.Recorder{} | |
| 43 | + manager, err := channels.NewManager(source, db, channels.Options{Emitter: recorder}) | |
| 44 | + if err != nil { | |
| 45 | + t.Fatal(err) | |
| 46 | + } | |
| 47 | + t.Cleanup(manager.Close) | |
| 48 | + return manager, recorder | |
| 49 | +} | |
| 50 | + | |
| 51 | +func waitAnswer(t *testing.T, manager *channels.Manager, id string, messages int) channels.Conversation { | |
| 52 | + t.Helper() | |
| 53 | + deadline := time.Now().Add(4 * time.Minute) | |
| 54 | + for time.Now().Before(deadline) { | |
| 55 | + view, _ := manager.Conversation(id) | |
| 56 | + if !view.Busy && len(view.Messages) >= messages { | |
| 57 | + return view | |
| 58 | + } | |
| 59 | + time.Sleep(200 * time.Millisecond) | |
| 60 | + } | |
| 61 | + t.Fatal("no answer in time") | |
| 62 | + return channels.Conversation{} | |
| 63 | +} | |
| 64 | + | |
| 65 | +func lastText(message channels.Message) string { | |
| 66 | + return message.Parts[len(message.Parts)-1].Text | |
| 67 | +} | |
| 68 | + | |
| 69 | +func TestRikerAsksPermissionBeforeRunningACommand(t *testing.T) { | |
| 70 | + db, _ := store.OpenInMemory() | |
| 71 | + defer func() { _ = db.Close() }() | |
| 72 | + manager, recorder := realManager(t, realAgents(t, "riker"), db) | |
| 73 | + direct, _ := manager.Direct("riker") | |
| 74 | + _, _ = manager.Send(direct.ID, "Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.") | |
| 75 | + deadline := time.Now().Add(4 * time.Minute) | |
| 76 | + for len(recorder.Named(events.PermissionRequested)) == 0 && time.Now().Before(deadline) { | |
| 77 | + time.Sleep(200 * time.Millisecond) | |
| 78 | + } | |
| 79 | + requests := recorder.Named(events.PermissionRequested) | |
| 80 | + if len(requests) == 0 { | |
| 81 | + t.Fatal("riker should ask for permission") | |
| 82 | + } | |
| 83 | + request := requests[0].(channels.Permission) | |
| 84 | + if request.ToolCall.Kind != "execute" || !strings.Contains(request.ToolCall.Input, "ls") { | |
| 85 | + t.Fatalf("request: %+v", request) | |
| 86 | + } | |
| 87 | + if err := manager.AnswerPermission(request.ID, "allow"); err != nil { | |
| 88 | + t.Fatal(err) | |
| 89 | + } | |
| 90 | + view := waitAnswer(t, manager, direct.ID, 2) | |
| 91 | + if answer := view.Messages[1]; answer.StopReason != "end_turn" { | |
| 92 | + t.Fatalf("answer: %+v", answer) | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +// Bob reads Alice's answer in the channel and can talk about it (issue #23). | |
| 97 | +func TestBobCommentsOnAliceInAChannel(t *testing.T) { | |
| 98 | + db, _ := store.OpenInMemory() | |
| 99 | + defer func() { _ = db.Close() }() | |
| 100 | + manager, _ := realManager(t, realAgents(t, "alice", "bob"), db) | |
| 101 | + general, _ := manager.Create("general", "") | |
| 102 | + _, _ = manager.Send(general.ID, "@alice Donne un nombre secret : réponds uniquement « 4217 ».") | |
| 103 | + view := waitAnswer(t, manager, general.ID, 3) | |
| 104 | + if answer := view.Messages[len(view.Messages)-1]; answer.Agent != "alice" || !strings.Contains(lastText(answer), "4217") { | |
| 105 | + t.Fatalf("alice: %s", describe(view.Messages)) | |
| 106 | + } | |
| 107 | + _, _ = manager.Send(general.ID, "@bob Quel nombre Alice a-t-elle donné ? Réponds avec le nombre seulement.") | |
| 108 | + view = waitAnswer(t, manager, general.ID, 6) | |
| 109 | + if answer := view.Messages[len(view.Messages)-1]; answer.Agent != "bob" || !strings.Contains(lastText(answer), "4217") { | |
| 110 | + t.Fatalf("bob should have read alice's answer: %s", describe(view.Messages)) | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +// After a restart, Alice (mm has no session/load) gets the last messages and | |
| 115 | +// still knows the subject (issue #25). | |
| 116 | +func TestAliceRemembersAfterARestart(t *testing.T) { | |
| 117 | + db, err := store.Open(filepath.Join(t.TempDir(), "db")) | |
| 118 | + if err != nil { | |
| 119 | + t.Fatal(err) | |
| 120 | + } | |
| 121 | + defer func() { _ = db.Close() }() | |
| 122 | + firstAgents := realAgents(t, "alice") | |
| 123 | + first, _ := realManager(t, firstAgents, db) | |
| 124 | + direct, _ := first.Direct("alice") | |
| 125 | + _, _ = first.Send(direct.ID, "Je m'appelle Philippe. Réponds juste « Bonjour Philippe ».") | |
| 126 | + waitAnswer(t, first, direct.ID, 2) | |
| 127 | + first.Close() | |
| 128 | + firstAgents.StopAll(context.Background()) // quitting Sidekicks stops the agents | |
| 129 | + | |
| 130 | + second, _ := realManager(t, realAgents(t, "alice"), db) | |
| 131 | + _, _ = second.Send(direct.ID, "Comment je m'appelle ? Un seul mot.") | |
| 132 | + view := waitAnswer(t, second, direct.ID, 4) | |
| 133 | + if answer := view.Messages[3]; !strings.Contains(lastText(answer), "Philippe") { | |
| 134 | + t.Fatalf("alice should remember the name: %s", describe(view.Messages)) | |
| 135 | + } | |
| 136 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,136 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +//go:build realagents | ||
| 4 | + | ||
| 5 | +// Channels with the real mm test agents, started through scripts/agents.sh | ||
| 6 | +// (two at most, model unloaded at exit). Run with: | ||
| 7 | +// | ||
| 8 | +// go test -tags realagents -count=1 ./internal/channels | ||
| 9 | +package channels_test | ||
| 10 | + | ||
| 11 | +import ( | ||
| 12 | + "context" | ||
| 13 | + "path/filepath" | ||
| 14 | + "strings" | ||
| 15 | + "testing" | ||
| 16 | + "time" | ||
| 17 | + | ||
| 18 | + "git.rickub.com/bots-garden/sidekicks/internal/agents" | ||
| 19 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | ||
| 20 | + "git.rickub.com/bots-garden/sidekicks/internal/config" | ||
| 21 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | ||
| 22 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | ||
| 23 | +) | ||
| 24 | + | ||
| 25 | +func realAgents(t *testing.T, names ...string) *agents.Manager { | ||
| 26 | + t.Helper() | ||
| 27 | + root, _ := filepath.Abs("../..") | ||
| 28 | + var definitions []config.Agent | ||
| 29 | + for _, name := range names { | ||
| 30 | + definitions = append(definitions, config.Agent{ | ||
| 31 | + Name: name, Title: name, Command: filepath.Join(root, "scripts", "agents.sh"), | ||
| 32 | + Args: []string{"run", name}, Workspace: filepath.Join(root, "agents", name), | ||
| 33 | + }) | ||
| 34 | + } | ||
| 35 | + manager := agents.NewManager(definitions, agents.Options{MaxRunning: 2}) | ||
| 36 | + t.Cleanup(func() { manager.StopAll(context.Background()) }) | ||
| 37 | + return manager | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +func realManager(t *testing.T, source channels.AgentSource, db *store.Store) (*channels.Manager, *events.Recorder) { | ||
| 41 | + t.Helper() | ||
| 42 | + recorder := &events.Recorder{} | ||
| 43 | + manager, err := channels.NewManager(source, db, channels.Options{Emitter: recorder}) | ||
| 44 | + if err != nil { | ||
| 45 | + t.Fatal(err) | ||
| 46 | + } | ||
| 47 | + t.Cleanup(manager.Close) | ||
| 48 | + return manager, recorder | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +func waitAnswer(t *testing.T, manager *channels.Manager, id string, messages int) channels.Conversation { | ||
| 52 | + t.Helper() | ||
| 53 | + deadline := time.Now().Add(4 * time.Minute) | ||
| 54 | + for time.Now().Before(deadline) { | ||
| 55 | + view, _ := manager.Conversation(id) | ||
| 56 | + if !view.Busy && len(view.Messages) >= messages { | ||
| 57 | + return view | ||
| 58 | + } | ||
| 59 | + time.Sleep(200 * time.Millisecond) | ||
| 60 | + } | ||
| 61 | + t.Fatal("no answer in time") | ||
| 62 | + return channels.Conversation{} | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +func lastText(message channels.Message) string { | ||
| 66 | + return message.Parts[len(message.Parts)-1].Text | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +func TestRikerAsksPermissionBeforeRunningACommand(t *testing.T) { | ||
| 70 | + db, _ := store.OpenInMemory() | ||
| 71 | + defer func() { _ = db.Close() }() | ||
| 72 | + manager, recorder := realManager(t, realAgents(t, "riker"), db) | ||
| 73 | + direct, _ := manager.Direct("riker") | ||
| 74 | + _, _ = manager.Send(direct.ID, "Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.") | ||
| 75 | + deadline := time.Now().Add(4 * time.Minute) | ||
| 76 | + for len(recorder.Named(events.PermissionRequested)) == 0 && time.Now().Before(deadline) { | ||
| 77 | + time.Sleep(200 * time.Millisecond) | ||
| 78 | + } | ||
| 79 | + requests := recorder.Named(events.PermissionRequested) | ||
| 80 | + if len(requests) == 0 { | ||
| 81 | + t.Fatal("riker should ask for permission") | ||
| 82 | + } | ||
| 83 | + request := requests[0].(channels.Permission) | ||
| 84 | + if request.ToolCall.Kind != "execute" || !strings.Contains(request.ToolCall.Input, "ls") { | ||
| 85 | + t.Fatalf("request: %+v", request) | ||
| 86 | + } | ||
| 87 | + if err := manager.AnswerPermission(request.ID, "allow"); err != nil { | ||
| 88 | + t.Fatal(err) | ||
| 89 | + } | ||
| 90 | + view := waitAnswer(t, manager, direct.ID, 2) | ||
| 91 | + if answer := view.Messages[1]; answer.StopReason != "end_turn" { | ||
| 92 | + t.Fatalf("answer: %+v", answer) | ||
| 93 | + } | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +// Bob reads Alice's answer in the channel and can talk about it (issue #23). | ||
| 97 | +func TestBobCommentsOnAliceInAChannel(t *testing.T) { | ||
| 98 | + db, _ := store.OpenInMemory() | ||
| 99 | + defer func() { _ = db.Close() }() | ||
| 100 | + manager, _ := realManager(t, realAgents(t, "alice", "bob"), db) | ||
| 101 | + general, _ := manager.Create("general", "") | ||
| 102 | + _, _ = manager.Send(general.ID, "@alice Donne un nombre secret : réponds uniquement « 4217 ».") | ||
| 103 | + view := waitAnswer(t, manager, general.ID, 3) | ||
| 104 | + if answer := view.Messages[len(view.Messages)-1]; answer.Agent != "alice" || !strings.Contains(lastText(answer), "4217") { | ||
| 105 | + t.Fatalf("alice: %s", describe(view.Messages)) | ||
| 106 | + } | ||
| 107 | + _, _ = manager.Send(general.ID, "@bob Quel nombre Alice a-t-elle donné ? Réponds avec le nombre seulement.") | ||
| 108 | + view = waitAnswer(t, manager, general.ID, 6) | ||
| 109 | + if answer := view.Messages[len(view.Messages)-1]; answer.Agent != "bob" || !strings.Contains(lastText(answer), "4217") { | ||
| 110 | + t.Fatalf("bob should have read alice's answer: %s", describe(view.Messages)) | ||
| 111 | + } | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +// After a restart, Alice (mm has no session/load) gets the last messages and | ||
| 115 | +// still knows the subject (issue #25). | ||
| 116 | +func TestAliceRemembersAfterARestart(t *testing.T) { | ||
| 117 | + db, err := store.Open(filepath.Join(t.TempDir(), "db")) | ||
| 118 | + if err != nil { | ||
| 119 | + t.Fatal(err) | ||
| 120 | + } | ||
| 121 | + defer func() { _ = db.Close() }() | ||
| 122 | + firstAgents := realAgents(t, "alice") | ||
| 123 | + first, _ := realManager(t, firstAgents, db) | ||
| 124 | + direct, _ := first.Direct("alice") | ||
| 125 | + _, _ = first.Send(direct.ID, "Je m'appelle Philippe. Réponds juste « Bonjour Philippe ».") | ||
| 126 | + waitAnswer(t, first, direct.ID, 2) | ||
| 127 | + first.Close() | ||
| 128 | + firstAgents.StopAll(context.Background()) // quitting Sidekicks stops the agents | ||
| 129 | + | ||
| 130 | + second, _ := realManager(t, realAgents(t, "alice"), db) | ||
| 131 | + _, _ = second.Send(direct.ID, "Comment je m'appelle ? Un seul mot.") | ||
| 132 | + view := waitAnswer(t, second, direct.ID, 4) | ||
| 133 | + if answer := view.Messages[3]; !strings.Contains(lastText(answer), "Philippe") { | ||
| 134 | + t.Fatalf("alice should remember the name: %s", describe(view.Messages)) | ||
| 135 | + } | ||
| 136 | +} | ||
added
internal/channels/route.go +112 -0 | new file mode 100644 | ||
| @@ -0,0 +1,112 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +package channels | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "fmt" | |
| 7 | + "regexp" | |
| 8 | + "strings" | |
| 9 | +) | |
| 10 | + | |
| 11 | +// The rules of who answers a message (issue #23) and what each agent reads | |
| 12 | +// (issues #23 and #25), as pure functions. | |
| 13 | + | |
| 14 | +var ( | |
| 15 | + mentionPattern = regexp.MustCompile(`(^|\s)@([a-z][a-z0-9_-]*)`) | |
| 16 | + // /agent:command arguments | |
| 17 | + agentCommandPattern = regexp.MustCompile(`^/([a-z][a-z0-9_-]*):(\S+)(.*)$`) | |
| 18 | + leadingMentions = regexp.MustCompile(`^(@[a-z][a-z0-9_-]*\s+)+`) | |
| 19 | +) | |
| 20 | + | |
| 21 | +// Mentions returns the agents named with @name in text, in order and without | |
| 22 | +// repetition. known tells whether a name is an agent: "@nobody" stays text. | |
| 23 | +// | |
| 24 | +// Mentions("@alice ask @bob, @alice", isAgent) // [alice bob] | |
| 25 | +func Mentions(text string, known func(string) bool) []string { | |
| 26 | + var names []string | |
| 27 | + seen := map[string]bool{} | |
| 28 | + for _, match := range mentionPattern.FindAllStringSubmatch(text, -1) { | |
| 29 | + name := match[2] | |
| 30 | + if known(name) && !seen[name] { | |
| 31 | + seen[name] = true | |
| 32 | + names = append(names, name) | |
| 33 | + } | |
| 34 | + } | |
| 35 | + return names | |
| 36 | +} | |
| 37 | + | |
| 38 | +// Route says who answers a user message and what they receive. | |
| 39 | +type Route struct { | |
| 40 | + // Agents answer one after the other, in this order. | |
| 41 | + Agents []string | |
| 42 | + // Command is set when the message is a slash command: it is sent as is, | |
| 43 | + // without channel context, so that the agent recognises it. | |
| 44 | + Command string | |
| 45 | +} | |
| 46 | + | |
| 47 | +// Resolve applies the rules of issue #23 and #24: | |
| 48 | +// | |
| 49 | +// - "/agent:command arguments" goes to that agent, as "/command arguments"; | |
| 50 | +// - otherwise the agents mentioned with @ answer, in order; | |
| 51 | +// - otherwise the last agent who spoke in the channel (fallback), if any; | |
| 52 | +// - a message starting with "/" (after the mentions) is a command for them. | |
| 53 | +// | |
| 54 | +// An empty Agents means nobody can answer: the user must mention an agent. | |
| 55 | +// | |
| 56 | +// Resolve("@garfield /idioms Il pleut", "alice", isAgent) // {[garfield] "/idioms Il pleut"} | |
| 57 | +func Resolve(text, fallback string, known func(string) bool) Route { | |
| 58 | + if match := agentCommandPattern.FindStringSubmatch(text); match != nil && known(match[1]) { | |
| 59 | + return Route{Agents: []string{match[1]}, Command: "/" + match[2] + match[3]} | |
| 60 | + } | |
| 61 | + route := Route{Agents: Mentions(text, known)} | |
| 62 | + if len(route.Agents) == 0 && fallback != "" { | |
| 63 | + route.Agents = []string{fallback} | |
| 64 | + } | |
| 65 | + if rest := leadingMentions.ReplaceAllString(text, ""); strings.HasPrefix(rest, "/") { | |
| 66 | + route.Command = rest | |
| 67 | + } | |
| 68 | + return route | |
| 69 | +} | |
| 70 | + | |
| 71 | +// Line is a message of the channel as an agent reads it. | |
| 72 | +type Line struct { | |
| 73 | + // Author is "user", or the name of the agent who wrote it. | |
| 74 | + Author string | |
| 75 | + Text string | |
| 76 | +} | |
| 77 | + | |
| 78 | +// The limits of what is re-sent to an agent: small local models have small | |
| 79 | +// context windows. | |
| 80 | +const ( | |
| 81 | + contextMessages = 20 | |
| 82 | + maxLineLength = 1500 | |
| 83 | +) | |
| 84 | + | |
| 85 | +// Prompt builds what agent receives for the user's text, given the channel | |
| 86 | +// messages it has not seen (oldest first). With nothing unseen, the text is | |
| 87 | +// sent alone: the agent's session already holds the conversation. | |
| 88 | +// | |
| 89 | +// Prompt("bob", "#general", "What do you think?", []Line{{Author: "alice", Text: "Go is fun."}}) | |
| 90 | +func Prompt(agent, channelName, text string, unseen []Line) string { | |
| 91 | + if len(unseen) == 0 { | |
| 92 | + return text | |
| 93 | + } | |
| 94 | + if len(unseen) > contextMessages { | |
| 95 | + unseen = unseen[len(unseen)-contextMessages:] | |
| 96 | + } | |
| 97 | + var builder strings.Builder | |
| 98 | + fmt.Fprintf(&builder, "[Messages in %s you have not seen yet]\n", channelName) | |
| 99 | + for _, line := range unseen { | |
| 100 | + author := line.Author | |
| 101 | + if author == agent { | |
| 102 | + author = "you" | |
| 103 | + } | |
| 104 | + lineText := strings.TrimSpace(line.Text) | |
| 105 | + if runes := []rune(lineText); len(runes) > maxLineLength { | |
| 106 | + lineText = string(runes[:maxLineLength]) + "…" | |
| 107 | + } | |
| 108 | + fmt.Fprintf(&builder, "%s: %s\n", author, lineText) | |
| 109 | + } | |
| 110 | + fmt.Fprintf(&builder, "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\n%s", text) | |
| 111 | + return builder.String() | |
| 112 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,112 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +package channels | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "fmt" | ||
| 7 | + "regexp" | ||
| 8 | + "strings" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +// The rules of who answers a message (issue #23) and what each agent reads | ||
| 12 | +// (issues #23 and #25), as pure functions. | ||
| 13 | + | ||
| 14 | +var ( | ||
| 15 | + mentionPattern = regexp.MustCompile(`(^|\s)@([a-z][a-z0-9_-]*)`) | ||
| 16 | + // /agent:command arguments | ||
| 17 | + agentCommandPattern = regexp.MustCompile(`^/([a-z][a-z0-9_-]*):(\S+)(.*)$`) | ||
| 18 | + leadingMentions = regexp.MustCompile(`^(@[a-z][a-z0-9_-]*\s+)+`) | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | +// Mentions returns the agents named with @name in text, in order and without | ||
| 22 | +// repetition. known tells whether a name is an agent: "@nobody" stays text. | ||
| 23 | +// | ||
| 24 | +// Mentions("@alice ask @bob, @alice", isAgent) // [alice bob] | ||
| 25 | +func Mentions(text string, known func(string) bool) []string { | ||
| 26 | + var names []string | ||
| 27 | + seen := map[string]bool{} | ||
| 28 | + for _, match := range mentionPattern.FindAllStringSubmatch(text, -1) { | ||
| 29 | + name := match[2] | ||
| 30 | + if known(name) && !seen[name] { | ||
| 31 | + seen[name] = true | ||
| 32 | + names = append(names, name) | ||
| 33 | + } | ||
| 34 | + } | ||
| 35 | + return names | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// Route says who answers a user message and what they receive. | ||
| 39 | +type Route struct { | ||
| 40 | + // Agents answer one after the other, in this order. | ||
| 41 | + Agents []string | ||
| 42 | + // Command is set when the message is a slash command: it is sent as is, | ||
| 43 | + // without channel context, so that the agent recognises it. | ||
| 44 | + Command string | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +// Resolve applies the rules of issue #23 and #24: | ||
| 48 | +// | ||
| 49 | +// - "/agent:command arguments" goes to that agent, as "/command arguments"; | ||
| 50 | +// - otherwise the agents mentioned with @ answer, in order; | ||
| 51 | +// - otherwise the last agent who spoke in the channel (fallback), if any; | ||
| 52 | +// - a message starting with "/" (after the mentions) is a command for them. | ||
| 53 | +// | ||
| 54 | +// An empty Agents means nobody can answer: the user must mention an agent. | ||
| 55 | +// | ||
| 56 | +// Resolve("@garfield /idioms Il pleut", "alice", isAgent) // {[garfield] "/idioms Il pleut"} | ||
| 57 | +func Resolve(text, fallback string, known func(string) bool) Route { | ||
| 58 | + if match := agentCommandPattern.FindStringSubmatch(text); match != nil && known(match[1]) { | ||
| 59 | + return Route{Agents: []string{match[1]}, Command: "/" + match[2] + match[3]} | ||
| 60 | + } | ||
| 61 | + route := Route{Agents: Mentions(text, known)} | ||
| 62 | + if len(route.Agents) == 0 && fallback != "" { | ||
| 63 | + route.Agents = []string{fallback} | ||
| 64 | + } | ||
| 65 | + if rest := leadingMentions.ReplaceAllString(text, ""); strings.HasPrefix(rest, "/") { | ||
| 66 | + route.Command = rest | ||
| 67 | + } | ||
| 68 | + return route | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +// Line is a message of the channel as an agent reads it. | ||
| 72 | +type Line struct { | ||
| 73 | + // Author is "user", or the name of the agent who wrote it. | ||
| 74 | + Author string | ||
| 75 | + Text string | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +// The limits of what is re-sent to an agent: small local models have small | ||
| 79 | +// context windows. | ||
| 80 | +const ( | ||
| 81 | + contextMessages = 20 | ||
| 82 | + maxLineLength = 1500 | ||
| 83 | +) | ||
| 84 | + | ||
| 85 | +// Prompt builds what agent receives for the user's text, given the channel | ||
| 86 | +// messages it has not seen (oldest first). With nothing unseen, the text is | ||
| 87 | +// sent alone: the agent's session already holds the conversation. | ||
| 88 | +// | ||
| 89 | +// Prompt("bob", "#general", "What do you think?", []Line{{Author: "alice", Text: "Go is fun."}}) | ||
| 90 | +func Prompt(agent, channelName, text string, unseen []Line) string { | ||
| 91 | + if len(unseen) == 0 { | ||
| 92 | + return text | ||
| 93 | + } | ||
| 94 | + if len(unseen) > contextMessages { | ||
| 95 | + unseen = unseen[len(unseen)-contextMessages:] | ||
| 96 | + } | ||
| 97 | + var builder strings.Builder | ||
| 98 | + fmt.Fprintf(&builder, "[Messages in %s you have not seen yet]\n", channelName) | ||
| 99 | + for _, line := range unseen { | ||
| 100 | + author := line.Author | ||
| 101 | + if author == agent { | ||
| 102 | + author = "you" | ||
| 103 | + } | ||
| 104 | + lineText := strings.TrimSpace(line.Text) | ||
| 105 | + if runes := []rune(lineText); len(runes) > maxLineLength { | ||
| 106 | + lineText = string(runes[:maxLineLength]) + "…" | ||
| 107 | + } | ||
| 108 | + fmt.Fprintf(&builder, "%s: %s\n", author, lineText) | ||
| 109 | + } | ||
| 110 | + fmt.Fprintf(&builder, "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\n%s", text) | ||
| 111 | + return builder.String() | ||
| 112 | +} | ||
added
internal/channels/route_test.go +97 -0 | new file mode 100644 | ||
| @@ -0,0 +1,97 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +package channels_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "fmt" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + | |
| 10 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func isAgent(name string) bool { | |
| 14 | + return name == "alice" || name == "bob" || name == "garfield" | |
| 15 | +} | |
| 16 | + | |
| 17 | +func TestMentionsAreAgentsInOrderWithoutRepetition(t *testing.T) { | |
| 18 | + cases := map[string]string{ | |
| 19 | + "@alice ask @bob, then @alice again": "[alice bob]", | |
| 20 | + "hello @nobody": "[]", | |
| 21 | + "mail me at me@alice.fr": "[]", | |
| 22 | + "@garfield": "[garfield]", | |
| 23 | + "(@bob)": "[]", | |
| 24 | + } | |
| 25 | + for text, want := range cases { | |
| 26 | + if got := fmt.Sprint(channels.Mentions(text, isAgent)); got != want { | |
| 27 | + t.Errorf("%q: got %s, want %s", text, got, want) | |
| 28 | + } | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +func TestResolveFollowsTheRoutingRules(t *testing.T) { | |
| 33 | + cases := []struct { | |
| 34 | + text, fallback string | |
| 35 | + agents string | |
| 36 | + command string | |
| 37 | + }{ | |
| 38 | + {"@bob what do you think?", "alice", "[bob]", ""}, | |
| 39 | + {"and you?", "alice", "[alice]", ""}, | |
| 40 | + {"anyone?", "", "[]", ""}, | |
| 41 | + {"@alice @bob compare", "", "[alice bob]", ""}, | |
| 42 | + {"/garfield:idioms Il pleut des cordes", "alice", "[garfield]", "/idioms Il pleut des cordes"}, | |
| 43 | + {"/nobody:idioms x", "alice", "[alice]", "/nobody:idioms x"}, | |
| 44 | + {"@garfield /idioms Il pleut", "alice", "[garfield]", "/idioms Il pleut"}, | |
| 45 | + {"/compact", "alice", "[alice]", "/compact"}, | |
| 46 | + {"@alice look at /etc/hosts", "", "[alice]", ""}, | |
| 47 | + } | |
| 48 | + for _, c := range cases { | |
| 49 | + route := channels.Resolve(c.text, c.fallback, isAgent) | |
| 50 | + if fmt.Sprint(route.Agents) != c.agents || route.Command != c.command { | |
| 51 | + t.Errorf("%q: got %v %q, want %s %q", c.text, route.Agents, route.Command, c.agents, c.command) | |
| 52 | + } | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +func TestPromptWithNothingUnseenIsTheTextAlone(t *testing.T) { | |
| 57 | + if got := channels.Prompt("bob", "#general", "Hi", nil); got != "Hi" { | |
| 58 | + t.Fatalf("got %q", got) | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +func TestPromptGivesTheUnseenMessagesBeforeTheText(t *testing.T) { | |
| 63 | + got := channels.Prompt("bob", "#general", "@bob what do you think?", []channels.Line{ | |
| 64 | + {Author: "user", Text: "@alice explain goroutines"}, | |
| 65 | + {Author: "alice", Text: " Goroutines are light threads.\n"}, | |
| 66 | + {Author: "bob", Text: "I said something before"}, | |
| 67 | + }) | |
| 68 | + want := "[Messages in #general you have not seen yet]\n" + | |
| 69 | + "user: @alice explain goroutines\n" + | |
| 70 | + "alice: Goroutines are light threads.\n" + | |
| 71 | + "you: I said something before\n" + | |
| 72 | + "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\n@bob what do you think?" | |
| 73 | + if got != want { | |
| 74 | + t.Fatalf("got:\n%s\nwant:\n%s", got, want) | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +func TestPromptKeepsTheLastMessagesAndShortensLongOnes(t *testing.T) { | |
| 79 | + var unseen []channels.Line | |
| 80 | + for i := 0; i < 30; i++ { | |
| 81 | + unseen = append(unseen, channels.Line{Author: "user", Text: fmt.Sprint("message ", i)}) | |
| 82 | + } | |
| 83 | + unseen = append(unseen, channels.Line{Author: "alice", Text: strings.Repeat("é", 2000)}) | |
| 84 | + got := channels.Prompt("bob", "#general", "?", unseen) | |
| 85 | + if strings.Contains(got, "message 10\n") || !strings.Contains(got, "message 11\n") { | |
| 86 | + t.Fatalf("only the last 20 messages are kept:\n%s", got) | |
| 87 | + } | |
| 88 | + if !strings.Contains(got, strings.Repeat("é", 1500)+"…\n") || strings.Contains(got, strings.Repeat("é", 1501)) { | |
| 89 | + t.Fatal("a long message is cut at 1500 characters") | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +func ExampleResolve() { | |
| 94 | + route := channels.Resolve("@garfield /idioms Il pleut des cordes", "alice", isAgent) | |
| 95 | + fmt.Println(route.Agents, route.Command) | |
| 96 | + // Output: [garfield] /idioms Il pleut des cordes | |
| 97 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,97 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +package channels_test | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "fmt" | ||
| 7 | + "strings" | ||
| 8 | + "testing" | ||
| 9 | + | ||
| 10 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +func isAgent(name string) bool { | ||
| 14 | + return name == "alice" || name == "bob" || name == "garfield" | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +func TestMentionsAreAgentsInOrderWithoutRepetition(t *testing.T) { | ||
| 18 | + cases := map[string]string{ | ||
| 19 | + "@alice ask @bob, then @alice again": "[alice bob]", | ||
| 20 | + "hello @nobody": "[]", | ||
| 21 | + "mail me at me@alice.fr": "[]", | ||
| 22 | + "@garfield": "[garfield]", | ||
| 23 | + "(@bob)": "[]", | ||
| 24 | + } | ||
| 25 | + for text, want := range cases { | ||
| 26 | + if got := fmt.Sprint(channels.Mentions(text, isAgent)); got != want { | ||
| 27 | + t.Errorf("%q: got %s, want %s", text, got, want) | ||
| 28 | + } | ||
| 29 | + } | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +func TestResolveFollowsTheRoutingRules(t *testing.T) { | ||
| 33 | + cases := []struct { | ||
| 34 | + text, fallback string | ||
| 35 | + agents string | ||
| 36 | + command string | ||
| 37 | + }{ | ||
| 38 | + {"@bob what do you think?", "alice", "[bob]", ""}, | ||
| 39 | + {"and you?", "alice", "[alice]", ""}, | ||
| 40 | + {"anyone?", "", "[]", ""}, | ||
| 41 | + {"@alice @bob compare", "", "[alice bob]", ""}, | ||
| 42 | + {"/garfield:idioms Il pleut des cordes", "alice", "[garfield]", "/idioms Il pleut des cordes"}, | ||
| 43 | + {"/nobody:idioms x", "alice", "[alice]", "/nobody:idioms x"}, | ||
| 44 | + {"@garfield /idioms Il pleut", "alice", "[garfield]", "/idioms Il pleut"}, | ||
| 45 | + {"/compact", "alice", "[alice]", "/compact"}, | ||
| 46 | + {"@alice look at /etc/hosts", "", "[alice]", ""}, | ||
| 47 | + } | ||
| 48 | + for _, c := range cases { | ||
| 49 | + route := channels.Resolve(c.text, c.fallback, isAgent) | ||
| 50 | + if fmt.Sprint(route.Agents) != c.agents || route.Command != c.command { | ||
| 51 | + t.Errorf("%q: got %v %q, want %s %q", c.text, route.Agents, route.Command, c.agents, c.command) | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +func TestPromptWithNothingUnseenIsTheTextAlone(t *testing.T) { | ||
| 57 | + if got := channels.Prompt("bob", "#general", "Hi", nil); got != "Hi" { | ||
| 58 | + t.Fatalf("got %q", got) | ||
| 59 | + } | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +func TestPromptGivesTheUnseenMessagesBeforeTheText(t *testing.T) { | ||
| 63 | + got := channels.Prompt("bob", "#general", "@bob what do you think?", []channels.Line{ | ||
| 64 | + {Author: "user", Text: "@alice explain goroutines"}, | ||
| 65 | + {Author: "alice", Text: " Goroutines are light threads.\n"}, | ||
| 66 | + {Author: "bob", Text: "I said something before"}, | ||
| 67 | + }) | ||
| 68 | + want := "[Messages in #general you have not seen yet]\n" + | ||
| 69 | + "user: @alice explain goroutines\n" + | ||
| 70 | + "alice: Goroutines are light threads.\n" + | ||
| 71 | + "you: I said something before\n" + | ||
| 72 | + "[End of the messages]\n\nThe user now writes to you (answer directly, without your name in front):\n@bob what do you think?" | ||
| 73 | + if got != want { | ||
| 74 | + t.Fatalf("got:\n%s\nwant:\n%s", got, want) | ||
| 75 | + } | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +func TestPromptKeepsTheLastMessagesAndShortensLongOnes(t *testing.T) { | ||
| 79 | + var unseen []channels.Line | ||
| 80 | + for i := 0; i < 30; i++ { | ||
| 81 | + unseen = append(unseen, channels.Line{Author: "user", Text: fmt.Sprint("message ", i)}) | ||
| 82 | + } | ||
| 83 | + unseen = append(unseen, channels.Line{Author: "alice", Text: strings.Repeat("é", 2000)}) | ||
| 84 | + got := channels.Prompt("bob", "#general", "?", unseen) | ||
| 85 | + if strings.Contains(got, "message 10\n") || !strings.Contains(got, "message 11\n") { | ||
| 86 | + t.Fatalf("only the last 20 messages are kept:\n%s", got) | ||
| 87 | + } | ||
| 88 | + if !strings.Contains(got, strings.Repeat("é", 1500)+"…\n") || strings.Contains(got, strings.Repeat("é", 1501)) { | ||
| 89 | + t.Fatal("a long message is cut at 1500 characters") | ||
| 90 | + } | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +func ExampleResolve() { | ||
| 94 | + route := channels.Resolve("@garfield /idioms Il pleut des cordes", "alice", isAgent) | ||
| 95 | + fmt.Println(route.Agents, route.Command) | ||
| 96 | + // Output: [garfield] /idioms Il pleut des cordes | ||
| 97 | +} | ||
added
internal/channels/turns.go +611 -0 | new file mode 100644 | ||
| @@ -0,0 +1,611 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +package channels | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "context" | |
| 7 | + "encoding/json" | |
| 8 | + "errors" | |
| 9 | + "fmt" | |
| 10 | + "strings" | |
| 11 | + | |
| 12 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | |
| 13 | + "git.rickub.com/bots-garden/sidekicks/internal/agents" | |
| 14 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// Send adds a user message to a channel and returns its id. The agents it | |
| 18 | +// addresses (Resolve) answer one after the other, right away or after the | |
| 19 | +// current answers (the message is then queued). A mentioned agent that is | |
| 20 | +// not a member yet is invited first. | |
| 21 | +func (m *Manager) Send(id, text string) (string, error) { | |
| 22 | + text = strings.TrimSpace(text) | |
| 23 | + if text == "" { | |
| 24 | + return "", errors.New("empty message") | |
| 25 | + } | |
| 26 | + m.mu.Lock() | |
| 27 | + defer m.mu.Unlock() | |
| 28 | + ch, err := m.channel(id) | |
| 29 | + if err != nil { | |
| 30 | + return "", err | |
| 31 | + } | |
| 32 | + if err := m.load(ch); err != nil { | |
| 33 | + return "", err | |
| 34 | + } | |
| 35 | + route := m.route(ch, text) | |
| 36 | + | |
| 37 | + message := m.newMessage(ch, RoleUser, "") | |
| 38 | + message.Parts = []Part{{Kind: PartText, Text: text}} | |
| 39 | + message.Done = true | |
| 40 | + ch.messages = append(ch.messages, message) | |
| 41 | + if len(route.Agents) == 0 { | |
| 42 | + m.emitMessage(message) | |
| 43 | + m.systemMessage(ch, CodeNoRecipient, "", "") | |
| 44 | + return message.ID, nil | |
| 45 | + } | |
| 46 | + item := &pending{message: message, route: route} | |
| 47 | + if ch.busy { | |
| 48 | + message.Queued = true | |
| 49 | + ch.queue = append(ch.queue, item) | |
| 50 | + m.emitMessage(message) | |
| 51 | + m.invite(ch, route.Agents) | |
| 52 | + m.emitTurn(ch) | |
| 53 | + return message.ID, nil | |
| 54 | + } | |
| 55 | + ch.busy = true | |
| 56 | + m.emitMessage(message) | |
| 57 | + m.invite(ch, route.Agents) | |
| 58 | + m.emitTurn(ch) | |
| 59 | + go m.run(ch, item) | |
| 60 | + return message.ID, nil | |
| 61 | +} | |
| 62 | + | |
| 63 | +// route says who answers text in ch. In a direct conversation, its agent | |
| 64 | +// always does: mentions are just text there. | |
| 65 | +func (m *Manager) route(ch *channel, text string) Route { | |
| 66 | + if ch.info.Direct != "" { | |
| 67 | + route := Route{Agents: []string{ch.info.Direct}} | |
| 68 | + if strings.HasPrefix(text, "/") { | |
| 69 | + route.Command = text | |
| 70 | + } | |
| 71 | + return route | |
| 72 | + } | |
| 73 | + known := func(name string) bool { | |
| 74 | + _, ok := m.agents.Definition(name) | |
| 75 | + return ok | |
| 76 | + } | |
| 77 | + return Resolve(text, ch.lastSpeaker(), known) | |
| 78 | +} | |
| 79 | + | |
| 80 | +// invite makes members of the agents that are not yet, and says so in the channel. | |
| 81 | +func (m *Manager) invite(ch *channel, names []string) { | |
| 82 | + invited := false | |
| 83 | + for _, name := range names { | |
| 84 | + if ch.member(name) != nil { | |
| 85 | + continue | |
| 86 | + } | |
| 87 | + if err := m.addMember(ch, name); err != nil { | |
| 88 | + m.systemMessage(ch, CodeAgentUnavailable, name, err.Error()) | |
| 89 | + continue | |
| 90 | + } | |
| 91 | + m.systemMessage(ch, CodeAgentJoined, name, "") | |
| 92 | + invited = true | |
| 93 | + } | |
| 94 | + if invited { | |
| 95 | + m.emitChannels() | |
| 96 | + } | |
| 97 | +} | |
| 98 | + | |
| 99 | +// run answers item, then the queued messages one by one; each message is | |
| 100 | +// answered by its agents in order. | |
| 101 | +func (m *Manager) run(ch *channel, item *pending) { | |
| 102 | + for item != nil { | |
| 103 | + m.mu.Lock() | |
| 104 | + ch.stopped = false | |
| 105 | + m.mu.Unlock() | |
| 106 | + for _, agent := range item.route.Agents { | |
| 107 | + if !m.turn(ch, item, agent) { | |
| 108 | + break // stopped by the user: the other agents do not answer | |
| 109 | + } | |
| 110 | + } | |
| 111 | + m.mu.Lock() | |
| 112 | + item = nil | |
| 113 | + ch.answering = "" | |
| 114 | + if len(ch.queue) > 0 { | |
| 115 | + item, ch.queue = ch.queue[0], ch.queue[1:] | |
| 116 | + item.message.Queued = false | |
| 117 | + m.emitMessage(item.message) | |
| 118 | + } else { | |
| 119 | + ch.busy = false | |
| 120 | + } | |
| 121 | + m.emitTurn(ch) | |
| 122 | + m.mu.Unlock() | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +// turn has agent answer item and streams its answer. It returns false when | |
| 127 | +// the user stopped the channel. | |
| 128 | +func (m *Manager) turn(ch *channel, item *pending, agent string) bool { | |
| 129 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 130 | + defer cancel() | |
| 131 | + m.mu.Lock() | |
| 132 | + if ch.stopped { | |
| 133 | + m.mu.Unlock() | |
| 134 | + return false | |
| 135 | + } | |
| 136 | + ch.cancel, ch.answering = cancel, agent | |
| 137 | + m.emitTurn(ch) | |
| 138 | + m.mu.Unlock() | |
| 139 | + | |
| 140 | + if !m.waitTurn(ctx, agent) { | |
| 141 | + return false | |
| 142 | + } | |
| 143 | + defer m.endTurn(agent) | |
| 144 | + conn, release, err := m.agents.Acquire(ctx, agent) | |
| 145 | + if err != nil { | |
| 146 | + return m.unavailable(ctx, ch, agent, err) | |
| 147 | + } | |
| 148 | + defer release() | |
| 149 | + sessionID, fresh, err := m.session(ctx, ch, agent, conn) | |
| 150 | + if err != nil { | |
| 151 | + return m.unavailable(ctx, ch, agent, err) | |
| 152 | + } | |
| 153 | + | |
| 154 | + m.mu.Lock() | |
| 155 | + prompt := m.prompt(ch, agent, item, fresh) | |
| 156 | + answer := m.newMessage(ch, RoleAgent, agent) | |
| 157 | + ch.messages = append(ch.messages, answer) | |
| 158 | + ch.current, ch.rejected = answer, 0 | |
| 159 | + m.emitMessage(answer) | |
| 160 | + m.mu.Unlock() | |
| 161 | + | |
| 162 | + result, err := conn.Prompt(ctx, sessionID, prompt) | |
| 163 | + | |
| 164 | + m.mu.Lock() | |
| 165 | + defer m.mu.Unlock() | |
| 166 | + answer.Done = true | |
| 167 | + switch { | |
| 168 | + case err == nil: | |
| 169 | + answer.StopReason = result.StopReason | |
| 170 | + case ctx.Err() != nil: | |
| 171 | + answer.StopReason = "cancelled" | |
| 172 | + default: | |
| 173 | + answer.Error = err.Error() | |
| 174 | + } | |
| 175 | + ch.current, ch.cancel = nil, nil | |
| 176 | + m.emitMessage(answer) | |
| 177 | + m.seen(ch, agent) | |
| 178 | + return !ch.stopped && ctx.Err() == nil | |
| 179 | +} | |
| 180 | + | |
| 181 | +// unavailable reports that agent could not answer, unless the user stopped | |
| 182 | +// the channel meanwhile. | |
| 183 | +func (m *Manager) unavailable(ctx context.Context, ch *channel, agent string, err error) bool { | |
| 184 | + if ctx.Err() != nil { | |
| 185 | + return false | |
| 186 | + } | |
| 187 | + m.mu.Lock() | |
| 188 | + defer m.mu.Unlock() | |
| 189 | + m.systemMessage(ch, CodeAgentUnavailable, agent, err.Error()) | |
| 190 | + return !ch.stopped | |
| 191 | +} | |
| 192 | + | |
| 193 | +// waitTurn waits until agent answers no other channel. | |
| 194 | +func (m *Manager) waitTurn(ctx context.Context, agent string) bool { | |
| 195 | + m.mu.Lock() | |
| 196 | + slot, ok := m.turns[agent] | |
| 197 | + if !ok { | |
| 198 | + slot = make(chan struct{}, 1) | |
| 199 | + m.turns[agent] = slot | |
| 200 | + } | |
| 201 | + m.mu.Unlock() | |
| 202 | + select { | |
| 203 | + case slot <- struct{}{}: | |
| 204 | + return true | |
| 205 | + case <-ctx.Done(): | |
| 206 | + return false | |
| 207 | + } | |
| 208 | +} | |
| 209 | + | |
| 210 | +func (m *Manager) endTurn(agent string) { | |
| 211 | + m.mu.Lock() | |
| 212 | + slot := m.turns[agent] | |
| 213 | + m.mu.Unlock() | |
| 214 | + <-slot | |
| 215 | +} | |
| 216 | + | |
| 217 | +// session returns the ACP session of agent for ch on conn. A new connection | |
| 218 | +// (first use, or the agent was restarted) resumes the stored session when the | |
| 219 | +// agent can (session/load), or opens a new one: fresh is then true — once, | |
| 220 | +// even when Prepare opened it — and the agent must be told what was said | |
| 221 | +// before (issue #25). | |
| 222 | +func (m *Manager) session(ctx context.Context, ch *channel, agent string, conn agents.Conn) (id string, fresh bool, err error) { | |
| 223 | + m.mu.Lock() | |
| 224 | + member := ch.member(agent) | |
| 225 | + if member == nil { | |
| 226 | + m.mu.Unlock() | |
| 227 | + return "", false, fmt.Errorf("%s is no longer in this channel", agent) | |
| 228 | + } | |
| 229 | + if member.conn == conn { | |
| 230 | + id, fresh = member.SessionID, member.fresh | |
| 231 | + member.fresh = false | |
| 232 | + m.mu.Unlock() | |
| 233 | + return id, fresh, nil | |
| 234 | + } | |
| 235 | + stored := member.SessionID | |
| 236 | + m.mu.Unlock() | |
| 237 | + | |
| 238 | + definition, _ := m.agents.Definition(agent) | |
| 239 | + handler := &handler{manager: m, channel: ch, agent: agent} | |
| 240 | + if stored != "" && conn.Info().LoadSession { | |
| 241 | + if err := conn.LoadSession(ctx, stored, definition.Workspace, handler); err == nil { | |
| 242 | + m.mu.Lock() | |
| 243 | + member.conn = conn | |
| 244 | + m.mu.Unlock() | |
| 245 | + return stored, false, nil | |
| 246 | + } | |
| 247 | + } | |
| 248 | + id, err = conn.NewSession(ctx, definition.Workspace, handler) | |
| 249 | + if err != nil { | |
| 250 | + return "", false, err | |
| 251 | + } | |
| 252 | + m.mu.Lock() | |
| 253 | + defer m.mu.Unlock() | |
| 254 | + member.conn, member.SessionID, member.fresh = conn, id, false | |
| 255 | + _ = m.db.PutMember(member.Membership) | |
| 256 | + return id, true, nil | |
| 257 | +} | |
| 258 | + | |
| 259 | +// Prepare starts the members of a channel whose slash commands are not | |
| 260 | +// known yet and opens their session for it: an agent announces its commands | |
| 261 | +// right after session/new, and mm does so without loading its model | |
| 262 | +// (measured: about 0.1 s). It returns once those sessions are open; the | |
| 263 | +// commands arrive as commands:changed events. The session is kept for the | |
| 264 | +// agent's first answer. A member busy elsewhere is left alone. | |
| 265 | +// | |
| 266 | +// _ = manager.Prepare(ctx, channelID) // the user typed "/" | |
| 267 | +func (m *Manager) Prepare(ctx context.Context, id string) error { | |
| 268 | + m.mu.Lock() | |
| 269 | + ch, err := m.channel(id) | |
| 270 | + if err != nil { | |
| 271 | + m.mu.Unlock() | |
| 272 | + return err | |
| 273 | + } | |
| 274 | + var unknown []string | |
| 275 | + for _, member := range ch.members { | |
| 276 | + if !m.commandsKnown(member.Agent) { | |
| 277 | + unknown = append(unknown, member.Agent) | |
| 278 | + } | |
| 279 | + } | |
| 280 | + m.mu.Unlock() | |
| 281 | + | |
| 282 | + var errs []error | |
| 283 | + for _, agent := range unknown { | |
| 284 | + if err := m.prepare(ctx, ch, agent); err != nil { | |
| 285 | + errs = append(errs, fmt.Errorf("%s: %w", agent, err)) | |
| 286 | + } | |
| 287 | + } | |
| 288 | + return errors.Join(errs...) | |
| 289 | +} | |
| 290 | + | |
| 291 | +func (m *Manager) prepare(ctx context.Context, ch *channel, agent string) error { | |
| 292 | + if !m.tryTurn(agent) { | |
| 293 | + return nil // answering elsewhere: it announced its commands then | |
| 294 | + } | |
| 295 | + defer m.endTurn(agent) | |
| 296 | + conn, release, err := m.agents.Acquire(ctx, agent) | |
| 297 | + if err != nil { | |
| 298 | + return err | |
| 299 | + } | |
| 300 | + defer release() | |
| 301 | + if _, fresh, err := m.session(ctx, ch, agent, conn); err != nil { | |
| 302 | + return err | |
| 303 | + } else if fresh { | |
| 304 | + m.mu.Lock() | |
| 305 | + if member := ch.member(agent); member != nil { | |
| 306 | + member.fresh = true // to be told the channel's past at its first turn | |
| 307 | + } | |
| 308 | + m.mu.Unlock() | |
| 309 | + } | |
| 310 | + return nil | |
| 311 | +} | |
| 312 | + | |
| 313 | +// tryTurn takes agent's turn if it is free. | |
| 314 | +func (m *Manager) tryTurn(agent string) bool { | |
| 315 | + m.mu.Lock() | |
| 316 | + slot, ok := m.turns[agent] | |
| 317 | + if !ok { | |
| 318 | + slot = make(chan struct{}, 1) | |
| 319 | + m.turns[agent] = slot | |
| 320 | + } | |
| 321 | + m.mu.Unlock() | |
| 322 | + select { | |
| 323 | + case slot <- struct{}{}: | |
| 324 | + return true | |
| 325 | + default: | |
| 326 | + return false | |
| 327 | + } | |
| 328 | +} | |
| 329 | + | |
| 330 | +// prompt is what agent receives for item: the command alone, or the text | |
| 331 | +// with the channel messages it has not seen. The caller holds m.mu. | |
| 332 | +func (m *Manager) prompt(ch *channel, agent string, item *pending, fresh bool) string { | |
| 333 | + if item.route.Command != "" { | |
| 334 | + return item.route.Command | |
| 335 | + } | |
| 336 | + place := "#" + ch.info.Name | |
| 337 | + if ch.info.Direct != "" { | |
| 338 | + place = "this conversation" | |
| 339 | + } | |
| 340 | + return Prompt(agent, place, textOf(item.message), unseen(ch, agent, item.message, fresh)) | |
| 341 | +} | |
| 342 | + | |
| 343 | +// unseen lists the finished user and agent messages of ch that agent has not | |
| 344 | +// seen, apart from current. A fresh session has seen nothing: it gets them | |
| 345 | +// all (Prompt keeps the last ones), its own included. | |
| 346 | +func unseen(ch *channel, agent string, current *Message, fresh bool) []Line { | |
| 347 | + lastSeen := "" | |
| 348 | + if member := ch.member(agent); member != nil && !fresh { | |
| 349 | + lastSeen = member.LastSeen | |
| 350 | + } | |
| 351 | + var lines []Line | |
| 352 | + for _, message := range ch.messages { | |
| 353 | + skip := message == current || message.Queued || !message.Done || message.Role == RoleSystem || message.ID <= lastSeen | |
| 354 | + // Without a fresh session, the agent's own answers are already in it. | |
| 355 | + ownAnswer := message.Role == RoleAgent && message.Agent == agent | |
| 356 | + if skip || (!fresh && ownAnswer) { | |
| 357 | + continue | |
| 358 | + } | |
| 359 | + if line, ok := lineOf(message); ok { | |
| 360 | + lines = append(lines, line) | |
| 361 | + } | |
| 362 | + } | |
| 363 | + return lines | |
| 364 | +} | |
| 365 | + | |
| 366 | +// lineOf is message as an agent reads it; false when it has no text. | |
| 367 | +func lineOf(message *Message) (Line, bool) { | |
| 368 | + text := textOf(message) | |
| 369 | + if text == "" { | |
| 370 | + return Line{}, false | |
| 371 | + } | |
| 372 | + author := "user" | |
| 373 | + if message.Role == RoleAgent { | |
| 374 | + author = message.Agent | |
| 375 | + } | |
| 376 | + return Line{Author: author, Text: text}, true | |
| 377 | +} | |
| 378 | + | |
| 379 | +// seen records that agent has seen every message of ch so far. The caller holds m.mu. | |
| 380 | +func (m *Manager) seen(ch *channel, agent string) { | |
| 381 | + member := ch.member(agent) | |
| 382 | + if member == nil || len(ch.messages) == 0 { | |
| 383 | + return | |
| 384 | + } | |
| 385 | + member.LastSeen = ch.messages[len(ch.messages)-1].ID | |
| 386 | + _ = m.db.PutMember(member.Membership) | |
| 387 | +} | |
| 388 | + | |
| 389 | +func textOf(message *Message) string { | |
| 390 | + var builder strings.Builder | |
| 391 | + for _, part := range message.Parts { | |
| 392 | + if part.Kind == PartText { | |
| 393 | + builder.WriteString(part.Text) | |
| 394 | + } | |
| 395 | + } | |
| 396 | + return builder.String() | |
| 397 | +} | |
| 398 | + | |
| 399 | +// Cancel stops the current answer of a channel, the agents still to answer | |
| 400 | +// and the queued messages. Pending permission requests are answered | |
| 401 | +// "cancelled", as ACP requires. | |
| 402 | +func (m *Manager) Cancel(id string) { | |
| 403 | + m.mu.Lock() | |
| 404 | + ch, ok := m.channels[id] | |
| 405 | + if !ok || !ch.busy { | |
| 406 | + m.mu.Unlock() | |
| 407 | + return | |
| 408 | + } | |
| 409 | + ch.stopped = true | |
| 410 | + for _, queued := range ch.queue { | |
| 411 | + queued.message.Queued = false | |
| 412 | + queued.message.Error = "cancelled" | |
| 413 | + m.emitMessage(queued.message) | |
| 414 | + } | |
| 415 | + ch.queue = nil | |
| 416 | + for requestID, pending := range m.permissions { | |
| 417 | + if pending.channel == ch { | |
| 418 | + m.resolve(requestID, acp.PermissionAnswer{}) | |
| 419 | + } | |
| 420 | + } | |
| 421 | + var conn agents.Conn | |
| 422 | + sessionID := "" | |
| 423 | + if ch.current != nil { | |
| 424 | + if member := ch.member(ch.current.Agent); member != nil { | |
| 425 | + conn, sessionID = member.conn, member.SessionID | |
| 426 | + } | |
| 427 | + } | |
| 428 | + cancel := ch.cancel | |
| 429 | + m.emitTurn(ch) | |
| 430 | + m.mu.Unlock() | |
| 431 | + | |
| 432 | + if conn != nil { | |
| 433 | + _ = conn.Cancel(context.Background(), sessionID) | |
| 434 | + } else if cancel != nil { | |
| 435 | + cancel() // still waiting for, or starting, the agent | |
| 436 | + } | |
| 437 | +} | |
| 438 | + | |
| 439 | +// AnswerPermission gives the user's choice for a permission request. | |
| 440 | +func (m *Manager) AnswerPermission(id, optionID string) error { | |
| 441 | + m.mu.Lock() | |
| 442 | + defer m.mu.Unlock() | |
| 443 | + pending, ok := m.permissions[id] | |
| 444 | + if !ok { | |
| 445 | + return fmt.Errorf("no permission request %s waits", id) | |
| 446 | + } | |
| 447 | + for _, option := range pending.permission.Options { | |
| 448 | + if option.ID == optionID { | |
| 449 | + if strings.HasPrefix(option.Kind, "reject") { | |
| 450 | + pending.channel.rejected++ | |
| 451 | + } | |
| 452 | + m.resolve(id, acp.PermissionAnswer{OptionID: optionID}) | |
| 453 | + return nil | |
| 454 | + } | |
| 455 | + } | |
| 456 | + return fmt.Errorf("permission request %s has no option %s", id, optionID) | |
| 457 | +} | |
| 458 | + | |
| 459 | +// resolve answers a pending request. The caller holds m.mu. | |
| 460 | +func (m *Manager) resolve(id string, answer acp.PermissionAnswer) { | |
| 461 | + pending := m.permissions[id] | |
| 462 | + delete(m.permissions, id) | |
| 463 | + pending.answer <- answer | |
| 464 | + m.emitter.Emit(events.PermissionResolved, PermissionResolvedPayload{ID: id, Channel: pending.channel.info.ID}) | |
| 465 | +} | |
| 466 | + | |
| 467 | +// ---------------------------------------------------------------- the session handler | |
| 468 | + | |
| 469 | +// handler receives what agent says in its session for channel. | |
| 470 | +type handler struct { | |
| 471 | + manager *Manager | |
| 472 | + channel *channel | |
| 473 | + agent string | |
| 474 | +} | |
| 475 | + | |
| 476 | +func (h *handler) Update(update acp.Update) { | |
| 477 | + m, ch := h.manager, h.channel | |
| 478 | + m.mu.Lock() | |
| 479 | + defer m.mu.Unlock() | |
| 480 | + switch update.Kind { | |
| 481 | + case acp.UpdateUsage: | |
| 482 | + ch.usage[h.agent] = *update.Usage | |
| 483 | + m.emitter.Emit(events.UsageChanged, UsagePayload{Channel: ch.info.ID, Agent: h.agent, Usage: *update.Usage}) | |
| 484 | + return | |
| 485 | + case acp.UpdateCommands: | |
| 486 | + m.commands[h.agent] = update.Commands | |
| 487 | + if data, err := json.Marshal(update.Commands); err == nil { | |
| 488 | + _ = m.db.SetValue("commands/"+h.agent, data) | |
| 489 | + } | |
| 490 | + m.emitter.Emit(events.CommandsChanged, CommandsPayload{Agent: h.agent, Commands: update.Commands}) | |
| 491 | + return | |
| 492 | + } | |
| 493 | + message := ch.current | |
| 494 | + if message == nil || message.Agent != h.agent { | |
| 495 | + return // nothing of this agent streams: a replayed or late update | |
| 496 | + } | |
| 497 | + switch update.Kind { | |
| 498 | + case acp.UpdateMessage, acp.UpdateThought: | |
| 499 | + m.appendText(message, update) | |
| 500 | + case acp.UpdateToolCall: | |
| 501 | + tool := *update.ToolCall | |
| 502 | + message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool}) | |
| 503 | + m.emitPart(message, len(message.Parts)-1) | |
| 504 | + case acp.UpdateToolCallUpdate: | |
| 505 | + index := toolIndex(message, update.ToolCall.ID) | |
| 506 | + if index < 0 { | |
| 507 | + tool := *update.ToolCall | |
| 508 | + message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool}) | |
| 509 | + index = len(message.Parts) - 1 | |
| 510 | + } else { | |
| 511 | + merge(message.Parts[index].Tool, update.ToolCall) | |
| 512 | + } | |
| 513 | + m.emitPart(message, index) | |
| 514 | + case acp.UpdatePlan: | |
| 515 | + index := -1 | |
| 516 | + for i, part := range message.Parts { | |
| 517 | + if part.Kind == PartPlan { | |
| 518 | + index = i | |
| 519 | + } | |
| 520 | + } | |
| 521 | + if index < 0 { | |
| 522 | + message.Parts = append(message.Parts, Part{Kind: PartPlan}) | |
| 523 | + index = len(message.Parts) - 1 | |
| 524 | + } | |
| 525 | + message.Parts[index].Plan = update.Plan | |
| 526 | + m.emitPart(message, index) | |
| 527 | + } | |
| 528 | +} | |
| 529 | + | |
| 530 | +// appendText adds a text or thought fragment to message. The caller holds m.mu. | |
| 531 | +func (m *Manager) appendText(message *Message, update acp.Update) { | |
| 532 | + kind := PartText | |
| 533 | + if update.Kind == acp.UpdateThought { | |
| 534 | + kind = PartThought | |
| 535 | + } | |
| 536 | + index := len(message.Parts) - 1 | |
| 537 | + if index < 0 || message.Parts[index].Kind != kind { | |
| 538 | + message.Parts = append(message.Parts, Part{Kind: kind}) | |
| 539 | + index++ | |
| 540 | + } | |
| 541 | + message.Parts[index].Text += update.Text | |
| 542 | + m.emitter.Emit(events.MessageDelta, DeltaPayload{Channel: message.Channel, MessageID: message.ID, Index: index, Kind: kind, Text: update.Text}) | |
| 543 | +} | |
| 544 | + | |
| 545 | +func (h *handler) RequestPermission(ctx context.Context, request acp.PermissionRequest) acp.PermissionAnswer { | |
| 546 | + m, ch := h.manager, h.channel | |
| 547 | + m.mu.Lock() | |
| 548 | + m.sequence++ | |
| 549 | + id := fmt.Sprintf("p-%d", m.sequence) | |
| 550 | + pending := &pendingPermission{channel: ch, answer: make(chan acp.PermissionAnswer, 1)} | |
| 551 | + pending.permission = Permission{ID: id, Channel: ch.info.ID, Agent: h.agent, ToolCall: request.ToolCall, Options: request.Options, Rejections: ch.rejected} | |
| 552 | + if message := ch.current; message != nil && message.Agent == h.agent { | |
| 553 | + pending.permission.MessageID = message.ID | |
| 554 | + // The request may name the tool call only: complete it with what the | |
| 555 | + // channel already knows (title, kind, input). | |
| 556 | + if index := toolIndex(message, request.ToolCall.ID); index >= 0 { | |
| 557 | + known := *message.Parts[index].Tool | |
| 558 | + merge(&known, &request.ToolCall) | |
| 559 | + pending.permission.ToolCall = known | |
| 560 | + } | |
| 561 | + } | |
| 562 | + m.permissions[id] = pending | |
| 563 | + m.emitter.Emit(events.PermissionRequested, pending.permission) | |
| 564 | + m.mu.Unlock() | |
| 565 | + | |
| 566 | + select { | |
| 567 | + case answer := <-pending.answer: | |
| 568 | + return answer | |
| 569 | + case <-ctx.Done(): | |
| 570 | + m.mu.Lock() | |
| 571 | + if _, waiting := m.permissions[id]; waiting { | |
| 572 | + m.resolve(id, acp.PermissionAnswer{}) | |
| 573 | + } | |
| 574 | + m.mu.Unlock() | |
| 575 | + return acp.PermissionAnswer{} | |
| 576 | + } | |
| 577 | +} | |
| 578 | + | |
| 579 | +func toolIndex(message *Message, id string) int { | |
| 580 | + for i, part := range message.Parts { | |
| 581 | + if part.Kind == PartTool && part.Tool.ID == id { | |
| 582 | + return i | |
| 583 | + } | |
| 584 | + } | |
| 585 | + return -1 | |
| 586 | +} | |
| 587 | + | |
| 588 | +// merge copies the fields set in update onto tool. | |
| 589 | +func merge(tool *acp.ToolCall, update *acp.ToolCall) { | |
| 590 | + if update.Title != "" { | |
| 591 | + tool.Title = update.Title | |
| 592 | + } | |
| 593 | + if update.Kind != "" { | |
| 594 | + tool.Kind = update.Kind | |
| 595 | + } | |
| 596 | + if update.Status != "" { | |
| 597 | + tool.Status = update.Status | |
| 598 | + } | |
| 599 | + if update.Input != "" { | |
| 600 | + tool.Input = update.Input | |
| 601 | + } | |
| 602 | + if update.Output != "" { | |
| 603 | + tool.Output = update.Output | |
| 604 | + } | |
| 605 | + if len(update.Locations) > 0 { | |
| 606 | + tool.Locations = update.Locations | |
| 607 | + } | |
| 608 | + if len(update.Diffs) > 0 { | |
| 609 | + tool.Diffs = update.Diffs | |
| 610 | + } | |
| 611 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,611 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +package channels | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "context" | ||
| 7 | + "encoding/json" | ||
| 8 | + "errors" | ||
| 9 | + "fmt" | ||
| 10 | + "strings" | ||
| 11 | + | ||
| 12 | + "git.rickub.com/bots-garden/sidekicks/internal/acp" | ||
| 13 | + "git.rickub.com/bots-garden/sidekicks/internal/agents" | ||
| 14 | + "git.rickub.com/bots-garden/sidekicks/internal/events" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +// Send adds a user message to a channel and returns its id. The agents it | ||
| 18 | +// addresses (Resolve) answer one after the other, right away or after the | ||
| 19 | +// current answers (the message is then queued). A mentioned agent that is | ||
| 20 | +// not a member yet is invited first. | ||
| 21 | +func (m *Manager) Send(id, text string) (string, error) { | ||
| 22 | + text = strings.TrimSpace(text) | ||
| 23 | + if text == "" { | ||
| 24 | + return "", errors.New("empty message") | ||
| 25 | + } | ||
| 26 | + m.mu.Lock() | ||
| 27 | + defer m.mu.Unlock() | ||
| 28 | + ch, err := m.channel(id) | ||
| 29 | + if err != nil { | ||
| 30 | + return "", err | ||
| 31 | + } | ||
| 32 | + if err := m.load(ch); err != nil { | ||
| 33 | + return "", err | ||
| 34 | + } | ||
| 35 | + route := m.route(ch, text) | ||
| 36 | + | ||
| 37 | + message := m.newMessage(ch, RoleUser, "") | ||
| 38 | + message.Parts = []Part{{Kind: PartText, Text: text}} | ||
| 39 | + message.Done = true | ||
| 40 | + ch.messages = append(ch.messages, message) | ||
| 41 | + if len(route.Agents) == 0 { | ||
| 42 | + m.emitMessage(message) | ||
| 43 | + m.systemMessage(ch, CodeNoRecipient, "", "") | ||
| 44 | + return message.ID, nil | ||
| 45 | + } | ||
| 46 | + item := &pending{message: message, route: route} | ||
| 47 | + if ch.busy { | ||
| 48 | + message.Queued = true | ||
| 49 | + ch.queue = append(ch.queue, item) | ||
| 50 | + m.emitMessage(message) | ||
| 51 | + m.invite(ch, route.Agents) | ||
| 52 | + m.emitTurn(ch) | ||
| 53 | + return message.ID, nil | ||
| 54 | + } | ||
| 55 | + ch.busy = true | ||
| 56 | + m.emitMessage(message) | ||
| 57 | + m.invite(ch, route.Agents) | ||
| 58 | + m.emitTurn(ch) | ||
| 59 | + go m.run(ch, item) | ||
| 60 | + return message.ID, nil | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +// route says who answers text in ch. In a direct conversation, its agent | ||
| 64 | +// always does: mentions are just text there. | ||
| 65 | +func (m *Manager) route(ch *channel, text string) Route { | ||
| 66 | + if ch.info.Direct != "" { | ||
| 67 | + route := Route{Agents: []string{ch.info.Direct}} | ||
| 68 | + if strings.HasPrefix(text, "/") { | ||
| 69 | + route.Command = text | ||
| 70 | + } | ||
| 71 | + return route | ||
| 72 | + } | ||
| 73 | + known := func(name string) bool { | ||
| 74 | + _, ok := m.agents.Definition(name) | ||
| 75 | + return ok | ||
| 76 | + } | ||
| 77 | + return Resolve(text, ch.lastSpeaker(), known) | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +// invite makes members of the agents that are not yet, and says so in the channel. | ||
| 81 | +func (m *Manager) invite(ch *channel, names []string) { | ||
| 82 | + invited := false | ||
| 83 | + for _, name := range names { | ||
| 84 | + if ch.member(name) != nil { | ||
| 85 | + continue | ||
| 86 | + } | ||
| 87 | + if err := m.addMember(ch, name); err != nil { | ||
| 88 | + m.systemMessage(ch, CodeAgentUnavailable, name, err.Error()) | ||
| 89 | + continue | ||
| 90 | + } | ||
| 91 | + m.systemMessage(ch, CodeAgentJoined, name, "") | ||
| 92 | + invited = true | ||
| 93 | + } | ||
| 94 | + if invited { | ||
| 95 | + m.emitChannels() | ||
| 96 | + } | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +// run answers item, then the queued messages one by one; each message is | ||
| 100 | +// answered by its agents in order. | ||
| 101 | +func (m *Manager) run(ch *channel, item *pending) { | ||
| 102 | + for item != nil { | ||
| 103 | + m.mu.Lock() | ||
| 104 | + ch.stopped = false | ||
| 105 | + m.mu.Unlock() | ||
| 106 | + for _, agent := range item.route.Agents { | ||
| 107 | + if !m.turn(ch, item, agent) { | ||
| 108 | + break // stopped by the user: the other agents do not answer | ||
| 109 | + } | ||
| 110 | + } | ||
| 111 | + m.mu.Lock() | ||
| 112 | + item = nil | ||
| 113 | + ch.answering = "" | ||
| 114 | + if len(ch.queue) > 0 { | ||
| 115 | + item, ch.queue = ch.queue[0], ch.queue[1:] | ||
| 116 | + item.message.Queued = false | ||
| 117 | + m.emitMessage(item.message) | ||
| 118 | + } else { | ||
| 119 | + ch.busy = false | ||
| 120 | + } | ||
| 121 | + m.emitTurn(ch) | ||
| 122 | + m.mu.Unlock() | ||
| 123 | + } | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +// turn has agent answer item and streams its answer. It returns false when | ||
| 127 | +// the user stopped the channel. | ||
| 128 | +func (m *Manager) turn(ch *channel, item *pending, agent string) bool { | ||
| 129 | + ctx, cancel := context.WithCancel(context.Background()) | ||
| 130 | + defer cancel() | ||
| 131 | + m.mu.Lock() | ||
| 132 | + if ch.stopped { | ||
| 133 | + m.mu.Unlock() | ||
| 134 | + return false | ||
| 135 | + } | ||
| 136 | + ch.cancel, ch.answering = cancel, agent | ||
| 137 | + m.emitTurn(ch) | ||
| 138 | + m.mu.Unlock() | ||
| 139 | + | ||
| 140 | + if !m.waitTurn(ctx, agent) { | ||
| 141 | + return false | ||
| 142 | + } | ||
| 143 | + defer m.endTurn(agent) | ||
| 144 | + conn, release, err := m.agents.Acquire(ctx, agent) | ||
| 145 | + if err != nil { | ||
| 146 | + return m.unavailable(ctx, ch, agent, err) | ||
| 147 | + } | ||
| 148 | + defer release() | ||
| 149 | + sessionID, fresh, err := m.session(ctx, ch, agent, conn) | ||
| 150 | + if err != nil { | ||
| 151 | + return m.unavailable(ctx, ch, agent, err) | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + m.mu.Lock() | ||
| 155 | + prompt := m.prompt(ch, agent, item, fresh) | ||
| 156 | + answer := m.newMessage(ch, RoleAgent, agent) | ||
| 157 | + ch.messages = append(ch.messages, answer) | ||
| 158 | + ch.current, ch.rejected = answer, 0 | ||
| 159 | + m.emitMessage(answer) | ||
| 160 | + m.mu.Unlock() | ||
| 161 | + | ||
| 162 | + result, err := conn.Prompt(ctx, sessionID, prompt) | ||
| 163 | + | ||
| 164 | + m.mu.Lock() | ||
| 165 | + defer m.mu.Unlock() | ||
| 166 | + answer.Done = true | ||
| 167 | + switch { | ||
| 168 | + case err == nil: | ||
| 169 | + answer.StopReason = result.StopReason | ||
| 170 | + case ctx.Err() != nil: | ||
| 171 | + answer.StopReason = "cancelled" | ||
| 172 | + default: | ||
| 173 | + answer.Error = err.Error() | ||
| 174 | + } | ||
| 175 | + ch.current, ch.cancel = nil, nil | ||
| 176 | + m.emitMessage(answer) | ||
| 177 | + m.seen(ch, agent) | ||
| 178 | + return !ch.stopped && ctx.Err() == nil | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +// unavailable reports that agent could not answer, unless the user stopped | ||
| 182 | +// the channel meanwhile. | ||
| 183 | +func (m *Manager) unavailable(ctx context.Context, ch *channel, agent string, err error) bool { | ||
| 184 | + if ctx.Err() != nil { | ||
| 185 | + return false | ||
| 186 | + } | ||
| 187 | + m.mu.Lock() | ||
| 188 | + defer m.mu.Unlock() | ||
| 189 | + m.systemMessage(ch, CodeAgentUnavailable, agent, err.Error()) | ||
| 190 | + return !ch.stopped | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +// waitTurn waits until agent answers no other channel. | ||
| 194 | +func (m *Manager) waitTurn(ctx context.Context, agent string) bool { | ||
| 195 | + m.mu.Lock() | ||
| 196 | + slot, ok := m.turns[agent] | ||
| 197 | + if !ok { | ||
| 198 | + slot = make(chan struct{}, 1) | ||
| 199 | + m.turns[agent] = slot | ||
| 200 | + } | ||
| 201 | + m.mu.Unlock() | ||
| 202 | + select { | ||
| 203 | + case slot <- struct{}{}: | ||
| 204 | + return true | ||
| 205 | + case <-ctx.Done(): | ||
| 206 | + return false | ||
| 207 | + } | ||
| 208 | +} | ||
| 209 | + | ||
| 210 | +func (m *Manager) endTurn(agent string) { | ||
| 211 | + m.mu.Lock() | ||
| 212 | + slot := m.turns[agent] | ||
| 213 | + m.mu.Unlock() | ||
| 214 | + <-slot | ||
| 215 | +} | ||
| 216 | + | ||
| 217 | +// session returns the ACP session of agent for ch on conn. A new connection | ||
| 218 | +// (first use, or the agent was restarted) resumes the stored session when the | ||
| 219 | +// agent can (session/load), or opens a new one: fresh is then true — once, | ||
| 220 | +// even when Prepare opened it — and the agent must be told what was said | ||
| 221 | +// before (issue #25). | ||
| 222 | +func (m *Manager) session(ctx context.Context, ch *channel, agent string, conn agents.Conn) (id string, fresh bool, err error) { | ||
| 223 | + m.mu.Lock() | ||
| 224 | + member := ch.member(agent) | ||
| 225 | + if member == nil { | ||
| 226 | + m.mu.Unlock() | ||
| 227 | + return "", false, fmt.Errorf("%s is no longer in this channel", agent) | ||
| 228 | + } | ||
| 229 | + if member.conn == conn { | ||
| 230 | + id, fresh = member.SessionID, member.fresh | ||
| 231 | + member.fresh = false | ||
| 232 | + m.mu.Unlock() | ||
| 233 | + return id, fresh, nil | ||
| 234 | + } | ||
| 235 | + stored := member.SessionID | ||
| 236 | + m.mu.Unlock() | ||
| 237 | + | ||
| 238 | + definition, _ := m.agents.Definition(agent) | ||
| 239 | + handler := &handler{manager: m, channel: ch, agent: agent} | ||
| 240 | + if stored != "" && conn.Info().LoadSession { | ||
| 241 | + if err := conn.LoadSession(ctx, stored, definition.Workspace, handler); err == nil { | ||
| 242 | + m.mu.Lock() | ||
| 243 | + member.conn = conn | ||
| 244 | + m.mu.Unlock() | ||
| 245 | + return stored, false, nil | ||
| 246 | + } | ||
| 247 | + } | ||
| 248 | + id, err = conn.NewSession(ctx, definition.Workspace, handler) | ||
| 249 | + if err != nil { | ||
| 250 | + return "", false, err | ||
| 251 | + } | ||
| 252 | + m.mu.Lock() | ||
| 253 | + defer m.mu.Unlock() | ||
| 254 | + member.conn, member.SessionID, member.fresh = conn, id, false | ||
| 255 | + _ = m.db.PutMember(member.Membership) | ||
| 256 | + return id, true, nil | ||
| 257 | +} | ||
| 258 | + | ||
| 259 | +// Prepare starts the members of a channel whose slash commands are not | ||
| 260 | +// known yet and opens their session for it: an agent announces its commands | ||
| 261 | +// right after session/new, and mm does so without loading its model | ||
| 262 | +// (measured: about 0.1 s). It returns once those sessions are open; the | ||
| 263 | +// commands arrive as commands:changed events. The session is kept for the | ||
| 264 | +// agent's first answer. A member busy elsewhere is left alone. | ||
| 265 | +// | ||
| 266 | +// _ = manager.Prepare(ctx, channelID) // the user typed "/" | ||
| 267 | +func (m *Manager) Prepare(ctx context.Context, id string) error { | ||
| 268 | + m.mu.Lock() | ||
| 269 | + ch, err := m.channel(id) | ||
| 270 | + if err != nil { | ||
| 271 | + m.mu.Unlock() | ||
| 272 | + return err | ||
| 273 | + } | ||
| 274 | + var unknown []string | ||
| 275 | + for _, member := range ch.members { | ||
| 276 | + if !m.commandsKnown(member.Agent) { | ||
| 277 | + unknown = append(unknown, member.Agent) | ||
| 278 | + } | ||
| 279 | + } | ||
| 280 | + m.mu.Unlock() | ||
| 281 | + | ||
| 282 | + var errs []error | ||
| 283 | + for _, agent := range unknown { | ||
| 284 | + if err := m.prepare(ctx, ch, agent); err != nil { | ||
| 285 | + errs = append(errs, fmt.Errorf("%s: %w", agent, err)) | ||
| 286 | + } | ||
| 287 | + } | ||
| 288 | + return errors.Join(errs...) | ||
| 289 | +} | ||
| 290 | + | ||
| 291 | +func (m *Manager) prepare(ctx context.Context, ch *channel, agent string) error { | ||
| 292 | + if !m.tryTurn(agent) { | ||
| 293 | + return nil // answering elsewhere: it announced its commands then | ||
| 294 | + } | ||
| 295 | + defer m.endTurn(agent) | ||
| 296 | + conn, release, err := m.agents.Acquire(ctx, agent) | ||
| 297 | + if err != nil { | ||
| 298 | + return err | ||
| 299 | + } | ||
| 300 | + defer release() | ||
| 301 | + if _, fresh, err := m.session(ctx, ch, agent, conn); err != nil { | ||
| 302 | + return err | ||
| 303 | + } else if fresh { | ||
| 304 | + m.mu.Lock() | ||
| 305 | + if member := ch.member(agent); member != nil { | ||
| 306 | + member.fresh = true // to be told the channel's past at its first turn | ||
| 307 | + } | ||
| 308 | + m.mu.Unlock() | ||
| 309 | + } | ||
| 310 | + return nil | ||
| 311 | +} | ||
| 312 | + | ||
| 313 | +// tryTurn takes agent's turn if it is free. | ||
| 314 | +func (m *Manager) tryTurn(agent string) bool { | ||
| 315 | + m.mu.Lock() | ||
| 316 | + slot, ok := m.turns[agent] | ||
| 317 | + if !ok { | ||
| 318 | + slot = make(chan struct{}, 1) | ||
| 319 | + m.turns[agent] = slot | ||
| 320 | + } | ||
| 321 | + m.mu.Unlock() | ||
| 322 | + select { | ||
| 323 | + case slot <- struct{}{}: | ||
| 324 | + return true | ||
| 325 | + default: | ||
| 326 | + return false | ||
| 327 | + } | ||
| 328 | +} | ||
| 329 | + | ||
| 330 | +// prompt is what agent receives for item: the command alone, or the text | ||
| 331 | +// with the channel messages it has not seen. The caller holds m.mu. | ||
| 332 | +func (m *Manager) prompt(ch *channel, agent string, item *pending, fresh bool) string { | ||
| 333 | + if item.route.Command != "" { | ||
| 334 | + return item.route.Command | ||
| 335 | + } | ||
| 336 | + place := "#" + ch.info.Name | ||
| 337 | + if ch.info.Direct != "" { | ||
| 338 | + place = "this conversation" | ||
| 339 | + } | ||
| 340 | + return Prompt(agent, place, textOf(item.message), unseen(ch, agent, item.message, fresh)) | ||
| 341 | +} | ||
| 342 | + | ||
| 343 | +// unseen lists the finished user and agent messages of ch that agent has not | ||
| 344 | +// seen, apart from current. A fresh session has seen nothing: it gets them | ||
| 345 | +// all (Prompt keeps the last ones), its own included. | ||
| 346 | +func unseen(ch *channel, agent string, current *Message, fresh bool) []Line { | ||
| 347 | + lastSeen := "" | ||
| 348 | + if member := ch.member(agent); member != nil && !fresh { | ||
| 349 | + lastSeen = member.LastSeen | ||
| 350 | + } | ||
| 351 | + var lines []Line | ||
| 352 | + for _, message := range ch.messages { | ||
| 353 | + skip := message == current || message.Queued || !message.Done || message.Role == RoleSystem || message.ID <= lastSeen | ||
| 354 | + // Without a fresh session, the agent's own answers are already in it. | ||
| 355 | + ownAnswer := message.Role == RoleAgent && message.Agent == agent | ||
| 356 | + if skip || (!fresh && ownAnswer) { | ||
| 357 | + continue | ||
| 358 | + } | ||
| 359 | + if line, ok := lineOf(message); ok { | ||
| 360 | + lines = append(lines, line) | ||
| 361 | + } | ||
| 362 | + } | ||
| 363 | + return lines | ||
| 364 | +} | ||
| 365 | + | ||
| 366 | +// lineOf is message as an agent reads it; false when it has no text. | ||
| 367 | +func lineOf(message *Message) (Line, bool) { | ||
| 368 | + text := textOf(message) | ||
| 369 | + if text == "" { | ||
| 370 | + return Line{}, false | ||
| 371 | + } | ||
| 372 | + author := "user" | ||
| 373 | + if message.Role == RoleAgent { | ||
| 374 | + author = message.Agent | ||
| 375 | + } | ||
| 376 | + return Line{Author: author, Text: text}, true | ||
| 377 | +} | ||
| 378 | + | ||
| 379 | +// seen records that agent has seen every message of ch so far. The caller holds m.mu. | ||
| 380 | +func (m *Manager) seen(ch *channel, agent string) { | ||
| 381 | + member := ch.member(agent) | ||
| 382 | + if member == nil || len(ch.messages) == 0 { | ||
| 383 | + return | ||
| 384 | + } | ||
| 385 | + member.LastSeen = ch.messages[len(ch.messages)-1].ID | ||
| 386 | + _ = m.db.PutMember(member.Membership) | ||
| 387 | +} | ||
| 388 | + | ||
| 389 | +func textOf(message *Message) string { | ||
| 390 | + var builder strings.Builder | ||
| 391 | + for _, part := range message.Parts { | ||
| 392 | + if part.Kind == PartText { | ||
| 393 | + builder.WriteString(part.Text) | ||
| 394 | + } | ||
| 395 | + } | ||
| 396 | + return builder.String() | ||
| 397 | +} | ||
| 398 | + | ||
| 399 | +// Cancel stops the current answer of a channel, the agents still to answer | ||
| 400 | +// and the queued messages. Pending permission requests are answered | ||
| 401 | +// "cancelled", as ACP requires. | ||
| 402 | +func (m *Manager) Cancel(id string) { | ||
| 403 | + m.mu.Lock() | ||
| 404 | + ch, ok := m.channels[id] | ||
| 405 | + if !ok || !ch.busy { | ||
| 406 | + m.mu.Unlock() | ||
| 407 | + return | ||
| 408 | + } | ||
| 409 | + ch.stopped = true | ||
| 410 | + for _, queued := range ch.queue { | ||
| 411 | + queued.message.Queued = false | ||
| 412 | + queued.message.Error = "cancelled" | ||
| 413 | + m.emitMessage(queued.message) | ||
| 414 | + } | ||
| 415 | + ch.queue = nil | ||
| 416 | + for requestID, pending := range m.permissions { | ||
| 417 | + if pending.channel == ch { | ||
| 418 | + m.resolve(requestID, acp.PermissionAnswer{}) | ||
| 419 | + } | ||
| 420 | + } | ||
| 421 | + var conn agents.Conn | ||
| 422 | + sessionID := "" | ||
| 423 | + if ch.current != nil { | ||
| 424 | + if member := ch.member(ch.current.Agent); member != nil { | ||
| 425 | + conn, sessionID = member.conn, member.SessionID | ||
| 426 | + } | ||
| 427 | + } | ||
| 428 | + cancel := ch.cancel | ||
| 429 | + m.emitTurn(ch) | ||
| 430 | + m.mu.Unlock() | ||
| 431 | + | ||
| 432 | + if conn != nil { | ||
| 433 | + _ = conn.Cancel(context.Background(), sessionID) | ||
| 434 | + } else if cancel != nil { | ||
| 435 | + cancel() // still waiting for, or starting, the agent | ||
| 436 | + } | ||
| 437 | +} | ||
| 438 | + | ||
| 439 | +// AnswerPermission gives the user's choice for a permission request. | ||
| 440 | +func (m *Manager) AnswerPermission(id, optionID string) error { | ||
| 441 | + m.mu.Lock() | ||
| 442 | + defer m.mu.Unlock() | ||
| 443 | + pending, ok := m.permissions[id] | ||
| 444 | + if !ok { | ||
| 445 | + return fmt.Errorf("no permission request %s waits", id) | ||
| 446 | + } | ||
| 447 | + for _, option := range pending.permission.Options { | ||
| 448 | + if option.ID == optionID { | ||
| 449 | + if strings.HasPrefix(option.Kind, "reject") { | ||
| 450 | + pending.channel.rejected++ | ||
| 451 | + } | ||
| 452 | + m.resolve(id, acp.PermissionAnswer{OptionID: optionID}) | ||
| 453 | + return nil | ||
| 454 | + } | ||
| 455 | + } | ||
| 456 | + return fmt.Errorf("permission request %s has no option %s", id, optionID) | ||
| 457 | +} | ||
| 458 | + | ||
| 459 | +// resolve answers a pending request. The caller holds m.mu. | ||
| 460 | +func (m *Manager) resolve(id string, answer acp.PermissionAnswer) { | ||
| 461 | + pending := m.permissions[id] | ||
| 462 | + delete(m.permissions, id) | ||
| 463 | + pending.answer <- answer | ||
| 464 | + m.emitter.Emit(events.PermissionResolved, PermissionResolvedPayload{ID: id, Channel: pending.channel.info.ID}) | ||
| 465 | +} | ||
| 466 | + | ||
| 467 | +// ---------------------------------------------------------------- the session handler | ||
| 468 | + | ||
| 469 | +// handler receives what agent says in its session for channel. | ||
| 470 | +type handler struct { | ||
| 471 | + manager *Manager | ||
| 472 | + channel *channel | ||
| 473 | + agent string | ||
| 474 | +} | ||
| 475 | + | ||
| 476 | +func (h *handler) Update(update acp.Update) { | ||
| 477 | + m, ch := h.manager, h.channel | ||
| 478 | + m.mu.Lock() | ||
| 479 | + defer m.mu.Unlock() | ||
| 480 | + switch update.Kind { | ||
| 481 | + case acp.UpdateUsage: | ||
| 482 | + ch.usage[h.agent] = *update.Usage | ||
| 483 | + m.emitter.Emit(events.UsageChanged, UsagePayload{Channel: ch.info.ID, Agent: h.agent, Usage: *update.Usage}) | ||
| 484 | + return | ||
| 485 | + case acp.UpdateCommands: | ||
| 486 | + m.commands[h.agent] = update.Commands | ||
| 487 | + if data, err := json.Marshal(update.Commands); err == nil { | ||
| 488 | + _ = m.db.SetValue("commands/"+h.agent, data) | ||
| 489 | + } | ||
| 490 | + m.emitter.Emit(events.CommandsChanged, CommandsPayload{Agent: h.agent, Commands: update.Commands}) | ||
| 491 | + return | ||
| 492 | + } | ||
| 493 | + message := ch.current | ||
| 494 | + if message == nil || message.Agent != h.agent { | ||
| 495 | + return // nothing of this agent streams: a replayed or late update | ||
| 496 | + } | ||
| 497 | + switch update.Kind { | ||
| 498 | + case acp.UpdateMessage, acp.UpdateThought: | ||
| 499 | + m.appendText(message, update) | ||
| 500 | + case acp.UpdateToolCall: | ||
| 501 | + tool := *update.ToolCall | ||
| 502 | + message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool}) | ||
| 503 | + m.emitPart(message, len(message.Parts)-1) | ||
| 504 | + case acp.UpdateToolCallUpdate: | ||
| 505 | + index := toolIndex(message, update.ToolCall.ID) | ||
| 506 | + if index < 0 { | ||
| 507 | + tool := *update.ToolCall | ||
| 508 | + message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool}) | ||
| 509 | + index = len(message.Parts) - 1 | ||
| 510 | + } else { | ||
| 511 | + merge(message.Parts[index].Tool, update.ToolCall) | ||
| 512 | + } | ||
| 513 | + m.emitPart(message, index) | ||
| 514 | + case acp.UpdatePlan: | ||
| 515 | + index := -1 | ||
| 516 | + for i, part := range message.Parts { | ||
| 517 | + if part.Kind == PartPlan { | ||
| 518 | + index = i | ||
| 519 | + } | ||
| 520 | + } | ||
| 521 | + if index < 0 { | ||
| 522 | + message.Parts = append(message.Parts, Part{Kind: PartPlan}) | ||
| 523 | + index = len(message.Parts) - 1 | ||
| 524 | + } | ||
| 525 | + message.Parts[index].Plan = update.Plan | ||
| 526 | + m.emitPart(message, index) | ||
| 527 | + } | ||
| 528 | +} | ||
| 529 | + | ||
| 530 | +// appendText adds a text or thought fragment to message. The caller holds m.mu. | ||
| 531 | +func (m *Manager) appendText(message *Message, update acp.Update) { | ||
| 532 | + kind := PartText | ||
| 533 | + if update.Kind == acp.UpdateThought { | ||
| 534 | + kind = PartThought | ||
| 535 | + } | ||
| 536 | + index := len(message.Parts) - 1 | ||
| 537 | + if index < 0 || message.Parts[index].Kind != kind { | ||
| 538 | + message.Parts = append(message.Parts, Part{Kind: kind}) | ||
| 539 | + index++ | ||
| 540 | + } | ||
| 541 | + message.Parts[index].Text += update.Text | ||
| 542 | + m.emitter.Emit(events.MessageDelta, DeltaPayload{Channel: message.Channel, MessageID: message.ID, Index: index, Kind: kind, Text: update.Text}) | ||
| 543 | +} | ||
| 544 | + | ||
| 545 | +func (h *handler) RequestPermission(ctx context.Context, request acp.PermissionRequest) acp.PermissionAnswer { | ||
| 546 | + m, ch := h.manager, h.channel | ||
| 547 | + m.mu.Lock() | ||
| 548 | + m.sequence++ | ||
| 549 | + id := fmt.Sprintf("p-%d", m.sequence) | ||
| 550 | + pending := &pendingPermission{channel: ch, answer: make(chan acp.PermissionAnswer, 1)} | ||
| 551 | + pending.permission = Permission{ID: id, Channel: ch.info.ID, Agent: h.agent, ToolCall: request.ToolCall, Options: request.Options, Rejections: ch.rejected} | ||
| 552 | + if message := ch.current; message != nil && message.Agent == h.agent { | ||
| 553 | + pending.permission.MessageID = message.ID | ||
| 554 | + // The request may name the tool call only: complete it with what the | ||
| 555 | + // channel already knows (title, kind, input). | ||
| 556 | + if index := toolIndex(message, request.ToolCall.ID); index >= 0 { | ||
| 557 | + known := *message.Parts[index].Tool | ||
| 558 | + merge(&known, &request.ToolCall) | ||
| 559 | + pending.permission.ToolCall = known | ||
| 560 | + } | ||
| 561 | + } | ||
| 562 | + m.permissions[id] = pending | ||
| 563 | + m.emitter.Emit(events.PermissionRequested, pending.permission) | ||
| 564 | + m.mu.Unlock() | ||
| 565 | + | ||
| 566 | + select { | ||
| 567 | + case answer := <-pending.answer: | ||
| 568 | + return answer | ||
| 569 | + case <-ctx.Done(): | ||
| 570 | + m.mu.Lock() | ||
| 571 | + if _, waiting := m.permissions[id]; waiting { | ||
| 572 | + m.resolve(id, acp.PermissionAnswer{}) | ||
| 573 | + } | ||
| 574 | + m.mu.Unlock() | ||
| 575 | + return acp.PermissionAnswer{} | ||
| 576 | + } | ||
| 577 | +} | ||
| 578 | + | ||
| 579 | +func toolIndex(message *Message, id string) int { | ||
| 580 | + for i, part := range message.Parts { | ||
| 581 | + if part.Kind == PartTool && part.Tool.ID == id { | ||
| 582 | + return i | ||
| 583 | + } | ||
| 584 | + } | ||
| 585 | + return -1 | ||
| 586 | +} | ||
| 587 | + | ||
| 588 | +// merge copies the fields set in update onto tool. | ||
| 589 | +func merge(tool *acp.ToolCall, update *acp.ToolCall) { | ||
| 590 | + if update.Title != "" { | ||
| 591 | + tool.Title = update.Title | ||
| 592 | + } | ||
| 593 | + if update.Kind != "" { | ||
| 594 | + tool.Kind = update.Kind | ||
| 595 | + } | ||
| 596 | + if update.Status != "" { | ||
| 597 | + tool.Status = update.Status | ||
| 598 | + } | ||
| 599 | + if update.Input != "" { | ||
| 600 | + tool.Input = update.Input | ||
| 601 | + } | ||
| 602 | + if update.Output != "" { | ||
| 603 | + tool.Output = update.Output | ||
| 604 | + } | ||
| 605 | + if len(update.Locations) > 0 { | ||
| 606 | + tool.Locations = update.Locations | ||
| 607 | + } | ||
| 608 | + if len(update.Diffs) > 0 { | ||
| 609 | + tool.Diffs = update.Diffs | ||
| 610 | + } | ||
| 611 | +} | ||
deleted
internal/conversations/conversations.go +0 -618 | deleted file mode 100644 | ||
| @@ -1,618 +0,0 @@ | ||
| 1 | -// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | - | |
| 3 | -// Package conversations holds the direct conversations with agents of | |
| 4 | -// milestone M2: one conversation per agent, kept in memory (ADR 0004). | |
| 5 | -// | |
| 6 | -// Sending a message borrows the agent from the agent manager (starting it if | |
| 7 | -// needed), opens an ACP session on first use, and streams the answer to the | |
| 8 | -// interface as events. Messages sent while the agent answers are queued. | |
| 9 | -// | |
| 10 | -// manager := conversations.NewManager(agentManager, conversations.Options{Emitter: emitter}) | |
| 11 | -// id, err := manager.Send("alice", "Bonjour !") | |
| 12 | -// … | |
| 13 | -// manager.Cancel("alice") | |
| 14 | -package conversations | |
| 15 | - | |
| 16 | -import ( | |
| 17 | - "context" | |
| 18 | - "errors" | |
| 19 | - "fmt" | |
| 20 | - "strings" | |
| 21 | - "sync" | |
| 22 | - "time" | |
| 23 | - | |
| 24 | - "git.rickub.com/bots-garden/sidekicks/internal/acp" | |
| 25 | - "git.rickub.com/bots-garden/sidekicks/internal/agents" | |
| 26 | - "git.rickub.com/bots-garden/sidekicks/internal/config" | |
| 27 | - "git.rickub.com/bots-garden/sidekicks/internal/events" | |
| 28 | -) | |
| 29 | - | |
| 30 | -// Role tells who wrote a message. | |
| 31 | -type Role string | |
| 32 | - | |
| 33 | -// The roles. | |
| 34 | -const ( | |
| 35 | - RoleUser Role = "user" | |
| 36 | - RoleAgent Role = "agent" | |
| 37 | - RoleSystem Role = "system" | |
| 38 | -) | |
| 39 | - | |
| 40 | -// The kinds of Part. | |
| 41 | -const ( | |
| 42 | - PartText = "text" | |
| 43 | - PartThought = "thought" | |
| 44 | - PartTool = "tool" | |
| 45 | - PartPlan = "plan" | |
| 46 | -) | |
| 47 | - | |
| 48 | -// System message codes; the interface turns them into sentences. | |
| 49 | -const ( | |
| 50 | - // CodeAgentRestarted: the agent was restarted and lost the conversation's context. | |
| 51 | - CodeAgentRestarted = "agent-restarted" | |
| 52 | - // CodeAgentUnavailable: the agent could not be started or reached; Error says why. | |
| 53 | - CodeAgentUnavailable = "agent-unavailable" | |
| 54 | -) | |
| 55 | - | |
| 56 | -// ErrUnknownAgent is returned for a name that is not in the configuration. | |
| 57 | -var ErrUnknownAgent = errors.New("unknown agent") | |
| 58 | - | |
| 59 | -// Part is a piece of a message: text, reasoning, a tool call or a plan. | |
| 60 | -type Part struct { | |
| 61 | - Kind string `json:"kind"` | |
| 62 | - Text string `json:"text,omitempty"` | |
| 63 | - Tool *acp.ToolCall `json:"tool,omitempty"` | |
| 64 | - Plan []acp.PlanEntry `json:"plan,omitempty"` | |
| 65 | -} | |
| 66 | - | |
| 67 | -// Message is one message of a conversation. | |
| 68 | -type Message struct { | |
| 69 | - ID string `json:"id"` | |
| 70 | - Agent string `json:"agent"` | |
| 71 | - Role Role `json:"role"` | |
| 72 | - Parts []Part `json:"parts"` | |
| 73 | - // Done is false while an agent message streams. | |
| 74 | - Done bool `json:"done"` | |
| 75 | - // Queued: a user message waiting for the current answer to end. | |
| 76 | - Queued bool `json:"queued"` | |
| 77 | - // StopReason of an agent message: end_turn, cancelled, max_tokens… | |
| 78 | - StopReason string `json:"stopReason,omitempty"` | |
| 79 | - // Error explains why an agent message ended without a stop reason, or a system message. | |
| 80 | - Error string `json:"error,omitempty"` | |
| 81 | - // Code identifies a system message (CodeAgentRestarted…). | |
| 82 | - Code string `json:"code,omitempty"` | |
| 83 | - CreatedAt time.Time `json:"createdAt"` | |
| 84 | -} | |
| 85 | - | |
| 86 | -// Permission is a permission request waiting for the user. | |
| 87 | -type Permission struct { | |
| 88 | - ID string `json:"id"` | |
| 89 | - Agent string `json:"agent"` | |
| 90 | - MessageID string `json:"messageId"` | |
| 91 | - ToolCall acp.ToolCall `json:"toolCall"` | |
| 92 | - Options []acp.PermissionOption `json:"options"` | |
| 93 | - // Rejections counts the refusals already given during this turn: an agent | |
| 94 | - // that keeps retrying is better stopped (ADR 0001, fact 4). | |
| 95 | - Rejections int `json:"rejections"` | |
| 96 | -} | |
| 97 | - | |
| 98 | -// Conversation is the state of the conversation with one agent. | |
| 99 | -type Conversation struct { | |
| 100 | - Agent string `json:"agent"` | |
| 101 | - Messages []Message `json:"messages"` | |
| 102 | - Busy bool `json:"busy"` | |
| 103 | - Queued int `json:"queued"` | |
| 104 | - Usage acp.Usage `json:"usage"` | |
| 105 | - Commands []acp.Command `json:"commands"` | |
| 106 | - Permissions []Permission `json:"permissions"` | |
| 107 | -} | |
| 108 | - | |
| 109 | -// Event payloads, registered with Wails in main.go. | |
| 110 | - | |
| 111 | -// MessagePayload carries a whole message: added, or finished. | |
| 112 | -type MessagePayload struct { | |
| 113 | - Agent string `json:"agent"` | |
| 114 | - Message Message `json:"message"` | |
| 115 | -} | |
| 116 | - | |
| 117 | -// DeltaPayload changes one part of a streaming message: Text is appended to | |
| 118 | -// a text or thought part; Part replaces a tool or plan part. | |
| 119 | -type DeltaPayload struct { | |
| 120 | - Agent string `json:"agent"` | |
| 121 | - MessageID string `json:"messageId"` | |
| 122 | - Index int `json:"index"` | |
| 123 | - Kind string `json:"kind"` | |
| 124 | - Text string `json:"text,omitempty"` | |
| 125 | - Part *Part `json:"part,omitempty"` | |
| 126 | -} | |
| 127 | - | |
| 128 | -// TurnPayload tells whether the agent is answering and how many messages wait. | |
| 129 | -type TurnPayload struct { | |
| 130 | - Agent string `json:"agent"` | |
| 131 | - Busy bool `json:"busy"` | |
| 132 | - Queued int `json:"queued"` | |
| 133 | -} | |
| 134 | - | |
| 135 | -// PermissionResolvedPayload says a permission request no longer waits. | |
| 136 | -type PermissionResolvedPayload struct { | |
| 137 | - ID string `json:"id"` | |
| 138 | - Agent string `json:"agent"` | |
| 139 | -} | |
| 140 | - | |
| 141 | -// UsagePayload is the context window use of a conversation. | |
| 142 | -type UsagePayload struct { | |
| 143 | - Agent string `json:"agent"` | |
| 144 | - Usage acp.Usage `json:"usage"` | |
| 145 | -} | |
| 146 | - | |
| 147 | -// AgentSource lends agents. *agents.Manager implements it. | |
| 148 | -type AgentSource interface { | |
| 149 | - Acquire(ctx context.Context, name string) (agents.Conn, func(), error) | |
| 150 | - Definition(name string) (config.Agent, bool) | |
| 151 | -} | |
| 152 | - | |
| 153 | -// Options configure a Manager. | |
| 154 | -type Options struct { | |
| 155 | - // Emitter receives the conversation events, in order, under the | |
| 156 | - // manager's lock: it must not block nor call the Manager. | |
| 157 | - Emitter events.Emitter | |
| 158 | - // Now defaults to time.Now. | |
| 159 | - Now func() time.Time | |
| 160 | -} | |
| 161 | - | |
| 162 | -// Manager holds the conversations. It is safe for concurrent use. | |
| 163 | -type Manager struct { | |
| 164 | - agents AgentSource | |
| 165 | - emitter events.Emitter | |
| 166 | - now func() time.Time | |
| 167 | - | |
| 168 | - mu sync.Mutex | |
| 169 | - conversations map[string]*conversation | |
| 170 | - permissions map[string]*pendingPermission | |
| 171 | - sequence int | |
| 172 | -} | |
| 173 | - | |
| 174 | -type conversation struct { | |
| 175 | - agent string | |
| 176 | - messages []*Message | |
| 177 | - conn agents.Conn | |
| 178 | - sessionID string | |
| 179 | - busy bool | |
| 180 | - queue []*Message | |
| 181 | - current *Message | |
| 182 | - cancel context.CancelFunc | |
| 183 | - usage acp.Usage | |
| 184 | - commands []acp.Command | |
| 185 | - rejected int | |
| 186 | -} | |
| 187 | - | |
| 188 | -type pendingPermission struct { | |
| 189 | - permission Permission | |
| 190 | - conv *conversation | |
| 191 | - answer chan acp.PermissionAnswer | |
| 192 | -} | |
| 193 | - | |
| 194 | -// NewManager returns a manager with no conversation. | |
| 195 | -func NewManager(source AgentSource, options Options) *Manager { | |
| 196 | - if options.Emitter == nil { | |
| 197 | - options.Emitter = events.Discard | |
| 198 | - } | |
| 199 | - if options.Now == nil { | |
| 200 | - options.Now = time.Now | |
| 201 | - } | |
| 202 | - return &Manager{ | |
| 203 | - agents: source, emitter: options.Emitter, now: options.Now, | |
| 204 | - conversations: map[string]*conversation{}, permissions: map[string]*pendingPermission{}, | |
| 205 | - } | |
| 206 | -} | |
| 207 | - | |
| 208 | -// Send adds a user message to the conversation with agent and returns its | |
| 209 | -// id. The agent answers right away, or after the current answer if it is | |
| 210 | -// busy (the message is then queued). | |
| 211 | -func (m *Manager) Send(agent, text string) (string, error) { | |
| 212 | - text = strings.TrimSpace(text) | |
| 213 | - if text == "" { | |
| 214 | - return "", errors.New("empty message") | |
| 215 | - } | |
| 216 | - if _, ok := m.agents.Definition(agent); !ok { | |
| 217 | - return "", fmt.Errorf("%w: %s", ErrUnknownAgent, agent) | |
| 218 | - } | |
| 219 | - m.mu.Lock() | |
| 220 | - defer m.mu.Unlock() | |
| 221 | - conv := m.conversation(agent) | |
| 222 | - message := m.newMessage(agent, RoleUser) | |
| 223 | - message.Parts = []Part{{Kind: PartText, Text: text}} | |
| 224 | - message.Done = true | |
| 225 | - conv.messages = append(conv.messages, message) | |
| 226 | - if conv.busy { | |
| 227 | - message.Queued = true | |
| 228 | - conv.queue = append(conv.queue, message) | |
| 229 | - m.emitMessage(message) | |
| 230 | - m.emitTurn(conv) | |
| 231 | - return message.ID, nil | |
| 232 | - } | |
| 233 | - conv.busy = true | |
| 234 | - m.emitMessage(message) | |
| 235 | - m.emitTurn(conv) | |
| 236 | - go m.run(conv, message) | |
| 237 | - return message.ID, nil | |
| 238 | -} | |
| 239 | - | |
| 240 | -// run answers message, then the queued messages one by one. | |
| 241 | -func (m *Manager) run(conv *conversation, message *Message) { | |
| 242 | - for message != nil { | |
| 243 | - m.turn(conv, message) | |
| 244 | - m.mu.Lock() | |
| 245 | - message = nil | |
| 246 | - if len(conv.queue) > 0 { | |
| 247 | - message, conv.queue = conv.queue[0], conv.queue[1:] | |
| 248 | - message.Queued = false | |
| 249 | - m.emitMessage(message) | |
| 250 | - } else { | |
| 251 | - conv.busy = false | |
| 252 | - } | |
| 253 | - m.emitTurn(conv) | |
| 254 | - m.mu.Unlock() | |
| 255 | - } | |
| 256 | -} | |
| 257 | - | |
| 258 | -// turn sends one user message to the agent and streams the answer. | |
| 259 | -func (m *Manager) turn(conv *conversation, user *Message) { | |
| 260 | - ctx, cancel := context.WithCancel(context.Background()) | |
| 261 | - defer cancel() | |
| 262 | - m.mu.Lock() | |
| 263 | - conv.cancel = cancel | |
| 264 | - m.mu.Unlock() | |
| 265 | - | |
| 266 | - conn, release, err := m.agents.Acquire(ctx, conv.agent) | |
| 267 | - if err != nil { | |
| 268 | - m.systemMessage(conv, CodeAgentUnavailable, err.Error()) | |
| 269 | - return | |
| 270 | - } | |
| 271 | - defer release() | |
| 272 | - sessionID, err := m.session(ctx, conv, conn) | |
| 273 | - if err != nil { | |
| 274 | - m.systemMessage(conv, CodeAgentUnavailable, err.Error()) | |
| 275 | - return | |
| 276 | - } | |
| 277 | - | |
| 278 | - m.mu.Lock() | |
| 279 | - answer := m.newMessage(conv.agent, RoleAgent) | |
| 280 | - answer.Parts = []Part{} | |
| 281 | - conv.messages = append(conv.messages, answer) | |
| 282 | - conv.current = answer | |
| 283 | - conv.rejected = 0 | |
| 284 | - m.emitMessage(answer) | |
| 285 | - m.mu.Unlock() | |
| 286 | - | |
| 287 | - result, err := conn.Prompt(ctx, sessionID, user.Parts[0].Text) | |
| 288 | - | |
| 289 | - m.mu.Lock() | |
| 290 | - defer m.mu.Unlock() | |
| 291 | - answer.Done = true | |
| 292 | - switch { | |
| 293 | - case err == nil: | |
| 294 | - answer.StopReason = result.StopReason | |
| 295 | - case ctx.Err() != nil: | |
| 296 | - answer.StopReason = "cancelled" | |
| 297 | - default: | |
| 298 | - answer.Error = err.Error() | |
| 299 | - } | |
| 300 | - conv.current, conv.cancel = nil, nil | |
| 301 | - m.emitMessage(answer) | |
| 302 | -} | |
| 303 | - | |
| 304 | -// session returns the ACP session of the conversation on conn, opening one | |
| 305 | -// when the agent is new or was restarted (it then forgot the conversation). | |
| 306 | -func (m *Manager) session(ctx context.Context, conv *conversation, conn agents.Conn) (string, error) { | |
| 307 | - m.mu.Lock() | |
| 308 | - if conv.conn == conn { | |
| 309 | - id := conv.sessionID | |
| 310 | - m.mu.Unlock() | |
| 311 | - return id, nil | |
| 312 | - } | |
| 313 | - hadSession := conv.conn != nil | |
| 314 | - m.mu.Unlock() | |
| 315 | - | |
| 316 | - definition, _ := m.agents.Definition(conv.agent) | |
| 317 | - id, err := conn.NewSession(ctx, definition.Workspace, &handler{manager: m, conv: conv}) | |
| 318 | - if err != nil { | |
| 319 | - return "", err | |
| 320 | - } | |
| 321 | - m.mu.Lock() | |
| 322 | - conv.conn, conv.sessionID = conn, id | |
| 323 | - m.mu.Unlock() | |
| 324 | - if hadSession { | |
| 325 | - m.systemMessage(conv, CodeAgentRestarted, "") | |
| 326 | - } | |
| 327 | - return id, nil | |
| 328 | -} | |
| 329 | - | |
| 330 | -func (m *Manager) systemMessage(conv *conversation, code, reason string) { | |
| 331 | - m.mu.Lock() | |
| 332 | - defer m.mu.Unlock() | |
| 333 | - message := m.newMessage(conv.agent, RoleSystem) | |
| 334 | - message.Code, message.Error, message.Done = code, reason, true | |
| 335 | - message.Parts = []Part{} | |
| 336 | - conv.messages = append(conv.messages, message) | |
| 337 | - m.emitMessage(message) | |
| 338 | -} | |
| 339 | - | |
| 340 | -// Cancel stops the current answer of agent and drops its queued messages. | |
| 341 | -// Pending permission requests are answered "cancelled", as ACP requires. | |
| 342 | -func (m *Manager) Cancel(agent string) { | |
| 343 | - m.mu.Lock() | |
| 344 | - conv, ok := m.conversations[agent] | |
| 345 | - if !ok { | |
| 346 | - m.mu.Unlock() | |
| 347 | - return | |
| 348 | - } | |
| 349 | - for _, queued := range conv.queue { | |
| 350 | - queued.Queued = false | |
| 351 | - queued.Error = "cancelled" | |
| 352 | - m.emitMessage(queued) | |
| 353 | - } | |
| 354 | - conv.queue = nil | |
| 355 | - for id, pending := range m.permissions { | |
| 356 | - if pending.conv == conv { | |
| 357 | - m.resolve(id, acp.PermissionAnswer{}) | |
| 358 | - } | |
| 359 | - } | |
| 360 | - conn, sessionID, streaming, cancel := conv.conn, conv.sessionID, conv.current != nil, conv.cancel | |
| 361 | - m.emitTurn(conv) | |
| 362 | - m.mu.Unlock() | |
| 363 | - | |
| 364 | - if streaming && conn != nil { | |
| 365 | - _ = conn.Cancel(context.Background(), sessionID) | |
| 366 | - } else if cancel != nil { | |
| 367 | - cancel() // still starting the agent | |
| 368 | - } | |
| 369 | -} | |
| 370 | - | |
| 371 | -// AnswerPermission gives the user's choice for a permission request. | |
| 372 | -func (m *Manager) AnswerPermission(id, optionID string) error { | |
| 373 | - m.mu.Lock() | |
| 374 | - defer m.mu.Unlock() | |
| 375 | - pending, ok := m.permissions[id] | |
| 376 | - if !ok { | |
| 377 | - return fmt.Errorf("no permission request %s waits", id) | |
| 378 | - } | |
| 379 | - for _, option := range pending.permission.Options { | |
| 380 | - if option.ID == optionID { | |
| 381 | - if strings.HasPrefix(option.Kind, "reject") { | |
| 382 | - pending.conv.rejected++ | |
| 383 | - } | |
| 384 | - m.resolve(id, acp.PermissionAnswer{OptionID: optionID}) | |
| 385 | - return nil | |
| 386 | - } | |
| 387 | - } | |
| 388 | - return fmt.Errorf("permission request %s has no option %s", id, optionID) | |
| 389 | -} | |
| 390 | - | |
| 391 | -// resolve answers a pending request. The caller holds m.mu. | |
| 392 | -func (m *Manager) resolve(id string, answer acp.PermissionAnswer) { | |
| 393 | - pending := m.permissions[id] | |
| 394 | - delete(m.permissions, id) | |
| 395 | - pending.answer <- answer | |
| 396 | - m.emitter.Emit(events.PermissionResolved, PermissionResolvedPayload{ID: id, Agent: pending.conv.agent}) | |
| 397 | -} | |
| 398 | - | |
| 399 | -// Conversation returns a copy of the conversation with agent (empty if none). | |
| 400 | -func (m *Manager) Conversation(agent string) Conversation { | |
| 401 | - m.mu.Lock() | |
| 402 | - defer m.mu.Unlock() | |
| 403 | - view := Conversation{Agent: agent, Messages: []Message{}, Commands: []acp.Command{}, Permissions: []Permission{}} | |
| 404 | - conv, ok := m.conversations[agent] | |
| 405 | - if !ok { | |
| 406 | - return view | |
| 407 | - } | |
| 408 | - for _, message := range conv.messages { | |
| 409 | - view.Messages = append(view.Messages, copyMessage(message)) | |
| 410 | - } | |
| 411 | - view.Busy, view.Queued, view.Usage = conv.busy, len(conv.queue), conv.usage | |
| 412 | - view.Commands = append(view.Commands, conv.commands...) | |
| 413 | - for _, pending := range m.permissions { | |
| 414 | - if pending.conv == conv { | |
| 415 | - view.Permissions = append(view.Permissions, pending.permission) | |
| 416 | - } | |
| 417 | - } | |
| 418 | - return view | |
| 419 | -} | |
| 420 | - | |
| 421 | -// Reset cancels every answer and forgets every conversation, e.g. when | |
| 422 | -// another configuration is opened. | |
| 423 | -func (m *Manager) Reset() { | |
| 424 | - m.mu.Lock() | |
| 425 | - names := make([]string, 0, len(m.conversations)) | |
| 426 | - for name := range m.conversations { | |
| 427 | - names = append(names, name) | |
| 428 | - } | |
| 429 | - m.mu.Unlock() | |
| 430 | - for _, name := range names { | |
| 431 | - m.Cancel(name) | |
| 432 | - } | |
| 433 | - m.mu.Lock() | |
| 434 | - m.conversations = map[string]*conversation{} | |
| 435 | - m.mu.Unlock() | |
| 436 | -} | |
| 437 | - | |
| 438 | -// ---------------------------------------------------------------- the session handler | |
| 439 | - | |
| 440 | -type handler struct { | |
| 441 | - manager *Manager | |
| 442 | - conv *conversation | |
| 443 | -} | |
| 444 | - | |
| 445 | -func (h *handler) Update(update acp.Update) { | |
| 446 | - m, conv := h.manager, h.conv | |
| 447 | - m.mu.Lock() | |
| 448 | - defer m.mu.Unlock() | |
| 449 | - switch update.Kind { | |
| 450 | - case acp.UpdateUsage: | |
| 451 | - conv.usage = *update.Usage | |
| 452 | - m.emitter.Emit(events.UsageChanged, UsagePayload{Agent: conv.agent, Usage: conv.usage}) | |
| 453 | - return | |
| 454 | - case acp.UpdateCommands: | |
| 455 | - conv.commands = update.Commands | |
| 456 | - return | |
| 457 | - } | |
| 458 | - message := conv.current | |
| 459 | - if message == nil { | |
| 460 | - return // nothing streams: a late update after the turn ended | |
| 461 | - } | |
| 462 | - switch update.Kind { | |
| 463 | - case acp.UpdateMessage, acp.UpdateThought: | |
| 464 | - kind := PartText | |
| 465 | - if update.Kind == acp.UpdateThought { | |
| 466 | - kind = PartThought | |
| 467 | - } | |
| 468 | - index := len(message.Parts) - 1 | |
| 469 | - if index < 0 || message.Parts[index].Kind != kind { | |
| 470 | - message.Parts = append(message.Parts, Part{Kind: kind}) | |
| 471 | - index++ | |
| 472 | - } | |
| 473 | - message.Parts[index].Text += update.Text | |
| 474 | - m.emitter.Emit(events.MessageDelta, DeltaPayload{Agent: conv.agent, MessageID: message.ID, Index: index, Kind: kind, Text: update.Text}) | |
| 475 | - case acp.UpdateToolCall: | |
| 476 | - tool := *update.ToolCall | |
| 477 | - message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool}) | |
| 478 | - m.emitPart(message, len(message.Parts)-1) | |
| 479 | - case acp.UpdateToolCallUpdate: | |
| 480 | - index := toolIndex(message, update.ToolCall.ID) | |
| 481 | - if index < 0 { | |
| 482 | - tool := *update.ToolCall | |
| 483 | - message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool}) | |
| 484 | - index = len(message.Parts) - 1 | |
| 485 | - } else { | |
| 486 | - merge(message.Parts[index].Tool, update.ToolCall) | |
| 487 | - } | |
| 488 | - m.emitPart(message, index) | |
| 489 | - case acp.UpdatePlan: | |
| 490 | - index := -1 | |
| 491 | - for i, part := range message.Parts { | |
| 492 | - if part.Kind == PartPlan { | |
| 493 | - index = i | |
| 494 | - } | |
| 495 | - } | |
| 496 | - if index < 0 { | |
| 497 | - message.Parts = append(message.Parts, Part{Kind: PartPlan}) | |
| 498 | - index = len(message.Parts) - 1 | |
| 499 | - } | |
| 500 | - message.Parts[index].Plan = update.Plan | |
| 501 | - m.emitPart(message, index) | |
| 502 | - } | |
| 503 | -} | |
| 504 | - | |
| 505 | -func (h *handler) RequestPermission(ctx context.Context, request acp.PermissionRequest) acp.PermissionAnswer { | |
| 506 | - m, conv := h.manager, h.conv | |
| 507 | - m.mu.Lock() | |
| 508 | - m.sequence++ | |
| 509 | - id := fmt.Sprintf("p-%d", m.sequence) | |
| 510 | - pending := &pendingPermission{conv: conv, answer: make(chan acp.PermissionAnswer, 1)} | |
| 511 | - pending.permission = Permission{ID: id, Agent: conv.agent, ToolCall: request.ToolCall, Options: request.Options, Rejections: conv.rejected} | |
| 512 | - if message := conv.current; message != nil { | |
| 513 | - pending.permission.MessageID = message.ID | |
| 514 | - // The request may name the tool call only: complete it with what the | |
| 515 | - // conversation already knows (title, kind, input). | |
| 516 | - if index := toolIndex(message, request.ToolCall.ID); index >= 0 { | |
| 517 | - known := *message.Parts[index].Tool | |
| 518 | - merge(&known, &request.ToolCall) | |
| 519 | - pending.permission.ToolCall = known | |
| 520 | - } | |
| 521 | - } | |
| 522 | - m.permissions[id] = pending | |
| 523 | - m.emitter.Emit(events.PermissionRequested, pending.permission) | |
| 524 | - m.mu.Unlock() | |
| 525 | - | |
| 526 | - select { | |
| 527 | - case answer := <-pending.answer: | |
| 528 | - return answer | |
| 529 | - case <-ctx.Done(): | |
| 530 | - m.mu.Lock() | |
| 531 | - if _, waiting := m.permissions[id]; waiting { | |
| 532 | - m.resolve(id, acp.PermissionAnswer{}) | |
| 533 | - } | |
| 534 | - m.mu.Unlock() | |
| 535 | - return acp.PermissionAnswer{} | |
| 536 | - } | |
| 537 | -} | |
| 538 | - | |
| 539 | -// ---------------------------------------------------------------- helpers (callers hold m.mu) | |
| 540 | - | |
| 541 | -func (m *Manager) conversation(agent string) *conversation { | |
| 542 | - conv, ok := m.conversations[agent] | |
| 543 | - if !ok { | |
| 544 | - conv = &conversation{agent: agent} | |
| 545 | - m.conversations[agent] = conv | |
| 546 | - } | |
| 547 | - return conv | |
| 548 | -} | |
| 549 | - | |
| 550 | -func (m *Manager) newMessage(agent string, role Role) *Message { | |
| 551 | - m.sequence++ | |
| 552 | - return &Message{ID: fmt.Sprintf("m-%d", m.sequence), Agent: agent, Role: role, CreatedAt: m.now()} | |
| 553 | -} | |
| 554 | - | |
| 555 | -func (m *Manager) emitMessage(message *Message) { | |
| 556 | - m.emitter.Emit(events.MessageUpdated, MessagePayload{Agent: message.Agent, Message: copyMessage(message)}) | |
| 557 | -} | |
| 558 | - | |
| 559 | -func (m *Manager) emitPart(message *Message, index int) { | |
| 560 | - part := copyPart(message.Parts[index]) | |
| 561 | - m.emitter.Emit(events.MessageDelta, DeltaPayload{Agent: message.Agent, MessageID: message.ID, Index: index, Kind: part.Kind, Part: &part}) | |
| 562 | -} | |
| 563 | - | |
| 564 | -func (m *Manager) emitTurn(conv *conversation) { | |
| 565 | - m.emitter.Emit(events.TurnChanged, TurnPayload{Agent: conv.agent, Busy: conv.busy, Queued: len(conv.queue)}) | |
| 566 | -} | |
| 567 | - | |
| 568 | -func toolIndex(message *Message, id string) int { | |
| 569 | - for i, part := range message.Parts { | |
| 570 | - if part.Kind == PartTool && part.Tool.ID == id { | |
| 571 | - return i | |
| 572 | - } | |
| 573 | - } | |
| 574 | - return -1 | |
| 575 | -} | |
| 576 | - | |
| 577 | -// merge copies the fields set in update onto tool. | |
| 578 | -func merge(tool *acp.ToolCall, update *acp.ToolCall) { | |
| 579 | - if update.Title != "" { | |
| 580 | - tool.Title = update.Title | |
| 581 | - } | |
| 582 | - if update.Kind != "" { | |
| 583 | - tool.Kind = update.Kind | |
| 584 | - } | |
| 585 | - if update.Status != "" { | |
| 586 | - tool.Status = update.Status | |
| 587 | - } | |
| 588 | - if update.Input != "" { | |
| 589 | - tool.Input = update.Input | |
| 590 | - } | |
| 591 | - if update.Output != "" { | |
| 592 | - tool.Output = update.Output | |
| 593 | - } | |
| 594 | - if len(update.Locations) > 0 { | |
| 595 | - tool.Locations = update.Locations | |
| 596 | - } | |
| 597 | - if len(update.Diffs) > 0 { | |
| 598 | - tool.Diffs = update.Diffs | |
| 599 | - } | |
| 600 | -} | |
| 601 | - | |
| 602 | -func copyMessage(message *Message) Message { | |
| 603 | - copied := *message | |
| 604 | - copied.Parts = make([]Part, len(message.Parts)) | |
| 605 | - for i, part := range message.Parts { | |
| 606 | - copied.Parts[i] = copyPart(part) | |
| 607 | - } | |
| 608 | - return copied | |
| 609 | -} | |
| 610 | - | |
| 611 | -func copyPart(part Part) Part { | |
| 612 | - if part.Tool != nil { | |
| 613 | - tool := *part.Tool | |
| 614 | - part.Tool = &tool | |
| 615 | - } | |
| 616 | - part.Plan = append([]acp.PlanEntry(nil), part.Plan...) | |
| 617 | - return part | |
| 618 | -} | |
| deleted file mode 100644 | |||
| @@ -1,618 +0,0 @@ | |||
| 1 | -// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | - | ||
| 3 | -// Package conversations holds the direct conversations with agents of | ||
| 4 | -// milestone M2: one conversation per agent, kept in memory (ADR 0004). | ||
| 5 | -// | ||
| 6 | -// Sending a message borrows the agent from the agent manager (starting it if | ||
| 7 | -// needed), opens an ACP session on first use, and streams the answer to the | ||
| 8 | -// interface as events. Messages sent while the agent answers are queued. | ||
| 9 | -// | ||
| 10 | -// manager := conversations.NewManager(agentManager, conversations.Options{Emitter: emitter}) | ||
| 11 | -// id, err := manager.Send("alice", "Bonjour !") | ||
| 12 | -// … | ||
| 13 | -// manager.Cancel("alice") | ||
| 14 | -package conversations | ||
| 15 | - | ||
| 16 | -import ( | ||
| 17 | - "context" | ||
| 18 | - "errors" | ||
| 19 | - "fmt" | ||
| 20 | - "strings" | ||
| 21 | - "sync" | ||
| 22 | - "time" | ||
| 23 | - | ||
| 24 | - "git.rickub.com/bots-garden/sidekicks/internal/acp" | ||
| 25 | - "git.rickub.com/bots-garden/sidekicks/internal/agents" | ||
| 26 | - "git.rickub.com/bots-garden/sidekicks/internal/config" | ||
| 27 | - "git.rickub.com/bots-garden/sidekicks/internal/events" | ||
| 28 | -) | ||
| 29 | - | ||
| 30 | -// Role tells who wrote a message. | ||
| 31 | -type Role string | ||
| 32 | - | ||
| 33 | -// The roles. | ||
| 34 | -const ( | ||
| 35 | - RoleUser Role = "user" | ||
| 36 | - RoleAgent Role = "agent" | ||
| 37 | - RoleSystem Role = "system" | ||
| 38 | -) | ||
| 39 | - | ||
| 40 | -// The kinds of Part. | ||
| 41 | -const ( | ||
| 42 | - PartText = "text" | ||
| 43 | - PartThought = "thought" | ||
| 44 | - PartTool = "tool" | ||
| 45 | - PartPlan = "plan" | ||
| 46 | -) | ||
| 47 | - | ||
| 48 | -// System message codes; the interface turns them into sentences. | ||
| 49 | -const ( | ||
| 50 | - // CodeAgentRestarted: the agent was restarted and lost the conversation's context. | ||
| 51 | - CodeAgentRestarted = "agent-restarted" | ||
| 52 | - // CodeAgentUnavailable: the agent could not be started or reached; Error says why. | ||
| 53 | - CodeAgentUnavailable = "agent-unavailable" | ||
| 54 | -) | ||
| 55 | - | ||
| 56 | -// ErrUnknownAgent is returned for a name that is not in the configuration. | ||
| 57 | -var ErrUnknownAgent = errors.New("unknown agent") | ||
| 58 | - | ||
| 59 | -// Part is a piece of a message: text, reasoning, a tool call or a plan. | ||
| 60 | -type Part struct { | ||
| 61 | - Kind string `json:"kind"` | ||
| 62 | - Text string `json:"text,omitempty"` | ||
| 63 | - Tool *acp.ToolCall `json:"tool,omitempty"` | ||
| 64 | - Plan []acp.PlanEntry `json:"plan,omitempty"` | ||
| 65 | -} | ||
| 66 | - | ||
| 67 | -// Message is one message of a conversation. | ||
| 68 | -type Message struct { | ||
| 69 | - ID string `json:"id"` | ||
| 70 | - Agent string `json:"agent"` | ||
| 71 | - Role Role `json:"role"` | ||
| 72 | - Parts []Part `json:"parts"` | ||
| 73 | - // Done is false while an agent message streams. | ||
| 74 | - Done bool `json:"done"` | ||
| 75 | - // Queued: a user message waiting for the current answer to end. | ||
| 76 | - Queued bool `json:"queued"` | ||
| 77 | - // StopReason of an agent message: end_turn, cancelled, max_tokens… | ||
| 78 | - StopReason string `json:"stopReason,omitempty"` | ||
| 79 | - // Error explains why an agent message ended without a stop reason, or a system message. | ||
| 80 | - Error string `json:"error,omitempty"` | ||
| 81 | - // Code identifies a system message (CodeAgentRestarted…). | ||
| 82 | - Code string `json:"code,omitempty"` | ||
| 83 | - CreatedAt time.Time `json:"createdAt"` | ||
| 84 | -} | ||
| 85 | - | ||
| 86 | -// Permission is a permission request waiting for the user. | ||
| 87 | -type Permission struct { | ||
| 88 | - ID string `json:"id"` | ||
| 89 | - Agent string `json:"agent"` | ||
| 90 | - MessageID string `json:"messageId"` | ||
| 91 | - ToolCall acp.ToolCall `json:"toolCall"` | ||
| 92 | - Options []acp.PermissionOption `json:"options"` | ||
| 93 | - // Rejections counts the refusals already given during this turn: an agent | ||
| 94 | - // that keeps retrying is better stopped (ADR 0001, fact 4). | ||
| 95 | - Rejections int `json:"rejections"` | ||
| 96 | -} | ||
| 97 | - | ||
| 98 | -// Conversation is the state of the conversation with one agent. | ||
| 99 | -type Conversation struct { | ||
| 100 | - Agent string `json:"agent"` | ||
| 101 | - Messages []Message `json:"messages"` | ||
| 102 | - Busy bool `json:"busy"` | ||
| 103 | - Queued int `json:"queued"` | ||
| 104 | - Usage acp.Usage `json:"usage"` | ||
| 105 | - Commands []acp.Command `json:"commands"` | ||
| 106 | - Permissions []Permission `json:"permissions"` | ||
| 107 | -} | ||
| 108 | - | ||
| 109 | -// Event payloads, registered with Wails in main.go. | ||
| 110 | - | ||
| 111 | -// MessagePayload carries a whole message: added, or finished. | ||
| 112 | -type MessagePayload struct { | ||
| 113 | - Agent string `json:"agent"` | ||
| 114 | - Message Message `json:"message"` | ||
| 115 | -} | ||
| 116 | - | ||
| 117 | -// DeltaPayload changes one part of a streaming message: Text is appended to | ||
| 118 | -// a text or thought part; Part replaces a tool or plan part. | ||
| 119 | -type DeltaPayload struct { | ||
| 120 | - Agent string `json:"agent"` | ||
| 121 | - MessageID string `json:"messageId"` | ||
| 122 | - Index int `json:"index"` | ||
| 123 | - Kind string `json:"kind"` | ||
| 124 | - Text string `json:"text,omitempty"` | ||
| 125 | - Part *Part `json:"part,omitempty"` | ||
| 126 | -} | ||
| 127 | - | ||
| 128 | -// TurnPayload tells whether the agent is answering and how many messages wait. | ||
| 129 | -type TurnPayload struct { | ||
| 130 | - Agent string `json:"agent"` | ||
| 131 | - Busy bool `json:"busy"` | ||
| 132 | - Queued int `json:"queued"` | ||
| 133 | -} | ||
| 134 | - | ||
| 135 | -// PermissionResolvedPayload says a permission request no longer waits. | ||
| 136 | -type PermissionResolvedPayload struct { | ||
| 137 | - ID string `json:"id"` | ||
| 138 | - Agent string `json:"agent"` | ||
| 139 | -} | ||
| 140 | - | ||
| 141 | -// UsagePayload is the context window use of a conversation. | ||
| 142 | -type UsagePayload struct { | ||
| 143 | - Agent string `json:"agent"` | ||
| 144 | - Usage acp.Usage `json:"usage"` | ||
| 145 | -} | ||
| 146 | - | ||
| 147 | -// AgentSource lends agents. *agents.Manager implements it. | ||
| 148 | -type AgentSource interface { | ||
| 149 | - Acquire(ctx context.Context, name string) (agents.Conn, func(), error) | ||
| 150 | - Definition(name string) (config.Agent, bool) | ||
| 151 | -} | ||
| 152 | - | ||
| 153 | -// Options configure a Manager. | ||
| 154 | -type Options struct { | ||
| 155 | - // Emitter receives the conversation events, in order, under the | ||
| 156 | - // manager's lock: it must not block nor call the Manager. | ||
| 157 | - Emitter events.Emitter | ||
| 158 | - // Now defaults to time.Now. | ||
| 159 | - Now func() time.Time | ||
| 160 | -} | ||
| 161 | - | ||
| 162 | -// Manager holds the conversations. It is safe for concurrent use. | ||
| 163 | -type Manager struct { | ||
| 164 | - agents AgentSource | ||
| 165 | - emitter events.Emitter | ||
| 166 | - now func() time.Time | ||
| 167 | - | ||
| 168 | - mu sync.Mutex | ||
| 169 | - conversations map[string]*conversation | ||
| 170 | - permissions map[string]*pendingPermission | ||
| 171 | - sequence int | ||
| 172 | -} | ||
| 173 | - | ||
| 174 | -type conversation struct { | ||
| 175 | - agent string | ||
| 176 | - messages []*Message | ||
| 177 | - conn agents.Conn | ||
| 178 | - sessionID string | ||
| 179 | - busy bool | ||
| 180 | - queue []*Message | ||
| 181 | - current *Message | ||
| 182 | - cancel context.CancelFunc | ||
| 183 | - usage acp.Usage | ||
| 184 | - commands []acp.Command | ||
| 185 | - rejected int | ||
| 186 | -} | ||
| 187 | - | ||
| 188 | -type pendingPermission struct { | ||
| 189 | - permission Permission | ||
| 190 | - conv *conversation | ||
| 191 | - answer chan acp.PermissionAnswer | ||
| 192 | -} | ||
| 193 | - | ||
| 194 | -// NewManager returns a manager with no conversation. | ||
| 195 | -func NewManager(source AgentSource, options Options) *Manager { | ||
| 196 | - if options.Emitter == nil { | ||
| 197 | - options.Emitter = events.Discard | ||
| 198 | - } | ||
| 199 | - if options.Now == nil { | ||
| 200 | - options.Now = time.Now | ||
| 201 | - } | ||
| 202 | - return &Manager{ | ||
| 203 | - agents: source, emitter: options.Emitter, now: options.Now, | ||
| 204 | - conversations: map[string]*conversation{}, permissions: map[string]*pendingPermission{}, | ||
| 205 | - } | ||
| 206 | -} | ||
| 207 | - | ||
| 208 | -// Send adds a user message to the conversation with agent and returns its | ||
| 209 | -// id. The agent answers right away, or after the current answer if it is | ||
| 210 | -// busy (the message is then queued). | ||
| 211 | -func (m *Manager) Send(agent, text string) (string, error) { | ||
| 212 | - text = strings.TrimSpace(text) | ||
| 213 | - if text == "" { | ||
| 214 | - return "", errors.New("empty message") | ||
| 215 | - } | ||
| 216 | - if _, ok := m.agents.Definition(agent); !ok { | ||
| 217 | - return "", fmt.Errorf("%w: %s", ErrUnknownAgent, agent) | ||
| 218 | - } | ||
| 219 | - m.mu.Lock() | ||
| 220 | - defer m.mu.Unlock() | ||
| 221 | - conv := m.conversation(agent) | ||
| 222 | - message := m.newMessage(agent, RoleUser) | ||
| 223 | - message.Parts = []Part{{Kind: PartText, Text: text}} | ||
| 224 | - message.Done = true | ||
| 225 | - conv.messages = append(conv.messages, message) | ||
| 226 | - if conv.busy { | ||
| 227 | - message.Queued = true | ||
| 228 | - conv.queue = append(conv.queue, message) | ||
| 229 | - m.emitMessage(message) | ||
| 230 | - m.emitTurn(conv) | ||
| 231 | - return message.ID, nil | ||
| 232 | - } | ||
| 233 | - conv.busy = true | ||
| 234 | - m.emitMessage(message) | ||
| 235 | - m.emitTurn(conv) | ||
| 236 | - go m.run(conv, message) | ||
| 237 | - return message.ID, nil | ||
| 238 | -} | ||
| 239 | - | ||
| 240 | -// run answers message, then the queued messages one by one. | ||
| 241 | -func (m *Manager) run(conv *conversation, message *Message) { | ||
| 242 | - for message != nil { | ||
| 243 | - m.turn(conv, message) | ||
| 244 | - m.mu.Lock() | ||
| 245 | - message = nil | ||
| 246 | - if len(conv.queue) > 0 { | ||
| 247 | - message, conv.queue = conv.queue[0], conv.queue[1:] | ||
| 248 | - message.Queued = false | ||
| 249 | - m.emitMessage(message) | ||
| 250 | - } else { | ||
| 251 | - conv.busy = false | ||
| 252 | - } | ||
| 253 | - m.emitTurn(conv) | ||
| 254 | - m.mu.Unlock() | ||
| 255 | - } | ||
| 256 | -} | ||
| 257 | - | ||
| 258 | -// turn sends one user message to the agent and streams the answer. | ||
| 259 | -func (m *Manager) turn(conv *conversation, user *Message) { | ||
| 260 | - ctx, cancel := context.WithCancel(context.Background()) | ||
| 261 | - defer cancel() | ||
| 262 | - m.mu.Lock() | ||
| 263 | - conv.cancel = cancel | ||
| 264 | - m.mu.Unlock() | ||
| 265 | - | ||
| 266 | - conn, release, err := m.agents.Acquire(ctx, conv.agent) | ||
| 267 | - if err != nil { | ||
| 268 | - m.systemMessage(conv, CodeAgentUnavailable, err.Error()) | ||
| 269 | - return | ||
| 270 | - } | ||
| 271 | - defer release() | ||
| 272 | - sessionID, err := m.session(ctx, conv, conn) | ||
| 273 | - if err != nil { | ||
| 274 | - m.systemMessage(conv, CodeAgentUnavailable, err.Error()) | ||
| 275 | - return | ||
| 276 | - } | ||
| 277 | - | ||
| 278 | - m.mu.Lock() | ||
| 279 | - answer := m.newMessage(conv.agent, RoleAgent) | ||
| 280 | - answer.Parts = []Part{} | ||
| 281 | - conv.messages = append(conv.messages, answer) | ||
| 282 | - conv.current = answer | ||
| 283 | - conv.rejected = 0 | ||
| 284 | - m.emitMessage(answer) | ||
| 285 | - m.mu.Unlock() | ||
| 286 | - | ||
| 287 | - result, err := conn.Prompt(ctx, sessionID, user.Parts[0].Text) | ||
| 288 | - | ||
| 289 | - m.mu.Lock() | ||
| 290 | - defer m.mu.Unlock() | ||
| 291 | - answer.Done = true | ||
| 292 | - switch { | ||
| 293 | - case err == nil: | ||
| 294 | - answer.StopReason = result.StopReason | ||
| 295 | - case ctx.Err() != nil: | ||
| 296 | - answer.StopReason = "cancelled" | ||
| 297 | - default: | ||
| 298 | - answer.Error = err.Error() | ||
| 299 | - } | ||
| 300 | - conv.current, conv.cancel = nil, nil | ||
| 301 | - m.emitMessage(answer) | ||
| 302 | -} | ||
| 303 | - | ||
| 304 | -// session returns the ACP session of the conversation on conn, opening one | ||
| 305 | -// when the agent is new or was restarted (it then forgot the conversation). | ||
| 306 | -func (m *Manager) session(ctx context.Context, conv *conversation, conn agents.Conn) (string, error) { | ||
| 307 | - m.mu.Lock() | ||
| 308 | - if conv.conn == conn { | ||
| 309 | - id := conv.sessionID | ||
| 310 | - m.mu.Unlock() | ||
| 311 | - return id, nil | ||
| 312 | - } | ||
| 313 | - hadSession := conv.conn != nil | ||
| 314 | - m.mu.Unlock() | ||
| 315 | - | ||
| 316 | - definition, _ := m.agents.Definition(conv.agent) | ||
| 317 | - id, err := conn.NewSession(ctx, definition.Workspace, &handler{manager: m, conv: conv}) | ||
| 318 | - if err != nil { | ||
| 319 | - return "", err | ||
| 320 | - } | ||
| 321 | - m.mu.Lock() | ||
| 322 | - conv.conn, conv.sessionID = conn, id | ||
| 323 | - m.mu.Unlock() | ||
| 324 | - if hadSession { | ||
| 325 | - m.systemMessage(conv, CodeAgentRestarted, "") | ||
| 326 | - } | ||
| 327 | - return id, nil | ||
| 328 | -} | ||
| 329 | - | ||
| 330 | -func (m *Manager) systemMessage(conv *conversation, code, reason string) { | ||
| 331 | - m.mu.Lock() | ||
| 332 | - defer m.mu.Unlock() | ||
| 333 | - message := m.newMessage(conv.agent, RoleSystem) | ||
| 334 | - message.Code, message.Error, message.Done = code, reason, true | ||
| 335 | - message.Parts = []Part{} | ||
| 336 | - conv.messages = append(conv.messages, message) | ||
| 337 | - m.emitMessage(message) | ||
| 338 | -} | ||
| 339 | - | ||
| 340 | -// Cancel stops the current answer of agent and drops its queued messages. | ||
| 341 | -// Pending permission requests are answered "cancelled", as ACP requires. | ||
| 342 | -func (m *Manager) Cancel(agent string) { | ||
| 343 | - m.mu.Lock() | ||
| 344 | - conv, ok := m.conversations[agent] | ||
| 345 | - if !ok { | ||
| 346 | - m.mu.Unlock() | ||
| 347 | - return | ||
| 348 | - } | ||
| 349 | - for _, queued := range conv.queue { | ||
| 350 | - queued.Queued = false | ||
| 351 | - queued.Error = "cancelled" | ||
| 352 | - m.emitMessage(queued) | ||
| 353 | - } | ||
| 354 | - conv.queue = nil | ||
| 355 | - for id, pending := range m.permissions { | ||
| 356 | - if pending.conv == conv { | ||
| 357 | - m.resolve(id, acp.PermissionAnswer{}) | ||
| 358 | - } | ||
| 359 | - } | ||
| 360 | - conn, sessionID, streaming, cancel := conv.conn, conv.sessionID, conv.current != nil, conv.cancel | ||
| 361 | - m.emitTurn(conv) | ||
| 362 | - m.mu.Unlock() | ||
| 363 | - | ||
| 364 | - if streaming && conn != nil { | ||
| 365 | - _ = conn.Cancel(context.Background(), sessionID) | ||
| 366 | - } else if cancel != nil { | ||
| 367 | - cancel() // still starting the agent | ||
| 368 | - } | ||
| 369 | -} | ||
| 370 | - | ||
| 371 | -// AnswerPermission gives the user's choice for a permission request. | ||
| 372 | -func (m *Manager) AnswerPermission(id, optionID string) error { | ||
| 373 | - m.mu.Lock() | ||
| 374 | - defer m.mu.Unlock() | ||
| 375 | - pending, ok := m.permissions[id] | ||
| 376 | - if !ok { | ||
| 377 | - return fmt.Errorf("no permission request %s waits", id) | ||
| 378 | - } | ||
| 379 | - for _, option := range pending.permission.Options { | ||
| 380 | - if option.ID == optionID { | ||
| 381 | - if strings.HasPrefix(option.Kind, "reject") { | ||
| 382 | - pending.conv.rejected++ | ||
| 383 | - } | ||
| 384 | - m.resolve(id, acp.PermissionAnswer{OptionID: optionID}) | ||
| 385 | - return nil | ||
| 386 | - } | ||
| 387 | - } | ||
| 388 | - return fmt.Errorf("permission request %s has no option %s", id, optionID) | ||
| 389 | -} | ||
| 390 | - | ||
| 391 | -// resolve answers a pending request. The caller holds m.mu. | ||
| 392 | -func (m *Manager) resolve(id string, answer acp.PermissionAnswer) { | ||
| 393 | - pending := m.permissions[id] | ||
| 394 | - delete(m.permissions, id) | ||
| 395 | - pending.answer <- answer | ||
| 396 | - m.emitter.Emit(events.PermissionResolved, PermissionResolvedPayload{ID: id, Agent: pending.conv.agent}) | ||
| 397 | -} | ||
| 398 | - | ||
| 399 | -// Conversation returns a copy of the conversation with agent (empty if none). | ||
| 400 | -func (m *Manager) Conversation(agent string) Conversation { | ||
| 401 | - m.mu.Lock() | ||
| 402 | - defer m.mu.Unlock() | ||
| 403 | - view := Conversation{Agent: agent, Messages: []Message{}, Commands: []acp.Command{}, Permissions: []Permission{}} | ||
| 404 | - conv, ok := m.conversations[agent] | ||
| 405 | - if !ok { | ||
| 406 | - return view | ||
| 407 | - } | ||
| 408 | - for _, message := range conv.messages { | ||
| 409 | - view.Messages = append(view.Messages, copyMessage(message)) | ||
| 410 | - } | ||
| 411 | - view.Busy, view.Queued, view.Usage = conv.busy, len(conv.queue), conv.usage | ||
| 412 | - view.Commands = append(view.Commands, conv.commands...) | ||
| 413 | - for _, pending := range m.permissions { | ||
| 414 | - if pending.conv == conv { | ||
| 415 | - view.Permissions = append(view.Permissions, pending.permission) | ||
| 416 | - } | ||
| 417 | - } | ||
| 418 | - return view | ||
| 419 | -} | ||
| 420 | - | ||
| 421 | -// Reset cancels every answer and forgets every conversation, e.g. when | ||
| 422 | -// another configuration is opened. | ||
| 423 | -func (m *Manager) Reset() { | ||
| 424 | - m.mu.Lock() | ||
| 425 | - names := make([]string, 0, len(m.conversations)) | ||
| 426 | - for name := range m.conversations { | ||
| 427 | - names = append(names, name) | ||
| 428 | - } | ||
| 429 | - m.mu.Unlock() | ||
| 430 | - for _, name := range names { | ||
| 431 | - m.Cancel(name) | ||
| 432 | - } | ||
| 433 | - m.mu.Lock() | ||
| 434 | - m.conversations = map[string]*conversation{} | ||
| 435 | - m.mu.Unlock() | ||
| 436 | -} | ||
| 437 | - | ||
| 438 | -// ---------------------------------------------------------------- the session handler | ||
| 439 | - | ||
| 440 | -type handler struct { | ||
| 441 | - manager *Manager | ||
| 442 | - conv *conversation | ||
| 443 | -} | ||
| 444 | - | ||
| 445 | -func (h *handler) Update(update acp.Update) { | ||
| 446 | - m, conv := h.manager, h.conv | ||
| 447 | - m.mu.Lock() | ||
| 448 | - defer m.mu.Unlock() | ||
| 449 | - switch update.Kind { | ||
| 450 | - case acp.UpdateUsage: | ||
| 451 | - conv.usage = *update.Usage | ||
| 452 | - m.emitter.Emit(events.UsageChanged, UsagePayload{Agent: conv.agent, Usage: conv.usage}) | ||
| 453 | - return | ||
| 454 | - case acp.UpdateCommands: | ||
| 455 | - conv.commands = update.Commands | ||
| 456 | - return | ||
| 457 | - } | ||
| 458 | - message := conv.current | ||
| 459 | - if message == nil { | ||
| 460 | - return // nothing streams: a late update after the turn ended | ||
| 461 | - } | ||
| 462 | - switch update.Kind { | ||
| 463 | - case acp.UpdateMessage, acp.UpdateThought: | ||
| 464 | - kind := PartText | ||
| 465 | - if update.Kind == acp.UpdateThought { | ||
| 466 | - kind = PartThought | ||
| 467 | - } | ||
| 468 | - index := len(message.Parts) - 1 | ||
| 469 | - if index < 0 || message.Parts[index].Kind != kind { | ||
| 470 | - message.Parts = append(message.Parts, Part{Kind: kind}) | ||
| 471 | - index++ | ||
| 472 | - } | ||
| 473 | - message.Parts[index].Text += update.Text | ||
| 474 | - m.emitter.Emit(events.MessageDelta, DeltaPayload{Agent: conv.agent, MessageID: message.ID, Index: index, Kind: kind, Text: update.Text}) | ||
| 475 | - case acp.UpdateToolCall: | ||
| 476 | - tool := *update.ToolCall | ||
| 477 | - message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool}) | ||
| 478 | - m.emitPart(message, len(message.Parts)-1) | ||
| 479 | - case acp.UpdateToolCallUpdate: | ||
| 480 | - index := toolIndex(message, update.ToolCall.ID) | ||
| 481 | - if index < 0 { | ||
| 482 | - tool := *update.ToolCall | ||
| 483 | - message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool}) | ||
| 484 | - index = len(message.Parts) - 1 | ||
| 485 | - } else { | ||
| 486 | - merge(message.Parts[index].Tool, update.ToolCall) | ||
| 487 | - } | ||
| 488 | - m.emitPart(message, index) | ||
| 489 | - case acp.UpdatePlan: | ||
| 490 | - index := -1 | ||
| 491 | - for i, part := range message.Parts { | ||
| 492 | - if part.Kind == PartPlan { | ||
| 493 | - index = i | ||
| 494 | - } | ||
| 495 | - } | ||
| 496 | - if index < 0 { | ||
| 497 | - message.Parts = append(message.Parts, Part{Kind: PartPlan}) | ||
| 498 | - index = len(message.Parts) - 1 | ||
| 499 | - } | ||
| 500 | - message.Parts[index].Plan = update.Plan | ||
| 501 | - m.emitPart(message, index) | ||
| 502 | - } | ||
| 503 | -} | ||
| 504 | - | ||
| 505 | -func (h *handler) RequestPermission(ctx context.Context, request acp.PermissionRequest) acp.PermissionAnswer { | ||
| 506 | - m, conv := h.manager, h.conv | ||
| 507 | - m.mu.Lock() | ||
| 508 | - m.sequence++ | ||
| 509 | - id := fmt.Sprintf("p-%d", m.sequence) | ||
| 510 | - pending := &pendingPermission{conv: conv, answer: make(chan acp.PermissionAnswer, 1)} | ||
| 511 | - pending.permission = Permission{ID: id, Agent: conv.agent, ToolCall: request.ToolCall, Options: request.Options, Rejections: conv.rejected} | ||
| 512 | - if message := conv.current; message != nil { | ||
| 513 | - pending.permission.MessageID = message.ID | ||
| 514 | - // The request may name the tool call only: complete it with what the | ||
| 515 | - // conversation already knows (title, kind, input). | ||
| 516 | - if index := toolIndex(message, request.ToolCall.ID); index >= 0 { | ||
| 517 | - known := *message.Parts[index].Tool | ||
| 518 | - merge(&known, &request.ToolCall) | ||
| 519 | - pending.permission.ToolCall = known | ||
| 520 | - } | ||
| 521 | - } | ||
| 522 | - m.permissions[id] = pending | ||
| 523 | - m.emitter.Emit(events.PermissionRequested, pending.permission) | ||
| 524 | - m.mu.Unlock() | ||
| 525 | - | ||
| 526 | - select { | ||
| 527 | - case answer := <-pending.answer: | ||
| 528 | - return answer | ||
| 529 | - case <-ctx.Done(): | ||
| 530 | - m.mu.Lock() | ||
| 531 | - if _, waiting := m.permissions[id]; waiting { | ||
| 532 | - m.resolve(id, acp.PermissionAnswer{}) | ||
| 533 | - } | ||
| 534 | - m.mu.Unlock() | ||
| 535 | - return acp.PermissionAnswer{} | ||
| 536 | - } | ||
| 537 | -} | ||
| 538 | - | ||
| 539 | -// ---------------------------------------------------------------- helpers (callers hold m.mu) | ||
| 540 | - | ||
| 541 | -func (m *Manager) conversation(agent string) *conversation { | ||
| 542 | - conv, ok := m.conversations[agent] | ||
| 543 | - if !ok { | ||
| 544 | - conv = &conversation{agent: agent} | ||
| 545 | - m.conversations[agent] = conv | ||
| 546 | - } | ||
| 547 | - return conv | ||
| 548 | -} | ||
| 549 | - | ||
| 550 | -func (m *Manager) newMessage(agent string, role Role) *Message { | ||
| 551 | - m.sequence++ | ||
| 552 | - return &Message{ID: fmt.Sprintf("m-%d", m.sequence), Agent: agent, Role: role, CreatedAt: m.now()} | ||
| 553 | -} | ||
| 554 | - | ||
| 555 | -func (m *Manager) emitMessage(message *Message) { | ||
| 556 | - m.emitter.Emit(events.MessageUpdated, MessagePayload{Agent: message.Agent, Message: copyMessage(message)}) | ||
| 557 | -} | ||
| 558 | - | ||
| 559 | -func (m *Manager) emitPart(message *Message, index int) { | ||
| 560 | - part := copyPart(message.Parts[index]) | ||
| 561 | - m.emitter.Emit(events.MessageDelta, DeltaPayload{Agent: message.Agent, MessageID: message.ID, Index: index, Kind: part.Kind, Part: &part}) | ||
| 562 | -} | ||
| 563 | - | ||
| 564 | -func (m *Manager) emitTurn(conv *conversation) { | ||
| 565 | - m.emitter.Emit(events.TurnChanged, TurnPayload{Agent: conv.agent, Busy: conv.busy, Queued: len(conv.queue)}) | ||
| 566 | -} | ||
| 567 | - | ||
| 568 | -func toolIndex(message *Message, id string) int { | ||
| 569 | - for i, part := range message.Parts { | ||
| 570 | - if part.Kind == PartTool && part.Tool.ID == id { | ||
| 571 | - return i | ||
| 572 | - } | ||
| 573 | - } | ||
| 574 | - return -1 | ||
| 575 | -} | ||
| 576 | - | ||
| 577 | -// merge copies the fields set in update onto tool. | ||
| 578 | -func merge(tool *acp.ToolCall, update *acp.ToolCall) { | ||
| 579 | - if update.Title != "" { | ||
| 580 | - tool.Title = update.Title | ||
| 581 | - } | ||
| 582 | - if update.Kind != "" { | ||
| 583 | - tool.Kind = update.Kind | ||
| 584 | - } | ||
| 585 | - if update.Status != "" { | ||
| 586 | - tool.Status = update.Status | ||
| 587 | - } | ||
| 588 | - if update.Input != "" { | ||
| 589 | - tool.Input = update.Input | ||
| 590 | - } | ||
| 591 | - if update.Output != "" { | ||
| 592 | - tool.Output = update.Output | ||
| 593 | - } | ||
| 594 | - if len(update.Locations) > 0 { | ||
| 595 | - tool.Locations = update.Locations | ||
| 596 | - } | ||
| 597 | - if len(update.Diffs) > 0 { | ||
| 598 | - tool.Diffs = update.Diffs | ||
| 599 | - } | ||
| 600 | -} | ||
| 601 | - | ||
| 602 | -func copyMessage(message *Message) Message { | ||
| 603 | - copied := *message | ||
| 604 | - copied.Parts = make([]Part, len(message.Parts)) | ||
| 605 | - for i, part := range message.Parts { | ||
| 606 | - copied.Parts[i] = copyPart(part) | ||
| 607 | - } | ||
| 608 | - return copied | ||
| 609 | -} | ||
| 610 | - | ||
| 611 | -func copyPart(part Part) Part { | ||
| 612 | - if part.Tool != nil { | ||
| 613 | - tool := *part.Tool | ||
| 614 | - part.Tool = &tool | ||
| 615 | - } | ||
| 616 | - part.Plan = append([]acp.PlanEntry(nil), part.Plan...) | ||
| 617 | - return part | ||
| 618 | -} | ||
deleted
internal/conversations/conversations_test.go +0 -405 | deleted file mode 100644 | ||
| @@ -1,405 +0,0 @@ | ||
| 1 | -// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | - | |
| 3 | -package conversations_test | |
| 4 | - | |
| 5 | -import ( | |
| 6 | - "context" | |
| 7 | - "errors" | |
| 8 | - "fmt" | |
| 9 | - "strings" | |
| 10 | - "sync" | |
| 11 | - "testing" | |
| 12 | - "time" | |
| 13 | - | |
| 14 | - "git.rickub.com/bots-garden/sidekicks/internal/acp" | |
| 15 | - "git.rickub.com/bots-garden/sidekicks/internal/agents" | |
| 16 | - "git.rickub.com/bots-garden/sidekicks/internal/config" | |
| 17 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | |
| 18 | - "git.rickub.com/bots-garden/sidekicks/internal/events" | |
| 19 | -) | |
| 20 | - | |
| 21 | -// ---------------------------------------------------------------- fakes | |
| 22 | - | |
| 23 | -// script is what the fake agent does with a prompt, given the session handler. | |
| 24 | -type script func(ctx context.Context, handler acp.SessionHandler) (acp.TurnResult, error) | |
| 25 | - | |
| 26 | -type fakeConn struct { | |
| 27 | - mu sync.Mutex | |
| 28 | - handler acp.SessionHandler | |
| 29 | - sessions int | |
| 30 | - prompts []string | |
| 31 | - cancelled chan struct{} | |
| 32 | - scripts map[string]script | |
| 33 | -} | |
| 34 | - | |
| 35 | -func newFakeConn(scripts map[string]script) *fakeConn { | |
| 36 | - return &fakeConn{scripts: scripts, cancelled: make(chan struct{}, 10)} | |
| 37 | -} | |
| 38 | - | |
| 39 | -func (c *fakeConn) Info() acp.Info { return acp.Info{} } | |
| 40 | -func (c *fakeConn) Done() <-chan struct{} { return make(chan struct{}) } | |
| 41 | -func (c *fakeConn) Err() error { return nil } | |
| 42 | -func (c *fakeConn) Close(context.Context) error { return nil } | |
| 43 | -func (c *fakeConn) Cancel(context.Context, string) error { | |
| 44 | - c.cancelled <- struct{}{} | |
| 45 | - return nil | |
| 46 | -} | |
| 47 | -func (c *fakeConn) NewSession(_ context.Context, cwd string, handler acp.SessionHandler) (string, error) { | |
| 48 | - c.mu.Lock() | |
| 49 | - defer c.mu.Unlock() | |
| 50 | - if cwd != "/agents/alice" { | |
| 51 | - return "", fmt.Errorf("unexpected cwd %s", cwd) | |
| 52 | - } | |
| 53 | - c.handler = handler | |
| 54 | - c.sessions++ | |
| 55 | - return fmt.Sprintf("s-%d", c.sessions), nil | |
| 56 | -} | |
| 57 | -func (c *fakeConn) Prompt(ctx context.Context, _ string, text string) (acp.TurnResult, error) { | |
| 58 | - c.mu.Lock() | |
| 59 | - c.prompts = append(c.prompts, text) | |
| 60 | - handler, play := c.handler, c.scripts[text] | |
| 61 | - c.mu.Unlock() | |
| 62 | - if play == nil { | |
| 63 | - handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: "ok"}) | |
| 64 | - return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 65 | - } | |
| 66 | - return play(withCancel(ctx, c.cancelled), handler) | |
| 67 | -} | |
| 68 | - | |
| 69 | -func (c *fakeConn) sent() []string { | |
| 70 | - c.mu.Lock() | |
| 71 | - defer c.mu.Unlock() | |
| 72 | - return append([]string(nil), c.prompts...) | |
| 73 | -} | |
| 74 | - | |
| 75 | -func (c *fakeConn) sessionCount() int { | |
| 76 | - c.mu.Lock() | |
| 77 | - defer c.mu.Unlock() | |
| 78 | - return c.sessions | |
| 79 | -} | |
| 80 | - | |
| 81 | -// withCancel ends ctx when the fake agent receives session/cancel. | |
| 82 | -func withCancel(parent context.Context, cancelled chan struct{}) context.Context { | |
| 83 | - ctx, cancel := context.WithCancel(parent) | |
| 84 | - go func() { | |
| 85 | - select { | |
| 86 | - case <-cancelled: | |
| 87 | - cancel() | |
| 88 | - case <-ctx.Done(): | |
| 89 | - } | |
| 90 | - }() | |
| 91 | - return ctx | |
| 92 | -} | |
| 93 | - | |
| 94 | -type fakeSource struct { | |
| 95 | - mu sync.Mutex | |
| 96 | - conn agents.Conn | |
| 97 | - acquired int | |
| 98 | - fail error | |
| 99 | -} | |
| 100 | - | |
| 101 | -func (s *fakeSource) Acquire(context.Context, string) (agents.Conn, func(), error) { | |
| 102 | - s.mu.Lock() | |
| 103 | - defer s.mu.Unlock() | |
| 104 | - if s.fail != nil { | |
| 105 | - return nil, nil, s.fail | |
| 106 | - } | |
| 107 | - s.acquired++ | |
| 108 | - return s.conn, func() {}, nil | |
| 109 | -} | |
| 110 | - | |
| 111 | -func (s *fakeSource) Definition(name string) (config.Agent, bool) { | |
| 112 | - if name != "alice" { | |
| 113 | - return config.Agent{}, false | |
| 114 | - } | |
| 115 | - return config.Agent{Name: "alice", Workspace: "/agents/alice"}, true | |
| 116 | -} | |
| 117 | - | |
| 118 | -func (s *fakeSource) swap(conn agents.Conn) { | |
| 119 | - s.mu.Lock() | |
| 120 | - defer s.mu.Unlock() | |
| 121 | - s.conn = conn | |
| 122 | -} | |
| 123 | - | |
| 124 | -func setup(scripts map[string]script) (*conversations.Manager, *fakeSource, *fakeConn, *events.Recorder) { | |
| 125 | - conn := newFakeConn(scripts) | |
| 126 | - source := &fakeSource{conn: conn} | |
| 127 | - recorder := &events.Recorder{} | |
| 128 | - manager := conversations.NewManager(source, conversations.Options{Emitter: recorder}) | |
| 129 | - return manager, source, conn, recorder | |
| 130 | -} | |
| 131 | - | |
| 132 | -func waitIdle(t *testing.T, manager *conversations.Manager) conversations.Conversation { | |
| 133 | - t.Helper() | |
| 134 | - deadline := time.Now().Add(3 * time.Second) | |
| 135 | - for { | |
| 136 | - view := manager.Conversation("alice") | |
| 137 | - if !view.Busy && len(view.Messages) > 0 { | |
| 138 | - return view | |
| 139 | - } | |
| 140 | - if time.Now().After(deadline) { | |
| 141 | - t.Fatalf("still busy: %+v", view) | |
| 142 | - } | |
| 143 | - time.Sleep(5 * time.Millisecond) | |
| 144 | - } | |
| 145 | -} | |
| 146 | - | |
| 147 | -func waitFor(t *testing.T, condition func() bool) { | |
| 148 | - t.Helper() | |
| 149 | - deadline := time.Now().Add(3 * time.Second) | |
| 150 | - for !condition() { | |
| 151 | - if time.Now().After(deadline) { | |
| 152 | - t.Fatal("condition not met in time") | |
| 153 | - } | |
| 154 | - time.Sleep(5 * time.Millisecond) | |
| 155 | - } | |
| 156 | -} | |
| 157 | - | |
| 158 | -// ---------------------------------------------------------------- tests | |
| 159 | - | |
| 160 | -// streamHello sends "hello" to a fake agent that thinks, writes, plans and | |
| 161 | -// reports its usage and commands, and returns the finished conversation. | |
| 162 | -func streamHello(t *testing.T) (conversations.Conversation, *fakeConn, *events.Recorder) { | |
| 163 | - t.Helper() | |
| 164 | - manager, _, conn, recorder := setup(map[string]script{ | |
| 165 | - "hello": func(_ context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | |
| 166 | - h.Update(acp.Update{Kind: acp.UpdateThought, Text: "hmm"}) | |
| 167 | - h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "Bon"}) | |
| 168 | - h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "jour"}) | |
| 169 | - h.Update(acp.Update{Kind: acp.UpdatePlan, Plan: []acp.PlanEntry{{Content: "greet", Status: "completed"}}}) | |
| 170 | - h.Update(acp.Update{Kind: acp.UpdateUsage, Usage: &acp.Usage{Used: 50, Size: 1000}}) | |
| 171 | - h.Update(acp.Update{Kind: acp.UpdateCommands, Commands: []acp.Command{{Name: "compact"}}}) | |
| 172 | - return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 173 | - }, | |
| 174 | - }) | |
| 175 | - if _, err := manager.Send("alice", " hello \n"); err != nil { | |
| 176 | - t.Fatal(err) | |
| 177 | - } | |
| 178 | - return waitIdle(t, manager), conn, recorder | |
| 179 | -} | |
| 180 | - | |
| 181 | -func TestAnAnswerIsStreamedIntoParts(t *testing.T) { | |
| 182 | - view, conn, _ := streamHello(t) | |
| 183 | - if len(view.Messages) != 2 || view.Messages[0].Role != conversations.RoleUser || view.Messages[0].Parts[0].Text != "hello" { | |
| 184 | - t.Fatalf("messages: %+v", view.Messages) | |
| 185 | - } | |
| 186 | - if conn.sent()[0] != "hello" { | |
| 187 | - t.Fatalf("prompt sent: %q", conn.sent()[0]) | |
| 188 | - } | |
| 189 | - answer := view.Messages[1] | |
| 190 | - if !answer.Done || answer.StopReason != "end_turn" || len(answer.Parts) != 3 { | |
| 191 | - t.Fatalf("answer: %+v", answer) | |
| 192 | - } | |
| 193 | - if answer.Parts[0].Kind != "thought" || answer.Parts[1].Text != "Bonjour" || answer.Parts[2].Plan[0].Content != "greet" { | |
| 194 | - t.Fatalf("parts: %+v", answer.Parts) | |
| 195 | - } | |
| 196 | -} | |
| 197 | - | |
| 198 | -func TestUsageAndCommandsAreKept(t *testing.T) { | |
| 199 | - view, _, recorder := streamHello(t) | |
| 200 | - if view.Usage.Used != 50 || view.Commands[0].Name != "compact" { | |
| 201 | - t.Fatalf("usage %+v, commands %+v", view.Usage, view.Commands) | |
| 202 | - } | |
| 203 | - if len(recorder.Named(events.UsageChanged)) != 1 { | |
| 204 | - t.Fatal("usage:changed expected") | |
| 205 | - } | |
| 206 | -} | |
| 207 | - | |
| 208 | -func TestTextFragmentsAreSentAsAppends(t *testing.T) { | |
| 209 | - _, _, recorder := streamHello(t) | |
| 210 | - deltas := recorder.Named(events.MessageDelta) | |
| 211 | - if len(deltas) != 4 { | |
| 212 | - t.Fatalf("deltas: %+v", deltas) | |
| 213 | - } | |
| 214 | - if third := deltas[2].(conversations.DeltaPayload); third.Text != "jour" || third.Index != 1 || third.Part != nil { | |
| 215 | - t.Fatalf("a text fragment carries only the appended text: %+v", third) | |
| 216 | - } | |
| 217 | -} | |
| 218 | - | |
| 219 | -func TestMessagesSentDuringAnAnswerAreQueued(t *testing.T) { | |
| 220 | - release := make(chan struct{}) | |
| 221 | - manager, _, conn, recorder := setup(map[string]script{ | |
| 222 | - "first": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | |
| 223 | - <-release | |
| 224 | - return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 225 | - }, | |
| 226 | - }) | |
| 227 | - _, _ = manager.Send("alice", "first") | |
| 228 | - waitFor(t, func() bool { return len(conn.sent()) == 1 }) | |
| 229 | - _, _ = manager.Send("alice", "second") | |
| 230 | - view := manager.Conversation("alice") | |
| 231 | - if !view.Busy || view.Queued != 1 || !view.Messages[2].Queued { | |
| 232 | - t.Fatalf("second should be queued: %+v", view) | |
| 233 | - } | |
| 234 | - close(release) | |
| 235 | - view = waitIdle(t, manager) | |
| 236 | - waitFor(t, func() bool { return len(manager.Conversation("alice").Messages) == 4 }) | |
| 237 | - if fmt.Sprint(conn.sent()) != "[first second]" { | |
| 238 | - t.Fatalf("prompts: %v", conn.sent()) | |
| 239 | - } | |
| 240 | - turns := recorder.Named(events.TurnChanged) | |
| 241 | - if last := turns[len(turns)-1].(conversations.TurnPayload); last.Busy || last.Queued != 0 { | |
| 242 | - t.Fatalf("last turn event: %+v", last) | |
| 243 | - } | |
| 244 | - if conn.sessionCount() != 1 { | |
| 245 | - t.Fatalf("one session for both turns, got %d", conn.sessionCount()) | |
| 246 | - } | |
| 247 | -} | |
| 248 | - | |
| 249 | -func TestAToolCallIsMergedAndItsPermissionAnswered(t *testing.T) { | |
| 250 | - manager, _, _, recorder := setup(map[string]script{ | |
| 251 | - "tool": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | |
| 252 | - h.Update(acp.Update{Kind: acp.UpdateToolCall, ToolCall: &acp.ToolCall{ID: "c1", Title: "bash: ls", Kind: "execute", Status: "pending", Input: `{"command":"ls"}`}}) | |
| 253 | - answer := h.RequestPermission(ctx, acp.PermissionRequest{ | |
| 254 | - ToolCall: acp.ToolCall{ID: "c1"}, | |
| 255 | - Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}, {ID: "never", Kind: "reject_always"}}, | |
| 256 | - }) | |
| 257 | - if answer.OptionID != "allow" { | |
| 258 | - return acp.TurnResult{}, errors.New("refused") | |
| 259 | - } | |
| 260 | - h.Update(acp.Update{Kind: acp.UpdateToolCallUpdate, ToolCall: &acp.ToolCall{ID: "c1", Status: "completed", Output: "agent.yaml"}}) | |
| 261 | - return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 262 | - }, | |
| 263 | - }) | |
| 264 | - _, _ = manager.Send("alice", "tool") | |
| 265 | - waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == 1 }) | |
| 266 | - request := recorder.Named(events.PermissionRequested)[0].(conversations.Permission) | |
| 267 | - if request.ToolCall.Title != "bash: ls" || request.ToolCall.Input != `{"command":"ls"}` || request.MessageID == "" { | |
| 268 | - t.Fatalf("the request should be completed with the known tool call: %+v", request) | |
| 269 | - } | |
| 270 | - if len(manager.Conversation("alice").Permissions) != 1 { | |
| 271 | - t.Fatal("the pending request should be in the conversation") | |
| 272 | - } | |
| 273 | - if err := manager.AnswerPermission(request.ID, "nope"); err == nil { | |
| 274 | - t.Fatal("an unknown option must be refused") | |
| 275 | - } | |
| 276 | - if err := manager.AnswerPermission(request.ID, "allow"); err != nil { | |
| 277 | - t.Fatal(err) | |
| 278 | - } | |
| 279 | - view := waitIdle(t, manager) | |
| 280 | - tool := view.Messages[1].Parts[0].Tool | |
| 281 | - if tool.Status != "completed" || tool.Output != "agent.yaml" || tool.Title != "bash: ls" { | |
| 282 | - t.Fatalf("merged tool call: %+v", tool) | |
| 283 | - } | |
| 284 | - if len(recorder.Named(events.PermissionResolved)) != 1 || len(view.Permissions) != 0 { | |
| 285 | - t.Fatal("the request should be resolved") | |
| 286 | - } | |
| 287 | -} | |
| 288 | - | |
| 289 | -func TestRefusalsAreCountedWithinATurn(t *testing.T) { | |
| 290 | - manager, _, _, recorder := setup(map[string]script{ | |
| 291 | - "stubborn": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | |
| 292 | - for i := 0; i < 3; i++ { | |
| 293 | - h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: fmt.Sprint(i)}, Options: []acp.PermissionOption{{ID: "reject", Kind: "reject_once"}}}) | |
| 294 | - } | |
| 295 | - return acp.TurnResult{StopReason: "end_turn"}, nil | |
| 296 | - }, | |
| 297 | - }) | |
| 298 | - _, _ = manager.Send("alice", "stubborn") | |
| 299 | - for i := 0; i < 3; i++ { | |
| 300 | - waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == i+1 }) | |
| 301 | - request := recorder.Named(events.PermissionRequested)[i].(conversations.Permission) | |
| 302 | - if request.Rejections != i { | |
| 303 | - t.Fatalf("request %d: want %d previous refusals, got %d", i, i, request.Rejections) | |
| 304 | - } | |
| 305 | - _ = manager.AnswerPermission(request.ID, "reject") | |
| 306 | - } | |
| 307 | - waitIdle(t, manager) | |
| 308 | -} | |
| 309 | - | |
| 310 | -func TestCancelStopsTheAnswerAndCancelsThePermissionAndTheQueue(t *testing.T) { | |
| 311 | - manager, _, _, recorder := setup(map[string]script{ | |
| 312 | - "wait": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | |
| 313 | - h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "…"}) | |
| 314 | - answer := h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: "c"}, Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}}}) | |
| 315 | - if answer.OptionID != "" { | |
| 316 | - return acp.TurnResult{}, errors.New("a cancelled request must be answered empty") | |
| 317 | - } | |
| 318 | - <-ctx.Done() | |
| 319 | - return acp.TurnResult{StopReason: "cancelled"}, nil | |
| 320 | - }, | |
| 321 | - }) | |
| 322 | - _, _ = manager.Send("alice", "wait") | |
| 323 | - waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == 1 }) | |
| 324 | - _, _ = manager.Send("alice", "later") | |
| 325 | - manager.Cancel("alice") | |
| 326 | - view := waitIdle(t, manager) | |
| 327 | - // [user "wait", the answer, user "later" (queued, never sent)] | |
| 328 | - if answer := view.Messages[1]; answer.Role != conversations.RoleAgent || answer.StopReason != "cancelled" { | |
| 329 | - t.Fatalf("the answer should end cancelled: %+v", answer) | |
| 330 | - } | |
| 331 | - if later := view.Messages[2]; later.Parts[0].Text != "later" || later.Queued || later.Error != "cancelled" { | |
| 332 | - t.Fatalf("the queued message should be cancelled: %+v", later) | |
| 333 | - } | |
| 334 | - if len(recorder.Named(events.PermissionResolved)) != 1 { | |
| 335 | - t.Fatal("the pending permission should be resolved") | |
| 336 | - } | |
| 337 | -} | |
| 338 | - | |
| 339 | -func TestAnAgentErrorEndsTheAnswer(t *testing.T) { | |
| 340 | - manager, _, _, _ := setup(map[string]script{ | |
| 341 | - "loop": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | |
| 342 | - return acp.TurnResult{}, errors.New("exceeded maximum tool call iterations (40)") | |
| 343 | - }, | |
| 344 | - }) | |
| 345 | - _, _ = manager.Send("alice", "loop") | |
| 346 | - view := waitIdle(t, manager) | |
| 347 | - if answer := view.Messages[1]; !answer.Done || !strings.Contains(answer.Error, "maximum tool call") { | |
| 348 | - t.Fatalf("answer: %+v", answer) | |
| 349 | - } | |
| 350 | -} | |
| 351 | - | |
| 352 | -func TestAnUnavailableAgentGivesASystemMessage(t *testing.T) { | |
| 353 | - manager, source, _, _ := setup(nil) | |
| 354 | - source.fail = errors.New("too many agents running") | |
| 355 | - _, _ = manager.Send("alice", "hello") | |
| 356 | - view := waitIdle(t, manager) | |
| 357 | - if last := view.Messages[1]; last.Role != conversations.RoleSystem || last.Code != conversations.CodeAgentUnavailable || last.Error != "too many agents running" { | |
| 358 | - t.Fatalf("system message: %+v", last) | |
| 359 | - } | |
| 360 | -} | |
| 361 | - | |
| 362 | -func TestARestartedAgentGetsANewSessionAndTheUserIsTold(t *testing.T) { | |
| 363 | - manager, source, _, _ := setup(nil) | |
| 364 | - _, _ = manager.Send("alice", "one") | |
| 365 | - waitIdle(t, manager) | |
| 366 | - source.swap(newFakeConn(nil)) | |
| 367 | - _, _ = manager.Send("alice", "two") | |
| 368 | - waitFor(t, func() bool { return len(manager.Conversation("alice").Messages) == 5 }) | |
| 369 | - view := waitIdle(t, manager) | |
| 370 | - if notice := view.Messages[3]; notice.Code != conversations.CodeAgentRestarted { | |
| 371 | - t.Fatalf("want a restart notice before the answer: %+v", view.Messages) | |
| 372 | - } | |
| 373 | -} | |
| 374 | - | |
| 375 | -func TestUnknownAgentAndEmptyMessage(t *testing.T) { | |
| 376 | - manager, _, _, _ := setup(nil) | |
| 377 | - if _, err := manager.Send("nobody", "hi"); !errors.Is(err, conversations.ErrUnknownAgent) { | |
| 378 | - t.Fatalf("want ErrUnknownAgent, got %v", err) | |
| 379 | - } | |
| 380 | - if _, err := manager.Send("alice", " "); err == nil { | |
| 381 | - t.Fatal("an empty message must be refused") | |
| 382 | - } | |
| 383 | -} | |
| 384 | - | |
| 385 | -func TestResetForgetsEverything(t *testing.T) { | |
| 386 | - manager, _, _, _ := setup(nil) | |
| 387 | - _, _ = manager.Send("alice", "hello") | |
| 388 | - waitIdle(t, manager) | |
| 389 | - manager.Reset() | |
| 390 | - if view := manager.Conversation("alice"); len(view.Messages) != 0 { | |
| 391 | - t.Fatalf("got %+v", view) | |
| 392 | - } | |
| 393 | -} | |
| 394 | - | |
| 395 | -func ExampleManager_Send() { | |
| 396 | - conn := newFakeConn(nil) | |
| 397 | - manager := conversations.NewManager(&fakeSource{conn: conn}, conversations.Options{}) | |
| 398 | - _, _ = manager.Send("alice", "Bonjour") | |
| 399 | - for manager.Conversation("alice").Busy || len(manager.Conversation("alice").Messages) < 2 { | |
| 400 | - time.Sleep(time.Millisecond) | |
| 401 | - } | |
| 402 | - answer := manager.Conversation("alice").Messages[1] | |
| 403 | - fmt.Println(answer.Role, answer.Parts[0].Text, answer.StopReason) | |
| 404 | - // Output: agent ok end_turn | |
| 405 | -} | |
| deleted file mode 100644 | |||
| @@ -1,405 +0,0 @@ | |||
| 1 | -// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | - | ||
| 3 | -package conversations_test | ||
| 4 | - | ||
| 5 | -import ( | ||
| 6 | - "context" | ||
| 7 | - "errors" | ||
| 8 | - "fmt" | ||
| 9 | - "strings" | ||
| 10 | - "sync" | ||
| 11 | - "testing" | ||
| 12 | - "time" | ||
| 13 | - | ||
| 14 | - "git.rickub.com/bots-garden/sidekicks/internal/acp" | ||
| 15 | - "git.rickub.com/bots-garden/sidekicks/internal/agents" | ||
| 16 | - "git.rickub.com/bots-garden/sidekicks/internal/config" | ||
| 17 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | ||
| 18 | - "git.rickub.com/bots-garden/sidekicks/internal/events" | ||
| 19 | -) | ||
| 20 | - | ||
| 21 | -// ---------------------------------------------------------------- fakes | ||
| 22 | - | ||
| 23 | -// script is what the fake agent does with a prompt, given the session handler. | ||
| 24 | -type script func(ctx context.Context, handler acp.SessionHandler) (acp.TurnResult, error) | ||
| 25 | - | ||
| 26 | -type fakeConn struct { | ||
| 27 | - mu sync.Mutex | ||
| 28 | - handler acp.SessionHandler | ||
| 29 | - sessions int | ||
| 30 | - prompts []string | ||
| 31 | - cancelled chan struct{} | ||
| 32 | - scripts map[string]script | ||
| 33 | -} | ||
| 34 | - | ||
| 35 | -func newFakeConn(scripts map[string]script) *fakeConn { | ||
| 36 | - return &fakeConn{scripts: scripts, cancelled: make(chan struct{}, 10)} | ||
| 37 | -} | ||
| 38 | - | ||
| 39 | -func (c *fakeConn) Info() acp.Info { return acp.Info{} } | ||
| 40 | -func (c *fakeConn) Done() <-chan struct{} { return make(chan struct{}) } | ||
| 41 | -func (c *fakeConn) Err() error { return nil } | ||
| 42 | -func (c *fakeConn) Close(context.Context) error { return nil } | ||
| 43 | -func (c *fakeConn) Cancel(context.Context, string) error { | ||
| 44 | - c.cancelled <- struct{}{} | ||
| 45 | - return nil | ||
| 46 | -} | ||
| 47 | -func (c *fakeConn) NewSession(_ context.Context, cwd string, handler acp.SessionHandler) (string, error) { | ||
| 48 | - c.mu.Lock() | ||
| 49 | - defer c.mu.Unlock() | ||
| 50 | - if cwd != "/agents/alice" { | ||
| 51 | - return "", fmt.Errorf("unexpected cwd %s", cwd) | ||
| 52 | - } | ||
| 53 | - c.handler = handler | ||
| 54 | - c.sessions++ | ||
| 55 | - return fmt.Sprintf("s-%d", c.sessions), nil | ||
| 56 | -} | ||
| 57 | -func (c *fakeConn) Prompt(ctx context.Context, _ string, text string) (acp.TurnResult, error) { | ||
| 58 | - c.mu.Lock() | ||
| 59 | - c.prompts = append(c.prompts, text) | ||
| 60 | - handler, play := c.handler, c.scripts[text] | ||
| 61 | - c.mu.Unlock() | ||
| 62 | - if play == nil { | ||
| 63 | - handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: "ok"}) | ||
| 64 | - return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 65 | - } | ||
| 66 | - return play(withCancel(ctx, c.cancelled), handler) | ||
| 67 | -} | ||
| 68 | - | ||
| 69 | -func (c *fakeConn) sent() []string { | ||
| 70 | - c.mu.Lock() | ||
| 71 | - defer c.mu.Unlock() | ||
| 72 | - return append([]string(nil), c.prompts...) | ||
| 73 | -} | ||
| 74 | - | ||
| 75 | -func (c *fakeConn) sessionCount() int { | ||
| 76 | - c.mu.Lock() | ||
| 77 | - defer c.mu.Unlock() | ||
| 78 | - return c.sessions | ||
| 79 | -} | ||
| 80 | - | ||
| 81 | -// withCancel ends ctx when the fake agent receives session/cancel. | ||
| 82 | -func withCancel(parent context.Context, cancelled chan struct{}) context.Context { | ||
| 83 | - ctx, cancel := context.WithCancel(parent) | ||
| 84 | - go func() { | ||
| 85 | - select { | ||
| 86 | - case <-cancelled: | ||
| 87 | - cancel() | ||
| 88 | - case <-ctx.Done(): | ||
| 89 | - } | ||
| 90 | - }() | ||
| 91 | - return ctx | ||
| 92 | -} | ||
| 93 | - | ||
| 94 | -type fakeSource struct { | ||
| 95 | - mu sync.Mutex | ||
| 96 | - conn agents.Conn | ||
| 97 | - acquired int | ||
| 98 | - fail error | ||
| 99 | -} | ||
| 100 | - | ||
| 101 | -func (s *fakeSource) Acquire(context.Context, string) (agents.Conn, func(), error) { | ||
| 102 | - s.mu.Lock() | ||
| 103 | - defer s.mu.Unlock() | ||
| 104 | - if s.fail != nil { | ||
| 105 | - return nil, nil, s.fail | ||
| 106 | - } | ||
| 107 | - s.acquired++ | ||
| 108 | - return s.conn, func() {}, nil | ||
| 109 | -} | ||
| 110 | - | ||
| 111 | -func (s *fakeSource) Definition(name string) (config.Agent, bool) { | ||
| 112 | - if name != "alice" { | ||
| 113 | - return config.Agent{}, false | ||
| 114 | - } | ||
| 115 | - return config.Agent{Name: "alice", Workspace: "/agents/alice"}, true | ||
| 116 | -} | ||
| 117 | - | ||
| 118 | -func (s *fakeSource) swap(conn agents.Conn) { | ||
| 119 | - s.mu.Lock() | ||
| 120 | - defer s.mu.Unlock() | ||
| 121 | - s.conn = conn | ||
| 122 | -} | ||
| 123 | - | ||
| 124 | -func setup(scripts map[string]script) (*conversations.Manager, *fakeSource, *fakeConn, *events.Recorder) { | ||
| 125 | - conn := newFakeConn(scripts) | ||
| 126 | - source := &fakeSource{conn: conn} | ||
| 127 | - recorder := &events.Recorder{} | ||
| 128 | - manager := conversations.NewManager(source, conversations.Options{Emitter: recorder}) | ||
| 129 | - return manager, source, conn, recorder | ||
| 130 | -} | ||
| 131 | - | ||
| 132 | -func waitIdle(t *testing.T, manager *conversations.Manager) conversations.Conversation { | ||
| 133 | - t.Helper() | ||
| 134 | - deadline := time.Now().Add(3 * time.Second) | ||
| 135 | - for { | ||
| 136 | - view := manager.Conversation("alice") | ||
| 137 | - if !view.Busy && len(view.Messages) > 0 { | ||
| 138 | - return view | ||
| 139 | - } | ||
| 140 | - if time.Now().After(deadline) { | ||
| 141 | - t.Fatalf("still busy: %+v", view) | ||
| 142 | - } | ||
| 143 | - time.Sleep(5 * time.Millisecond) | ||
| 144 | - } | ||
| 145 | -} | ||
| 146 | - | ||
| 147 | -func waitFor(t *testing.T, condition func() bool) { | ||
| 148 | - t.Helper() | ||
| 149 | - deadline := time.Now().Add(3 * time.Second) | ||
| 150 | - for !condition() { | ||
| 151 | - if time.Now().After(deadline) { | ||
| 152 | - t.Fatal("condition not met in time") | ||
| 153 | - } | ||
| 154 | - time.Sleep(5 * time.Millisecond) | ||
| 155 | - } | ||
| 156 | -} | ||
| 157 | - | ||
| 158 | -// ---------------------------------------------------------------- tests | ||
| 159 | - | ||
| 160 | -// streamHello sends "hello" to a fake agent that thinks, writes, plans and | ||
| 161 | -// reports its usage and commands, and returns the finished conversation. | ||
| 162 | -func streamHello(t *testing.T) (conversations.Conversation, *fakeConn, *events.Recorder) { | ||
| 163 | - t.Helper() | ||
| 164 | - manager, _, conn, recorder := setup(map[string]script{ | ||
| 165 | - "hello": func(_ context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | ||
| 166 | - h.Update(acp.Update{Kind: acp.UpdateThought, Text: "hmm"}) | ||
| 167 | - h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "Bon"}) | ||
| 168 | - h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "jour"}) | ||
| 169 | - h.Update(acp.Update{Kind: acp.UpdatePlan, Plan: []acp.PlanEntry{{Content: "greet", Status: "completed"}}}) | ||
| 170 | - h.Update(acp.Update{Kind: acp.UpdateUsage, Usage: &acp.Usage{Used: 50, Size: 1000}}) | ||
| 171 | - h.Update(acp.Update{Kind: acp.UpdateCommands, Commands: []acp.Command{{Name: "compact"}}}) | ||
| 172 | - return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 173 | - }, | ||
| 174 | - }) | ||
| 175 | - if _, err := manager.Send("alice", " hello \n"); err != nil { | ||
| 176 | - t.Fatal(err) | ||
| 177 | - } | ||
| 178 | - return waitIdle(t, manager), conn, recorder | ||
| 179 | -} | ||
| 180 | - | ||
| 181 | -func TestAnAnswerIsStreamedIntoParts(t *testing.T) { | ||
| 182 | - view, conn, _ := streamHello(t) | ||
| 183 | - if len(view.Messages) != 2 || view.Messages[0].Role != conversations.RoleUser || view.Messages[0].Parts[0].Text != "hello" { | ||
| 184 | - t.Fatalf("messages: %+v", view.Messages) | ||
| 185 | - } | ||
| 186 | - if conn.sent()[0] != "hello" { | ||
| 187 | - t.Fatalf("prompt sent: %q", conn.sent()[0]) | ||
| 188 | - } | ||
| 189 | - answer := view.Messages[1] | ||
| 190 | - if !answer.Done || answer.StopReason != "end_turn" || len(answer.Parts) != 3 { | ||
| 191 | - t.Fatalf("answer: %+v", answer) | ||
| 192 | - } | ||
| 193 | - if answer.Parts[0].Kind != "thought" || answer.Parts[1].Text != "Bonjour" || answer.Parts[2].Plan[0].Content != "greet" { | ||
| 194 | - t.Fatalf("parts: %+v", answer.Parts) | ||
| 195 | - } | ||
| 196 | -} | ||
| 197 | - | ||
| 198 | -func TestUsageAndCommandsAreKept(t *testing.T) { | ||
| 199 | - view, _, recorder := streamHello(t) | ||
| 200 | - if view.Usage.Used != 50 || view.Commands[0].Name != "compact" { | ||
| 201 | - t.Fatalf("usage %+v, commands %+v", view.Usage, view.Commands) | ||
| 202 | - } | ||
| 203 | - if len(recorder.Named(events.UsageChanged)) != 1 { | ||
| 204 | - t.Fatal("usage:changed expected") | ||
| 205 | - } | ||
| 206 | -} | ||
| 207 | - | ||
| 208 | -func TestTextFragmentsAreSentAsAppends(t *testing.T) { | ||
| 209 | - _, _, recorder := streamHello(t) | ||
| 210 | - deltas := recorder.Named(events.MessageDelta) | ||
| 211 | - if len(deltas) != 4 { | ||
| 212 | - t.Fatalf("deltas: %+v", deltas) | ||
| 213 | - } | ||
| 214 | - if third := deltas[2].(conversations.DeltaPayload); third.Text != "jour" || third.Index != 1 || third.Part != nil { | ||
| 215 | - t.Fatalf("a text fragment carries only the appended text: %+v", third) | ||
| 216 | - } | ||
| 217 | -} | ||
| 218 | - | ||
| 219 | -func TestMessagesSentDuringAnAnswerAreQueued(t *testing.T) { | ||
| 220 | - release := make(chan struct{}) | ||
| 221 | - manager, _, conn, recorder := setup(map[string]script{ | ||
| 222 | - "first": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | ||
| 223 | - <-release | ||
| 224 | - return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 225 | - }, | ||
| 226 | - }) | ||
| 227 | - _, _ = manager.Send("alice", "first") | ||
| 228 | - waitFor(t, func() bool { return len(conn.sent()) == 1 }) | ||
| 229 | - _, _ = manager.Send("alice", "second") | ||
| 230 | - view := manager.Conversation("alice") | ||
| 231 | - if !view.Busy || view.Queued != 1 || !view.Messages[2].Queued { | ||
| 232 | - t.Fatalf("second should be queued: %+v", view) | ||
| 233 | - } | ||
| 234 | - close(release) | ||
| 235 | - view = waitIdle(t, manager) | ||
| 236 | - waitFor(t, func() bool { return len(manager.Conversation("alice").Messages) == 4 }) | ||
| 237 | - if fmt.Sprint(conn.sent()) != "[first second]" { | ||
| 238 | - t.Fatalf("prompts: %v", conn.sent()) | ||
| 239 | - } | ||
| 240 | - turns := recorder.Named(events.TurnChanged) | ||
| 241 | - if last := turns[len(turns)-1].(conversations.TurnPayload); last.Busy || last.Queued != 0 { | ||
| 242 | - t.Fatalf("last turn event: %+v", last) | ||
| 243 | - } | ||
| 244 | - if conn.sessionCount() != 1 { | ||
| 245 | - t.Fatalf("one session for both turns, got %d", conn.sessionCount()) | ||
| 246 | - } | ||
| 247 | -} | ||
| 248 | - | ||
| 249 | -func TestAToolCallIsMergedAndItsPermissionAnswered(t *testing.T) { | ||
| 250 | - manager, _, _, recorder := setup(map[string]script{ | ||
| 251 | - "tool": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | ||
| 252 | - h.Update(acp.Update{Kind: acp.UpdateToolCall, ToolCall: &acp.ToolCall{ID: "c1", Title: "bash: ls", Kind: "execute", Status: "pending", Input: `{"command":"ls"}`}}) | ||
| 253 | - answer := h.RequestPermission(ctx, acp.PermissionRequest{ | ||
| 254 | - ToolCall: acp.ToolCall{ID: "c1"}, | ||
| 255 | - Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}, {ID: "never", Kind: "reject_always"}}, | ||
| 256 | - }) | ||
| 257 | - if answer.OptionID != "allow" { | ||
| 258 | - return acp.TurnResult{}, errors.New("refused") | ||
| 259 | - } | ||
| 260 | - h.Update(acp.Update{Kind: acp.UpdateToolCallUpdate, ToolCall: &acp.ToolCall{ID: "c1", Status: "completed", Output: "agent.yaml"}}) | ||
| 261 | - return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 262 | - }, | ||
| 263 | - }) | ||
| 264 | - _, _ = manager.Send("alice", "tool") | ||
| 265 | - waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == 1 }) | ||
| 266 | - request := recorder.Named(events.PermissionRequested)[0].(conversations.Permission) | ||
| 267 | - if request.ToolCall.Title != "bash: ls" || request.ToolCall.Input != `{"command":"ls"}` || request.MessageID == "" { | ||
| 268 | - t.Fatalf("the request should be completed with the known tool call: %+v", request) | ||
| 269 | - } | ||
| 270 | - if len(manager.Conversation("alice").Permissions) != 1 { | ||
| 271 | - t.Fatal("the pending request should be in the conversation") | ||
| 272 | - } | ||
| 273 | - if err := manager.AnswerPermission(request.ID, "nope"); err == nil { | ||
| 274 | - t.Fatal("an unknown option must be refused") | ||
| 275 | - } | ||
| 276 | - if err := manager.AnswerPermission(request.ID, "allow"); err != nil { | ||
| 277 | - t.Fatal(err) | ||
| 278 | - } | ||
| 279 | - view := waitIdle(t, manager) | ||
| 280 | - tool := view.Messages[1].Parts[0].Tool | ||
| 281 | - if tool.Status != "completed" || tool.Output != "agent.yaml" || tool.Title != "bash: ls" { | ||
| 282 | - t.Fatalf("merged tool call: %+v", tool) | ||
| 283 | - } | ||
| 284 | - if len(recorder.Named(events.PermissionResolved)) != 1 || len(view.Permissions) != 0 { | ||
| 285 | - t.Fatal("the request should be resolved") | ||
| 286 | - } | ||
| 287 | -} | ||
| 288 | - | ||
| 289 | -func TestRefusalsAreCountedWithinATurn(t *testing.T) { | ||
| 290 | - manager, _, _, recorder := setup(map[string]script{ | ||
| 291 | - "stubborn": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | ||
| 292 | - for i := 0; i < 3; i++ { | ||
| 293 | - h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: fmt.Sprint(i)}, Options: []acp.PermissionOption{{ID: "reject", Kind: "reject_once"}}}) | ||
| 294 | - } | ||
| 295 | - return acp.TurnResult{StopReason: "end_turn"}, nil | ||
| 296 | - }, | ||
| 297 | - }) | ||
| 298 | - _, _ = manager.Send("alice", "stubborn") | ||
| 299 | - for i := 0; i < 3; i++ { | ||
| 300 | - waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == i+1 }) | ||
| 301 | - request := recorder.Named(events.PermissionRequested)[i].(conversations.Permission) | ||
| 302 | - if request.Rejections != i { | ||
| 303 | - t.Fatalf("request %d: want %d previous refusals, got %d", i, i, request.Rejections) | ||
| 304 | - } | ||
| 305 | - _ = manager.AnswerPermission(request.ID, "reject") | ||
| 306 | - } | ||
| 307 | - waitIdle(t, manager) | ||
| 308 | -} | ||
| 309 | - | ||
| 310 | -func TestCancelStopsTheAnswerAndCancelsThePermissionAndTheQueue(t *testing.T) { | ||
| 311 | - manager, _, _, recorder := setup(map[string]script{ | ||
| 312 | - "wait": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) { | ||
| 313 | - h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "…"}) | ||
| 314 | - answer := h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: "c"}, Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}}}) | ||
| 315 | - if answer.OptionID != "" { | ||
| 316 | - return acp.TurnResult{}, errors.New("a cancelled request must be answered empty") | ||
| 317 | - } | ||
| 318 | - <-ctx.Done() | ||
| 319 | - return acp.TurnResult{StopReason: "cancelled"}, nil | ||
| 320 | - }, | ||
| 321 | - }) | ||
| 322 | - _, _ = manager.Send("alice", "wait") | ||
| 323 | - waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == 1 }) | ||
| 324 | - _, _ = manager.Send("alice", "later") | ||
| 325 | - manager.Cancel("alice") | ||
| 326 | - view := waitIdle(t, manager) | ||
| 327 | - // [user "wait", the answer, user "later" (queued, never sent)] | ||
| 328 | - if answer := view.Messages[1]; answer.Role != conversations.RoleAgent || answer.StopReason != "cancelled" { | ||
| 329 | - t.Fatalf("the answer should end cancelled: %+v", answer) | ||
| 330 | - } | ||
| 331 | - if later := view.Messages[2]; later.Parts[0].Text != "later" || later.Queued || later.Error != "cancelled" { | ||
| 332 | - t.Fatalf("the queued message should be cancelled: %+v", later) | ||
| 333 | - } | ||
| 334 | - if len(recorder.Named(events.PermissionResolved)) != 1 { | ||
| 335 | - t.Fatal("the pending permission should be resolved") | ||
| 336 | - } | ||
| 337 | -} | ||
| 338 | - | ||
| 339 | -func TestAnAgentErrorEndsTheAnswer(t *testing.T) { | ||
| 340 | - manager, _, _, _ := setup(map[string]script{ | ||
| 341 | - "loop": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) { | ||
| 342 | - return acp.TurnResult{}, errors.New("exceeded maximum tool call iterations (40)") | ||
| 343 | - }, | ||
| 344 | - }) | ||
| 345 | - _, _ = manager.Send("alice", "loop") | ||
| 346 | - view := waitIdle(t, manager) | ||
| 347 | - if answer := view.Messages[1]; !answer.Done || !strings.Contains(answer.Error, "maximum tool call") { | ||
| 348 | - t.Fatalf("answer: %+v", answer) | ||
| 349 | - } | ||
| 350 | -} | ||
| 351 | - | ||
| 352 | -func TestAnUnavailableAgentGivesASystemMessage(t *testing.T) { | ||
| 353 | - manager, source, _, _ := setup(nil) | ||
| 354 | - source.fail = errors.New("too many agents running") | ||
| 355 | - _, _ = manager.Send("alice", "hello") | ||
| 356 | - view := waitIdle(t, manager) | ||
| 357 | - if last := view.Messages[1]; last.Role != conversations.RoleSystem || last.Code != conversations.CodeAgentUnavailable || last.Error != "too many agents running" { | ||
| 358 | - t.Fatalf("system message: %+v", last) | ||
| 359 | - } | ||
| 360 | -} | ||
| 361 | - | ||
| 362 | -func TestARestartedAgentGetsANewSessionAndTheUserIsTold(t *testing.T) { | ||
| 363 | - manager, source, _, _ := setup(nil) | ||
| 364 | - _, _ = manager.Send("alice", "one") | ||
| 365 | - waitIdle(t, manager) | ||
| 366 | - source.swap(newFakeConn(nil)) | ||
| 367 | - _, _ = manager.Send("alice", "two") | ||
| 368 | - waitFor(t, func() bool { return len(manager.Conversation("alice").Messages) == 5 }) | ||
| 369 | - view := waitIdle(t, manager) | ||
| 370 | - if notice := view.Messages[3]; notice.Code != conversations.CodeAgentRestarted { | ||
| 371 | - t.Fatalf("want a restart notice before the answer: %+v", view.Messages) | ||
| 372 | - } | ||
| 373 | -} | ||
| 374 | - | ||
| 375 | -func TestUnknownAgentAndEmptyMessage(t *testing.T) { | ||
| 376 | - manager, _, _, _ := setup(nil) | ||
| 377 | - if _, err := manager.Send("nobody", "hi"); !errors.Is(err, conversations.ErrUnknownAgent) { | ||
| 378 | - t.Fatalf("want ErrUnknownAgent, got %v", err) | ||
| 379 | - } | ||
| 380 | - if _, err := manager.Send("alice", " "); err == nil { | ||
| 381 | - t.Fatal("an empty message must be refused") | ||
| 382 | - } | ||
| 383 | -} | ||
| 384 | - | ||
| 385 | -func TestResetForgetsEverything(t *testing.T) { | ||
| 386 | - manager, _, _, _ := setup(nil) | ||
| 387 | - _, _ = manager.Send("alice", "hello") | ||
| 388 | - waitIdle(t, manager) | ||
| 389 | - manager.Reset() | ||
| 390 | - if view := manager.Conversation("alice"); len(view.Messages) != 0 { | ||
| 391 | - t.Fatalf("got %+v", view) | ||
| 392 | - } | ||
| 393 | -} | ||
| 394 | - | ||
| 395 | -func ExampleManager_Send() { | ||
| 396 | - conn := newFakeConn(nil) | ||
| 397 | - manager := conversations.NewManager(&fakeSource{conn: conn}, conversations.Options{}) | ||
| 398 | - _, _ = manager.Send("alice", "Bonjour") | ||
| 399 | - for manager.Conversation("alice").Busy || len(manager.Conversation("alice").Messages) < 2 { | ||
| 400 | - time.Sleep(time.Millisecond) | ||
| 401 | - } | ||
| 402 | - answer := manager.Conversation("alice").Messages[1] | ||
| 403 | - fmt.Println(answer.Role, answer.Parts[0].Text, answer.StopReason) | ||
| 404 | - // Output: agent ok end_turn | ||
| 405 | -} | ||
deleted
internal/conversations/realagents_test.go +0 -90 | deleted file mode 100644 | ||
| @@ -1,90 +0,0 @@ | ||
| 1 | -// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | - | |
| 3 | -//go:build realagents | |
| 4 | - | |
| 5 | -// Conversations with the real mm test agents, started through | |
| 6 | -// scripts/agents.sh (two at most, model unloaded at exit). Run with: | |
| 7 | -// | |
| 8 | -// go test -tags realagents -count=1 ./internal/conversations | |
| 9 | -package conversations_test | |
| 10 | - | |
| 11 | -import ( | |
| 12 | - "context" | |
| 13 | - "path/filepath" | |
| 14 | - "strings" | |
| 15 | - "testing" | |
| 16 | - "time" | |
| 17 | - | |
| 18 | - "git.rickub.com/bots-garden/sidekicks/internal/agents" | |
| 19 | - "git.rickub.com/bots-garden/sidekicks/internal/config" | |
| 20 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | |
| 21 | - "git.rickub.com/bots-garden/sidekicks/internal/events" | |
| 22 | -) | |
| 23 | - | |
| 24 | -func realManager(t *testing.T, names ...string) (*conversations.Manager, *events.Recorder) { | |
| 25 | - t.Helper() | |
| 26 | - root, _ := filepath.Abs("../..") | |
| 27 | - var definitions []config.Agent | |
| 28 | - for _, name := range names { | |
| 29 | - definitions = append(definitions, config.Agent{ | |
| 30 | - Name: name, Title: name, Command: filepath.Join(root, "scripts", "agents.sh"), | |
| 31 | - Args: []string{"run", name}, Workspace: filepath.Join(root, "agents", name), | |
| 32 | - }) | |
| 33 | - } | |
| 34 | - recorder := &events.Recorder{} | |
| 35 | - agentManager := agents.NewManager(definitions, agents.Options{MaxRunning: 2}) | |
| 36 | - t.Cleanup(func() { agentManager.StopAll(context.Background()) }) | |
| 37 | - return conversations.NewManager(agentManager, conversations.Options{Emitter: recorder}), recorder | |
| 38 | -} | |
| 39 | - | |
| 40 | -func waitAnswer(t *testing.T, manager *conversations.Manager, agent string, messages int) conversations.Conversation { | |
| 41 | - t.Helper() | |
| 42 | - deadline := time.Now().Add(4 * time.Minute) | |
| 43 | - for time.Now().Before(deadline) { | |
| 44 | - view := manager.Conversation(agent) | |
| 45 | - if !view.Busy && len(view.Messages) >= messages { | |
| 46 | - return view | |
| 47 | - } | |
| 48 | - time.Sleep(200 * time.Millisecond) | |
| 49 | - } | |
| 50 | - t.Fatal("no answer in time") | |
| 51 | - return conversations.Conversation{} | |
| 52 | -} | |
| 53 | - | |
| 54 | -func TestAliceAnswersAndRemembersTheConversation(t *testing.T) { | |
| 55 | - manager, _ := realManager(t, "alice") | |
| 56 | - _, _ = manager.Send("alice", "Je m'appelle Philippe. Réponds juste « Bonjour Philippe ».") | |
| 57 | - view := waitAnswer(t, manager, "alice", 2) | |
| 58 | - if answer := view.Messages[1]; answer.StopReason != "end_turn" || !strings.Contains(answer.Parts[len(answer.Parts)-1].Text, "Philippe") { | |
| 59 | - t.Fatalf("answer: %+v", answer) | |
| 60 | - } | |
| 61 | - _, _ = manager.Send("alice", "Comment je m'appelle ? Un seul mot.") | |
| 62 | - view = waitAnswer(t, manager, "alice", 4) | |
| 63 | - if answer := view.Messages[3]; !strings.Contains(answer.Parts[len(answer.Parts)-1].Text, "Philippe") { | |
| 64 | - t.Fatalf("the same session should remember the name: %+v", answer) | |
| 65 | - } | |
| 66 | -} | |
| 67 | - | |
| 68 | -func TestRikerAsksPermissionBeforeRunningACommand(t *testing.T) { | |
| 69 | - manager, recorder := realManager(t, "riker") | |
| 70 | - _, _ = manager.Send("riker", "Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.") | |
| 71 | - deadline := time.Now().Add(4 * time.Minute) | |
| 72 | - for len(recorder.Named(events.PermissionRequested)) == 0 && time.Now().Before(deadline) { | |
| 73 | - time.Sleep(200 * time.Millisecond) | |
| 74 | - } | |
| 75 | - requests := recorder.Named(events.PermissionRequested) | |
| 76 | - if len(requests) == 0 { | |
| 77 | - t.Fatal("riker should ask for permission") | |
| 78 | - } | |
| 79 | - request := requests[0].(conversations.Permission) | |
| 80 | - if request.ToolCall.Kind != "execute" || !strings.Contains(request.ToolCall.Input, "ls") { | |
| 81 | - t.Fatalf("request: %+v", request) | |
| 82 | - } | |
| 83 | - if err := manager.AnswerPermission(request.ID, "allow"); err != nil { | |
| 84 | - t.Fatal(err) | |
| 85 | - } | |
| 86 | - view := waitAnswer(t, manager, "riker", 2) | |
| 87 | - if answer := view.Messages[1]; answer.StopReason != "end_turn" { | |
| 88 | - t.Fatalf("answer: %+v", answer) | |
| 89 | - } | |
| 90 | -} | |
| deleted file mode 100644 | |||
| @@ -1,90 +0,0 @@ | |||
| 1 | -// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | - | ||
| 3 | -//go:build realagents | ||
| 4 | - | ||
| 5 | -// Conversations with the real mm test agents, started through | ||
| 6 | -// scripts/agents.sh (two at most, model unloaded at exit). Run with: | ||
| 7 | -// | ||
| 8 | -// go test -tags realagents -count=1 ./internal/conversations | ||
| 9 | -package conversations_test | ||
| 10 | - | ||
| 11 | -import ( | ||
| 12 | - "context" | ||
| 13 | - "path/filepath" | ||
| 14 | - "strings" | ||
| 15 | - "testing" | ||
| 16 | - "time" | ||
| 17 | - | ||
| 18 | - "git.rickub.com/bots-garden/sidekicks/internal/agents" | ||
| 19 | - "git.rickub.com/bots-garden/sidekicks/internal/config" | ||
| 20 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | ||
| 21 | - "git.rickub.com/bots-garden/sidekicks/internal/events" | ||
| 22 | -) | ||
| 23 | - | ||
| 24 | -func realManager(t *testing.T, names ...string) (*conversations.Manager, *events.Recorder) { | ||
| 25 | - t.Helper() | ||
| 26 | - root, _ := filepath.Abs("../..") | ||
| 27 | - var definitions []config.Agent | ||
| 28 | - for _, name := range names { | ||
| 29 | - definitions = append(definitions, config.Agent{ | ||
| 30 | - Name: name, Title: name, Command: filepath.Join(root, "scripts", "agents.sh"), | ||
| 31 | - Args: []string{"run", name}, Workspace: filepath.Join(root, "agents", name), | ||
| 32 | - }) | ||
| 33 | - } | ||
| 34 | - recorder := &events.Recorder{} | ||
| 35 | - agentManager := agents.NewManager(definitions, agents.Options{MaxRunning: 2}) | ||
| 36 | - t.Cleanup(func() { agentManager.StopAll(context.Background()) }) | ||
| 37 | - return conversations.NewManager(agentManager, conversations.Options{Emitter: recorder}), recorder | ||
| 38 | -} | ||
| 39 | - | ||
| 40 | -func waitAnswer(t *testing.T, manager *conversations.Manager, agent string, messages int) conversations.Conversation { | ||
| 41 | - t.Helper() | ||
| 42 | - deadline := time.Now().Add(4 * time.Minute) | ||
| 43 | - for time.Now().Before(deadline) { | ||
| 44 | - view := manager.Conversation(agent) | ||
| 45 | - if !view.Busy && len(view.Messages) >= messages { | ||
| 46 | - return view | ||
| 47 | - } | ||
| 48 | - time.Sleep(200 * time.Millisecond) | ||
| 49 | - } | ||
| 50 | - t.Fatal("no answer in time") | ||
| 51 | - return conversations.Conversation{} | ||
| 52 | -} | ||
| 53 | - | ||
| 54 | -func TestAliceAnswersAndRemembersTheConversation(t *testing.T) { | ||
| 55 | - manager, _ := realManager(t, "alice") | ||
| 56 | - _, _ = manager.Send("alice", "Je m'appelle Philippe. Réponds juste « Bonjour Philippe ».") | ||
| 57 | - view := waitAnswer(t, manager, "alice", 2) | ||
| 58 | - if answer := view.Messages[1]; answer.StopReason != "end_turn" || !strings.Contains(answer.Parts[len(answer.Parts)-1].Text, "Philippe") { | ||
| 59 | - t.Fatalf("answer: %+v", answer) | ||
| 60 | - } | ||
| 61 | - _, _ = manager.Send("alice", "Comment je m'appelle ? Un seul mot.") | ||
| 62 | - view = waitAnswer(t, manager, "alice", 4) | ||
| 63 | - if answer := view.Messages[3]; !strings.Contains(answer.Parts[len(answer.Parts)-1].Text, "Philippe") { | ||
| 64 | - t.Fatalf("the same session should remember the name: %+v", answer) | ||
| 65 | - } | ||
| 66 | -} | ||
| 67 | - | ||
| 68 | -func TestRikerAsksPermissionBeforeRunningACommand(t *testing.T) { | ||
| 69 | - manager, recorder := realManager(t, "riker") | ||
| 70 | - _, _ = manager.Send("riker", "Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.") | ||
| 71 | - deadline := time.Now().Add(4 * time.Minute) | ||
| 72 | - for len(recorder.Named(events.PermissionRequested)) == 0 && time.Now().Before(deadline) { | ||
| 73 | - time.Sleep(200 * time.Millisecond) | ||
| 74 | - } | ||
| 75 | - requests := recorder.Named(events.PermissionRequested) | ||
| 76 | - if len(requests) == 0 { | ||
| 77 | - t.Fatal("riker should ask for permission") | ||
| 78 | - } | ||
| 79 | - request := requests[0].(conversations.Permission) | ||
| 80 | - if request.ToolCall.Kind != "execute" || !strings.Contains(request.ToolCall.Input, "ls") { | ||
| 81 | - t.Fatalf("request: %+v", request) | ||
| 82 | - } | ||
| 83 | - if err := manager.AnswerPermission(request.ID, "allow"); err != nil { | ||
| 84 | - t.Fatal(err) | ||
| 85 | - } | ||
| 86 | - view := waitAnswer(t, manager, "riker", 2) | ||
| 87 | - if answer := view.Messages[1]; answer.StopReason != "end_turn" { | ||
| 88 | - t.Fatalf("answer: %+v", answer) | ||
| 89 | - } | ||
| 90 | -} | ||
modified
internal/events/events.go +12 -8 | @@ -22,21 +22,25 @@ const ( | ||
| 22 | 22 | // AgentLog: an agent wrote a line on its standard error. Payload: AgentLogPayload. |
| 23 | 23 | AgentLog = "agent:log" |
| 24 | 24 | |
| 25 | - // The conversation events (milestone M2). Their payloads are defined in | |
| 26 | - // internal/conversations, next to the conversation types they carry. | |
| 25 | + // The channel events (milestones M2 and M3). Their payloads are defined in | |
| 26 | + // internal/channels, next to the types they carry. | |
| 27 | 27 | |
| 28 | - // MessageUpdated: a message was added or finished. Payload: conversations.MessagePayload. | |
| 28 | + // ChannelsChanged: a channel was created, renamed, deleted, or its members changed. Payload: channels.ListPayload. | |
| 29 | + ChannelsChanged = "channels:changed" | |
| 30 | + // MessageUpdated: a message was added or finished. Payload: channels.MessagePayload. | |
| 29 | 31 | MessageUpdated = "message:updated" |
| 30 | - // MessageDelta: a streaming message changed. Payload: conversations.DeltaPayload. | |
| 32 | + // MessageDelta: a streaming message changed. Payload: channels.DeltaPayload. | |
| 31 | 33 | MessageDelta = "message:delta" |
| 32 | - // TurnChanged: an agent started or stopped answering. Payload: conversations.TurnPayload. | |
| 34 | + // TurnChanged: a channel started or stopped answering, or another agent's turn began. Payload: channels.TurnPayload. | |
| 33 | 35 | TurnChanged = "turn:changed" |
| 34 | - // PermissionRequested: an agent asks to run a tool. Payload: conversations.Permission. | |
| 36 | + // PermissionRequested: an agent asks to run a tool. Payload: channels.Permission. | |
| 35 | 37 | PermissionRequested = "permission:requested" |
| 36 | - // PermissionResolved: a permission request was answered or cancelled. Payload: conversations.PermissionResolvedPayload. | |
| 38 | + // PermissionResolved: a permission request was answered or cancelled. Payload: channels.PermissionResolvedPayload. | |
| 37 | 39 | PermissionResolved = "permission:resolved" |
| 38 | - // UsageChanged: the context window use of a conversation changed. Payload: conversations.UsagePayload. | |
| 40 | + // UsageChanged: the context window use of an agent in a channel changed. Payload: channels.UsagePayload. | |
| 39 | 41 | UsageChanged = "usage:changed" |
| 42 | + // CommandsChanged: an agent announced its slash commands. Payload: channels.CommandsPayload. | |
| 43 | + CommandsChanged = "commands:changed" | |
| 40 | 44 | ) |
| 41 | 45 | |
| 42 | 46 | // ConfigChangedPayload tells the interface which configuration is loaded and |
| @@ -22,21 +22,25 @@ const ( | |||
| 22 | // AgentLog: an agent wrote a line on its standard error. Payload: AgentLogPayload. | 22 | // AgentLog: an agent wrote a line on its standard error. Payload: AgentLogPayload. |
| 23 | AgentLog = "agent:log" | 23 | AgentLog = "agent:log" |
| 24 | 24 | ||
| 25 | - // The conversation events (milestone M2). Their payloads are defined in | 25 | + // The channel events (milestones M2 and M3). Their payloads are defined in |
| 26 | - // internal/conversations, next to the conversation types they carry. | 26 | + // internal/channels, next to the types they carry. |
| 27 | 27 | ||
| 28 | - // MessageUpdated: a message was added or finished. Payload: conversations.MessagePayload. | 28 | + // ChannelsChanged: a channel was created, renamed, deleted, or its members changed. Payload: channels.ListPayload. |
| 29 | + ChannelsChanged = "channels:changed" | ||
| 30 | + // MessageUpdated: a message was added or finished. Payload: channels.MessagePayload. | ||
| 29 | MessageUpdated = "message:updated" | 31 | MessageUpdated = "message:updated" |
| 30 | - // MessageDelta: a streaming message changed. Payload: conversations.DeltaPayload. | 32 | + // MessageDelta: a streaming message changed. Payload: channels.DeltaPayload. |
| 31 | MessageDelta = "message:delta" | 33 | MessageDelta = "message:delta" |
| 32 | - // TurnChanged: an agent started or stopped answering. Payload: conversations.TurnPayload. | 34 | + // TurnChanged: a channel started or stopped answering, or another agent's turn began. Payload: channels.TurnPayload. |
| 33 | TurnChanged = "turn:changed" | 35 | TurnChanged = "turn:changed" |
| 34 | - // PermissionRequested: an agent asks to run a tool. Payload: conversations.Permission. | 36 | + // PermissionRequested: an agent asks to run a tool. Payload: channels.Permission. |
| 35 | PermissionRequested = "permission:requested" | 37 | PermissionRequested = "permission:requested" |
| 36 | - // PermissionResolved: a permission request was answered or cancelled. Payload: conversations.PermissionResolvedPayload. | 38 | + // PermissionResolved: a permission request was answered or cancelled. Payload: channels.PermissionResolvedPayload. |
| 37 | PermissionResolved = "permission:resolved" | 39 | PermissionResolved = "permission:resolved" |
| 38 | - // UsageChanged: the context window use of a conversation changed. Payload: conversations.UsagePayload. | 40 | + // UsageChanged: the context window use of an agent in a channel changed. Payload: channels.UsagePayload. |
| 39 | UsageChanged = "usage:changed" | 41 | UsageChanged = "usage:changed" |
| 42 | + // CommandsChanged: an agent announced its slash commands. Payload: channels.CommandsPayload. | ||
| 43 | + CommandsChanged = "commands:changed" | ||
| 40 | ) | 44 | ) |
| 41 | 45 | ||
| 42 | // ConfigChangedPayload tells the interface which configuration is loaded and | 46 | // ConfigChangedPayload tells the interface which configuration is loaded and |
added
internal/store/id.go +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +package store | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "crypto/rand" | |
| 7 | + "encoding/hex" | |
| 8 | + "fmt" | |
| 9 | + "sync" | |
| 10 | + "time" | |
| 11 | +) | |
| 12 | + | |
| 13 | +var ids struct { | |
| 14 | + sync.Mutex | |
| 15 | + lastMillis int64 | |
| 16 | + counter uint32 | |
| 17 | +} | |
| 18 | + | |
| 19 | +// NewID returns a unique id that sorts by creation time: 12 hex digits of | |
| 20 | +// milliseconds, 6 of a counter (several ids in the same millisecond keep their | |
| 21 | +// order) and 6 random ones (two processes never collide). | |
| 22 | +// | |
| 23 | +// a, b := store.NewID(), store.NewID() // a < b | |
| 24 | +func NewID() string { | |
| 25 | + return newID(time.Now()) | |
| 26 | +} | |
| 27 | + | |
| 28 | +func newID(now time.Time) string { | |
| 29 | + ids.Lock() | |
| 30 | + millis := now.UnixMilli() | |
| 31 | + if millis <= ids.lastMillis { | |
| 32 | + millis = ids.lastMillis // the clock went back, or the same millisecond | |
| 33 | + ids.counter++ | |
| 34 | + } else { | |
| 35 | + ids.lastMillis, ids.counter = millis, 0 | |
| 36 | + } | |
| 37 | + counter := ids.counter | |
| 38 | + ids.Unlock() | |
| 39 | + random := make([]byte, 3) | |
| 40 | + _, _ = rand.Read(random) | |
| 41 | + return fmt.Sprintf("%012x%06x%s", millis, counter&0xffffff, hex.EncodeToString(random)) | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +package store | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "crypto/rand" | ||
| 7 | + "encoding/hex" | ||
| 8 | + "fmt" | ||
| 9 | + "sync" | ||
| 10 | + "time" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +var ids struct { | ||
| 14 | + sync.Mutex | ||
| 15 | + lastMillis int64 | ||
| 16 | + counter uint32 | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +// NewID returns a unique id that sorts by creation time: 12 hex digits of | ||
| 20 | +// milliseconds, 6 of a counter (several ids in the same millisecond keep their | ||
| 21 | +// order) and 6 random ones (two processes never collide). | ||
| 22 | +// | ||
| 23 | +// a, b := store.NewID(), store.NewID() // a < b | ||
| 24 | +func NewID() string { | ||
| 25 | + return newID(time.Now()) | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +func newID(now time.Time) string { | ||
| 29 | + ids.Lock() | ||
| 30 | + millis := now.UnixMilli() | ||
| 31 | + if millis <= ids.lastMillis { | ||
| 32 | + millis = ids.lastMillis // the clock went back, or the same millisecond | ||
| 33 | + ids.counter++ | ||
| 34 | + } else { | ||
| 35 | + ids.lastMillis, ids.counter = millis, 0 | ||
| 36 | + } | ||
| 37 | + counter := ids.counter | ||
| 38 | + ids.Unlock() | ||
| 39 | + random := make([]byte, 3) | ||
| 40 | + _, _ = rand.Read(random) | ||
| 41 | + return fmt.Sprintf("%012x%06x%s", millis, counter&0xffffff, hex.EncodeToString(random)) | ||
| 42 | +} | ||
added
internal/store/store.go +503 -0 | new file mode 100644 | ||
| @@ -0,0 +1,503 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +// Package store keeps what happens in Sidekicks — channels, their members and | |
| 4 | +// their messages — in a Pebble database, one per configuration file (ADR 0003, | |
| 5 | +// "What is stored where"). It knows nothing about ACP: messages are stored as | |
| 6 | +// opaque bytes that internal/channels encodes. | |
| 7 | +// | |
| 8 | +// dir, _ := store.DirFor("/Users/me/sidekicks.yaml") | |
| 9 | +// db, _ := store.Open(dir) | |
| 10 | +// defer db.Close() | |
| 11 | +// channel := store.Channel{ID: store.NewID(), Name: "general"} | |
| 12 | +// _ = db.CreateChannel(channel) | |
| 13 | +// _ = db.PutMessage(channel.ID, store.NewID(), []byte(`{"text":"hello"}`)) | |
| 14 | +package store | |
| 15 | + | |
| 16 | +import ( | |
| 17 | + "bytes" | |
| 18 | + "crypto/sha256" | |
| 19 | + "encoding/hex" | |
| 20 | + "encoding/json" | |
| 21 | + "errors" | |
| 22 | + "fmt" | |
| 23 | + "os" | |
| 24 | + "path/filepath" | |
| 25 | + "sort" | |
| 26 | + "strings" | |
| 27 | + "sync" | |
| 28 | + "time" | |
| 29 | + | |
| 30 | + "github.com/cockroachdb/pebble/v2" | |
| 31 | + "github.com/cockroachdb/pebble/v2/vfs" | |
| 32 | +) | |
| 33 | + | |
| 34 | +// SchemaVersion is the version of the key layout below. A database written by | |
| 35 | +// a newer Sidekicks is refused rather than misread. | |
| 36 | +// | |
| 37 | +// meta/version → SchemaVersion | |
| 38 | +// channel/<channelId> → Channel (JSON) | |
| 39 | +// channel-name/<lowercased name> → channelId (uniqueness, lookup) | |
| 40 | +// direct/<agent> → channelId of the direct conversation with agent | |
| 41 | +// member/<channelId>/<agent> → Membership (JSON) | |
| 42 | +// msg/<channelId>/<messageId> → message (opaque bytes) | |
| 43 | +// value/<key> → opaque bytes (caches, e.g. an agent's commands) | |
| 44 | +const SchemaVersion = 1 | |
| 45 | + | |
| 46 | +// Errors returned by the store. | |
| 47 | +var ( | |
| 48 | + ErrNotFound = errors.New("not found") | |
| 49 | + ErrNameTaken = errors.New("a channel already has this name") | |
| 50 | + ErrClosed = errors.New("the history is closed") | |
| 51 | +) | |
| 52 | + | |
| 53 | +// Channel is a place where the user talks with one or several agents. A | |
| 54 | +// direct conversation with one agent is a channel whose Direct field names | |
| 55 | +// that agent; it has no name. | |
| 56 | +type Channel struct { | |
| 57 | + ID string `json:"id"` | |
| 58 | + Name string `json:"name"` | |
| 59 | + Description string `json:"description,omitempty"` | |
| 60 | + Direct string `json:"direct,omitempty"` | |
| 61 | + CreatedAt time.Time `json:"createdAt"` | |
| 62 | + UpdatedAt time.Time `json:"updatedAt"` | |
| 63 | +} | |
| 64 | + | |
| 65 | +// Membership is an agent taking part in a channel. | |
| 66 | +type Membership struct { | |
| 67 | + ChannelID string `json:"channelId"` | |
| 68 | + Agent string `json:"agent"` | |
| 69 | + JoinedAt time.Time `json:"joinedAt"` | |
| 70 | + // SessionID is the agent's ACP session for this channel, for agents that | |
| 71 | + // can resume one (session/load). | |
| 72 | + SessionID string `json:"sessionId,omitempty"` | |
| 73 | + // LastSeen is the id of the last channel message the agent has seen. | |
| 74 | + LastSeen string `json:"lastSeen,omitempty"` | |
| 75 | +} | |
| 76 | + | |
| 77 | +// Store is a Pebble database. It is safe for concurrent use. | |
| 78 | +type Store struct { | |
| 79 | + db *pebble.DB | |
| 80 | + // mu makes the check-then-write of channel names atomic. | |
| 81 | + mu sync.Mutex | |
| 82 | + // life lets every operation finish before Close, and refuses the ones | |
| 83 | + // after it (an answer may end while Sidekicks quits): Pebble panics | |
| 84 | + // when used once closed. | |
| 85 | + life sync.RWMutex | |
| 86 | + closed bool | |
| 87 | +} | |
| 88 | + | |
| 89 | +// DirFor returns the folder of the database of the configuration file at | |
| 90 | +// configPath: <user config dir>/sidekicks/data/<hash of the path>. Two | |
| 91 | +// configuration files never share their history. | |
| 92 | +func DirFor(configPath string) (string, error) { | |
| 93 | + base, err := os.UserConfigDir() | |
| 94 | + if err != nil { | |
| 95 | + return "", err | |
| 96 | + } | |
| 97 | + absolute, err := filepath.Abs(configPath) | |
| 98 | + if err != nil { | |
| 99 | + return "", err | |
| 100 | + } | |
| 101 | + sum := sha256.Sum256([]byte(absolute)) | |
| 102 | + return filepath.Join(base, "sidekicks", "data", hex.EncodeToString(sum[:8])), nil | |
| 103 | +} | |
| 104 | + | |
| 105 | +// Open opens (or creates) the database in dir. | |
| 106 | +func Open(dir string) (*Store, error) { | |
| 107 | + return open(dir, &pebble.Options{Logger: quiet{}}) | |
| 108 | +} | |
| 109 | + | |
| 110 | +// OpenInMemory returns an empty database that lives in memory only: for | |
| 111 | +// tests, and when the database on disk cannot be opened. | |
| 112 | +func OpenInMemory() (*Store, error) { | |
| 113 | + return open("", &pebble.Options{FS: vfs.NewMem(), Logger: quiet{}}) | |
| 114 | +} | |
| 115 | + | |
| 116 | +func open(dir string, options *pebble.Options) (*Store, error) { | |
| 117 | + db, err := pebble.Open(dir, options) | |
| 118 | + if err != nil { | |
| 119 | + return nil, err | |
| 120 | + } | |
| 121 | + store := &Store{db: db} | |
| 122 | + if err := store.checkVersion(); err != nil { | |
| 123 | + _ = db.Close() | |
| 124 | + return nil, err | |
| 125 | + } | |
| 126 | + return store, nil | |
| 127 | +} | |
| 128 | + | |
| 129 | +func (s *Store) checkVersion() error { | |
| 130 | + var version int | |
| 131 | + found, err := s.getJSON("meta/version", &version) | |
| 132 | + if err != nil { | |
| 133 | + return err | |
| 134 | + } | |
| 135 | + if !found { | |
| 136 | + return s.setJSON("meta/version", SchemaVersion) | |
| 137 | + } | |
| 138 | + if version > SchemaVersion { | |
| 139 | + return fmt.Errorf("the history was written by a newer Sidekicks (schema %d, this one reads %d)", version, SchemaVersion) | |
| 140 | + } | |
| 141 | + return nil | |
| 142 | +} | |
| 143 | + | |
| 144 | +// Close closes the database. Later operations return ErrClosed. | |
| 145 | +func (s *Store) Close() error { | |
| 146 | + s.life.Lock() | |
| 147 | + defer s.life.Unlock() | |
| 148 | + if s.closed { | |
| 149 | + return nil | |
| 150 | + } | |
| 151 | + s.closed = true | |
| 152 | + return s.db.Close() | |
| 153 | +} | |
| 154 | + | |
| 155 | +// enter starts an operation; leave must follow when it returns nil. | |
| 156 | +func (s *Store) enter() error { | |
| 157 | + s.life.RLock() | |
| 158 | + if s.closed { | |
| 159 | + s.life.RUnlock() | |
| 160 | + return ErrClosed | |
| 161 | + } | |
| 162 | + return nil | |
| 163 | +} | |
| 164 | + | |
| 165 | +func (s *Store) leave() { s.life.RUnlock() } | |
| 166 | + | |
| 167 | +// ---------------------------------------------------------------- channels | |
| 168 | + | |
| 169 | +// Channels returns every channel, direct conversations included, oldest first. | |
| 170 | +func (s *Store) Channels() ([]Channel, error) { | |
| 171 | + if err := s.enter(); err != nil { | |
| 172 | + return nil, err | |
| 173 | + } | |
| 174 | + defer s.leave() | |
| 175 | + channels := []Channel{} | |
| 176 | + err := s.scan("channel/", func(_, value []byte) error { | |
| 177 | + var channel Channel | |
| 178 | + if err := json.Unmarshal(value, &channel); err != nil { | |
| 179 | + return err | |
| 180 | + } | |
| 181 | + channels = append(channels, channel) | |
| 182 | + return nil | |
| 183 | + }) | |
| 184 | + sort.SliceStable(channels, func(i, j int) bool { return channels[i].ID < channels[j].ID }) | |
| 185 | + return channels, err | |
| 186 | +} | |
| 187 | + | |
| 188 | +// Channel returns the channel with id, or ErrNotFound. | |
| 189 | +func (s *Store) Channel(id string) (Channel, error) { | |
| 190 | + if err := s.enter(); err != nil { | |
| 191 | + return Channel{}, err | |
| 192 | + } | |
| 193 | + defer s.leave() | |
| 194 | + return s.channel(id) | |
| 195 | +} | |
| 196 | + | |
| 197 | +// channel reads a channel without entering: for the operations that have. | |
| 198 | +func (s *Store) channel(id string) (Channel, error) { | |
| 199 | + var channel Channel | |
| 200 | + found, err := s.getJSON("channel/"+id, &channel) | |
| 201 | + if err == nil && !found { | |
| 202 | + err = fmt.Errorf("channel %s: %w", id, ErrNotFound) | |
| 203 | + } | |
| 204 | + return channel, err | |
| 205 | +} | |
| 206 | + | |
| 207 | +// ChannelByName returns the channel called name (case-insensitive), or ErrNotFound. | |
| 208 | +func (s *Store) ChannelByName(name string) (Channel, error) { | |
| 209 | + if err := s.enter(); err != nil { | |
| 210 | + return Channel{}, err | |
| 211 | + } | |
| 212 | + defer s.leave() | |
| 213 | + return s.channelAt("channel-name/" + nameKey(name)) | |
| 214 | +} | |
| 215 | + | |
| 216 | +// DirectChannel returns the direct conversation with agent, or ErrNotFound. | |
| 217 | +func (s *Store) DirectChannel(agent string) (Channel, error) { | |
| 218 | + if err := s.enter(); err != nil { | |
| 219 | + return Channel{}, err | |
| 220 | + } | |
| 221 | + defer s.leave() | |
| 222 | + return s.channelAt("direct/" + agent) | |
| 223 | +} | |
| 224 | + | |
| 225 | +func (s *Store) channelAt(indexKey string) (Channel, error) { | |
| 226 | + id, found, err := s.get(indexKey) | |
| 227 | + if err != nil { | |
| 228 | + return Channel{}, err | |
| 229 | + } | |
| 230 | + if !found { | |
| 231 | + return Channel{}, fmt.Errorf("%s: %w", indexKey, ErrNotFound) | |
| 232 | + } | |
| 233 | + return s.channel(string(id)) | |
| 234 | +} | |
| 235 | + | |
| 236 | +// CreateChannel stores a new channel. Its name must not be taken | |
| 237 | +// (ErrNameTaken); a direct channel is indexed by its agent instead. | |
| 238 | +func (s *Store) CreateChannel(channel Channel) error { | |
| 239 | + if err := s.enter(); err != nil { | |
| 240 | + return err | |
| 241 | + } | |
| 242 | + defer s.leave() | |
| 243 | + s.mu.Lock() | |
| 244 | + defer s.mu.Unlock() | |
| 245 | + batch := s.db.NewBatch() | |
| 246 | + defer func() { _ = batch.Close() }() | |
| 247 | + if channel.Direct != "" { | |
| 248 | + if err := batch.Set([]byte("direct/"+channel.Direct), []byte(channel.ID), nil); err != nil { | |
| 249 | + return err | |
| 250 | + } | |
| 251 | + } else if err := s.indexName(batch, channel); err != nil { | |
| 252 | + return err | |
| 253 | + } | |
| 254 | + if err := setJSON(batch, "channel/"+channel.ID, channel); err != nil { | |
| 255 | + return err | |
| 256 | + } | |
| 257 | + return batch.Commit(pebble.Sync) | |
| 258 | +} | |
| 259 | + | |
| 260 | +// UpdateChannel replaces a channel, e.g. renamed; the new name must not be | |
| 261 | +// taken by another channel. | |
| 262 | +func (s *Store) UpdateChannel(channel Channel) error { | |
| 263 | + if err := s.enter(); err != nil { | |
| 264 | + return err | |
| 265 | + } | |
| 266 | + defer s.leave() | |
| 267 | + s.mu.Lock() | |
| 268 | + defer s.mu.Unlock() | |
| 269 | + old, err := s.channel(channel.ID) | |
| 270 | + if err != nil { | |
| 271 | + return err | |
| 272 | + } | |
| 273 | + batch := s.db.NewBatch() | |
| 274 | + defer func() { _ = batch.Close() }() | |
| 275 | + if old.Direct == "" && nameKey(old.Name) != nameKey(channel.Name) { | |
| 276 | + if err := s.indexName(batch, channel); err != nil { | |
| 277 | + return err | |
| 278 | + } | |
| 279 | + if err := batch.Delete([]byte("channel-name/"+nameKey(old.Name)), nil); err != nil { | |
| 280 | + return err | |
| 281 | + } | |
| 282 | + } | |
| 283 | + channel.Direct = old.Direct | |
| 284 | + if err := setJSON(batch, "channel/"+channel.ID, channel); err != nil { | |
| 285 | + return err | |
| 286 | + } | |
| 287 | + return batch.Commit(pebble.Sync) | |
| 288 | +} | |
| 289 | + | |
| 290 | +// indexName adds the name index of channel to batch. The caller holds s.mu. | |
| 291 | +func (s *Store) indexName(batch *pebble.Batch, channel Channel) error { | |
| 292 | + key := "channel-name/" + nameKey(channel.Name) | |
| 293 | + owner, found, err := s.get(key) | |
| 294 | + if err != nil { | |
| 295 | + return err | |
| 296 | + } | |
| 297 | + if found && string(owner) != channel.ID { | |
| 298 | + return fmt.Errorf("%q: %w", channel.Name, ErrNameTaken) | |
| 299 | + } | |
| 300 | + return batch.Set([]byte(key), []byte(channel.ID), nil) | |
| 301 | +} | |
| 302 | + | |
| 303 | +// DeleteChannel deletes a channel with its members and messages. | |
| 304 | +func (s *Store) DeleteChannel(id string) error { | |
| 305 | + if err := s.enter(); err != nil { | |
| 306 | + return err | |
| 307 | + } | |
| 308 | + defer s.leave() | |
| 309 | + s.mu.Lock() | |
| 310 | + defer s.mu.Unlock() | |
| 311 | + channel, err := s.channel(id) | |
| 312 | + if err != nil { | |
| 313 | + return err | |
| 314 | + } | |
| 315 | + batch := s.db.NewBatch() | |
| 316 | + defer func() { _ = batch.Close() }() | |
| 317 | + index := "channel-name/" + nameKey(channel.Name) | |
| 318 | + if channel.Direct != "" { | |
| 319 | + index = "direct/" + channel.Direct | |
| 320 | + } | |
| 321 | + for _, key := range []string{index, "channel/" + id} { | |
| 322 | + if err := batch.Delete([]byte(key), nil); err != nil { | |
| 323 | + return err | |
| 324 | + } | |
| 325 | + } | |
| 326 | + for _, prefix := range []string{"member/" + id + "/", "msg/" + id + "/"} { | |
| 327 | + if err := batch.DeleteRange([]byte(prefix), upperBound(prefix), nil); err != nil { | |
| 328 | + return err | |
| 329 | + } | |
| 330 | + } | |
| 331 | + return batch.Commit(pebble.Sync) | |
| 332 | +} | |
| 333 | + | |
| 334 | +// ---------------------------------------------------------------- members | |
| 335 | + | |
| 336 | +// Members returns the members of a channel, in the order they joined. | |
| 337 | +func (s *Store) Members(channelID string) ([]Membership, error) { | |
| 338 | + if err := s.enter(); err != nil { | |
| 339 | + return nil, err | |
| 340 | + } | |
| 341 | + defer s.leave() | |
| 342 | + members := []Membership{} | |
| 343 | + err := s.scan("member/"+channelID+"/", func(_, value []byte) error { | |
| 344 | + var member Membership | |
| 345 | + if err := json.Unmarshal(value, &member); err != nil { | |
| 346 | + return err | |
| 347 | + } | |
| 348 | + members = append(members, member) | |
| 349 | + return nil | |
| 350 | + }) | |
| 351 | + sort.SliceStable(members, func(i, j int) bool { return members[i].JoinedAt.Before(members[j].JoinedAt) }) | |
| 352 | + return members, err | |
| 353 | +} | |
| 354 | + | |
| 355 | +// PutMember adds or updates a membership. | |
| 356 | +func (s *Store) PutMember(member Membership) error { | |
| 357 | + if err := s.enter(); err != nil { | |
| 358 | + return err | |
| 359 | + } | |
| 360 | + defer s.leave() | |
| 361 | + return s.setJSON("member/"+member.ChannelID+"/"+member.Agent, member) | |
| 362 | +} | |
| 363 | + | |
| 364 | +// DeleteMember removes an agent from a channel. | |
| 365 | +func (s *Store) DeleteMember(channelID, agent string) error { | |
| 366 | + if err := s.enter(); err != nil { | |
| 367 | + return err | |
| 368 | + } | |
| 369 | + defer s.leave() | |
| 370 | + return s.db.Delete([]byte("member/"+channelID+"/"+agent), pebble.Sync) | |
| 371 | +} | |
| 372 | + | |
| 373 | +// ---------------------------------------------------------------- messages | |
| 374 | + | |
| 375 | +// PutMessage stores (or replaces) a message. Message ids sort by time | |
| 376 | +// (NewID), so a channel's messages are kept in order. | |
| 377 | +func (s *Store) PutMessage(channelID, messageID string, data []byte) error { | |
| 378 | + if err := s.enter(); err != nil { | |
| 379 | + return err | |
| 380 | + } | |
| 381 | + defer s.leave() | |
| 382 | + return s.db.Set([]byte("msg/"+channelID+"/"+messageID), data, pebble.Sync) | |
| 383 | +} | |
| 384 | + | |
| 385 | +// Messages returns at most limit messages of a channel, oldest first: the | |
| 386 | +// last ones, or the last ones before the message beforeID when it is not | |
| 387 | +// empty (to page backwards). more tells whether older messages exist. | |
| 388 | +func (s *Store) Messages(channelID, beforeID string, limit int) (messages [][]byte, more bool, err error) { | |
| 389 | + if err := s.enter(); err != nil { | |
| 390 | + return nil, false, err | |
| 391 | + } | |
| 392 | + defer s.leave() | |
| 393 | + prefix := "msg/" + channelID + "/" | |
| 394 | + upper := upperBound(prefix) | |
| 395 | + if beforeID != "" { | |
| 396 | + upper = []byte(prefix + beforeID) | |
| 397 | + } | |
| 398 | + iterator, err := s.db.NewIter(&pebble.IterOptions{LowerBound: []byte(prefix), UpperBound: upper}) | |
| 399 | + if err != nil { | |
| 400 | + return nil, false, err | |
| 401 | + } | |
| 402 | + defer func() { _ = iterator.Close() }() | |
| 403 | + for valid := iterator.Last(); valid; valid = iterator.Prev() { | |
| 404 | + if len(messages) == limit { | |
| 405 | + more = true | |
| 406 | + break | |
| 407 | + } | |
| 408 | + messages = append(messages, bytes.Clone(iterator.Value())) | |
| 409 | + } | |
| 410 | + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { | |
| 411 | + messages[i], messages[j] = messages[j], messages[i] | |
| 412 | + } | |
| 413 | + return messages, more, iterator.Error() | |
| 414 | +} | |
| 415 | + | |
| 416 | +// ---------------------------------------------------------------- values | |
| 417 | + | |
| 418 | +// Value returns the bytes stored under key with SetValue. | |
| 419 | +func (s *Store) Value(key string) ([]byte, bool, error) { | |
| 420 | + if err := s.enter(); err != nil { | |
| 421 | + return nil, false, err | |
| 422 | + } | |
| 423 | + defer s.leave() | |
| 424 | + return s.get("value/" + key) | |
| 425 | +} | |
| 426 | + | |
| 427 | +// SetValue stores bytes under key. | |
| 428 | +func (s *Store) SetValue(key string, data []byte) error { | |
| 429 | + if err := s.enter(); err != nil { | |
| 430 | + return err | |
| 431 | + } | |
| 432 | + defer s.leave() | |
| 433 | + return s.db.Set([]byte("value/"+key), data, pebble.Sync) | |
| 434 | +} | |
| 435 | + | |
| 436 | +// ---------------------------------------------------------------- helpers | |
| 437 | + | |
| 438 | +func (s *Store) get(key string) ([]byte, bool, error) { | |
| 439 | + value, closer, err := s.db.Get([]byte(key)) | |
| 440 | + if errors.Is(err, pebble.ErrNotFound) { | |
| 441 | + return nil, false, nil | |
| 442 | + } | |
| 443 | + if err != nil { | |
| 444 | + return nil, false, err | |
| 445 | + } | |
| 446 | + defer func() { _ = closer.Close() }() | |
| 447 | + return bytes.Clone(value), true, nil | |
| 448 | +} | |
| 449 | + | |
| 450 | +func (s *Store) getJSON(key string, target any) (bool, error) { | |
| 451 | + value, found, err := s.get(key) | |
| 452 | + if err != nil || !found { | |
| 453 | + return found, err | |
| 454 | + } | |
| 455 | + return true, json.Unmarshal(value, target) | |
| 456 | +} | |
| 457 | + | |
| 458 | +func (s *Store) setJSON(key string, value any) error { | |
| 459 | + data, err := json.Marshal(value) | |
| 460 | + if err != nil { | |
| 461 | + return err | |
| 462 | + } | |
| 463 | + return s.db.Set([]byte(key), data, pebble.Sync) | |
| 464 | +} | |
| 465 | + | |
| 466 | +func setJSON(batch *pebble.Batch, key string, value any) error { | |
| 467 | + data, err := json.Marshal(value) | |
| 468 | + if err != nil { | |
| 469 | + return err | |
| 470 | + } | |
| 471 | + return batch.Set([]byte(key), data, nil) | |
| 472 | +} | |
| 473 | + | |
| 474 | +func (s *Store) scan(prefix string, visit func(key, value []byte) error) error { | |
| 475 | + iterator, err := s.db.NewIter(&pebble.IterOptions{LowerBound: []byte(prefix), UpperBound: upperBound(prefix)}) | |
| 476 | + if err != nil { | |
| 477 | + return err | |
| 478 | + } | |
| 479 | + defer func() { _ = iterator.Close() }() | |
| 480 | + for valid := iterator.First(); valid; valid = iterator.Next() { | |
| 481 | + if err := visit(iterator.Key(), iterator.Value()); err != nil { | |
| 482 | + return err | |
| 483 | + } | |
| 484 | + } | |
| 485 | + return iterator.Error() | |
| 486 | +} | |
| 487 | + | |
| 488 | +// upperBound is the first key after every key starting with prefix (which | |
| 489 | +// always ends with "/"). | |
| 490 | +func upperBound(prefix string) []byte { | |
| 491 | + return []byte(strings.TrimSuffix(prefix, "/") + "0") // '0' follows '/' | |
| 492 | +} | |
| 493 | + | |
| 494 | +func nameKey(name string) string { | |
| 495 | + return strings.ToLower(strings.TrimSpace(name)) | |
| 496 | +} | |
| 497 | + | |
| 498 | +// quiet drops Pebble's informational logs; errors are returned to the caller. | |
| 499 | +type quiet struct{} | |
| 500 | + | |
| 501 | +func (quiet) Infof(string, ...any) {} | |
| 502 | +func (quiet) Errorf(string, ...any) {} | |
| 503 | +func (quiet) Fatalf(format string, a ...any) { panic(fmt.Sprintf(format, a...)) } | |
| new file mode 100644 | |||
| @@ -0,0 +1,503 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +// Package store keeps what happens in Sidekicks — channels, their members and | ||
| 4 | +// their messages — in a Pebble database, one per configuration file (ADR 0003, | ||
| 5 | +// "What is stored where"). It knows nothing about ACP: messages are stored as | ||
| 6 | +// opaque bytes that internal/channels encodes. | ||
| 7 | +// | ||
| 8 | +// dir, _ := store.DirFor("/Users/me/sidekicks.yaml") | ||
| 9 | +// db, _ := store.Open(dir) | ||
| 10 | +// defer db.Close() | ||
| 11 | +// channel := store.Channel{ID: store.NewID(), Name: "general"} | ||
| 12 | +// _ = db.CreateChannel(channel) | ||
| 13 | +// _ = db.PutMessage(channel.ID, store.NewID(), []byte(`{"text":"hello"}`)) | ||
| 14 | +package store | ||
| 15 | + | ||
| 16 | +import ( | ||
| 17 | + "bytes" | ||
| 18 | + "crypto/sha256" | ||
| 19 | + "encoding/hex" | ||
| 20 | + "encoding/json" | ||
| 21 | + "errors" | ||
| 22 | + "fmt" | ||
| 23 | + "os" | ||
| 24 | + "path/filepath" | ||
| 25 | + "sort" | ||
| 26 | + "strings" | ||
| 27 | + "sync" | ||
| 28 | + "time" | ||
| 29 | + | ||
| 30 | + "github.com/cockroachdb/pebble/v2" | ||
| 31 | + "github.com/cockroachdb/pebble/v2/vfs" | ||
| 32 | +) | ||
| 33 | + | ||
| 34 | +// SchemaVersion is the version of the key layout below. A database written by | ||
| 35 | +// a newer Sidekicks is refused rather than misread. | ||
| 36 | +// | ||
| 37 | +// meta/version → SchemaVersion | ||
| 38 | +// channel/<channelId> → Channel (JSON) | ||
| 39 | +// channel-name/<lowercased name> → channelId (uniqueness, lookup) | ||
| 40 | +// direct/<agent> → channelId of the direct conversation with agent | ||
| 41 | +// member/<channelId>/<agent> → Membership (JSON) | ||
| 42 | +// msg/<channelId>/<messageId> → message (opaque bytes) | ||
| 43 | +// value/<key> → opaque bytes (caches, e.g. an agent's commands) | ||
| 44 | +const SchemaVersion = 1 | ||
| 45 | + | ||
| 46 | +// Errors returned by the store. | ||
| 47 | +var ( | ||
| 48 | + ErrNotFound = errors.New("not found") | ||
| 49 | + ErrNameTaken = errors.New("a channel already has this name") | ||
| 50 | + ErrClosed = errors.New("the history is closed") | ||
| 51 | +) | ||
| 52 | + | ||
| 53 | +// Channel is a place where the user talks with one or several agents. A | ||
| 54 | +// direct conversation with one agent is a channel whose Direct field names | ||
| 55 | +// that agent; it has no name. | ||
| 56 | +type Channel struct { | ||
| 57 | + ID string `json:"id"` | ||
| 58 | + Name string `json:"name"` | ||
| 59 | + Description string `json:"description,omitempty"` | ||
| 60 | + Direct string `json:"direct,omitempty"` | ||
| 61 | + CreatedAt time.Time `json:"createdAt"` | ||
| 62 | + UpdatedAt time.Time `json:"updatedAt"` | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +// Membership is an agent taking part in a channel. | ||
| 66 | +type Membership struct { | ||
| 67 | + ChannelID string `json:"channelId"` | ||
| 68 | + Agent string `json:"agent"` | ||
| 69 | + JoinedAt time.Time `json:"joinedAt"` | ||
| 70 | + // SessionID is the agent's ACP session for this channel, for agents that | ||
| 71 | + // can resume one (session/load). | ||
| 72 | + SessionID string `json:"sessionId,omitempty"` | ||
| 73 | + // LastSeen is the id of the last channel message the agent has seen. | ||
| 74 | + LastSeen string `json:"lastSeen,omitempty"` | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +// Store is a Pebble database. It is safe for concurrent use. | ||
| 78 | +type Store struct { | ||
| 79 | + db *pebble.DB | ||
| 80 | + // mu makes the check-then-write of channel names atomic. | ||
| 81 | + mu sync.Mutex | ||
| 82 | + // life lets every operation finish before Close, and refuses the ones | ||
| 83 | + // after it (an answer may end while Sidekicks quits): Pebble panics | ||
| 84 | + // when used once closed. | ||
| 85 | + life sync.RWMutex | ||
| 86 | + closed bool | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +// DirFor returns the folder of the database of the configuration file at | ||
| 90 | +// configPath: <user config dir>/sidekicks/data/<hash of the path>. Two | ||
| 91 | +// configuration files never share their history. | ||
| 92 | +func DirFor(configPath string) (string, error) { | ||
| 93 | + base, err := os.UserConfigDir() | ||
| 94 | + if err != nil { | ||
| 95 | + return "", err | ||
| 96 | + } | ||
| 97 | + absolute, err := filepath.Abs(configPath) | ||
| 98 | + if err != nil { | ||
| 99 | + return "", err | ||
| 100 | + } | ||
| 101 | + sum := sha256.Sum256([]byte(absolute)) | ||
| 102 | + return filepath.Join(base, "sidekicks", "data", hex.EncodeToString(sum[:8])), nil | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +// Open opens (or creates) the database in dir. | ||
| 106 | +func Open(dir string) (*Store, error) { | ||
| 107 | + return open(dir, &pebble.Options{Logger: quiet{}}) | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +// OpenInMemory returns an empty database that lives in memory only: for | ||
| 111 | +// tests, and when the database on disk cannot be opened. | ||
| 112 | +func OpenInMemory() (*Store, error) { | ||
| 113 | + return open("", &pebble.Options{FS: vfs.NewMem(), Logger: quiet{}}) | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +func open(dir string, options *pebble.Options) (*Store, error) { | ||
| 117 | + db, err := pebble.Open(dir, options) | ||
| 118 | + if err != nil { | ||
| 119 | + return nil, err | ||
| 120 | + } | ||
| 121 | + store := &Store{db: db} | ||
| 122 | + if err := store.checkVersion(); err != nil { | ||
| 123 | + _ = db.Close() | ||
| 124 | + return nil, err | ||
| 125 | + } | ||
| 126 | + return store, nil | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +func (s *Store) checkVersion() error { | ||
| 130 | + var version int | ||
| 131 | + found, err := s.getJSON("meta/version", &version) | ||
| 132 | + if err != nil { | ||
| 133 | + return err | ||
| 134 | + } | ||
| 135 | + if !found { | ||
| 136 | + return s.setJSON("meta/version", SchemaVersion) | ||
| 137 | + } | ||
| 138 | + if version > SchemaVersion { | ||
| 139 | + return fmt.Errorf("the history was written by a newer Sidekicks (schema %d, this one reads %d)", version, SchemaVersion) | ||
| 140 | + } | ||
| 141 | + return nil | ||
| 142 | +} | ||
| 143 | + | ||
| 144 | +// Close closes the database. Later operations return ErrClosed. | ||
| 145 | +func (s *Store) Close() error { | ||
| 146 | + s.life.Lock() | ||
| 147 | + defer s.life.Unlock() | ||
| 148 | + if s.closed { | ||
| 149 | + return nil | ||
| 150 | + } | ||
| 151 | + s.closed = true | ||
| 152 | + return s.db.Close() | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +// enter starts an operation; leave must follow when it returns nil. | ||
| 156 | +func (s *Store) enter() error { | ||
| 157 | + s.life.RLock() | ||
| 158 | + if s.closed { | ||
| 159 | + s.life.RUnlock() | ||
| 160 | + return ErrClosed | ||
| 161 | + } | ||
| 162 | + return nil | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +func (s *Store) leave() { s.life.RUnlock() } | ||
| 166 | + | ||
| 167 | +// ---------------------------------------------------------------- channels | ||
| 168 | + | ||
| 169 | +// Channels returns every channel, direct conversations included, oldest first. | ||
| 170 | +func (s *Store) Channels() ([]Channel, error) { | ||
| 171 | + if err := s.enter(); err != nil { | ||
| 172 | + return nil, err | ||
| 173 | + } | ||
| 174 | + defer s.leave() | ||
| 175 | + channels := []Channel{} | ||
| 176 | + err := s.scan("channel/", func(_, value []byte) error { | ||
| 177 | + var channel Channel | ||
| 178 | + if err := json.Unmarshal(value, &channel); err != nil { | ||
| 179 | + return err | ||
| 180 | + } | ||
| 181 | + channels = append(channels, channel) | ||
| 182 | + return nil | ||
| 183 | + }) | ||
| 184 | + sort.SliceStable(channels, func(i, j int) bool { return channels[i].ID < channels[j].ID }) | ||
| 185 | + return channels, err | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +// Channel returns the channel with id, or ErrNotFound. | ||
| 189 | +func (s *Store) Channel(id string) (Channel, error) { | ||
| 190 | + if err := s.enter(); err != nil { | ||
| 191 | + return Channel{}, err | ||
| 192 | + } | ||
| 193 | + defer s.leave() | ||
| 194 | + return s.channel(id) | ||
| 195 | +} | ||
| 196 | + | ||
| 197 | +// channel reads a channel without entering: for the operations that have. | ||
| 198 | +func (s *Store) channel(id string) (Channel, error) { | ||
| 199 | + var channel Channel | ||
| 200 | + found, err := s.getJSON("channel/"+id, &channel) | ||
| 201 | + if err == nil && !found { | ||
| 202 | + err = fmt.Errorf("channel %s: %w", id, ErrNotFound) | ||
| 203 | + } | ||
| 204 | + return channel, err | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +// ChannelByName returns the channel called name (case-insensitive), or ErrNotFound. | ||
| 208 | +func (s *Store) ChannelByName(name string) (Channel, error) { | ||
| 209 | + if err := s.enter(); err != nil { | ||
| 210 | + return Channel{}, err | ||
| 211 | + } | ||
| 212 | + defer s.leave() | ||
| 213 | + return s.channelAt("channel-name/" + nameKey(name)) | ||
| 214 | +} | ||
| 215 | + | ||
| 216 | +// DirectChannel returns the direct conversation with agent, or ErrNotFound. | ||
| 217 | +func (s *Store) DirectChannel(agent string) (Channel, error) { | ||
| 218 | + if err := s.enter(); err != nil { | ||
| 219 | + return Channel{}, err | ||
| 220 | + } | ||
| 221 | + defer s.leave() | ||
| 222 | + return s.channelAt("direct/" + agent) | ||
| 223 | +} | ||
| 224 | + | ||
| 225 | +func (s *Store) channelAt(indexKey string) (Channel, error) { | ||
| 226 | + id, found, err := s.get(indexKey) | ||
| 227 | + if err != nil { | ||
| 228 | + return Channel{}, err | ||
| 229 | + } | ||
| 230 | + if !found { | ||
| 231 | + return Channel{}, fmt.Errorf("%s: %w", indexKey, ErrNotFound) | ||
| 232 | + } | ||
| 233 | + return s.channel(string(id)) | ||
| 234 | +} | ||
| 235 | + | ||
| 236 | +// CreateChannel stores a new channel. Its name must not be taken | ||
| 237 | +// (ErrNameTaken); a direct channel is indexed by its agent instead. | ||
| 238 | +func (s *Store) CreateChannel(channel Channel) error { | ||
| 239 | + if err := s.enter(); err != nil { | ||
| 240 | + return err | ||
| 241 | + } | ||
| 242 | + defer s.leave() | ||
| 243 | + s.mu.Lock() | ||
| 244 | + defer s.mu.Unlock() | ||
| 245 | + batch := s.db.NewBatch() | ||
| 246 | + defer func() { _ = batch.Close() }() | ||
| 247 | + if channel.Direct != "" { | ||
| 248 | + if err := batch.Set([]byte("direct/"+channel.Direct), []byte(channel.ID), nil); err != nil { | ||
| 249 | + return err | ||
| 250 | + } | ||
| 251 | + } else if err := s.indexName(batch, channel); err != nil { | ||
| 252 | + return err | ||
| 253 | + } | ||
| 254 | + if err := setJSON(batch, "channel/"+channel.ID, channel); err != nil { | ||
| 255 | + return err | ||
| 256 | + } | ||
| 257 | + return batch.Commit(pebble.Sync) | ||
| 258 | +} | ||
| 259 | + | ||
| 260 | +// UpdateChannel replaces a channel, e.g. renamed; the new name must not be | ||
| 261 | +// taken by another channel. | ||
| 262 | +func (s *Store) UpdateChannel(channel Channel) error { | ||
| 263 | + if err := s.enter(); err != nil { | ||
| 264 | + return err | ||
| 265 | + } | ||
| 266 | + defer s.leave() | ||
| 267 | + s.mu.Lock() | ||
| 268 | + defer s.mu.Unlock() | ||
| 269 | + old, err := s.channel(channel.ID) | ||
| 270 | + if err != nil { | ||
| 271 | + return err | ||
| 272 | + } | ||
| 273 | + batch := s.db.NewBatch() | ||
| 274 | + defer func() { _ = batch.Close() }() | ||
| 275 | + if old.Direct == "" && nameKey(old.Name) != nameKey(channel.Name) { | ||
| 276 | + if err := s.indexName(batch, channel); err != nil { | ||
| 277 | + return err | ||
| 278 | + } | ||
| 279 | + if err := batch.Delete([]byte("channel-name/"+nameKey(old.Name)), nil); err != nil { | ||
| 280 | + return err | ||
| 281 | + } | ||
| 282 | + } | ||
| 283 | + channel.Direct = old.Direct | ||
| 284 | + if err := setJSON(batch, "channel/"+channel.ID, channel); err != nil { | ||
| 285 | + return err | ||
| 286 | + } | ||
| 287 | + return batch.Commit(pebble.Sync) | ||
| 288 | +} | ||
| 289 | + | ||
| 290 | +// indexName adds the name index of channel to batch. The caller holds s.mu. | ||
| 291 | +func (s *Store) indexName(batch *pebble.Batch, channel Channel) error { | ||
| 292 | + key := "channel-name/" + nameKey(channel.Name) | ||
| 293 | + owner, found, err := s.get(key) | ||
| 294 | + if err != nil { | ||
| 295 | + return err | ||
| 296 | + } | ||
| 297 | + if found && string(owner) != channel.ID { | ||
| 298 | + return fmt.Errorf("%q: %w", channel.Name, ErrNameTaken) | ||
| 299 | + } | ||
| 300 | + return batch.Set([]byte(key), []byte(channel.ID), nil) | ||
| 301 | +} | ||
| 302 | + | ||
| 303 | +// DeleteChannel deletes a channel with its members and messages. | ||
| 304 | +func (s *Store) DeleteChannel(id string) error { | ||
| 305 | + if err := s.enter(); err != nil { | ||
| 306 | + return err | ||
| 307 | + } | ||
| 308 | + defer s.leave() | ||
| 309 | + s.mu.Lock() | ||
| 310 | + defer s.mu.Unlock() | ||
| 311 | + channel, err := s.channel(id) | ||
| 312 | + if err != nil { | ||
| 313 | + return err | ||
| 314 | + } | ||
| 315 | + batch := s.db.NewBatch() | ||
| 316 | + defer func() { _ = batch.Close() }() | ||
| 317 | + index := "channel-name/" + nameKey(channel.Name) | ||
| 318 | + if channel.Direct != "" { | ||
| 319 | + index = "direct/" + channel.Direct | ||
| 320 | + } | ||
| 321 | + for _, key := range []string{index, "channel/" + id} { | ||
| 322 | + if err := batch.Delete([]byte(key), nil); err != nil { | ||
| 323 | + return err | ||
| 324 | + } | ||
| 325 | + } | ||
| 326 | + for _, prefix := range []string{"member/" + id + "/", "msg/" + id + "/"} { | ||
| 327 | + if err := batch.DeleteRange([]byte(prefix), upperBound(prefix), nil); err != nil { | ||
| 328 | + return err | ||
| 329 | + } | ||
| 330 | + } | ||
| 331 | + return batch.Commit(pebble.Sync) | ||
| 332 | +} | ||
| 333 | + | ||
| 334 | +// ---------------------------------------------------------------- members | ||
| 335 | + | ||
| 336 | +// Members returns the members of a channel, in the order they joined. | ||
| 337 | +func (s *Store) Members(channelID string) ([]Membership, error) { | ||
| 338 | + if err := s.enter(); err != nil { | ||
| 339 | + return nil, err | ||
| 340 | + } | ||
| 341 | + defer s.leave() | ||
| 342 | + members := []Membership{} | ||
| 343 | + err := s.scan("member/"+channelID+"/", func(_, value []byte) error { | ||
| 344 | + var member Membership | ||
| 345 | + if err := json.Unmarshal(value, &member); err != nil { | ||
| 346 | + return err | ||
| 347 | + } | ||
| 348 | + members = append(members, member) | ||
| 349 | + return nil | ||
| 350 | + }) | ||
| 351 | + sort.SliceStable(members, func(i, j int) bool { return members[i].JoinedAt.Before(members[j].JoinedAt) }) | ||
| 352 | + return members, err | ||
| 353 | +} | ||
| 354 | + | ||
| 355 | +// PutMember adds or updates a membership. | ||
| 356 | +func (s *Store) PutMember(member Membership) error { | ||
| 357 | + if err := s.enter(); err != nil { | ||
| 358 | + return err | ||
| 359 | + } | ||
| 360 | + defer s.leave() | ||
| 361 | + return s.setJSON("member/"+member.ChannelID+"/"+member.Agent, member) | ||
| 362 | +} | ||
| 363 | + | ||
| 364 | +// DeleteMember removes an agent from a channel. | ||
| 365 | +func (s *Store) DeleteMember(channelID, agent string) error { | ||
| 366 | + if err := s.enter(); err != nil { | ||
| 367 | + return err | ||
| 368 | + } | ||
| 369 | + defer s.leave() | ||
| 370 | + return s.db.Delete([]byte("member/"+channelID+"/"+agent), pebble.Sync) | ||
| 371 | +} | ||
| 372 | + | ||
| 373 | +// ---------------------------------------------------------------- messages | ||
| 374 | + | ||
| 375 | +// PutMessage stores (or replaces) a message. Message ids sort by time | ||
| 376 | +// (NewID), so a channel's messages are kept in order. | ||
| 377 | +func (s *Store) PutMessage(channelID, messageID string, data []byte) error { | ||
| 378 | + if err := s.enter(); err != nil { | ||
| 379 | + return err | ||
| 380 | + } | ||
| 381 | + defer s.leave() | ||
| 382 | + return s.db.Set([]byte("msg/"+channelID+"/"+messageID), data, pebble.Sync) | ||
| 383 | +} | ||
| 384 | + | ||
| 385 | +// Messages returns at most limit messages of a channel, oldest first: the | ||
| 386 | +// last ones, or the last ones before the message beforeID when it is not | ||
| 387 | +// empty (to page backwards). more tells whether older messages exist. | ||
| 388 | +func (s *Store) Messages(channelID, beforeID string, limit int) (messages [][]byte, more bool, err error) { | ||
| 389 | + if err := s.enter(); err != nil { | ||
| 390 | + return nil, false, err | ||
| 391 | + } | ||
| 392 | + defer s.leave() | ||
| 393 | + prefix := "msg/" + channelID + "/" | ||
| 394 | + upper := upperBound(prefix) | ||
| 395 | + if beforeID != "" { | ||
| 396 | + upper = []byte(prefix + beforeID) | ||
| 397 | + } | ||
| 398 | + iterator, err := s.db.NewIter(&pebble.IterOptions{LowerBound: []byte(prefix), UpperBound: upper}) | ||
| 399 | + if err != nil { | ||
| 400 | + return nil, false, err | ||
| 401 | + } | ||
| 402 | + defer func() { _ = iterator.Close() }() | ||
| 403 | + for valid := iterator.Last(); valid; valid = iterator.Prev() { | ||
| 404 | + if len(messages) == limit { | ||
| 405 | + more = true | ||
| 406 | + break | ||
| 407 | + } | ||
| 408 | + messages = append(messages, bytes.Clone(iterator.Value())) | ||
| 409 | + } | ||
| 410 | + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { | ||
| 411 | + messages[i], messages[j] = messages[j], messages[i] | ||
| 412 | + } | ||
| 413 | + return messages, more, iterator.Error() | ||
| 414 | +} | ||
| 415 | + | ||
| 416 | +// ---------------------------------------------------------------- values | ||
| 417 | + | ||
| 418 | +// Value returns the bytes stored under key with SetValue. | ||
| 419 | +func (s *Store) Value(key string) ([]byte, bool, error) { | ||
| 420 | + if err := s.enter(); err != nil { | ||
| 421 | + return nil, false, err | ||
| 422 | + } | ||
| 423 | + defer s.leave() | ||
| 424 | + return s.get("value/" + key) | ||
| 425 | +} | ||
| 426 | + | ||
| 427 | +// SetValue stores bytes under key. | ||
| 428 | +func (s *Store) SetValue(key string, data []byte) error { | ||
| 429 | + if err := s.enter(); err != nil { | ||
| 430 | + return err | ||
| 431 | + } | ||
| 432 | + defer s.leave() | ||
| 433 | + return s.db.Set([]byte("value/"+key), data, pebble.Sync) | ||
| 434 | +} | ||
| 435 | + | ||
| 436 | +// ---------------------------------------------------------------- helpers | ||
| 437 | + | ||
| 438 | +func (s *Store) get(key string) ([]byte, bool, error) { | ||
| 439 | + value, closer, err := s.db.Get([]byte(key)) | ||
| 440 | + if errors.Is(err, pebble.ErrNotFound) { | ||
| 441 | + return nil, false, nil | ||
| 442 | + } | ||
| 443 | + if err != nil { | ||
| 444 | + return nil, false, err | ||
| 445 | + } | ||
| 446 | + defer func() { _ = closer.Close() }() | ||
| 447 | + return bytes.Clone(value), true, nil | ||
| 448 | +} | ||
| 449 | + | ||
| 450 | +func (s *Store) getJSON(key string, target any) (bool, error) { | ||
| 451 | + value, found, err := s.get(key) | ||
| 452 | + if err != nil || !found { | ||
| 453 | + return found, err | ||
| 454 | + } | ||
| 455 | + return true, json.Unmarshal(value, target) | ||
| 456 | +} | ||
| 457 | + | ||
| 458 | +func (s *Store) setJSON(key string, value any) error { | ||
| 459 | + data, err := json.Marshal(value) | ||
| 460 | + if err != nil { | ||
| 461 | + return err | ||
| 462 | + } | ||
| 463 | + return s.db.Set([]byte(key), data, pebble.Sync) | ||
| 464 | +} | ||
| 465 | + | ||
| 466 | +func setJSON(batch *pebble.Batch, key string, value any) error { | ||
| 467 | + data, err := json.Marshal(value) | ||
| 468 | + if err != nil { | ||
| 469 | + return err | ||
| 470 | + } | ||
| 471 | + return batch.Set([]byte(key), data, nil) | ||
| 472 | +} | ||
| 473 | + | ||
| 474 | +func (s *Store) scan(prefix string, visit func(key, value []byte) error) error { | ||
| 475 | + iterator, err := s.db.NewIter(&pebble.IterOptions{LowerBound: []byte(prefix), UpperBound: upperBound(prefix)}) | ||
| 476 | + if err != nil { | ||
| 477 | + return err | ||
| 478 | + } | ||
| 479 | + defer func() { _ = iterator.Close() }() | ||
| 480 | + for valid := iterator.First(); valid; valid = iterator.Next() { | ||
| 481 | + if err := visit(iterator.Key(), iterator.Value()); err != nil { | ||
| 482 | + return err | ||
| 483 | + } | ||
| 484 | + } | ||
| 485 | + return iterator.Error() | ||
| 486 | +} | ||
| 487 | + | ||
| 488 | +// upperBound is the first key after every key starting with prefix (which | ||
| 489 | +// always ends with "/"). | ||
| 490 | +func upperBound(prefix string) []byte { | ||
| 491 | + return []byte(strings.TrimSuffix(prefix, "/") + "0") // '0' follows '/' | ||
| 492 | +} | ||
| 493 | + | ||
| 494 | +func nameKey(name string) string { | ||
| 495 | + return strings.ToLower(strings.TrimSpace(name)) | ||
| 496 | +} | ||
| 497 | + | ||
| 498 | +// quiet drops Pebble's informational logs; errors are returned to the caller. | ||
| 499 | +type quiet struct{} | ||
| 500 | + | ||
| 501 | +func (quiet) Infof(string, ...any) {} | ||
| 502 | +func (quiet) Errorf(string, ...any) {} | ||
| 503 | +func (quiet) Fatalf(format string, a ...any) { panic(fmt.Sprintf(format, a...)) } | ||
added
internal/store/store_test.go +241 -0 | new file mode 100644 | ||
| @@ -0,0 +1,241 @@ | ||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | |
| 2 | + | |
| 3 | +package store_test | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "errors" | |
| 7 | + "fmt" | |
| 8 | + "path/filepath" | |
| 9 | + "strings" | |
| 10 | + "testing" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | |
| 14 | +) | |
| 15 | + | |
| 16 | +func memory(t *testing.T) *store.Store { | |
| 17 | + t.Helper() | |
| 18 | + db, err := store.OpenInMemory() | |
| 19 | + if err != nil { | |
| 20 | + t.Fatal(err) | |
| 21 | + } | |
| 22 | + t.Cleanup(func() { _ = db.Close() }) | |
| 23 | + return db | |
| 24 | +} | |
| 25 | + | |
| 26 | +func TestIDsSortByCreation(t *testing.T) { | |
| 27 | + previous := store.NewID() | |
| 28 | + for i := 0; i < 1000; i++ { | |
| 29 | + next := store.NewID() | |
| 30 | + if next <= previous { | |
| 31 | + t.Fatalf("%s should sort after %s", next, previous) | |
| 32 | + } | |
| 33 | + previous = next | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +func TestAChannelIsCreatedFoundAndRenamed(t *testing.T) { | |
| 38 | + db := memory(t) | |
| 39 | + general := store.Channel{ID: store.NewID(), Name: "Général"} | |
| 40 | + if err := db.CreateChannel(general); err != nil { | |
| 41 | + t.Fatal(err) | |
| 42 | + } | |
| 43 | + if found, err := db.ChannelByName(" général "); err != nil || found.ID != general.ID { | |
| 44 | + t.Fatalf("lookup by name ignores case and spaces: %+v %v", found, err) | |
| 45 | + } | |
| 46 | + other := store.Channel{ID: store.NewID(), Name: "GÉNÉRAL"} | |
| 47 | + if err := db.CreateChannel(other); !errors.Is(err, store.ErrNameTaken) { | |
| 48 | + t.Fatalf("want ErrNameTaken, got %v", err) | |
| 49 | + } | |
| 50 | + | |
| 51 | + general.Name = "team" | |
| 52 | + if err := db.UpdateChannel(general); err != nil { | |
| 53 | + t.Fatal(err) | |
| 54 | + } | |
| 55 | + if _, err := db.ChannelByName("général"); !errors.Is(err, store.ErrNotFound) { | |
| 56 | + t.Fatalf("the old name should be free: %v", err) | |
| 57 | + } | |
| 58 | + if err := db.CreateChannel(other); err != nil { | |
| 59 | + t.Fatalf("the old name can be reused: %v", err) | |
| 60 | + } | |
| 61 | + other.Name = "Team" | |
| 62 | + if err := db.UpdateChannel(other); !errors.Is(err, store.ErrNameTaken) { | |
| 63 | + t.Fatalf("renaming onto a taken name: %v", err) | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +func TestDeletingAChannelDeletesItsMembersMessagesAndName(t *testing.T) { | |
| 68 | + db := memory(t) | |
| 69 | + general := store.Channel{ID: store.NewID(), Name: "team"} | |
| 70 | + other := store.Channel{ID: store.NewID(), Name: "other"} | |
| 71 | + _ = db.CreateChannel(general) | |
| 72 | + _ = db.CreateChannel(other) | |
| 73 | + _ = db.PutMember(store.Membership{ChannelID: general.ID, Agent: "alice"}) | |
| 74 | + _ = db.PutMessage(general.ID, store.NewID(), []byte("hello")) | |
| 75 | + if err := db.DeleteChannel(general.ID); err != nil { | |
| 76 | + t.Fatal(err) | |
| 77 | + } | |
| 78 | + if _, err := db.Channel(general.ID); !errors.Is(err, store.ErrNotFound) { | |
| 79 | + t.Fatalf("deleted: %v", err) | |
| 80 | + } | |
| 81 | + members, _ := db.Members(general.ID) | |
| 82 | + messages, _, _ := db.Messages(general.ID, "", 10) | |
| 83 | + if len(members) != 0 || len(messages) != 0 { | |
| 84 | + t.Fatalf("members and messages go with the channel: %v %v", members, messages) | |
| 85 | + } | |
| 86 | + if _, err := db.ChannelByName("team"); !errors.Is(err, store.ErrNotFound) { | |
| 87 | + t.Fatal("the name index goes with the channel") | |
| 88 | + } | |
| 89 | + if channels, _ := db.Channels(); len(channels) != 1 || channels[0].ID != other.ID { | |
| 90 | + t.Fatalf("channels: %+v", channels) | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +func TestADirectChannelIsFoundByItsAgentAndHasNoName(t *testing.T) { | |
| 95 | + db := memory(t) | |
| 96 | + direct := store.Channel{ID: store.NewID(), Direct: "alice"} | |
| 97 | + named := store.Channel{ID: store.NewID(), Name: "alice"} | |
| 98 | + if err := db.CreateChannel(direct); err != nil { | |
| 99 | + t.Fatal(err) | |
| 100 | + } | |
| 101 | + if err := db.CreateChannel(named); err != nil { | |
| 102 | + t.Fatalf("a direct channel does not take a name: %v", err) | |
| 103 | + } | |
| 104 | + if found, err := db.DirectChannel("alice"); err != nil || found.ID != direct.ID { | |
| 105 | + t.Fatalf("%+v %v", found, err) | |
| 106 | + } | |
| 107 | + if err := db.DeleteChannel(direct.ID); err != nil { | |
| 108 | + t.Fatal(err) | |
| 109 | + } | |
| 110 | + if _, err := db.DirectChannel("alice"); !errors.Is(err, store.ErrNotFound) { | |
| 111 | + t.Fatal("the direct index goes with the channel") | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +func TestMembersKeepTheirJoiningOrderAndTheirSession(t *testing.T) { | |
| 116 | + db := memory(t) | |
| 117 | + start := time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC) | |
| 118 | + _ = db.PutMember(store.Membership{ChannelID: "c", Agent: "zoe", JoinedAt: start}) | |
| 119 | + _ = db.PutMember(store.Membership{ChannelID: "c", Agent: "alice", JoinedAt: start.Add(time.Minute)}) | |
| 120 | + _ = db.PutMember(store.Membership{ChannelID: "c", Agent: "zoe", JoinedAt: start, SessionID: "s1", LastSeen: "m9"}) | |
| 121 | + _ = db.PutMember(store.Membership{ChannelID: "other", Agent: "bob"}) | |
| 122 | + members, err := db.Members("c") | |
| 123 | + if err != nil || len(members) != 2 || members[0].Agent != "zoe" || members[0].SessionID != "s1" || members[1].Agent != "alice" { | |
| 124 | + t.Fatalf("%+v %v", members, err) | |
| 125 | + } | |
| 126 | + _ = db.DeleteMember("c", "zoe") | |
| 127 | + if members, _ = db.Members("c"); len(members) != 1 { | |
| 128 | + t.Fatalf("%+v", members) | |
| 129 | + } | |
| 130 | +} | |
| 131 | + | |
| 132 | +func TestMessagesArePagedBackwards(t *testing.T) { | |
| 133 | + db := memory(t) | |
| 134 | + var ids []string | |
| 135 | + for i := 0; i < 5; i++ { | |
| 136 | + id := store.NewID() | |
| 137 | + ids = append(ids, id) | |
| 138 | + _ = db.PutMessage("c", id, []byte(fmt.Sprint(i))) | |
| 139 | + } | |
| 140 | + _ = db.PutMessage("c2", store.NewID(), []byte("elsewhere")) | |
| 141 | + | |
| 142 | + last, more, err := db.Messages("c", "", 2) | |
| 143 | + if err != nil || !more || string(last[0]) != "3" || string(last[1]) != "4" { | |
| 144 | + t.Fatalf("the last two, oldest first: %q more=%v %v", last, more, err) | |
| 145 | + } | |
| 146 | + older, more, _ := db.Messages("c", ids[3], 2) | |
| 147 | + if !more || string(older[0]) != "1" || string(older[1]) != "2" { | |
| 148 | + t.Fatalf("the two before the 4th: %q more=%v", older, more) | |
| 149 | + } | |
| 150 | + first, more, _ := db.Messages("c", ids[1], 10) | |
| 151 | + if more || len(first) != 1 || string(first[0]) != "0" { | |
| 152 | + t.Fatalf("the beginning: %q more=%v", first, more) | |
| 153 | + } | |
| 154 | + _ = db.PutMessage("c", ids[4], []byte("4 edited")) | |
| 155 | + if last, _, _ := db.Messages("c", "", 1); string(last[0]) != "4 edited" { | |
| 156 | + t.Fatalf("a message is replaced in place: %q", last) | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +func TestValuesAreKept(t *testing.T) { | |
| 161 | + db := memory(t) | |
| 162 | + if _, found, _ := db.Value("commands/garfield"); found { | |
| 163 | + t.Fatal("nothing yet") | |
| 164 | + } | |
| 165 | + _ = db.SetValue("commands/garfield", []byte(`["idioms"]`)) | |
| 166 | + if value, found, err := db.Value("commands/garfield"); !found || err != nil || string(value) != `["idioms"]` { | |
| 167 | + t.Fatalf("%q %v %v", value, found, err) | |
| 168 | + } | |
| 169 | +} | |
| 170 | + | |
| 171 | +func TestTheDatabaseSurvivesAReopen(t *testing.T) { | |
| 172 | + dir := filepath.Join(t.TempDir(), "db") | |
| 173 | + db, err := store.Open(dir) | |
| 174 | + if err != nil { | |
| 175 | + t.Fatal(err) | |
| 176 | + } | |
| 177 | + channel := store.Channel{ID: store.NewID(), Name: "general"} | |
| 178 | + _ = db.CreateChannel(channel) | |
| 179 | + _ = db.Close() | |
| 180 | + | |
| 181 | + db, err = store.Open(dir) | |
| 182 | + if err != nil { | |
| 183 | + t.Fatal(err) | |
| 184 | + } | |
| 185 | + if found, err := db.ChannelByName("general"); err != nil || found.ID != channel.ID { | |
| 186 | + t.Fatalf("after reopening: %+v %v", found, err) | |
| 187 | + } | |
| 188 | + _ = db.Close() | |
| 189 | +} | |
| 190 | + | |
| 191 | +func TestOperationsAfterCloseAreRefused(t *testing.T) { | |
| 192 | + db, _ := store.OpenInMemory() | |
| 193 | + _ = db.Close() | |
| 194 | + if err := db.PutMessage("c", store.NewID(), []byte("late")); !errors.Is(err, store.ErrClosed) { | |
| 195 | + t.Fatalf("an answer ending after quitting must not crash: %v", err) | |
| 196 | + } | |
| 197 | + if _, err := db.Channels(); !errors.Is(err, store.ErrClosed) { | |
| 198 | + t.Fatalf("%v", err) | |
| 199 | + } | |
| 200 | + if err := db.Close(); err != nil { | |
| 201 | + t.Fatal("closing twice is harmless") | |
| 202 | + } | |
| 203 | +} | |
| 204 | + | |
| 205 | +func TestDirForDependsOnTheConfigurationPath(t *testing.T) { | |
| 206 | + t.Setenv("HOME", t.TempDir()) | |
| 207 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 208 | + one, err := store.DirFor("/a/config.yaml") | |
| 209 | + if err != nil { | |
| 210 | + t.Fatal(err) | |
| 211 | + } | |
| 212 | + two, _ := store.DirFor("/b/config.yaml") | |
| 213 | + again, _ := store.DirFor("/a/config.yaml") | |
| 214 | + if one == two || one != again || !strings.Contains(one, filepath.Join("sidekicks", "data")) { | |
| 215 | + t.Fatalf("%s %s %s", one, two, again) | |
| 216 | + } | |
| 217 | +} | |
| 218 | + | |
| 219 | +func BenchmarkLastHundredMessages(b *testing.B) { | |
| 220 | + db, _ := store.OpenInMemory() | |
| 221 | + defer func() { _ = db.Close() }() | |
| 222 | + payload := []byte(strings.Repeat("x", 2000)) | |
| 223 | + for i := 0; i < 5000; i++ { | |
| 224 | + _ = db.PutMessage("c", store.NewID(), payload) | |
| 225 | + } | |
| 226 | + b.ResetTimer() | |
| 227 | + for i := 0; i < b.N; i++ { | |
| 228 | + _, _, _ = db.Messages("c", "", 100) | |
| 229 | + } | |
| 230 | +} | |
| 231 | + | |
| 232 | +func ExampleStore_Messages() { | |
| 233 | + db, _ := store.OpenInMemory() | |
| 234 | + defer func() { _ = db.Close() }() | |
| 235 | + for _, text := range []string{"one", "two", "three"} { | |
| 236 | + _ = db.PutMessage("general", store.NewID(), []byte(text)) | |
| 237 | + } | |
| 238 | + last, more, _ := db.Messages("general", "", 2) | |
| 239 | + fmt.Printf("%s %s more=%v\n", last[0], last[1], more) | |
| 240 | + // Output: two three more=true | |
| 241 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,241 @@ | |||
| 1 | +// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE | ||
| 2 | + | ||
| 3 | +package store_test | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "errors" | ||
| 7 | + "fmt" | ||
| 8 | + "path/filepath" | ||
| 9 | + "strings" | ||
| 10 | + "testing" | ||
| 11 | + "time" | ||
| 12 | + | ||
| 13 | + "git.rickub.com/bots-garden/sidekicks/internal/store" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +func memory(t *testing.T) *store.Store { | ||
| 17 | + t.Helper() | ||
| 18 | + db, err := store.OpenInMemory() | ||
| 19 | + if err != nil { | ||
| 20 | + t.Fatal(err) | ||
| 21 | + } | ||
| 22 | + t.Cleanup(func() { _ = db.Close() }) | ||
| 23 | + return db | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +func TestIDsSortByCreation(t *testing.T) { | ||
| 27 | + previous := store.NewID() | ||
| 28 | + for i := 0; i < 1000; i++ { | ||
| 29 | + next := store.NewID() | ||
| 30 | + if next <= previous { | ||
| 31 | + t.Fatalf("%s should sort after %s", next, previous) | ||
| 32 | + } | ||
| 33 | + previous = next | ||
| 34 | + } | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +func TestAChannelIsCreatedFoundAndRenamed(t *testing.T) { | ||
| 38 | + db := memory(t) | ||
| 39 | + general := store.Channel{ID: store.NewID(), Name: "Général"} | ||
| 40 | + if err := db.CreateChannel(general); err != nil { | ||
| 41 | + t.Fatal(err) | ||
| 42 | + } | ||
| 43 | + if found, err := db.ChannelByName(" général "); err != nil || found.ID != general.ID { | ||
| 44 | + t.Fatalf("lookup by name ignores case and spaces: %+v %v", found, err) | ||
| 45 | + } | ||
| 46 | + other := store.Channel{ID: store.NewID(), Name: "GÉNÉRAL"} | ||
| 47 | + if err := db.CreateChannel(other); !errors.Is(err, store.ErrNameTaken) { | ||
| 48 | + t.Fatalf("want ErrNameTaken, got %v", err) | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + general.Name = "team" | ||
| 52 | + if err := db.UpdateChannel(general); err != nil { | ||
| 53 | + t.Fatal(err) | ||
| 54 | + } | ||
| 55 | + if _, err := db.ChannelByName("général"); !errors.Is(err, store.ErrNotFound) { | ||
| 56 | + t.Fatalf("the old name should be free: %v", err) | ||
| 57 | + } | ||
| 58 | + if err := db.CreateChannel(other); err != nil { | ||
| 59 | + t.Fatalf("the old name can be reused: %v", err) | ||
| 60 | + } | ||
| 61 | + other.Name = "Team" | ||
| 62 | + if err := db.UpdateChannel(other); !errors.Is(err, store.ErrNameTaken) { | ||
| 63 | + t.Fatalf("renaming onto a taken name: %v", err) | ||
| 64 | + } | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +func TestDeletingAChannelDeletesItsMembersMessagesAndName(t *testing.T) { | ||
| 68 | + db := memory(t) | ||
| 69 | + general := store.Channel{ID: store.NewID(), Name: "team"} | ||
| 70 | + other := store.Channel{ID: store.NewID(), Name: "other"} | ||
| 71 | + _ = db.CreateChannel(general) | ||
| 72 | + _ = db.CreateChannel(other) | ||
| 73 | + _ = db.PutMember(store.Membership{ChannelID: general.ID, Agent: "alice"}) | ||
| 74 | + _ = db.PutMessage(general.ID, store.NewID(), []byte("hello")) | ||
| 75 | + if err := db.DeleteChannel(general.ID); err != nil { | ||
| 76 | + t.Fatal(err) | ||
| 77 | + } | ||
| 78 | + if _, err := db.Channel(general.ID); !errors.Is(err, store.ErrNotFound) { | ||
| 79 | + t.Fatalf("deleted: %v", err) | ||
| 80 | + } | ||
| 81 | + members, _ := db.Members(general.ID) | ||
| 82 | + messages, _, _ := db.Messages(general.ID, "", 10) | ||
| 83 | + if len(members) != 0 || len(messages) != 0 { | ||
| 84 | + t.Fatalf("members and messages go with the channel: %v %v", members, messages) | ||
| 85 | + } | ||
| 86 | + if _, err := db.ChannelByName("team"); !errors.Is(err, store.ErrNotFound) { | ||
| 87 | + t.Fatal("the name index goes with the channel") | ||
| 88 | + } | ||
| 89 | + if channels, _ := db.Channels(); len(channels) != 1 || channels[0].ID != other.ID { | ||
| 90 | + t.Fatalf("channels: %+v", channels) | ||
| 91 | + } | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +func TestADirectChannelIsFoundByItsAgentAndHasNoName(t *testing.T) { | ||
| 95 | + db := memory(t) | ||
| 96 | + direct := store.Channel{ID: store.NewID(), Direct: "alice"} | ||
| 97 | + named := store.Channel{ID: store.NewID(), Name: "alice"} | ||
| 98 | + if err := db.CreateChannel(direct); err != nil { | ||
| 99 | + t.Fatal(err) | ||
| 100 | + } | ||
| 101 | + if err := db.CreateChannel(named); err != nil { | ||
| 102 | + t.Fatalf("a direct channel does not take a name: %v", err) | ||
| 103 | + } | ||
| 104 | + if found, err := db.DirectChannel("alice"); err != nil || found.ID != direct.ID { | ||
| 105 | + t.Fatalf("%+v %v", found, err) | ||
| 106 | + } | ||
| 107 | + if err := db.DeleteChannel(direct.ID); err != nil { | ||
| 108 | + t.Fatal(err) | ||
| 109 | + } | ||
| 110 | + if _, err := db.DirectChannel("alice"); !errors.Is(err, store.ErrNotFound) { | ||
| 111 | + t.Fatal("the direct index goes with the channel") | ||
| 112 | + } | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +func TestMembersKeepTheirJoiningOrderAndTheirSession(t *testing.T) { | ||
| 116 | + db := memory(t) | ||
| 117 | + start := time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC) | ||
| 118 | + _ = db.PutMember(store.Membership{ChannelID: "c", Agent: "zoe", JoinedAt: start}) | ||
| 119 | + _ = db.PutMember(store.Membership{ChannelID: "c", Agent: "alice", JoinedAt: start.Add(time.Minute)}) | ||
| 120 | + _ = db.PutMember(store.Membership{ChannelID: "c", Agent: "zoe", JoinedAt: start, SessionID: "s1", LastSeen: "m9"}) | ||
| 121 | + _ = db.PutMember(store.Membership{ChannelID: "other", Agent: "bob"}) | ||
| 122 | + members, err := db.Members("c") | ||
| 123 | + if err != nil || len(members) != 2 || members[0].Agent != "zoe" || members[0].SessionID != "s1" || members[1].Agent != "alice" { | ||
| 124 | + t.Fatalf("%+v %v", members, err) | ||
| 125 | + } | ||
| 126 | + _ = db.DeleteMember("c", "zoe") | ||
| 127 | + if members, _ = db.Members("c"); len(members) != 1 { | ||
| 128 | + t.Fatalf("%+v", members) | ||
| 129 | + } | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +func TestMessagesArePagedBackwards(t *testing.T) { | ||
| 133 | + db := memory(t) | ||
| 134 | + var ids []string | ||
| 135 | + for i := 0; i < 5; i++ { | ||
| 136 | + id := store.NewID() | ||
| 137 | + ids = append(ids, id) | ||
| 138 | + _ = db.PutMessage("c", id, []byte(fmt.Sprint(i))) | ||
| 139 | + } | ||
| 140 | + _ = db.PutMessage("c2", store.NewID(), []byte("elsewhere")) | ||
| 141 | + | ||
| 142 | + last, more, err := db.Messages("c", "", 2) | ||
| 143 | + if err != nil || !more || string(last[0]) != "3" || string(last[1]) != "4" { | ||
| 144 | + t.Fatalf("the last two, oldest first: %q more=%v %v", last, more, err) | ||
| 145 | + } | ||
| 146 | + older, more, _ := db.Messages("c", ids[3], 2) | ||
| 147 | + if !more || string(older[0]) != "1" || string(older[1]) != "2" { | ||
| 148 | + t.Fatalf("the two before the 4th: %q more=%v", older, more) | ||
| 149 | + } | ||
| 150 | + first, more, _ := db.Messages("c", ids[1], 10) | ||
| 151 | + if more || len(first) != 1 || string(first[0]) != "0" { | ||
| 152 | + t.Fatalf("the beginning: %q more=%v", first, more) | ||
| 153 | + } | ||
| 154 | + _ = db.PutMessage("c", ids[4], []byte("4 edited")) | ||
| 155 | + if last, _, _ := db.Messages("c", "", 1); string(last[0]) != "4 edited" { | ||
| 156 | + t.Fatalf("a message is replaced in place: %q", last) | ||
| 157 | + } | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +func TestValuesAreKept(t *testing.T) { | ||
| 161 | + db := memory(t) | ||
| 162 | + if _, found, _ := db.Value("commands/garfield"); found { | ||
| 163 | + t.Fatal("nothing yet") | ||
| 164 | + } | ||
| 165 | + _ = db.SetValue("commands/garfield", []byte(`["idioms"]`)) | ||
| 166 | + if value, found, err := db.Value("commands/garfield"); !found || err != nil || string(value) != `["idioms"]` { | ||
| 167 | + t.Fatalf("%q %v %v", value, found, err) | ||
| 168 | + } | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +func TestTheDatabaseSurvivesAReopen(t *testing.T) { | ||
| 172 | + dir := filepath.Join(t.TempDir(), "db") | ||
| 173 | + db, err := store.Open(dir) | ||
| 174 | + if err != nil { | ||
| 175 | + t.Fatal(err) | ||
| 176 | + } | ||
| 177 | + channel := store.Channel{ID: store.NewID(), Name: "general"} | ||
| 178 | + _ = db.CreateChannel(channel) | ||
| 179 | + _ = db.Close() | ||
| 180 | + | ||
| 181 | + db, err = store.Open(dir) | ||
| 182 | + if err != nil { | ||
| 183 | + t.Fatal(err) | ||
| 184 | + } | ||
| 185 | + if found, err := db.ChannelByName("general"); err != nil || found.ID != channel.ID { | ||
| 186 | + t.Fatalf("after reopening: %+v %v", found, err) | ||
| 187 | + } | ||
| 188 | + _ = db.Close() | ||
| 189 | +} | ||
| 190 | + | ||
| 191 | +func TestOperationsAfterCloseAreRefused(t *testing.T) { | ||
| 192 | + db, _ := store.OpenInMemory() | ||
| 193 | + _ = db.Close() | ||
| 194 | + if err := db.PutMessage("c", store.NewID(), []byte("late")); !errors.Is(err, store.ErrClosed) { | ||
| 195 | + t.Fatalf("an answer ending after quitting must not crash: %v", err) | ||
| 196 | + } | ||
| 197 | + if _, err := db.Channels(); !errors.Is(err, store.ErrClosed) { | ||
| 198 | + t.Fatalf("%v", err) | ||
| 199 | + } | ||
| 200 | + if err := db.Close(); err != nil { | ||
| 201 | + t.Fatal("closing twice is harmless") | ||
| 202 | + } | ||
| 203 | +} | ||
| 204 | + | ||
| 205 | +func TestDirForDependsOnTheConfigurationPath(t *testing.T) { | ||
| 206 | + t.Setenv("HOME", t.TempDir()) | ||
| 207 | + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | ||
| 208 | + one, err := store.DirFor("/a/config.yaml") | ||
| 209 | + if err != nil { | ||
| 210 | + t.Fatal(err) | ||
| 211 | + } | ||
| 212 | + two, _ := store.DirFor("/b/config.yaml") | ||
| 213 | + again, _ := store.DirFor("/a/config.yaml") | ||
| 214 | + if one == two || one != again || !strings.Contains(one, filepath.Join("sidekicks", "data")) { | ||
| 215 | + t.Fatalf("%s %s %s", one, two, again) | ||
| 216 | + } | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +func BenchmarkLastHundredMessages(b *testing.B) { | ||
| 220 | + db, _ := store.OpenInMemory() | ||
| 221 | + defer func() { _ = db.Close() }() | ||
| 222 | + payload := []byte(strings.Repeat("x", 2000)) | ||
| 223 | + for i := 0; i < 5000; i++ { | ||
| 224 | + _ = db.PutMessage("c", store.NewID(), payload) | ||
| 225 | + } | ||
| 226 | + b.ResetTimer() | ||
| 227 | + for i := 0; i < b.N; i++ { | ||
| 228 | + _, _, _ = db.Messages("c", "", 100) | ||
| 229 | + } | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +func ExampleStore_Messages() { | ||
| 233 | + db, _ := store.OpenInMemory() | ||
| 234 | + defer func() { _ = db.Close() }() | ||
| 235 | + for _, text := range []string{"one", "two", "three"} { | ||
| 236 | + _ = db.PutMessage("general", store.NewID(), []byte(text)) | ||
| 237 | + } | ||
| 238 | + last, more, _ := db.Messages("general", "", 2) | ||
| 239 | + fmt.Printf("%s %s more=%v\n", last[0], last[1], more) | ||
| 240 | + // Output: two three more=true | ||
| 241 | +} | ||
modified
main.go +18 -11 | @@ -17,8 +17,8 @@ import ( | ||
| 17 | 17 | "os" |
| 18 | 18 | |
| 19 | 19 | "git.rickub.com/bots-garden/sidekicks/internal/app" |
| 20 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | |
| 20 | 21 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 21 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | |
| 22 | 22 | "git.rickub.com/bots-garden/sidekicks/internal/events" |
| 23 | 23 | "git.rickub.com/bots-garden/sidekicks/internal/prefs" |
| 24 | 24 | "github.com/wailsapp/wails/v3/pkg/application" |
| @@ -36,12 +36,14 @@ func init() { | ||
| 36 | 36 | application.RegisterEvent[events.ConfigChangedPayload](events.ConfigChanged) |
| 37 | 37 | application.RegisterEvent[events.AgentStatePayload](events.AgentState) |
| 38 | 38 | application.RegisterEvent[events.AgentLogPayload](events.AgentLog) |
| 39 | - application.RegisterEvent[conversations.MessagePayload](events.MessageUpdated) | |
| 40 | - application.RegisterEvent[conversations.DeltaPayload](events.MessageDelta) | |
| 41 | - application.RegisterEvent[conversations.TurnPayload](events.TurnChanged) | |
| 42 | - application.RegisterEvent[conversations.Permission](events.PermissionRequested) | |
| 43 | - application.RegisterEvent[conversations.PermissionResolvedPayload](events.PermissionResolved) | |
| 44 | - application.RegisterEvent[conversations.UsagePayload](events.UsageChanged) | |
| 39 | + application.RegisterEvent[channels.ListPayload](events.ChannelsChanged) | |
| 40 | + application.RegisterEvent[channels.MessagePayload](events.MessageUpdated) | |
| 41 | + application.RegisterEvent[channels.DeltaPayload](events.MessageDelta) | |
| 42 | + application.RegisterEvent[channels.TurnPayload](events.TurnChanged) | |
| 43 | + application.RegisterEvent[channels.Permission](events.PermissionRequested) | |
| 44 | + application.RegisterEvent[channels.PermissionResolvedPayload](events.PermissionResolved) | |
| 45 | + application.RegisterEvent[channels.UsagePayload](events.UsageChanged) | |
| 46 | + application.RegisterEvent[channels.CommandsPayload](events.CommandsChanged) | |
| 45 | 47 | } |
| 46 | 48 | |
| 47 | 49 | func main() { |
| @@ -62,10 +64,15 @@ func main() { | ||
| 62 | 64 | os.Exit(1) |
| 63 | 65 | } |
| 64 | 66 | |
| 65 | - // The emitter and the file dialog are called once the application | |
| 66 | - // exists; core only uses them after Run has started. | |
| 67 | + // The file dialog is used once the application exists. Events sent while | |
| 68 | + // core is created (declared channels) have nobody to reach yet: the | |
| 69 | + // interface reads the initial state from the services. | |
| 67 | 70 | var wailsApp *application.App |
| 68 | - emitter := events.EmitterFunc(func(name string, payload any) { wailsApp.Event.Emit(name, payload) }) | |
| 71 | + emitter := events.EmitterFunc(func(name string, payload any) { | |
| 72 | + if wailsApp != nil { | |
| 73 | + wailsApp.Event.Emit(name, payload) | |
| 74 | + } | |
| 75 | + }) | |
| 69 | 76 | pickFile := func() (string, error) { |
| 70 | 77 | return wailsApp.Dialog.OpenFile(). |
| 71 | 78 | SetTitle("Open a Sidekicks configuration"). |
| @@ -81,7 +88,7 @@ func main() { | ||
| 81 | 88 | Services: []application.Service{ |
| 82 | 89 | application.NewService(&app.AgentService{Core: core}), |
| 83 | 90 | application.NewService(&app.ConfigService{Core: core}), |
| 84 | - application.NewService(&app.ChatService{Core: core}), | |
| 91 | + application.NewService(&app.ChannelService{Core: core}), | |
| 85 | 92 | }, |
| 86 | 93 | Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)}, |
| 87 | 94 | Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true}, |
| @@ -17,8 +17,8 @@ import ( | |||
| 17 | "os" | 17 | "os" |
| 18 | 18 | ||
| 19 | "git.rickub.com/bots-garden/sidekicks/internal/app" | 19 | "git.rickub.com/bots-garden/sidekicks/internal/app" |
| 20 | + "git.rickub.com/bots-garden/sidekicks/internal/channels" | ||
| 20 | "git.rickub.com/bots-garden/sidekicks/internal/config" | 21 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 21 | - "git.rickub.com/bots-garden/sidekicks/internal/conversations" | ||
| 22 | "git.rickub.com/bots-garden/sidekicks/internal/events" | 22 | "git.rickub.com/bots-garden/sidekicks/internal/events" |
| 23 | "git.rickub.com/bots-garden/sidekicks/internal/prefs" | 23 | "git.rickub.com/bots-garden/sidekicks/internal/prefs" |
| 24 | "github.com/wailsapp/wails/v3/pkg/application" | 24 | "github.com/wailsapp/wails/v3/pkg/application" |
| @@ -36,12 +36,14 @@ func init() { | |||
| 36 | application.RegisterEvent[events.ConfigChangedPayload](events.ConfigChanged) | 36 | application.RegisterEvent[events.ConfigChangedPayload](events.ConfigChanged) |
| 37 | application.RegisterEvent[events.AgentStatePayload](events.AgentState) | 37 | application.RegisterEvent[events.AgentStatePayload](events.AgentState) |
| 38 | application.RegisterEvent[events.AgentLogPayload](events.AgentLog) | 38 | application.RegisterEvent[events.AgentLogPayload](events.AgentLog) |
| 39 | - application.RegisterEvent[conversations.MessagePayload](events.MessageUpdated) | 39 | + application.RegisterEvent[channels.ListPayload](events.ChannelsChanged) |
| 40 | - application.RegisterEvent[conversations.DeltaPayload](events.MessageDelta) | 40 | + application.RegisterEvent[channels.MessagePayload](events.MessageUpdated) |
| 41 | - application.RegisterEvent[conversations.TurnPayload](events.TurnChanged) | 41 | + application.RegisterEvent[channels.DeltaPayload](events.MessageDelta) |
| 42 | - application.RegisterEvent[conversations.Permission](events.PermissionRequested) | 42 | + application.RegisterEvent[channels.TurnPayload](events.TurnChanged) |
| 43 | - application.RegisterEvent[conversations.PermissionResolvedPayload](events.PermissionResolved) | 43 | + application.RegisterEvent[channels.Permission](events.PermissionRequested) |
| 44 | - application.RegisterEvent[conversations.UsagePayload](events.UsageChanged) | 44 | + application.RegisterEvent[channels.PermissionResolvedPayload](events.PermissionResolved) |
| 45 | + application.RegisterEvent[channels.UsagePayload](events.UsageChanged) | ||
| 46 | + application.RegisterEvent[channels.CommandsPayload](events.CommandsChanged) | ||
| 45 | } | 47 | } |
| 46 | 48 | ||
| 47 | func main() { | 49 | func main() { |
| @@ -62,10 +64,15 @@ func main() { | |||
| 62 | os.Exit(1) | 64 | os.Exit(1) |
| 63 | } | 65 | } |
| 64 | 66 | ||
| 65 | - // The emitter and the file dialog are called once the application | 67 | + // The file dialog is used once the application exists. Events sent while |
| 66 | - // exists; core only uses them after Run has started. | 68 | + // core is created (declared channels) have nobody to reach yet: the |
| 69 | + // interface reads the initial state from the services. | ||
| 67 | var wailsApp *application.App | 70 | var wailsApp *application.App |
| 68 | - emitter := events.EmitterFunc(func(name string, payload any) { wailsApp.Event.Emit(name, payload) }) | 71 | + emitter := events.EmitterFunc(func(name string, payload any) { |
| 72 | + if wailsApp != nil { | ||
| 73 | + wailsApp.Event.Emit(name, payload) | ||
| 74 | + } | ||
| 75 | + }) | ||
| 69 | pickFile := func() (string, error) { | 76 | pickFile := func() (string, error) { |
| 70 | return wailsApp.Dialog.OpenFile(). | 77 | return wailsApp.Dialog.OpenFile(). |
| 71 | SetTitle("Open a Sidekicks configuration"). | 78 | SetTitle("Open a Sidekicks configuration"). |
| @@ -81,7 +88,7 @@ func main() { | |||
| 81 | Services: []application.Service{ | 88 | Services: []application.Service{ |
| 82 | application.NewService(&app.AgentService{Core: core}), | 89 | application.NewService(&app.AgentService{Core: core}), |
| 83 | application.NewService(&app.ConfigService{Core: core}), | 90 | application.NewService(&app.ConfigService{Core: core}), |
| 84 | - application.NewService(&app.ChatService{Core: core}), | 91 | + application.NewService(&app.ChannelService{Core: core}), |
| 85 | }, | 92 | }, |
| 86 | Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)}, | 93 | Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)}, |
| 87 | Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true}, | 94 | Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true}, |
modified
scripts/agents.sh +6 -1 | @@ -112,7 +112,12 @@ with_lock() { | ||
| 112 | 112 | done |
| 113 | 113 | local status=0 |
| 114 | 114 | "$@" || status=$? |
| 115 | - rmdir "$run_dir/.lock" | |
| 115 | + # Never fatal: on a folder shared between two systems (the Linux sandbox | |
| 116 | + # mounting the Mac's folder), mkdir is not atomic, two agents can both | |
| 117 | + # "take" the lock, and the second rmdir fails. Under set -e that failure | |
| 118 | + # ended the cleanup of an agent stopping with another one, and its model | |
| 119 | + # stayed loaded. | |
| 120 | + rmdir "$run_dir/.lock" 2>/dev/null || true | |
| 116 | 121 | return "$status" |
| 117 | 122 | } |
| 118 | 123 | |
| @@ -112,7 +112,12 @@ with_lock() { | |||
| 112 | done | 112 | done |
| 113 | local status=0 | 113 | local status=0 |
| 114 | "$@" || status=$? | 114 | "$@" || status=$? |
| 115 | - rmdir "$run_dir/.lock" | 115 | + # Never fatal: on a folder shared between two systems (the Linux sandbox |
| 116 | + # mounting the Mac's folder), mkdir is not atomic, two agents can both | ||
| 117 | + # "take" the lock, and the second rmdir fails. Under set -e that failure | ||
| 118 | + # ended the cleanup of an agent stopping with another one, and its model | ||
| 119 | + # stayed loaded. | ||
| 120 | + rmdir "$run_dir/.lock" 2>/dev/null || true | ||
| 116 | return "$status" | 121 | return "$status" |
| 117 | } | 122 | } |
| 118 | 123 | ||
modified
scripts/agents_test.sh +15 -0 | @@ -185,6 +185,21 @@ test_run_keeps_a_model_still_used_by_another_agent() { | ||
| 185 | 185 | grep -q '"model":"qwen-2b"' "$work/curl.generate" |
| 186 | 186 | } |
| 187 | 187 | |
| 188 | +# The lock folder can disappear under an agent (two agents "took" it on a | |
| 189 | +# shared folder): its cleanup must still unload the model. | |
| 190 | +test_run_unloads_its_model_even_when_the_lock_is_already_gone() { | |
| 191 | + cat > "$work/bin/rmdir" <<'EOF' | |
| 192 | +#!/usr/bin/env bash | |
| 193 | +/bin/rmdir "$@" 2>/dev/null | |
| 194 | +exit 1 | |
| 195 | +EOF | |
| 196 | + chmod +x "$work/bin/rmdir" | |
| 197 | + echo "gemma-e2b" > "$work/loaded" | |
| 198 | + start_agent alice | |
| 199 | + stop_agent alice | |
| 200 | + grep -q '"model":"gemma-e2b","keep_alive":0' "$work/curl.generate" | |
| 201 | +} | |
| 202 | + | |
| 188 | 203 | test_no_unload_variable_keeps_the_model() { |
| 189 | 204 | export SIDEKICKS_NO_UNLOAD=1 |
| 190 | 205 | echo "gemma-e2b" > "$work/loaded" |
| @@ -185,6 +185,21 @@ test_run_keeps_a_model_still_used_by_another_agent() { | |||
| 185 | grep -q '"model":"qwen-2b"' "$work/curl.generate" | 185 | grep -q '"model":"qwen-2b"' "$work/curl.generate" |
| 186 | } | 186 | } |
| 187 | 187 | ||
| 188 | +# The lock folder can disappear under an agent (two agents "took" it on a | ||
| 189 | +# shared folder): its cleanup must still unload the model. | ||
| 190 | +test_run_unloads_its_model_even_when_the_lock_is_already_gone() { | ||
| 191 | + cat > "$work/bin/rmdir" <<'EOF' | ||
| 192 | +#!/usr/bin/env bash | ||
| 193 | +/bin/rmdir "$@" 2>/dev/null | ||
| 194 | +exit 1 | ||
| 195 | +EOF | ||
| 196 | + chmod +x "$work/bin/rmdir" | ||
| 197 | + echo "gemma-e2b" > "$work/loaded" | ||
| 198 | + start_agent alice | ||
| 199 | + stop_agent alice | ||
| 200 | + grep -q '"model":"gemma-e2b","keep_alive":0' "$work/curl.generate" | ||
| 201 | +} | ||
| 202 | + | ||
| 188 | test_no_unload_variable_keeps_the_model() { | 203 | test_no_unload_variable_keeps_the_model() { |
| 189 | export SIDEKICKS_NO_UNLOAD=1 | 204 | export SIDEKICKS_NO_UNLOAD=1 |
| 190 | echo "gemma-e2b" > "$work/loaded" | 205 | echo "gemma-e2b" > "$work/loaded" |