bots-garden/sidekickspublic Fork
⑂ 2db99c4
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/sidekicks.git
git clone ssh://git@rickub.com/bots-garden/sidekicks.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Merge feature/m3-channels-multi-agents into main

rickub committed 2026-10-29T17:41:18Z Browse files
2db99c4 parents: 67f78b5 1d19377
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 differBinary 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).
22
33 python3 .claude/skills/drawio-fancy-diagram/generator/examples/sidekicks_packages.py docs/diagrams
44 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)
1616 CENTERS = [x + W // 2 for x in COLUMNS]
1717
1818 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)
2020
2121 # 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")
2424 d.box(COLUMNS[4], 100, W, 115, "hook", "⚙️", "main.go (root)", ["flags · Wails app · window", "events · file dialog"])
2525 d.edge([(COLUMNS[0] + W, 157), (COLUMNS[2], 157)], "bindings · events", at=(450, 157), lw=130, both=True)
2626 d.edge([(COLUMNS[4], 157), (COLUMNS[2] + W, 157)], "creates", at=(1010, 157), lw=70)
2727
2828 # 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"])
3030 d.box(COLUMNS[1], 300, W, 110, "agent", "🤖", "internal/agents", ["lazy start · LRU limit", "idle stop · states · logs"])
3131 d.box(COLUMNS[2], 300, W, 110, "agent", "📋", "internal/config", ["load · validate · locate", "default · save · watch"])
3232 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)])
3737 d.edge([(COLUMNS[1] + W, 355), (COLUMNS[2], 355)])
3838
3939 # 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"])
4141 d.box(COLUMNS[1], 490, W, 105, "agent", "🔧", "internal/acp", ["ACP v1 client · sessions", "stdin → TERM → KILL"])
4242 d.box(COLUMNS[2], 490, W, 105, "agent", "🛡️", "schema", ["config JSON Schema", "ValidateConfig()"])
4343 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)])
4545 d.edge([(CENTERS[1], 410), (CENTERS[1], 490)])
4646 d.edge([(CENTERS[2], 410), (CENTERS[2], 490)])
4747 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)
5151 d.chip(COLUMNS[1], 670, W, 50, "acp-go-sdk → mm -acp", size=12)
5252 d.chip(COLUMNS[2], 670, W, 50, "jsonschema · yaml · fsnotify", size=12)
5353 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)])
5555 d.edge([(CENTERS[1], 595), (CENTERS[1], 670)], "stdio", at=(CENTERS[1] + 45, 632), lw=50)
5656 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)
5858 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/diagrams3 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.svg4 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 @@
8787 ## 2026-10-29 — M2 closed
8888
8989 - 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 closed87 ## 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 @@
44
55 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`).
66
7-## State (2026-10-29, M2 closed)
7+## State (2026-10-29, M3 closed)
88
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.
1010
1111 What exists:
1212
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).
1515 - Tooling: `Makefile`, golangci-lint (`tools/go.mod`), CI, `scripts/agents.sh`, `scripts/install-test-config.sh`, `scripts/check-license-headers.sh`.
1616
1717 ## Decisions in force
@@ -19,6 +19,7 @@ What exists:
1919 - ACP **v1**, via `github.com/coder/acp-go-sdk` v0.13.5, wrapped in `internal/acp` (ADR 0001). One ACP session per (channel, agent).
2020 - **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`).
2121 - 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`).
2223 - 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.
2324 - 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).
2425 - 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:
4445
4546 - Alice (gemma E2B), Bob and Riker (same Qwen3.5 2B), Garfield (gemma E4B, skills). Never more than 2 at once (MacBook Air 32 GB).
4647 - 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).
4749 - 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).
4850 - Riker loops ~40 times after `reject_once`, then the turn ends with JSON-RPC error -32603.
4951
@@ -52,5 +54,5 @@ What exists:
5254 - How to exec a shell / read files inside an `sbx` sandbox (issue #28).
5355 - 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.
5456 - 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.
5658 - 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 force17 ## 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:
4343 name: k33g
4444 email: ph.charriere@gmail.com
4545 createdAt: 2026-10-29T07:20:10.000Z
46- updatedAt: 2026-10-29T12:09:30.000Z
47- closedAt: 2026-10-29T12:09:30.000Z
4846 updatedAt: 2026-10-29T10:19:30.000Z
4947 closedAt: 2026-10-29T10:19:30.000Z
5048 - id: 2
@@ -55,8 +53,6 @@ tasks:
5553 name: k33g
5654 email: ph.charriere@gmail.com
5755 createdAt: 2026-10-29T07:20:10.000Z
58- updatedAt: 2026-10-29T12:09:30.000Z
59- closedAt: 2026-10-29T12:09:30.000Z
6056 updatedAt: 2026-10-29T10:19:30.000Z
6157 closedAt: 2026-10-29T10:19:30.000Z
6258 - id: 3
@@ -67,8 +63,6 @@ tasks:
6763 name: k33g
6864 email: ph.charriere@gmail.com
6965 createdAt: 2026-10-29T07:20:10.000Z
70- updatedAt: 2026-10-29T12:09:30.000Z
71- closedAt: 2026-10-29T12:09:30.000Z
7266 updatedAt: 2026-10-29T10:19:30.000Z
7367 closedAt: 2026-10-29T10:19:30.000Z
7468 - id: 4
@@ -79,8 +73,6 @@ tasks:
7973 name: k33g
8074 email: ph.charriere@gmail.com
8175 createdAt: 2026-10-29T07:20:10.000Z
82- updatedAt: 2026-10-29T12:09:30.000Z
83- closedAt: 2026-10-29T12:09:30.000Z
8476 updatedAt: 2026-10-29T10:19:30.000Z
8577 closedAt: 2026-10-29T10:19:30.000Z
8678 - id: 5
@@ -91,8 +83,6 @@ tasks:
9183 name: k33g
9284 email: ph.charriere@gmail.com
9385 createdAt: 2026-10-29T07:20:10.000Z
94- updatedAt: 2026-10-29T12:09:30.000Z
95- closedAt: 2026-10-29T12:09:30.000Z
9686 updatedAt: 2026-10-29T10:19:30.000Z
9787 closedAt: 2026-10-29T10:19:30.000Z
9888 - id: 6
@@ -43,8 +43,6 @@ tasks:
43 name: k33g43 name: k33g
44 email: ph.charriere@gmail.com44 email: ph.charriere@gmail.com
45 createdAt: 2026-10-29T07:20:10.000Z45 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.000Z46 updatedAt: 2026-10-29T10:19:30.000Z
49 closedAt: 2026-10-29T10:19:30.000Z47 closedAt: 2026-10-29T10:19:30.000Z
50 - id: 248 - id: 2
@@ -55,8 +53,6 @@ tasks:
55 name: k33g53 name: k33g
56 email: ph.charriere@gmail.com54 email: ph.charriere@gmail.com
57 createdAt: 2026-10-29T07:20:10.000Z55 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.000Z56 updatedAt: 2026-10-29T10:19:30.000Z
61 closedAt: 2026-10-29T10:19:30.000Z57 closedAt: 2026-10-29T10:19:30.000Z
62 - id: 358 - id: 3
@@ -67,8 +63,6 @@ tasks:
67 name: k33g63 name: k33g
68 email: ph.charriere@gmail.com64 email: ph.charriere@gmail.com
69 createdAt: 2026-10-29T07:20:10.000Z65 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.000Z66 updatedAt: 2026-10-29T10:19:30.000Z
73 closedAt: 2026-10-29T10:19:30.000Z67 closedAt: 2026-10-29T10:19:30.000Z
74 - id: 468 - id: 4
@@ -79,8 +73,6 @@ tasks:
79 name: k33g73 name: k33g
80 email: ph.charriere@gmail.com74 email: ph.charriere@gmail.com
81 createdAt: 2026-10-29T07:20:10.000Z75 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.000Z76 updatedAt: 2026-10-29T10:19:30.000Z
85 closedAt: 2026-10-29T10:19:30.000Z77 closedAt: 2026-10-29T10:19:30.000Z
86 - id: 578 - id: 5
@@ -91,8 +83,6 @@ tasks:
91 name: k33g83 name: k33g
92 email: ph.charriere@gmail.com84 email: ph.charriere@gmail.com
93 createdAt: 2026-10-29T07:20:10.000Z85 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.000Z86 updatedAt: 2026-10-29T10:19:30.000Z
97 closedAt: 2026-10-29T10:19:30.000Z87 closedAt: 2026-10-29T10:19:30.000Z
98 - id: 688 - id: 6
modified .tickets/issues/0020-persister-channels-et-messages-avec-pebble.yaml +32 -9
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 20
33 title: "Persister channels et messages avec Pebble"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:18.000Z
9-updatedAt: 2026-10-29T07:20:18.000Z
9+updatedAt: 2026-10-29T16:35:01.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::persistence"
@@ -35,50 +35,73 @@ body: |
3535 tasks:
3636 - id: 1
3737 title: "Schéma de clés et encodage"
38- state: open
38+ state: closed
3939 priority: high
4040 author:
4141 name: k33g
4242 email: ph.charriere@gmail.com
4343 createdAt: 2026-10-29T07:20:18.000Z
44+ updatedAt: 2026-10-29T16:35:01.000Z
45+ closedAt: 2026-10-29T16:35:01.000Z
4446 - id: 2
4547 title: "API store : channels"
46- state: open
48+ state: closed
4749 priority: high
4850 author:
4951 name: k33g
5052 email: ph.charriere@gmail.com
5153 createdAt: 2026-10-29T07:20:18.000Z
54+ updatedAt: 2026-10-29T16:35:01.000Z
55+ closedAt: 2026-10-29T16:35:01.000Z
5256 - id: 3
5357 title: "API store : messages (append, scan paginé)"
54- state: open
58+ state: closed
5559 priority: high
5660 author:
5761 name: k33g
5862 email: ph.charriere@gmail.com
5963 createdAt: 2026-10-29T07:20:18.000Z
64+ updatedAt: 2026-10-29T16:35:01.000Z
65+ closedAt: 2026-10-29T16:35:01.000Z
6066 - id: 4
6167 title: "Emplacement de la base lié à la config"
62- state: open
68+ state: closed
6369 priority: medium
6470 author:
6571 name: k33g
6672 email: ph.charriere@gmail.com
6773 createdAt: 2026-10-29T07:20:18.000Z
74+ updatedAt: 2026-10-29T16:35:01.000Z
75+ closedAt: 2026-10-29T16:35:01.000Z
6876 - id: 5
6977 title: "Version de schéma"
70- state: open
78+ state: closed
7179 priority: medium
7280 author:
7381 name: k33g
7482 email: ph.charriere@gmail.com
7583 createdAt: 2026-10-29T07:20:18.000Z
84+ updatedAt: 2026-10-29T16:35:01.000Z
85+ closedAt: 2026-10-29T16:35:01.000Z
7686 - id: 6
7787 title: "Tests unitaires"
78- state: open
88+ state: closed
7989 priority: medium
8090 author:
8191 name: k33g
8292 email: ph.charriere@gmail.com
8393 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: 202 id: 20
3 title: "Persister channels et messages avec Pebble"3 title: "Persister channels et messages avec Pebble"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:18.000Z8 createdAt: 2026-10-29T07:20:18.000Z
9-updatedAt: 2026-10-29T07:20:18.000Z9+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: 136 - id: 1
37 title: "Schéma de clés et encodage"37 title: "Schéma de clés et encodage"
38- state: open38+ state: closed
39 priority: high39 priority: high
40 author:40 author:
41 name: k33g41 name: k33g
42 email: ph.charriere@gmail.com42 email: ph.charriere@gmail.com
43 createdAt: 2026-10-29T07:20:18.000Z43 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: 246 - id: 2
45 title: "API store : channels"47 title: "API store : channels"
46- state: open48+ state: closed
47 priority: high49 priority: high
48 author:50 author:
49 name: k33g51 name: k33g
50 email: ph.charriere@gmail.com52 email: ph.charriere@gmail.com
51 createdAt: 2026-10-29T07:20:18.000Z53 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: 356 - id: 3
53 title: "API store : messages (append, scan paginé)"57 title: "API store : messages (append, scan paginé)"
54- state: open58+ state: closed
55 priority: high59 priority: high
56 author:60 author:
57 name: k33g61 name: k33g
58 email: ph.charriere@gmail.com62 email: ph.charriere@gmail.com
59 createdAt: 2026-10-29T07:20:18.000Z63 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: 466 - id: 4
61 title: "Emplacement de la base lié à la config"67 title: "Emplacement de la base lié à la config"
62- state: open68+ state: closed
63 priority: medium69 priority: medium
64 author:70 author:
65 name: k33g71 name: k33g
66 email: ph.charriere@gmail.com72 email: ph.charriere@gmail.com
67 createdAt: 2026-10-29T07:20:18.000Z73 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: 576 - id: 5
69 title: "Version de schéma"77 title: "Version de schéma"
70- state: open78+ state: closed
71 priority: medium79 priority: medium
72 author:80 author:
73 name: k33g81 name: k33g
74 email: ph.charriere@gmail.com82 email: ph.charriere@gmail.com
75 createdAt: 2026-10-29T07:20:18.000Z83 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: 686 - id: 6
77 title: "Tests unitaires"87 title: "Tests unitaires"
78- state: open88+ state: closed
79 priority: medium89 priority: medium
80 author:90 author:
81 name: k33g91 name: k33g
82 email: ph.charriere@gmail.com92 email: ph.charriere@gmail.com
83 createdAt: 2026-10-29T07:20:18.000Z93 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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 21
33 title: "Panel Channels : créer, renommer, supprimer"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:19.000Z
9-updatedAt: 2026-10-29T07:20:19.000Z
9+updatedAt: 2026-10-29T17:30:42.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::ui"
@@ -33,42 +33,82 @@ body: |
3333 tasks:
3434 - id: 1
3535 title: "Liste des channels dans le dock"
36- state: open
36+ state: closed
3737 priority: high
3838 author:
3939 name: k33g
4040 email: ph.charriere@gmail.com
4141 createdAt: 2026-10-29T07:20:19.000Z
42+ updatedAt: 2026-10-29T16:35:01.000Z
43+ closedAt: 2026-10-29T16:35:01.000Z
4244 - id: 2
4345 title: "Créer et renommer"
44- state: open
46+ state: closed
4547 priority: high
4648 author:
4749 name: k33g
4850 email: ph.charriere@gmail.com
4951 createdAt: 2026-10-29T07:20:19.000Z
52+ updatedAt: 2026-10-29T16:35:01.000Z
53+ closedAt: 2026-10-29T16:35:01.000Z
5054 - id: 3
5155 title: "Supprimer avec confirmation"
52- state: open
56+ state: closed
5357 priority: medium
5458 author:
5559 name: k33g
5660 email: ph.charriere@gmail.com
5761 createdAt: 2026-10-29T07:20:19.000Z
62+ updatedAt: 2026-10-29T16:35:01.000Z
63+ closedAt: 2026-10-29T16:35:01.000Z
5864 - id: 4
5965 title: "Afficher les agents membres"
60- state: open
66+ state: closed
6167 priority: medium
6268 author:
6369 name: k33g
6470 email: ph.charriere@gmail.com
6571 createdAt: 2026-10-29T07:20:19.000Z
72+ updatedAt: 2026-10-29T16:35:01.000Z
73+ closedAt: 2026-10-29T16:35:01.000Z
6674 - id: 5
6775 title: "Badge non lus"
68- state: open
76+ state: closed
6977 priority: medium
7078 author:
7179 name: k33g
7280 email: ph.charriere@gmail.com
7381 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: 212 id: 21
3 title: "Panel Channels : créer, renommer, supprimer"3 title: "Panel Channels : créer, renommer, supprimer"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:19.000Z8 createdAt: 2026-10-29T07:20:19.000Z
9-updatedAt: 2026-10-29T07:20:19.000Z9+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: 134 - id: 1
35 title: "Liste des channels dans le dock"35 title: "Liste des channels dans le dock"
36- state: open36+ state: closed
37 priority: high37 priority: high
38 author:38 author:
39 name: k33g39 name: k33g
40 email: ph.charriere@gmail.com40 email: ph.charriere@gmail.com
41 createdAt: 2026-10-29T07:20:19.000Z41 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: 244 - id: 2
43 title: "Créer et renommer"45 title: "Créer et renommer"
44- state: open46+ state: closed
45 priority: high47 priority: high
46 author:48 author:
47 name: k33g49 name: k33g
48 email: ph.charriere@gmail.com50 email: ph.charriere@gmail.com
49 createdAt: 2026-10-29T07:20:19.000Z51 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: 354 - id: 3
51 title: "Supprimer avec confirmation"55 title: "Supprimer avec confirmation"
52- state: open56+ state: closed
53 priority: medium57 priority: medium
54 author:58 author:
55 name: k33g59 name: k33g
56 email: ph.charriere@gmail.com60 email: ph.charriere@gmail.com
57 createdAt: 2026-10-29T07:20:19.000Z61 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: 464 - id: 4
59 title: "Afficher les agents membres"65 title: "Afficher les agents membres"
60- state: open66+ state: closed
61 priority: medium67 priority: medium
62 author:68 author:
63 name: k33g69 name: k33g
64 email: ph.charriere@gmail.com70 email: ph.charriere@gmail.com
65 createdAt: 2026-10-29T07:20:19.000Z71 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: 574 - id: 5
67 title: "Badge non lus"75 title: "Badge non lus"
68- state: open76+ state: closed
69 priority: medium77 priority: medium
70 author:78 author:
71 name: k33g79 name: k33g
72 email: ph.charriere@gmail.com80 email: ph.charriere@gmail.com
73 createdAt: 2026-10-29T07:20:19.000Z81 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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 22
33 title: "Invoquer un agent avec @ et autocomplétion"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:20.000Z
9-updatedAt: 2026-10-29T07:20:20.000Z
9+updatedAt: 2026-10-29T16:35:01.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::ui"
@@ -34,42 +34,61 @@ body: |
3434 tasks:
3535 - id: 1
3636 title: "Popup de mentions avec filtrage"
37- state: open
37+ state: closed
3838 priority: high
3939 author:
4040 name: k33g
4141 email: ph.charriere@gmail.com
4242 createdAt: 2026-10-29T07:20:20.000Z
43+ updatedAt: 2026-10-29T16:35:01.000Z
44+ closedAt: 2026-10-29T16:35:01.000Z
4345 - id: 2
4446 title: "Tab, ↑, ↓, Échap"
45- state: open
47+ state: closed
4648 priority: medium
4749 author:
4850 name: k33g
4951 email: ph.charriere@gmail.com
5052 createdAt: 2026-10-29T07:20:20.000Z
53+ updatedAt: 2026-10-29T16:35:01.000Z
54+ closedAt: 2026-10-29T16:35:01.000Z
5155 - id: 3
5256 title: "Invitation au premier appel"
53- state: open
57+ state: closed
5458 priority: high
5559 author:
5660 name: k33g
5761 email: ph.charriere@gmail.com
5862 createdAt: 2026-10-29T07:20:20.000Z
63+ updatedAt: 2026-10-29T16:35:01.000Z
64+ closedAt: 2026-10-29T16:35:01.000Z
5965 - id: 4
6066 title: "Message système d'arrivée"
61- state: open
67+ state: closed
6268 priority: medium
6369 author:
6470 name: k33g
6571 email: ph.charriere@gmail.com
6672 createdAt: 2026-10-29T07:20:20.000Z
73+ updatedAt: 2026-10-29T16:35:01.000Z
74+ closedAt: 2026-10-29T16:35:01.000Z
6775 - id: 5
6876 title: "Rendu de la mention dans les messages"
69- state: open
77+ state: closed
7078 priority: medium
7179 author:
7280 name: k33g
7381 email: ph.charriere@gmail.com
7482 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: 222 id: 22
3 title: "Invoquer un agent avec @ et autocomplétion"3 title: "Invoquer un agent avec @ et autocomplétion"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:20.000Z8 createdAt: 2026-10-29T07:20:20.000Z
9-updatedAt: 2026-10-29T07:20:20.000Z9+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: 135 - id: 1
36 title: "Popup de mentions avec filtrage"36 title: "Popup de mentions avec filtrage"
37- state: open37+ state: closed
38 priority: high38 priority: high
39 author:39 author:
40 name: k33g40 name: k33g
41 email: ph.charriere@gmail.com41 email: ph.charriere@gmail.com
42 createdAt: 2026-10-29T07:20:20.000Z42 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: 245 - id: 2
44 title: "Tab, ↑, ↓, Échap"46 title: "Tab, ↑, ↓, Échap"
45- state: open47+ state: closed
46 priority: medium48 priority: medium
47 author:49 author:
48 name: k33g50 name: k33g
49 email: ph.charriere@gmail.com51 email: ph.charriere@gmail.com
50 createdAt: 2026-10-29T07:20:20.000Z52 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: 355 - id: 3
52 title: "Invitation au premier appel"56 title: "Invitation au premier appel"
53- state: open57+ state: closed
54 priority: high58 priority: high
55 author:59 author:
56 name: k33g60 name: k33g
57 email: ph.charriere@gmail.com61 email: ph.charriere@gmail.com
58 createdAt: 2026-10-29T07:20:20.000Z62 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: 465 - id: 4
60 title: "Message système d'arrivée"66 title: "Message système d'arrivée"
61- state: open67+ state: closed
62 priority: medium68 priority: medium
63 author:69 author:
64 name: k33g70 name: k33g
65 email: ph.charriere@gmail.com71 email: ph.charriere@gmail.com
66 createdAt: 2026-10-29T07:20:20.000Z72 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: 575 - id: 5
68 title: "Rendu de la mention dans les messages"76 title: "Rendu de la mention dans les messages"
69- state: open77+ state: closed
70 priority: medium78 priority: medium
71 author:79 author:
72 name: k33g80 name: k33g
73 email: ph.charriere@gmail.com81 email: ph.charriere@gmail.com
74 createdAt: 2026-10-29T07:20:20.000Z82 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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 23
33 title: "Router les messages entre plusieurs agents d'un channel"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:21.000Z
9-updatedAt: 2026-10-29T07:20:21.000Z
9+updatedAt: 2026-10-29T16:35:01.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::acp"
@@ -35,42 +35,65 @@ body: |
3535 tasks:
3636 - id: 1
3737 title: "Règles de destinataire (mentions, dernier agent)"
38- state: open
38+ state: closed
3939 priority: high
4040 author:
4141 name: k33g
4242 email: ph.charriere@gmail.com
4343 createdAt: 2026-10-29T07:20:21.000Z
44+ updatedAt: 2026-10-29T16:35:01.000Z
45+ closedAt: 2026-10-29T16:35:01.000Z
4446 - id: 2
4547 title: "Injection du contexte non vu dans le prompt"
46- state: open
48+ state: closed
4749 priority: high
4850 author:
4951 name: k33g
5052 email: ph.charriere@gmail.com
5153 createdAt: 2026-10-29T07:20:21.000Z
54+ updatedAt: 2026-10-29T16:35:01.000Z
55+ closedAt: 2026-10-29T16:35:01.000Z
5256 - id: 3
5357 title: "Exécution séquentielle / parallèle"
54- state: open
58+ state: closed
5559 priority: medium
5660 author:
5761 name: k33g
5862 email: ph.charriere@gmail.com
5963 createdAt: 2026-10-29T07:20:21.000Z
64+ updatedAt: 2026-10-29T16:35:01.000Z
65+ closedAt: 2026-10-29T16:35:01.000Z
6066 - id: 4
6167 title: "Tests unitaires avec faux agents"
62- state: open
68+ state: closed
6369 priority: medium
6470 author:
6571 name: k33g
6672 email: ph.charriere@gmail.com
6773 createdAt: 2026-10-29T07:20:21.000Z
74+ updatedAt: 2026-10-29T16:35:01.000Z
75+ closedAt: 2026-10-29T16:35:01.000Z
6876 - id: 5
6977 title: "Test realagents Alice + Bob"
70- state: open
78+ state: closed
7179 priority: medium
7280 author:
7381 name: k33g
7482 email: ph.charriere@gmail.com
7583 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: 232 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: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:21.000Z8 createdAt: 2026-10-29T07:20:21.000Z
9-updatedAt: 2026-10-29T07:20:21.000Z9+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: 136 - id: 1
37 title: "Règles de destinataire (mentions, dernier agent)"37 title: "Règles de destinataire (mentions, dernier agent)"
38- state: open38+ state: closed
39 priority: high39 priority: high
40 author:40 author:
41 name: k33g41 name: k33g
42 email: ph.charriere@gmail.com42 email: ph.charriere@gmail.com
43 createdAt: 2026-10-29T07:20:21.000Z43 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: 246 - id: 2
45 title: "Injection du contexte non vu dans le prompt"47 title: "Injection du contexte non vu dans le prompt"
46- state: open48+ state: closed
47 priority: high49 priority: high
48 author:50 author:
49 name: k33g51 name: k33g
50 email: ph.charriere@gmail.com52 email: ph.charriere@gmail.com
51 createdAt: 2026-10-29T07:20:21.000Z53 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: 356 - id: 3
53 title: "Exécution séquentielle / parallèle"57 title: "Exécution séquentielle / parallèle"
54- state: open58+ state: closed
55 priority: medium59 priority: medium
56 author:60 author:
57 name: k33g61 name: k33g
58 email: ph.charriere@gmail.com62 email: ph.charriere@gmail.com
59 createdAt: 2026-10-29T07:20:21.000Z63 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: 466 - id: 4
61 title: "Tests unitaires avec faux agents"67 title: "Tests unitaires avec faux agents"
62- state: open68+ state: closed
63 priority: medium69 priority: medium
64 author:70 author:
65 name: k33g71 name: k33g
66 email: ph.charriere@gmail.com72 email: ph.charriere@gmail.com
67 createdAt: 2026-10-29T07:20:21.000Z73 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: 576 - id: 5
69 title: "Test realagents Alice + Bob"77 title: "Test realagents Alice + Bob"
70- state: open78+ state: closed
71 priority: medium79 priority: medium
72 author:80 author:
73 name: k33g81 name: k33g
74 email: ph.charriere@gmail.com82 email: ph.charriere@gmail.com
75 createdAt: 2026-10-29T07:20:21.000Z83 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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 24
33 title: "Commandes slash exposées par les agents"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:22.000Z
9-updatedAt: 2026-10-29T08:51:00.000Z
9+updatedAt: 2026-10-29T17:05:41.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::acp"
@@ -35,44 +35,54 @@ body: |
3535 tasks:
3636 - id: 1
3737 title: "Réception et cache de available_commands_update"
38- state: open
38+ state: closed
3939 priority: high
4040 author:
4141 name: k33g
4242 email: ph.charriere@gmail.com
4343 createdAt: 2026-10-29T07:20:22.000Z
44+ updatedAt: 2026-10-29T16:35:01.000Z
45+ closedAt: 2026-10-29T16:35:01.000Z
4446 - id: 2
4547 title: "Popup / avec filtrage"
46- state: open
48+ state: closed
4749 priority: high
4850 author:
4951 name: k33g
5052 email: ph.charriere@gmail.com
5153 createdAt: 2026-10-29T07:20:22.000Z
54+ updatedAt: 2026-10-29T16:35:01.000Z
55+ closedAt: 2026-10-29T16:35:01.000Z
5256 - id: 3
5357 title: "Syntaxe /agent:commande en multi-agents"
54- state: open
58+ state: closed
5559 priority: medium
5660 author:
5761 name: k33g
5862 email: ph.charriere@gmail.com
5963 createdAt: 2026-10-29T07:20:22.000Z
64+ updatedAt: 2026-10-29T16:35:01.000Z
65+ closedAt: 2026-10-29T16:35:01.000Z
6066 - id: 4
6167 title: "Décider des commandes locales et de leur préfixe"
62- state: open
68+ state: closed
6369 priority: medium
6470 author:
6571 name: k33g
6672 email: ph.charriere@gmail.com
6773 createdAt: 2026-10-29T07:20:22.000Z
74+ updatedAt: 2026-10-29T16:35:01.000Z
75+ closedAt: 2026-10-29T16:35:01.000Z
6876 - id: 5
6977 title: "Tests avec Garfield"
70- state: open
78+ state: closed
7179 priority: medium
7280 author:
7381 name: k33g
7482 email: ph.charriere@gmail.com
7583 createdAt: 2026-10-29T07:20:22.000Z
84+ updatedAt: 2026-10-29T16:35:01.000Z
85+ closedAt: 2026-10-29T16:35:01.000Z
7686 comments:
7787 - id: 1
7888 author:
@@ -81,3 +91,21 @@ comments:
8191 createdAt: 2026-10-29T08:51:00.000Z
8292 body: |
8393 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: 242 id: 24
3 title: "Commandes slash exposées par les agents"3 title: "Commandes slash exposées par les agents"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:22.000Z8 createdAt: 2026-10-29T07:20:22.000Z
9-updatedAt: 2026-10-29T08:51:00.000Z9+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: 136 - id: 1
37 title: "Réception et cache de available_commands_update"37 title: "Réception et cache de available_commands_update"
38- state: open38+ state: closed
39 priority: high39 priority: high
40 author:40 author:
41 name: k33g41 name: k33g
42 email: ph.charriere@gmail.com42 email: ph.charriere@gmail.com
43 createdAt: 2026-10-29T07:20:22.000Z43 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: 246 - id: 2
45 title: "Popup / avec filtrage"47 title: "Popup / avec filtrage"
46- state: open48+ state: closed
47 priority: high49 priority: high
48 author:50 author:
49 name: k33g51 name: k33g
50 email: ph.charriere@gmail.com52 email: ph.charriere@gmail.com
51 createdAt: 2026-10-29T07:20:22.000Z53 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: 356 - id: 3
53 title: "Syntaxe /agent:commande en multi-agents"57 title: "Syntaxe /agent:commande en multi-agents"
54- state: open58+ state: closed
55 priority: medium59 priority: medium
56 author:60 author:
57 name: k33g61 name: k33g
58 email: ph.charriere@gmail.com62 email: ph.charriere@gmail.com
59 createdAt: 2026-10-29T07:20:22.000Z63 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: 466 - 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: open68+ state: closed
63 priority: medium69 priority: medium
64 author:70 author:
65 name: k33g71 name: k33g
66 email: ph.charriere@gmail.com72 email: ph.charriere@gmail.com
67 createdAt: 2026-10-29T07:20:22.000Z73 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: 576 - id: 5
69 title: "Tests avec Garfield"77 title: "Tests avec Garfield"
70- state: open78+ state: closed
71 priority: medium79 priority: medium
72 author:80 author:
73 name: k33g81 name: k33g
74 email: ph.charriere@gmail.com82 email: ph.charriere@gmail.com
75 createdAt: 2026-10-29T07:20:22.000Z83 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: 187 - id: 1
78 author:88 author:
@@ -81,3 +91,21 @@ comments:
81 createdAt: 2026-10-29T08:51:00.000Z91 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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 25
33 title: "Reprendre les conversations après redémarrage"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:23.000Z
9-updatedAt: 2026-10-29T08:51:00.000Z
9+updatedAt: 2026-10-29T16:35:01.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::persistence"
@@ -31,36 +31,44 @@ body: |
3131 tasks:
3232 - id: 1
3333 title: "Stocker l'id de session ACP par membership"
34- state: open
34+ state: closed
3535 priority: medium
3636 author:
3737 name: k33g
3838 email: ph.charriere@gmail.com
3939 createdAt: 2026-10-29T07:20:23.000Z
40+ updatedAt: 2026-10-29T16:35:01.000Z
41+ closedAt: 2026-10-29T16:35:01.000Z
4042 - id: 2
4143 title: "session/load si supporté"
42- state: open
44+ state: closed
4345 priority: medium
4446 author:
4547 name: k33g
4648 email: ph.charriere@gmail.com
4749 createdAt: 2026-10-29T07:20:23.000Z
50+ updatedAt: 2026-10-29T16:35:01.000Z
51+ closedAt: 2026-10-29T16:35:01.000Z
4852 - id: 3
4953 title: "Fallback : réinjection de contexte"
50- state: open
54+ state: closed
5155 priority: medium
5256 author:
5357 name: k33g
5458 email: ph.charriere@gmail.com
5559 createdAt: 2026-10-29T07:20:23.000Z
60+ updatedAt: 2026-10-29T16:35:01.000Z
61+ closedAt: 2026-10-29T16:35:01.000Z
5662 - id: 4
5763 title: "Test de bout en bout"
58- state: open
64+ state: closed
5965 priority: medium
6066 author:
6167 name: k33g
6268 email: ph.charriere@gmail.com
6369 createdAt: 2026-10-29T07:20:23.000Z
70+ updatedAt: 2026-10-29T16:35:01.000Z
71+ closedAt: 2026-10-29T16:35:01.000Z
6472 comments:
6573 - id: 1
6674 author:
@@ -69,3 +77,14 @@ comments:
6977 createdAt: 2026-10-29T08:51:00.000Z
7078 body: |
7179 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: 252 id: 25
3 title: "Reprendre les conversations après redémarrage"3 title: "Reprendre les conversations après redémarrage"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:23.000Z8 createdAt: 2026-10-29T07:20:23.000Z
9-updatedAt: 2026-10-29T08:51:00.000Z9+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: 132 - id: 1
33 title: "Stocker l'id de session ACP par membership"33 title: "Stocker l'id de session ACP par membership"
34- state: open34+ state: closed
35 priority: medium35 priority: medium
36 author:36 author:
37 name: k33g37 name: k33g
38 email: ph.charriere@gmail.com38 email: ph.charriere@gmail.com
39 createdAt: 2026-10-29T07:20:23.000Z39 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: 242 - id: 2
41 title: "session/load si supporté"43 title: "session/load si supporté"
42- state: open44+ state: closed
43 priority: medium45 priority: medium
44 author:46 author:
45 name: k33g47 name: k33g
46 email: ph.charriere@gmail.com48 email: ph.charriere@gmail.com
47 createdAt: 2026-10-29T07:20:23.000Z49 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: 352 - id: 3
49 title: "Fallback : réinjection de contexte"53 title: "Fallback : réinjection de contexte"
50- state: open54+ state: closed
51 priority: medium55 priority: medium
52 author:56 author:
53 name: k33g57 name: k33g
54 email: ph.charriere@gmail.com58 email: ph.charriere@gmail.com
55 createdAt: 2026-10-29T07:20:23.000Z59 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: 462 - id: 4
57 title: "Test de bout en bout"63 title: "Test de bout en bout"
58- state: open64+ state: closed
59 priority: medium65 priority: medium
60 author:66 author:
61 name: k33g67 name: k33g
62 email: ph.charriere@gmail.com68 email: ph.charriere@gmail.com
63 createdAt: 2026-10-29T07:20:23.000Z69 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: 173 - id: 1
66 author:74 author:
@@ -69,3 +77,14 @@ comments:
69 createdAt: 2026-10-29T08:51:00.000Z77 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:
1313 description: "Discuter avec UN agent de bout en bout : streaming, Markdown, tool calls et permissions, édition de la config dans le panel Settings."
1414 dueOn: 2026-11-27
1515 - title: "M3 · Channels & multi-agents"
16- state: open
16+ state: closed
1717 description: "Channels persistés, invocation @agent, routage multi-agents, commandes slash /agent:commande."
1818 dueOn: 2026-12-18
1919 - 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-2714 dueOn: 2026-11-27
15 - title: "M3 · Channels & multi-agents"15 - title: "M3 · Channels & multi-agents"
16- state: open16+ 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-1818 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 @@
22
33 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.
44
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).
66
77 Documentation: [English](docs/en/README.md) · [Français](docs/fr/README.md) · [decisions (ADR)](docs/adr/) · [wireframes](docs/design/README.md)
88
@@ -31,8 +31,9 @@ Details: [How to develop the application](docs/en/how-to/develop-the-application
3131 | Path | What it is |
3232 | --- | --- |
3333 | `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 |
3637 | `internal/prefs` | Last opened and recent configuration files |
3738 | `internal/agents` | The agent processes: lazy start, limit, idle stop, states, logs |
3839 | `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 differBinary 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 @@
11 # ADR 0003 — Architecture, data model and events
22
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)
44 - **Date**: 2026-10-29
55 - **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)
66 - **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
120120
121121 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.
122122
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+
123127 ## Consequences
124128
125129 - 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 events1 # 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-294 - **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 ## Consequences127 ## 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 @@
44 <root>
55 <mxCell id="0"/>
66 <mxCell id="1" parent="0"/>
7- <mxCell id="title" value="&lt;b style=&quot;font-size:25px;color:#1A237E&quot;&gt;Sidekicks — packages and dependencies&lt;/b&gt;&lt;br&gt;&lt;i style=&quot;font-size:14px;color:#546E7A&quot;&gt;arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails&lt;/i&gt;" 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="&lt;b style=&quot;font-size:25px;color:#1A237E&quot;&gt;Sidekicks — packages and dependencies&lt;/b&gt;&lt;br&gt;&lt;i style=&quot;font-size:14px;color:#546E7A&quot;&gt;arrows are imports · blue: exists (M3) · grey: planned · only main.go imports Wails&lt;/i&gt;" 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>
88 <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>
99 <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>
1010 <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 @@
1414 <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>
1515 <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>
1616 <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>
1818 <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>
1919 <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>
2020 <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>
2222 <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>
2323 <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="💻&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;frontend/&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Svelte 5 · panels · chat&lt;br&gt;editor · generated bindings&lt;/span&gt;" 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="🔀&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/app&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Agent · Chat · Config&lt;br&gt;services · Core&lt;/span&gt;" 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="💻&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;frontend/&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Svelte 5 · panels · chat · @ /&lt;br&gt;editor · generated bindings&lt;/span&gt;" 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="🔀&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/app&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Agent · Channel · Config&lt;br&gt;services · Core&lt;/span&gt;" 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>
2626 <mxCell id="c3" value="&lt;b&gt;★ only API of the UI&lt;/b&gt;" 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>
2727 <mxCell id="c4" value="⚙️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;main.go (root)&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;flags · Wails app · window&lt;br&gt;events · file dialog&lt;/span&gt;" 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>
2828 <mxCell id="c6" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;bindings · events&lt;/b&gt;" 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>
2929 <mxCell id="c8" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;creates&lt;/b&gt;" 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="💬&lt;br&gt;&lt;b style=&quot;font-size:14px&quot;&gt;internal/conversations&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;one per agent · in memory&lt;br&gt;queue · permissions (M3: channels)&lt;/span&gt;" 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="💬&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/channels&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;channels · direct · routing @&lt;br&gt;unseen context · queue · perms&lt;/span&gt;" 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>
3131 <mxCell id="c10" value="🤖&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/agents&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;lazy start · LRU limit&lt;br&gt;idle stop · states · logs&lt;/span&gt;" 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>
3232 <mxCell id="c11" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/config&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;load · validate · locate&lt;br&gt;default · save · watch&lt;/span&gt;" 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>
3333 <mxCell id="c12" value="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/prefs&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;last opened file&lt;br&gt;recent files (JSON)&lt;/span&gt;" 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>
3434 <mxCell id="c13" value="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/workspace&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M4&lt;br&gt;files + exec: local, sbx…&lt;/span&gt;" 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="📥&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/store&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M3&lt;br&gt;Pebble: channels, messages&lt;/span&gt;" 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="📥&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/store&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Pebble · one DB per config&lt;br&gt;channels · members · messages&lt;/span&gt;" 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>
3636 <mxCell id="c22" value="🔧&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/acp&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;ACP v1 client · sessions&lt;br&gt;stdin → TERM → KILL&lt;/span&gt;" 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>
3737 <mxCell id="c23" value="🛡️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;schema&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;config JSON Schema&lt;br&gt;ValidateConfig()&lt;/span&gt;" 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>
3838 <mxCell id="c24" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/events&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;event names + payloads&lt;br&gt;Emitter interface&lt;/span&gt;" 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 @@
4242 <mxCell id="c32" value="&lt;b&gt;jsonschema · yaml · fsnotify&lt;/b&gt;" 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>
4343 <mxCell id="c33" value="&lt;b&gt;Wails v3 (main.go only)&lt;/b&gt;" 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>
4444 <mxCell id="c36" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;stdio&lt;/b&gt;" 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>
4646 </root>
4747 </mxGraphModel>
4848 </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="&lt;b style=&quot;font-size:25px;color:#1A237E&quot;&gt;Sidekicks — packages and dependencies&lt;/b&gt;&lt;br&gt;&lt;i style=&quot;font-size:14px;color:#546E7A&quot;&gt;arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails&lt;/i&gt;" 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="&lt;b style=&quot;font-size:25px;color:#1A237E&quot;&gt;Sidekicks — packages and dependencies&lt;/b&gt;&lt;br&gt;&lt;i style=&quot;font-size:14px;color:#546E7A&quot;&gt;arrows are imports · blue: exists (M3) · grey: planned · only main.go imports Wails&lt;/i&gt;" 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="💻&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;frontend/&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Svelte 5 · panels · chat&lt;br&gt;editor · generated bindings&lt;/span&gt;" 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="💻&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;frontend/&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Svelte 5 · panels · chat · @ /&lt;br&gt;editor · generated bindings&lt;/span&gt;" 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="🔀&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/app&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Agent · Chat · Config&lt;br&gt;services · Core&lt;/span&gt;" 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="🔀&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/app&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Agent · Channel · Config&lt;br&gt;services · Core&lt;/span&gt;" 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="&lt;b&gt;★ only API of the UI&lt;/b&gt;" 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="&lt;b&gt;★ only API of the UI&lt;/b&gt;" 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="⚙️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;main.go (root)&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;flags · Wails app · window&lt;br&gt;events · file dialog&lt;/span&gt;" 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="⚙️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;main.go (root)&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;flags · Wails app · window&lt;br&gt;events · file dialog&lt;/span&gt;" 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="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;bindings · events&lt;/b&gt;" 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="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;bindings · events&lt;/b&gt;" 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="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;creates&lt;/b&gt;" 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="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;creates&lt;/b&gt;" 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="💬&lt;br&gt;&lt;b style=&quot;font-size:14px&quot;&gt;internal/conversations&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;one per agent · in memory&lt;br&gt;queue · permissions (M3: channels)&lt;/span&gt;" 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="💬&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/channels&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;channels · direct · routing @&lt;br&gt;unseen context · queue · perms&lt;/span&gt;" 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="🤖&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/agents&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;lazy start · LRU limit&lt;br&gt;idle stop · states · logs&lt;/span&gt;" 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="🤖&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/agents&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;lazy start · LRU limit&lt;br&gt;idle stop · states · logs&lt;/span&gt;" 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="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/config&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;load · validate · locate&lt;br&gt;default · save · watch&lt;/span&gt;" 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="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/config&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;load · validate · locate&lt;br&gt;default · save · watch&lt;/span&gt;" 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="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/prefs&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;last opened file&lt;br&gt;recent files (JSON)&lt;/span&gt;" 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="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/prefs&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;last opened file&lt;br&gt;recent files (JSON)&lt;/span&gt;" 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="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/workspace&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M4&lt;br&gt;files + exec: local, sbx…&lt;/span&gt;" 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="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/workspace&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M4&lt;br&gt;files + exec: local, sbx…&lt;/span&gt;" 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="📥&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/store&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M3&lt;br&gt;Pebble: channels, messages&lt;/span&gt;" 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="📥&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/store&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Pebble · one DB per config&lt;br&gt;channels · members · messages&lt;/span&gt;" 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="🔧&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/acp&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;ACP v1 client · sessions&lt;br&gt;stdin → TERM → KILL&lt;/span&gt;" 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="🔧&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/acp&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;ACP v1 client · sessions&lt;br&gt;stdin → TERM → KILL&lt;/span&gt;" 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="🛡️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;schema&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;config JSON Schema&lt;br&gt;ValidateConfig()&lt;/span&gt;" 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="🛡️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;schema&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;config JSON Schema&lt;br&gt;ValidateConfig()&lt;/span&gt;" 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="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/events&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;event names + payloads&lt;br&gt;Emitter interface&lt;/span&gt;" 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="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/events&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;event names + payloads&lt;br&gt;Emitter interface&lt;/span&gt;" 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="&lt;b&gt;jsonschema · yaml · fsnotify&lt;/b&gt;" 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="&lt;b&gt;jsonschema · yaml · fsnotify&lt;/b&gt;" 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="&lt;b&gt;Wails v3 (main.go only)&lt;/b&gt;" 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="&lt;b&gt;Wails v3 (main.go only)&lt;/b&gt;" 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="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;stdio&lt;/b&gt;" 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="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;stdio&lt;/b&gt;" 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 differBinary files a/docs/diagrams/packages.png and b/docs/diagrams/packages.png differ
modified docs/diagrams/packages.svg +13 -13
@@ -11,7 +11,7 @@
1111 </defs>
1212 <rect x="0" y="0" width="1410" height="800" fill="url(#gBg)"/>
1313 <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>
1515 <path d="M260,157 L590,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
1616 <path d="M1150,157 L820,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
1717 <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 @@
2121 <path d="M705,215 L705,255 L1265,255 L1265,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
2222 <path d="M260,355 L310,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
2323 <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)"/>
2525 <path d="M425,410 L425,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
2626 <path d="M705,410 L705,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
2727 <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)"/>
2929 <path d="M425,595 L425,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
3030 <path d="M705,595 L705,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
3131 <g filter="url(#shadow)"><rect x="30" y="100" width="230" height="115" rx="16" fill="url(#gBlue)" stroke="#1565C0" stroke-width="2"/></g>
3232 <text x="145.0" y="137.5" text-anchor="middle" font-size="22">💻</text>
3333 <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>
3535 <text x="145.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">editor · generated bindings</text>
3636 <g filter="url(#shadow)"><rect x="590" y="100" width="230" height="115" rx="16" fill="url(#gPurple)" stroke="#6A1B9A" stroke-width="3"/></g>
3737 <text x="705.0" y="137.5" text-anchor="middle" font-size="22">🔀</text>
3838 <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>
4040 <text x="705.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#4A148C">services · Core</text>
4141 <rect x="672.0" y="88" width="158.0" height="24" rx="12" fill="#6A1B9A"/>
4242 <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 @@
5151 <text x="1010" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">creates</text>
5252 <g filter="url(#shadow)"><rect x="30" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
5353 <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>
5757 <g filter="url(#shadow)"><rect x="310" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
5858 <text x="425.0" y="335.0" text-anchor="middle" font-size="22">🤖</text>
5959 <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 @@
7474 <text x="1265.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/workspace</text>
7575 <text x="1265.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M4</text>
7676 <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>
7878 <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>
8282 <g filter="url(#shadow)"><rect x="310" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
8383 <text x="425.0" y="522.5" text-anchor="middle" font-size="22">🔧</text>
8484 <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 @@
106106 <text x="985.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">Wails v3 (main.go only)</text>
107107 <rect x="445.0" y="620.0" width="50" height="24" rx="5" fill="#FFFFFF"/>
108108 <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>
110110 </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 @@
11 # Sidekicks documentation (English)
22
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.
44
55 This documentation follows [Diátaxis](https://diataxis.fr): pick the section that matches what you need right now.
66
77 | You want to… | Section | Pages |
88 | --- | --- | --- |
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) |
1313
1414 ## Design records
1515
1616 Shared by both languages and written in English:
1717
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
1919 - [Wireframes](../design/README.md) — the six main screens
2020 - [Package diagram](../diagrams/packages.drawio)
2121 - [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 records14 ## 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 language18+- [Architecture decision records](../adr/) — ACP, interface stack, architecture, conversations, interface language, channels
19 - [Wireframes](../design/README.md) — the six main screens19 - [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 agents21 - [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
1313 ## Open another configuration file
1414
1515 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.
1717 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).
1818
1919 ## Variants
@@ -13,7 +13,7 @@ This guide shows how to change the configuration file from the **Settings** pane
13 ## Open another configuration file13 ## 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 ## Variants19 ## 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
3535 make test-real
3636 ```
3737
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.
3939
4040 ## Variants
4141
@@ -35,7 +35,7 @@ The CI (`.github/workflows/ci.yml`) runs the same command, plus `make bindings-c
35 make test-real35 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 ## Variants40 ## 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
109109
110110 ## See also
111111
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 also110 ## 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
99 ```bash
1010 make install # new frontend dependencies (CodeMirror, Shiki, markdown-it…)
1111 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)
1313 make install-test-config
1414 make dev
1515 ```
@@ -18,9 +18,9 @@ make dev
1818
1919 ## 1. Talk with Alice (#14, #15, #16)
2020
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.
2222 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).
2424 4. Theme: System Settings → Appearance → Dark: the code changes colours with the rest.
2525 5. Send: `Mets un lien vers https://wails.io` and click the link: it opens in your browser, not in Sidekicks.
2626 6. Send `Je m'appelle <your first name>.` then `Comment je m'appelle ?` — Alice remembers (same session).
@@ -40,7 +40,7 @@ make dev
4040
4141 - [ ] Points 1 to 6 are as described.
4242
43-## 3. Garfield and his skills
43+## 3. Garfield and the skills
4444
4545 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".
4646
@@ -48,9 +48,9 @@ Click **Garfield**, send `/idioms Il pleut des cordes.` — a line "🛠 read_sk
4848
4949 ## 4. The memory limit with conversations
5050
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).
5252
53-- [ ] Two agents at most (`scripts/agents.sh ps`), and the restart message appears.
53+- [ ] Two agents at most (`scripts/agents.sh ps`).
5454
5555 ## 5. The configuration editor (#18)
5656
@@ -96,4 +96,4 @@ If everything is as described, M2 is confirmed: close issue #15 and milestone **
9696 ## See also
9797
9898 - [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 ```bash9 ```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 ok11 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-config13 make install-test-config
14 make dev14 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 skills43+## 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 conversations49 ## 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 also96 ## 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
22
33 ## Agent states
44
@@ -48,24 +48,46 @@ The Go names and types are in `internal/events`; the TypeScript constants and ty
4848 | `AgentService` | `List()` | Every agent with `name, title, icon, description, state, error, outdated`, in configuration order. |
4949 | | `Start(name)`, `Stop(name)`, `Restart(name)` | As in the transitions above; an error is returned to the interface. |
5050 | | `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`. |
5252
53-## Conversations (milestone M2)
53+## Channels (milestones M2 and M3)
5454
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.
5677
5778 ### Messages
5879
5980 | Field | Meaning |
6081 | --- | --- |
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 |
6284 | `role` | `user`, `agent` or `system` |
6385 | `parts` | `text` (Markdown), `thought` (reasoning), `tool` (a tool call), `plan` (plan entries) |
6486 | `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 |
6688 | `stopReason` | how an agent message ended: `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` |
6789 | `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) |
6991
7092 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`).
7193
@@ -73,21 +95,42 @@ A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, t
7395
7496 | Event | Payload | Sent when |
7597 | --- | --- | --- |
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`) |
82106
83-### `ChatService`
107+### `ChannelService`
84108
85109 | Method | Effect |
86110 | --- | --- |
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". |
89119 | `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 |
91134
92135 ### Settings methods of `ConfigService`
93136
@@ -97,7 +140,7 @@ A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, t
97140 | `Validate(text)` | The problems and warnings of `text`, with `line, column, message, warning`, without saving. |
98141 | `Save(text)` | Writes `text` if it is valid; otherwise returns its problems and writes nothing. |
99142 | `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. |
101144 | `Choose()` | Shows the native file dialog and opens the chosen file; returns `""` when cancelled. |
102145 | `Recent()` | The configuration files opened from the application, most recent first (8 at most). |
103146
@@ -1,4 +1,4 @@
1-# Reference: agent states and events1+# Reference: agents, channels and events
2 2
3 ## Agent states3 ## 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 ### Messages78 ### 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
8888 | Key | Type | Required | Meaning |
8989 | --- | --- | --- | --- |
9090 | `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. |
9393
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)).
9595
9696 ## Rules checked by the loader, not by the schema
9797
@@ -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 schema96 ## 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
5353
5454 - Change what agents exist from the window → [How to edit or switch the configuration](../how-to/edit-or-switch-the-configuration.md)
5555 - 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
9191 You have run Sidekicks, driven an agent's life and seen how the configuration is checked. To go further:
9292
9393 - 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)
9595 - 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 @@
11 # Documentation de Sidekicks (français)
22
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.
44
55 Cette documentation suit la méthode [Diátaxis](https://diataxis.fr) : choisissez la section qui correspond à votre besoin du moment.
66
77 | Vous voulez… | Section | Pages |
88 | --- | --- | --- |
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) |
1313
1414 ## Documents de conception
1515
1616 Communs aux deux langues et rédigés en anglais :
1717
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
1919 - [Maquettes](../design/README.md) — les six écrans principaux (textes d'interface en français)
2020 - [Schéma des packages](../diagrams/packages.drawio)
2121 - [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 conception14 ## 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'interface18+- [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 agents21 - [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-
3535 make test-real
3636 ```
3737
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.
3939
4040 ## Variantes
4141
@@ -35,7 +35,7 @@ La CI (`.github/workflows/ci.yml`) lance la même commande, plus `make bindings-
35 make test-real35 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 ## Variantes40 ## 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 *
1313 ## Ouvrir un autre fichier de configuration
1414
1515 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.
1717 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).
1818
1919 ## Variantes
@@ -13,7 +13,7 @@ Ce guide montre comment modifier le fichier de configuration depuis le panneau *
13 ## Ouvrir un autre fichier de configuration13 ## 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 ## Variantes19 ## 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
109109
110110 ## Voir aussi
111111
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 aussi110 ## 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
99 ```bash
1010 make install # nouvelles dépendances du frontend (CodeMirror, Shiki, markdown-it…)
1111 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)
1313 make install-test-config
1414 make dev
1515 ```
@@ -48,9 +48,9 @@ Cliquez sur **Garfield**, envoyez `/idioms Il pleut des cordes.` — une ligne
4848
4949 ## 4. La limite de mémoire avec les conversations
5050
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).
5252
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`).
5454
5555 ## 5. L'éditeur de configuration (#18)
5656
@@ -96,4 +96,4 @@ Si tout est conforme, M2 est confirmée : fermez l'issue #15 et la milestone **M
9696 ## Voir aussi
9797
9898 - [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 ```bash9 ```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 ok11 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-config13 make install-test-config
14 make dev14 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 conversations49 ## 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 aussi96 ## 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
22
33 ## États d'un agent
44
@@ -48,46 +48,89 @@ Les noms et types Go sont dans `internal/events` ; les constantes et types TypeS
4848 | `AgentService` | `List()` | Tous les agents avec `name, title, icon, description, state, error, outdated`, dans l'ordre de la configuration. |
4949 | | `Start(name)`, `Stop(name)`, `Restart(name)` | Comme dans les transitions ci-dessus ; une erreur est renvoyée à l'interface. |
5050 | | `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`. |
5252
53-## Conversations (milestone M2)
53+## Channels (milestones M2 et M3)
5454
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.
5677
5778 ### Messages
5879
5980 | Champ | Signification |
6081 | --- | --- |
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 |
6284 | `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) |
6991
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`).
7193
7294 ### Événements
7395
74-| Événement | Charge | Envoyé quand |
96+| Événement | Contenu | Envoyé quand |
7597 | --- | --- | --- |
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`) |
82106
83-### `ChatService`
107+### `ChannelService`
84108
85109 | Méthode | Effet |
86110 | --- | --- |
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 |
91134
92135 ### Méthodes Settings de `ConfigService`
93136
@@ -97,7 +140,7 @@ Un tool call a `id, title, kind` (`read, edit, delete, move, search, execute, th
97140 | `Validate(text)` | Les problèmes et avertissements de `text`, avec `line, column, message, warning`, sans enregistrer. |
98141 | `Save(text)` | Écrit `text` s'il est valide ; sinon renvoie ses problèmes et n'écrit rien. |
99142 | `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. |
101144 | `Choose()` | Affiche le sélecteur de fichier natif et ouvre le fichier choisi ; renvoie `""` en cas d'annulation. |
102145 | `Recent()` | Les fichiers de configuration ouverts depuis l'application, du plus récent au plus ancien (8 au plus). |
103146
@@ -1,4 +1,4 @@
1-# Référence : états des agents et événements1+# Référence : agents, channels et événements
2 2
3 ## États d'un agent3 ## É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 ### Messages78 ### 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énements94 ### É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*
8888 | Clé | Type | Obligatoire | Signification |
8989 | --- | --- | --- | --- |
9090 | `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. |
9393
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)).
9595
9696 ## Règles vérifiées par le chargeur, pas par le schéma
9797
@@ -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éma96 ## 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ê
9393 Vous avez lancé Sidekicks, piloté la vie d'un agent et vu comment la configuration est vérifiée. Pour aller plus loin :
9494
9595 - 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)
9797 - 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
5555
5656 - Changer les agents depuis la fenêtre → [Comment modifier ou changer de configuration](../how-to/modifier-ou-changer-la-configuration.md)
5757 - 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";
1818 */
1919 export const AgentState = "agent:state";
2020
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+
2131 /**
2232 * Name of the custom event "config:changed".
2333 */
@@ -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 @@
22 // This file is automatically generated. DO NOT EDIT
33
44 import * as AgentService from "./agentservice.js";
5-import * as ChatService from "./chatservice.js";
5+import * as ChannelService from "./channelservice.js";
66 import * as ConfigService from "./configservice.js";
77 export {
88 AgentService,
9- ChatService,
9+ ChannelService,
1010 ConfigService
1111 };
1212
1313 export type {
14- ChannelView,
1514 ConfigView,
15+ OlderView,
1616 ProblemView
1717 } from "./models.js";
@@ -2,16 +2,16 @@
2 // This file is automatically generated. DO NOT EDIT2 // 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 ConfigService10 ConfigService
11 };11 };
12 12
13 export type {13 export type {
14- ChannelView,
15 ConfigView,14 ConfigView,
15+ OlderView,
16 ProblemView16 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 @@
11 // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
22 // This file is automatically generated. DO NOT EDIT
33
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";
127
138 /**
149 * ConfigView is the configuration as the interface shows it.
@@ -35,7 +30,20 @@ export interface ConfigView {
3530 "warnings": string[] | null;
3631 "theme": string;
3732 "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;
3947 }
4048
4149 /**
@@ -1,14 +1,9 @@
1 // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL1 // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2 // This file is automatically generated. DO NOT EDIT2 // 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 {
66 } from "./models.js";
77
88 export type {
9+ Channel,
10+ CommandsPayload,
911 Conversation,
1012 DeltaPayload,
13+ ListPayload,
1114 Message,
1215 MessagePayload,
1316 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 @@
66 import * as acp$0 from "../acp/models.js";
77
88 /**
9- * Conversation is the state of the conversation with one agent.
9+ * Channel is a channel as the interface lists it.
1010 */
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 {
1227 "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;
1336 "messages": Message[] | null;
37+
38+ /**
39+ * More tells whether older messages can be loaded with Older.
40+ */
41+ "more": boolean;
1442 "busy": boolean;
1543 "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;
1854 "permissions": Permission[] | null;
1955 }
2056
@@ -23,7 +59,7 @@ export interface Conversation {
2359 * a text or thought part; Part replaces a tool or plan part.
2460 */
2561 export interface DeltaPayload {
26- "agent": string;
62+ "channel": string;
2763 "messageId": string;
2864 "index": number;
2965 "kind": string;
@@ -32,11 +68,24 @@ export interface DeltaPayload {
3268 }
3369
3470 /**
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.
3679 */
3780 export interface Message {
3881 "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;
4089 "role": Role;
4190 "parts": Part[] | null;
4291
@@ -46,7 +95,7 @@ export interface Message {
4695 "done": boolean;
4796
4897 /**
49- * Queued: a user message waiting for the current answer to end.
98+ * Queued: a user message waiting for the current answers to end.
5099 */
51100 "queued": boolean;
52101
@@ -61,7 +110,7 @@ export interface Message {
61110 "error"?: string;
62111
63112 /**
64- * Code identifies a system message (CodeAgentRestarted…).
113+ * Code identifies a system message (CodeAgentJoined…).
65114 */
66115 "code"?: string;
67116 "createdAt": string;
@@ -71,7 +120,7 @@ export interface Message {
71120 * MessagePayload carries a whole message: added, or finished.
72121 */
73122 export interface MessagePayload {
74- "agent": string;
123+ "channel": string;
75124 "message": Message;
76125 }
77126
@@ -90,6 +139,7 @@ export interface Part {
90139 */
91140 export interface Permission {
92141 "id": string;
142+ "channel": string;
93143 "agent": string;
94144 "messageId": string;
95145 "toolCall": acp$0.ToolCall;
@@ -107,7 +157,7 @@ export interface Permission {
107157 */
108158 export interface PermissionResolvedPayload {
109159 "id": string;
110- "agent": string;
160+ "channel": string;
111161 }
112162
113163 /**
@@ -128,18 +178,21 @@ export enum Role {
128178 };
129179
130180 /**
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.
132183 */
133184 export interface TurnPayload {
134- "agent": string;
185+ "channel": string;
135186 "busy": boolean;
136187 "queued": number;
188+ "answering": string;
137189 }
138190
139191 /**
140- * UsagePayload is the context window use of a conversation.
192+ * UsagePayload is the context window use of an agent in a channel.
141193 */
142194 export interface UsagePayload {
195+ "channel": string;
143196 "agent": string;
144197 "usage": acp$0.Usage;
145198 }
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";
77
88 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
99 // @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";
1111 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1212 // @ts-ignore: Unused imports
1313 import type * as events$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/events/models.js";
@@ -17,13 +17,15 @@ declare module "@wailsio/runtime" {
1717 interface CustomEvents {
1818 "agent:log": events$0.AgentLogPayload;
1919 "agent:state": events$0.AgentStatePayload;
20+ "channels:changed": channels$0.ListPayload;
21+ "commands:changed": channels$0.CommandsPayload;
2022 "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;
2729 }
2830 }
2931 }
@@ -7,7 +7,7 @@ import type { Events } from "@wailsio/runtime";
7 7
8 // eslint-disable-next-line @typescript-eslint/ban-ts-comment8 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
9 // @ts-ignore: Unused imports9 // @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-comment11 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
12 // @ts-ignore: Unused imports12 // @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 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
33 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'
56
67 let {
78 placeholder,
89 busy = false,
910 disabled = false,
1011 triggers = [],
12+ suggest = () => null,
13+ onTrigger = () => {},
1114 onSend,
1215 onStop,
13- onTrigger = () => {},
1416 }: {
1517 placeholder: string
1618 busy?: boolean
1719 disabled?: boolean
18- /** Characters that open a suggestion popup (@ and / in milestone M3). */
20+ /** Characters that open the suggestion popup (@ and /). */
1921 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
2026 onSend: (text: string) => void
2127 onStop: () => void
22- /** Called with the trigger the caret is in, or null. */
23- onTrigger?: (trigger: Trigger | null) => void
2428 } = $props()
2529
2630 let text = $state('')
2731 let textarea: HTMLTextAreaElement
2832 const history = createHistory()
2933
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+
3066 function submit() {
3167 const message = text.trim()
3268 if (!message || disabled) return
3369 history.add(message)
3470 onSend(message)
3571 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
3689 }
3790
3891 function onKeydown(event: KeyboardEvent) {
3992 if (event.isComposing) return // an accent or a character being composed
93+ if (popupKey(event)) {
94+ event.preventDefault()
95+ return
96+ }
4097 if (event.key === 'Enter' && !event.shiftKey) {
4198 event.preventDefault()
4299 submit()
@@ -53,7 +110,7 @@
53110 // Grow with the text, up to a third of the window.
54111 textarea.style.height = 'auto'
55112 textarea.style.height = Math.min(textarea.scrollHeight, window.innerHeight / 3) + 'px'
56- if (triggers.length > 0) onTrigger(findTrigger(text, textarea.selectionStart, triggers))
113+ updatePopup()
57114 }
58115
59116 /** Puts the caret in the composer. */
@@ -63,6 +120,24 @@
63120 </script>
64121
65122 <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}
66141 <textarea
67142 bind:this={textarea}
68143 bind:value={text}
@@ -72,6 +147,8 @@
72147 aria-label={t('composer.label')}
73148 onkeydown={onKeydown}
74149 oninput={onInput}
150+ onclick={updatePopup}
151+ onblur={() => (trigger = null)}
75152 ></textarea>
76153 <div class="actions">
77154 <span class="hint">{t('composer.hint')}</span>
@@ -83,10 +160,16 @@
83160 </div>
84161
85162 <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; }
87164 textarea { width: 100%; resize: none; border: none; outline: none; background: none; color: var(--text); font: inherit; line-height: 1.45; min-height: 40px; }
88165 .actions { display: flex; align-items: center; gap: 8px; }
89166 .hint { flex: 1; color: var(--muted); font-size: 11px; }
90167 .send { background: var(--accent); border-color: var(--accent); color: #fff; }
91168 .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; }
92175 </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: string17 placeholder: string
16 busy?: boolean18 busy?: boolean
17 disabled?: boolean19 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) => void26 onSend: (text: string) => void
21 onStop: () => void27 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: HTMLTextAreaElement31 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) return68 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 composed92 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 <textarea141 <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 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
33 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'
58 import Markdown from './Markdown.svelte'
9+ import Spinner from './Spinner.svelte'
610 import ToolCallCard from './ToolCallCard.svelte'
711
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')
920
1021 // Codes and stop reasons come from Go; their sentences live in i18n.
1122 const systemKey = $derived(`system.${message.code ?? ''}`)
@@ -14,17 +25,17 @@
1425 </script>
1526
1627 {#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>
1829 {:else}
1930 <article class="message {message.role}">
2031 <div class="author">
21- {message.role === 'user' ? t('message.you') : agentTitle}
32+ {message.role === 'user' ? t('message.you') : author}
2233 {#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}
2435 </div>
2536 {#each message.parts ?? [] as part, index (index)}
2637 {#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>
2839 {:else if part.kind === 'text'}
2940 <Markdown text={part.text ?? ''} />
3041 {:else if part.kind === 'thought'}
@@ -55,8 +66,9 @@
5566 .author { font-weight: 600; margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; }
5667 .agent .author { color: var(--accent); }
5768 .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; }
5970 .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; }
6072 .system { color: var(--muted); font-style: italic; text-align: center; font-size: 13px; margin: 4px 0; }
6173 .detail { font-style: normal; font-size: 12px; }
6274 .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 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
33 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'
66 import { describeAction, isIrreversible, mainDetail, orderOptions } from '../tools'
77
8- let { permission, agentTitle }: { permission: Permission; agentTitle: string } = $props()
8+ let { permission }: { permission: Permission } = $props()
9+
10+ const agentTitle = $derived(titleOf(permission.agent))
911
1012 // ACP option kinds in words a citizen user understands; the agent's own
1113 // label is kept as a tooltip.
@@ -34,7 +36,7 @@
3436 {label(option.kind, option.name)}
3537 </button>
3638 {/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>
3840 </div>
3941 <details>
4042 <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 own12 // 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 @@
11 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
22
33 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'] }
79
810 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' }
1012 }
1113
1214 describe('conversation events', () => {
1315 it('adds a message, then replaces it when it finishes', () => {
14- const conversation = emptyConversation('alice')
16+ const conversation = emptyConversation(general)
1517 applyMessage(conversation, message('m-1'))
1618 applyMessage(conversation, { ...message('m-1', true), stopReason: 'end_turn' })
1719 expect(conversation.messages).toHaveLength(1)
@@ -19,26 +21,26 @@ describe('conversation events', () => {
1921 })
2022
2123 it('appends streamed text and replaces tool parts', () => {
22- const conversation = emptyConversation('alice')
24+ const conversation = emptyConversation(general)
2325 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: [] } } })
2830 const parts = conversation.messages![0].parts!
2931 expect(parts[0].text).toBe('Bonjour')
3032 expect(parts[1].tool!.status).toBe('completed')
3133 })
3234
3335 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' })
3638 expect(conversation.messages).toHaveLength(0)
3739 })
3840
3941 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 }
4244 addPermission(conversation, permission)
4345 addPermission(conversation, permission)
4446 expect(conversation.permissions).toHaveLength(1)
@@ -47,9 +49,34 @@ describe('conversation events', () => {
4749 })
4850
4951 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()
5481 })
5582 })
@@ -1,17 +1,19 @@
1 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE1 // 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 @@
11 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
22
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.
55
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'
77
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
1124 }
1225
1326 /**
@@ -48,9 +61,30 @@ export function removePermission(conversation: Conversation, id: string) {
4861 conversation.permissions = (conversation.permissions ?? []).filter((candidate) => candidate.id !== id)
4962 }
5063
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
5569 return Math.min(100, Math.round((used / size) * 100))
5670 }
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 LICENSE1 // 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 by3+// 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.usage66+ const usage = conversation.usage?.[agent]
54- if (!size) return null67+ 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 = {
2626 'agent.open': 'Open the conversation with {agent}',
2727
2828 // 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',
3042
3143 // log
3244 'logs.of': 'Log of',
@@ -48,11 +60,23 @@ export const en = {
4860 'conversation.stop': '■ Stop {agent}',
4961 'conversation.placeholder': 'Write to {agent}…',
5062 '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.',
5272
5373 // composer
5474 'composer.label': 'Your message',
5575 '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}',
5680 'composer.stop': '■ Stop',
5781 'composer.send': 'Send',
5882
@@ -60,6 +84,9 @@ export const en = {
6084 'message.you': 'You',
6185 'message.queued': 'waiting',
6286 'message.typing': 'writing…',
87+ 'message.thinking': 'thinking…',
88+ 'message.usingTool': 'using a tool…',
89+ 'message.preparing': '{agent} is getting ready…',
6390 'message.thought': 'Reasoning',
6491 'message.stop.cancelled': 'Answer stopped.',
6592 'message.stop.max_tokens': 'Answer cut: the agent reached its length limit.',
@@ -67,7 +94,9 @@ export const en = {
6794 'message.stop.refusal': 'The agent refused to answer.',
6895 'message.error': 'The answer ended with an error: {error}',
6996 '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}).',
71100 'system.agent-unavailable': '{agent} could not answer.',
72101
73102 // tool calls
@@ -144,7 +173,19 @@ export const fr: Record<MessageKey, string> = {
144173 'agent.stop': 'Arrêter {agent}',
145174 'agent.open': 'Ouvrir la conversation avec {agent}',
146175
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',
148189
149190 'logs.of': 'Journal de',
150191 '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> = {
163204 'conversation.stop': '■ Arrêter {agent}',
164205 'conversation.placeholder': 'Écrivez à {agent}…',
165206 '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.",
167216
168217 'composer.label': 'Votre message',
169218 '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}",
170223 'composer.stop': '■ Arrêter',
171224 'composer.send': 'Envoyer',
172225
173226 'message.you': 'Vous',
174227 'message.queued': 'en attente',
175228 'message.typing': 'écrit…',
229+ 'message.thinking': 'réfléchit…',
230+ 'message.usingTool': 'utilise un outil…',
231+ 'message.preparing': '{agent} se prépare…',
176232 'message.thought': 'Réflexion',
177233 'message.stop.cancelled': 'Réponse arrêtée.',
178234 '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> = {
180236 'message.stop.refusal': "L'agent a refusé de répondre.",
181237 'message.error': "La réponse s'est terminée sur une erreur : {error}",
182238 '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}).",
184242 'system.agent-unavailable': "{agent} n'a pas pu répondre.",
185243
186244 '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 // channels28 // 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 // log43 // 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 // composer73 // 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 calls102 // 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 @@
22 <script lang="ts">
33 import { hasMessage, t, type MessageKey } from '../i18n/index.svelte'
44 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'
66
7+ // The agent of the direct conversation shown, to highlight it.
8+ const directAgent = $derived(channelOf(appState.selectedChannel)?.direct ?? '')
79 </script>
810
911 {#if appState.agents.length === 0}
@@ -11,9 +13,9 @@
1113 {:else}
1214 <ul>
1315 {#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}>
1517 <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>
1719 <span class="state">{hasMessage(`agent.state.${agent.state}`) ? t(`agent.state.${agent.state}` as MessageKey) : agent.state}{agent.outdated ? t('agent.outdated') : ''}</span>
1820 <span class="actions">
1921 {#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 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
33 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'
56
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+ }
855 </script>
956
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}
1160 <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 })}>
1777 # {channel.name}
1878 </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>
22118 {/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>
23132
24133 <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; }
25141 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; }
30157 </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 @@
55 import Composer from '../chat/Composer.svelte'
66 import MessageView from '../chat/MessageView.svelte'
77 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'
910 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'
1113 import { stuckAfter, trailingFailures } from '../tools'
1214
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)
1725 // An answer whose last tool calls all failed: the agent is probably looping.
1826 const failures = $derived.by(() => {
1927 const last = conversation?.messages?.at(-1)
2028 return conversation?.busy && last?.role === 'agent' && !last.done ? trailingFailures(last.parts ?? []) : 0
2129 })
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+ })
2274
2375 // Smart scrolling: follow new content only when the reader is at the bottom.
2476 let list: HTMLElement | undefined = $state()
@@ -28,14 +80,24 @@
2880 }
2981 $effect(() => {
3082 // 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
3285 void conversation?.permissions?.length
3386 if (following && list) tick().then(() => list && (list.scrollTop = list.scrollHeight))
3487 })
3588 $effect(() => {
36- void appState.selectedAgent
89+ void appState.selectedChannel
3790 following = true
3891 })
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+ }
39101 </script>
40102
41103 <section class="conversation">
@@ -45,42 +107,91 @@
45107 {t('conversation.invalidConfigHint')}
46108 </div>
47109 {/if}
110+ {#if appState.config?.historyProblem}
111+ <div class="banner warning" role="alert">{t('conversation.historyProblem', { problem: appState.config.historyProblem })}</div>
112+ {/if}
48113
49- {#if agent && conversation}
114+ {#if channel && conversation}
50115 <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}
53147 {#if percent !== null}
54148 <span class="usage" title={t('conversation.usageHint')}>{t('conversation.usage', { percent })}</span>
55149 {/if}
56150 </header>
57151 <div class="messages" bind:this={list} onscroll={onScroll}>
152+ {#if conversation.more}
153+ <button class="older" onclick={older}>{t('conversation.older')}</button>
154+ {/if}
58155 {#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>
60163 {/if}
61164 {#each conversation.messages ?? [] as message (message.id)}
62- <MessageView {message} agentTitle={agent.title} />
165+ <MessageView {message} />
63166 {/each}
167+ {#if gettingReady}
168+ <p class="pending"><Spinner /> {t('message.preparing', { agent: agentTitle(conversation.answering) })}</p>
169+ {/if}
64170 {#each conversation.permissions ?? [] as permission (permission.id)}
65- <PermissionCard {permission} agentTitle={agent.title} />
171+ <PermissionCard {permission} />
66172 {/each}
67173 </div>
68174 {#if failures >= stuckAfter}
69175 <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>
72178 </div>
73179 {/if}
74180 {#if appState.actionError}
75181 <p class="action-error" role="alert">{appState.actionError}</p>
76182 {/if}
77183 <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}
84195 </div>
85196 {:else}
86197 <div class="welcome">
@@ -97,11 +208,19 @@
97208 <style>
98209 .conversation { height: 100%; display: flex; flex-direction: column; background: var(--bg); }
99210 .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); }
101213 .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; }
103221 .usage { margin-left: auto; color: var(--muted); font-size: 12px; border: 1px solid var(--border); border-radius: 10px; padding: 1px 10px; }
104222 .messages { flex: 1; overflow-y: auto; padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; }
223+ .older { align-self: center; font-size: 12px; }
105224 .empty { color: var(--muted); margin: auto; text-align: center; max-width: 420px; }
106225 .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; }
107226 .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 ?? []) : 028 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?.length85 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.selectedAgent89+ void appState.selectedChannel
37 following = true90 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- <Composer184+ {#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 @@
11 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
22
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.
66
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'
88 import type { ConfigView } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
99 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'
1011 import type { AgentLogPayload, AgentStatePayload, ConfigChangedPayload } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/events'
1112 import type {
13+ Channel,
14+ ListPayload,
15+ CommandsPayload,
1216 Conversation,
1317 DeltaPayload,
1418 MessagePayload,
@@ -16,10 +20,12 @@ import type {
1620 PermissionResolvedPayload,
1721 TurnPayload,
1822 UsagePayload,
19-} from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
23+} from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/channels'
2024 import {
2125 AgentLog,
2226 AgentState,
27+ ChannelsChanged,
28+ CommandsChanged,
2329 ConfigChanged,
2430 MessageDelta,
2531 MessageUpdated,
@@ -28,7 +34,7 @@ import {
2834 TurnChanged,
2935 UsageChanged,
3036 } from '../../bindings/events'
31-import { addPermission, applyDelta, applyMessage, emptyConversation, removePermission } from './conversation'
37+import { addPermission, applyDelta, applyMessage, emptyConversation, prependMessages, removePermission } from './conversation'
3238 import { createFrameBatcher, subscribe } from './events'
3339 import { applyTheme } from './theme'
3440 import { setLanguage } from './i18n/index.svelte'
@@ -40,14 +46,29 @@ export const appState = $state({
4046 agents: [] as Status[],
4147 /** The last stderr lines of each agent, by agent name. */
4248 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. */
4452 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: '',
4763 /** The last error returned by an action, shown to the user. */
4864 actionError: '',
4965 })
5066
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+
5172 async function refreshConfig() {
5273 appState.config = await ConfigService.Current()
5374 applyTheme(document.documentElement, appState.config.theme)
@@ -56,14 +77,36 @@ async function refreshConfig() {
5677
5778 async function refreshAgents() {
5879 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 = ''
6194 }
6295 }
6396
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
67110 }
68111
69112 /** Runs an action and shows its error instead of throwing. */
@@ -76,32 +119,121 @@ export async function act(action: () => Promise<unknown>) {
76119 }
77120 }
78121
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+
79150 /**
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 "/".
83154 */
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+ }
87169 }
88170
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)) ?? []
91174 }
92175
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))
95193 }
96194
97195 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))
99230 }
100231
101232 /** Loads the state and follows the Go events; returns the clean-up function. */
102233 export function connect(): () => void {
103234 void refreshConfig()
104235 void refreshAgents()
236+ void refreshChannels()
105237
106238 const logBatcher = createFrameBatcher<AgentLogPayload>((payloads) => {
107239 for (const { agent, line } of payloads) {
@@ -115,19 +247,31 @@ export function connect(): () => void {
115247 type MessageEvent = { message: MessagePayload } | { delta: DeltaPayload }
116248 const messageBatcher = createFrameBatcher<MessageEvent>((items) => {
117249 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)
120257 }
121258 })
122259
123260 const stops = [
124261 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+ }
128270 void refreshConfig()
129271 void refreshAgents()
272+ void refreshChannels()
130273 }),
274+ subscribe<ListPayload>(ChannelsChanged, (payload) => setChannels(payload.channels ?? [])),
131275 subscribe<AgentStatePayload>(AgentState, (payload) => {
132276 const agent = appState.agents.find((candidate) => candidate.name === payload.agent)
133277 if (!agent) {
@@ -141,14 +285,19 @@ export function connect(): () => void {
141285 subscribe<MessagePayload>(MessageUpdated, (payload) => messageBatcher.push({ message: payload })),
142286 subscribe<DeltaPayload>(MessageDelta, (payload) => messageBatcher.push({ delta: payload })),
143287 subscribe<TurnPayload>(TurnChanged, (payload) => {
144- const conversation = conversationOf(payload.agent)
288+ const conversation = conversationOf(payload.channel)
145289 conversation.busy = payload.busy
146290 conversation.queued = payload.queued
291+ conversation.answering = payload.answering
147292 }),
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)),
150295 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 ?? []
152301 }),
153302 ]
154303 return () => stops.forEach((stop) => stop())
@@ -1,14 +1,18 @@
1 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE1 // 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 the3+// 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 Go4+// 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 the151+ * 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 = agent156+ 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.busy289 conversation.busy = payload.busy
146 conversation.queued = payload.queued290 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.usage296+ 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
33 go 1.26.0
44
55 require (
6+ github.com/cockroachdb/pebble/v2 v2.1.7
67 github.com/coder/acp-go-sdk v0.13.5
78 github.com/fsnotify/fsnotify v1.10.1
89 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
@@ -12,11 +13,39 @@ require (
1213 )
1314
1415 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
1519 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
1628 github.com/coder/websocket v1.8.14 // indirect
29+ github.com/getsentry/sentry-go v0.27.0 // indirect
1730 github.com/go-ole/go-ole v1.3.0 // indirect
1831 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
1938 github.com/mattn/go-colorable v0.1.14 // indirect
2039 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
2149 golang.org/x/sys v0.48.0 // indirect
50+ google.golang.org/protobuf v1.33.0 // indirect
2251 )
@@ -3,6 +3,7 @@ module git.rickub.com/bots-garden/sidekicks
3 go 1.26.03 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.57 github.com/coder/acp-go-sdk v0.13.5
7 github.com/fsnotify/fsnotify v1.10.18 github.com/fsnotify/fsnotify v1.10.1
8 github.com/santhosh-tekuri/jsonschema/v6 v6.0.39 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 // indirect19 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 // indirect28 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 // indirect30 github.com/go-ole/go-ole v1.3.0 // indirect
18 github.com/godbus/dbus/v5 v5.2.2 // indirect31 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 // indirect38 github.com/mattn/go-colorable v0.1.14 // indirect
20 github.com/mattn/go-isatty v0.0.20 // indirect39 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 // indirect49 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=
19 github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
210 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=
333 github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk=
434 github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
535 github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
636 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=
738 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
839 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
940 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
1041 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
1142 github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
1243 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=
1350 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
1451 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
1552 github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
1653 github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
1754 github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
1855 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=
1966 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
2067 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=
2176 github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
2277 github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
2378 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
2479 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
2580 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
2681 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=
2791 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2892 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=
29103 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
30104 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
31105 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
32106 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
33107 github.com/wailsapp/wails/v3 v3.0.0 h1:lBe89kFu23bVz1dc1H9v0srkHQgWsy7BAKpH4sLQuDw=
34108 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=
35111 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
36112 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=
37133 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
38134 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
39135 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
40136 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=
41139 golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
42140 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=
43153 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
44154 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.
331331 }
332332 return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": "exceeded maximum tool call iterations (40)"})
333333 }
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+
334345 func (*fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
335346 return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound("session/resume")
336347 }
@@ -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 {
118118 OutputTokens int
119119 }
120120
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+
121135 // NewSession opens a session whose working directory is cwd and sends what
122136 // the agent says about it to handler.
123137 //
@@ -118,6 +118,20 @@ type TurnResult struct {
118 OutputTokens int118 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 what135 // 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) {
155155 t.Fatalf("want the agent's own explanation, got %v", err)
156156 }
157157 }
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 {
6060 Err() error
6161 Close(ctx context.Context) error
6262 NewSession(ctx context.Context, cwd string, handler acp.SessionHandler) (string, error)
63+ LoadSession(ctx context.Context, id, cwd string, handler acp.SessionHandler) error
6364 Prompt(ctx context.Context, sessionID, text string) (acp.TurnResult, error)
6465 Cancel(ctx context.Context, sessionID string) error
6566 }
@@ -60,6 +60,7 @@ type Conn interface {
60 Err() error60 Err() error
61 Close(ctx context.Context) error61 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) error65 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) {
5252 func (c *fakeConn) NewSession(context.Context, string, acp.SessionHandler) (string, error) {
5353 return "s-1", nil
5454 }
55+func (c *fakeConn) LoadSession(context.Context, string, string, acp.SessionHandler) error { return nil }
5556 func (c *fakeConn) Prompt(context.Context, string, string) (acp.TurnResult, error) {
5657 return acp.TurnResult{StopReason: "end_turn"}, nil
5758 }
@@ -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", nil53 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"}, nil57 return acp.TurnResult{StopReason: "end_turn"}, nil
57 }58 }
modified internal/app/app.go +85 -17
@@ -1,8 +1,8 @@
11 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
22
33 // 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.
66 //
77 // It does not import Wails: main.go binds the services, plugs the Wails event
88 // manager in as the events.Emitter and the native file dialog in as
@@ -11,7 +11,7 @@
1111 // core := app.New(location, app.Options{Emitter: emitter})
1212 // go core.Run(ctx)
1313 // 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}}
1515 package app
1616
1717 import (
@@ -21,10 +21,11 @@ import (
2121 "sync"
2222
2323 "git.rickub.com/bots-garden/sidekicks/internal/agents"
24+ "git.rickub.com/bots-garden/sidekicks/internal/channels"
2425 "git.rickub.com/bots-garden/sidekicks/internal/config"
25- "git.rickub.com/bots-garden/sidekicks/internal/conversations"
2626 "git.rickub.com/bots-garden/sidekicks/internal/events"
2727 "git.rickub.com/bots-garden/sidekicks/internal/prefs"
28+ "git.rickub.com/bots-garden/sidekicks/internal/store"
2829 )
2930
3031 // Options configure a Core. Every field is optional.
@@ -37,21 +38,29 @@ type Options struct {
3738 // PickFile asks the user for a configuration file (native dialog); it
3839 // returns "" when the user cancels.
3940 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)
4044 }
4145
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.
4348 type Core struct {
44- options Options
45- manager *agents.Manager
46- conversations *conversations.Manager
49+ options Options
50+ manager *agents.Manager
4751
4852 mu sync.Mutex
4953 location config.Location
5054 cfg *config.Config
5155 problems []config.Problem
5256 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
5564 }
5665
5766 // New loads the configuration at location — creating the default file first
@@ -69,10 +78,66 @@ func New(location config.Location, options Options) *Core {
6978 MaxRunning: cfg.Limits.MaxRunningAgents, IdleTimeout: cfg.Limits.StopIdleAgentsAfter,
7079 Emitter: options.Emitter, Starter: options.Starter,
7180 })
72- core.conversations = conversations.NewManager(core.manager, conversations.Options{Emitter: options.Emitter})
81+ core.openChannels()
7382 return core
7483 }
7584
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+
76141 // load reads the configuration at location, creating the default file when
77142 // that is where it should be. A file with problems gives an empty configuration.
78143 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) {
157222 return
158223 }
159224 c.cfg, c.problems = cfg, nil
225+ manager := c.channels
160226 c.mu.Unlock()
161227 c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter)
228+ _ = manager.Declare(cfg.Channels)
162229 c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: path, Valid: true, Problems: []string{}})
163230 }
164231
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.
168235 func (c *Core) Open(ctx context.Context, path string) error {
169236 absolute, err := filepath.Abs(path)
170237 if err != nil {
171238 return err
172239 }
173- c.conversations.Reset()
240+ c.closeChannels()
174241 c.manager.StopAll(ctx)
175242
176243 location := config.Location{Path: absolute, Source: config.SourceLastOpened}
@@ -179,6 +246,7 @@ func (c *Core) Open(ctx context.Context, path string) error {
179246 c.location, c.cfg, c.problems, c.created = location, cfg, problems, false
180247 c.mu.Unlock()
181248 c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter)
249+ c.openChannels()
182250 c.watch()
183251 if c.options.Preferences != nil {
184252 _ = c.options.Preferences.Remember(absolute)
@@ -187,9 +255,9 @@ func (c *Core) Open(ctx context.Context, path string) error {
187255 return nil
188256 }
189257
190-// Shutdown cancels the answers and stops every agent.
258+// Shutdown cancels the answers, stops every agent and closes the history.
191259 func (c *Core) Shutdown(ctx context.Context) {
192- c.conversations.Reset()
260+ c.closeChannels()
193261 c.manager.StopAll(ctx)
194262 }
195263
@@ -1,8 +1,8 @@
1 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE1 // 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 to3 // 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 the4+// 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 event7 // 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 as8 // 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 app15 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); it38 // 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 Options49+ options Options
45- manager *agents.Manager50+ manager *agents.Manager
46- conversations *conversations.Manager
47 51
48 mu sync.Mutex52 mu sync.Mutex
49 location config.Location53 location config.Location
50 cfg *config.Config54 cfg *config.Config
51 problems []config.Problem55 problems []config.Problem
52 created bool56 created bool
53- runCtx context.Context57+ db *store.Store
54- stopWait context.CancelFunc58+ 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 first66 // 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 core82 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 when141 // 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 return222 return
158 }223 }
159 c.cfg, c.problems = cfg, nil224 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 are232+// 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 err238 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, false246 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 nil255 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 (
1313 "git.rickub.com/bots-garden/sidekicks/internal/acp"
1414 "git.rickub.com/bots-garden/sidekicks/internal/agents"
1515 "git.rickub.com/bots-garden/sidekicks/internal/app"
16+ "git.rickub.com/bots-garden/sidekicks/internal/channels"
1617 "git.rickub.com/bots-garden/sidekicks/internal/config"
1718 "git.rickub.com/bots-garden/sidekicks/internal/events"
1819 "git.rickub.com/bots-garden/sidekicks/internal/prefs"
1920 )
2021
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+}
2240
2341 func (c *conn) Info() acp.Info { return acp.Info{} }
2442 func (c *conn) Done() <-chan struct{} { return c.done }
2543 func (c *conn) Err() error { return nil }
2644 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
2847 return "s-1", nil
2948 }
49+func (c *conn) LoadSession(context.Context, string, string, acp.SessionHandler) error { return nil }
3050 func (c *conn) Prompt(context.Context, string, string) (acp.TurnResult, error) {
51+ c.handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: "ok"})
3152 return acp.TurnResult{StopReason: "end_turn"}, nil
3253 }
3354 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
4768 func TestTheDefaultFileIsCreatedAtTheDefaultLocation(t *testing.T) {
4869 path := filepath.Join(t.TempDir(), "sidekicks", "config.yaml")
4970 core := app.New(config.Location{Path: path, Source: config.SourceDefault}, app.Options{Starter: starter})
71+ t.Cleanup(func() { core.Shutdown(context.Background()) })
5072 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 != "" {
5274 t.Fatalf("got %+v", view)
5375 }
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+ }
5479 }
5580
5681 func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) {
@@ -69,10 +94,14 @@ func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) {
6994 path := filepath.Join(t.TempDir(), "config.yaml")
7095 write(t, path, twoAgents)
7196 core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter})
97+ t.Cleanup(func() { core.Shutdown(context.Background()) })
7298 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" {
74100 t.Fatalf("got %+v", view)
75101 }
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+ }
76105 service := &app.AgentService{Core: core}
77106 if list := service.List(); len(list) != 2 || list[0].State != agents.StateStopped {
78107 t.Fatalf("got %+v", list)
@@ -202,18 +231,50 @@ func newSwitchFixture(t *testing.T) *switchFixture {
202231 PickFile: func() (string, error) { return f.picked, nil },
203232 })
204233 ctx, cancel := context.WithCancel(context.Background())
205- t.Cleanup(cancel)
234+ t.Cleanup(func() {
235+ cancel()
236+ f.core.Shutdown(context.Background())
237+ })
206238 go f.core.Run(ctx)
207239 return f
208240 }
209241
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) {
211267 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 {
214271 t.Fatal(err)
215272 }
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
217278
218279 f.picked = f.second
219280 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) {
222283 if list := (&app.AgentService{Core: f.core}).List(); len(list) != 1 || list[0].Name != "garfield" {
223284 t.Fatalf("agents of the new file: %+v", list)
224285 }
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)
227288 }
228289 if view := (&app.ConfigService{Core: f.core}).Current(); view.Path != f.second || view.Source != "last-opened" {
229290 t.Fatalf("view: %+v", view)
@@ -231,6 +292,13 @@ func TestOpenSwitchesConfigurationAndForgetsConversations(t *testing.T) {
231292 if len(f.recorder.Named(events.ConfigChanged)) == 0 {
232293 t.Fatal("config:changed expected after opening")
233294 }
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+ }
234302 }
235303
236304 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", nil47 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"}, nil52 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 f239 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.second279 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 (
77 "errors"
88 "os"
99
10+ "git.rickub.com/bots-garden/sidekicks/internal/acp"
1011 "git.rickub.com/bots-garden/sidekicks/internal/agents"
12+ "git.rickub.com/bots-garden/sidekicks/internal/channels"
1113 "git.rickub.com/bots-garden/sidekicks/internal/config"
12- "git.rickub.com/bots-garden/sidekicks/internal/conversations"
1314 "git.rickub.com/bots-garden/sidekicks/schema"
1415 )
1516
@@ -44,33 +45,93 @@ func (s *AgentService) Logs(name string) ([]string, error) {
4445 return s.Core.manager.Logs(name)
4546 }
4647
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 {
5153 Core *Core
5254 }
5355
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()
5859 }
5960
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)
63101 }
64102
65103 // 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)
68129 }
69130
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"`
74135 }
75136
76137 // ConfigView is the configuration as the interface shows it.
@@ -82,19 +143,14 @@ type ConfigView struct {
82143 Created bool `json:"created"`
83144 // Valid is false when the file has problems; the last valid configuration
84145 // (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"`
98154 }
99155
100156 // ProblemView is a problem of the text being edited, located for the editor.
@@ -120,14 +176,7 @@ func (s *ConfigService) Current() ConfigView {
120176 view := ConfigView{
121177 Path: c.location.Path, Source: string(c.location.Source), Created: c.created,
122178 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,
131180 }
132181 return view
133182 }
@@ -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 *Core53 Core *Core
52 }54 }
53 55
54-// Send sends a message to the agent and returns the message id; the agent56+// 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 the131+// 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 configuration144 // 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 view181 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 (
2222 // AgentLog: an agent wrote a line on its standard error. Payload: AgentLogPayload.
2323 AgentLog = "agent:log"
2424
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.
2727
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.
2931 MessageUpdated = "message:updated"
30- // MessageDelta: a streaming message changed. Payload: conversations.DeltaPayload.
32+ // MessageDelta: a streaming message changed. Payload: channels.DeltaPayload.
3133 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.
3335 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.
3537 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.
3739 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.
3941 UsageChanged = "usage:changed"
42+ // CommandsChanged: an agent announced its slash commands. Payload: channels.CommandsPayload.
43+ CommandsChanged = "commands:changed"
4044 )
4145
4246 // 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 in25+ // 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 and46 // 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 (
1717 "os"
1818
1919 "git.rickub.com/bots-garden/sidekicks/internal/app"
20+ "git.rickub.com/bots-garden/sidekicks/internal/channels"
2021 "git.rickub.com/bots-garden/sidekicks/internal/config"
21- "git.rickub.com/bots-garden/sidekicks/internal/conversations"
2222 "git.rickub.com/bots-garden/sidekicks/internal/events"
2323 "git.rickub.com/bots-garden/sidekicks/internal/prefs"
2424 "github.com/wailsapp/wails/v3/pkg/application"
@@ -36,12 +36,14 @@ func init() {
3636 application.RegisterEvent[events.ConfigChangedPayload](events.ConfigChanged)
3737 application.RegisterEvent[events.AgentStatePayload](events.AgentState)
3838 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)
4547 }
4648
4749 func main() {
@@ -62,10 +64,15 @@ func main() {
6264 os.Exit(1)
6365 }
6466
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.
6770 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+ })
6976 pickFile := func() (string, error) {
7077 return wailsApp.Dialog.OpenFile().
7178 SetTitle("Open a Sidekicks configuration").
@@ -81,7 +88,7 @@ func main() {
8188 Services: []application.Service{
8289 application.NewService(&app.AgentService{Core: core}),
8390 application.NewService(&app.ConfigService{Core: core}),
84- application.NewService(&app.ChatService{Core: core}),
91+ application.NewService(&app.ChannelService{Core: core}),
8592 },
8693 Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)},
8794 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 application67+ // 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.App70 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() {
112112 done
113113 local status=0
114114 "$@" || 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
116121 return "$status"
117122 }
118123
@@ -112,7 +112,12 @@ with_lock() {
112 done112 done
113 local status=0113 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() {
185185 grep -q '"model":"qwen-2b"' "$work/curl.generate"
186186 }
187187
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+
188203 test_no_unload_variable_keeps_the_model() {
189204 export SIDEKICKS_NO_UNLOAD=1
190205 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=1204 export SIDEKICKS_NO_UNLOAD=1
190 echo "gemma-e2b" > "$work/loaded"205 echo "gemma-e2b" > "$work/loaded"