detached 5e699ec

fix: preserve bot reply anchors and status state

commit
5e699ec40a50daa309fddc7b6395ea419b0a47d8
author
patriceckhart · · 2026-06-29 03:14 UTC
parent
1ae0519
signature
unsigned
5 files · +36 -15
M packages/agent/botcmd.go +2 -1
@@ -384,6 +384,7 @@ func botRun(rawTail []string, version string) error {
384384 runner = bot.NewRunner(adapter, agent, bot.Config{
385385 ZotHome: ZotHome(),
386386 Provider: resolved.Provider,
387+ Model: resolved.Model,
387388 AuthMethod: resolved.AuthMethod,
388389 CWD: args.CWD,
389390 RefreshCreds: func() error {
@@ -397,7 +398,7 @@ func botRun(rawTail []string, version string) error {
397398 }
398399 agent.Client = next.NewClient()
399400 agent.Model = next.Model
400- runner.UpdateRuntimeConfig(next.Provider, next.AuthMethod, next.CWD)
401+ runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
401402 return nil
402403 },
403404 })
@@ -384,6 +384,7 @@ func botRun(rawTail []string, version string) error {
384 runner = bot.NewRunner(adapter, agent, bot.Config{384 runner = bot.NewRunner(adapter, agent, bot.Config{
385 ZotHome: ZotHome(),385 ZotHome: ZotHome(),
386 Provider: resolved.Provider,386 Provider: resolved.Provider,
387+ Model: resolved.Model,
387 AuthMethod: resolved.AuthMethod,388 AuthMethod: resolved.AuthMethod,
388 CWD: args.CWD,389 CWD: args.CWD,
389 RefreshCreds: func() error {390 RefreshCreds: func() error {
@@ -397,7 +398,7 @@ func botRun(rawTail []string, version string) error {
397 }398 }
398 agent.Client = next.NewClient()399 agent.Client = next.NewClient()
399 agent.Model = next.Model400 agent.Model = next.Model
400- runner.UpdateRuntimeConfig(next.Provider, next.AuthMethod, next.CWD)401+ runner.UpdateRuntimeConfig(next.Provider, next.Model, next.AuthMethod, next.CWD)
401 return nil402 return nil
402 },403 },
403 })404 })
M packages/agent/modes/bot/adapter.go +7 -1
@@ -28,6 +28,12 @@ const (
2828 CmdStop // cancel the active turn
2929 )
3030
31+// SendOptions carries protocol-agnostic delivery hints for outbound messages.
32+type SendOptions struct {
33+ // ReplyToMessageID is an optional adapter-owned message identifier to anchor replies.
34+ ReplyToMessageID string
35+}
36+
3137 // BotAdapter is the transport layer a concrete protocol must implement.
3238 // The Runner calls these methods; it never touches protocol types directly.
3339 type BotAdapter interface {
@@ -39,7 +45,7 @@ type BotAdapter interface {
3945 ) error
4046
4147 // Send delivers a reply. The adapter chunks to protocol limits.
42- Send(ctx context.Context, channelID, text string) error
48+ Send(ctx context.Context, channelID, text string, opts SendOptions) error
4349
4450 // IndicateWorking fires a "typing…" signal; returns a stop func.
4551 // Return a no-op if the protocol doesn't support it.
@@ -28,6 +28,12 @@ const (
28 CmdStop // cancel the active turn28 CmdStop // cancel the active turn
29 )29 )
30 30
31+// SendOptions carries protocol-agnostic delivery hints for outbound messages.
32+type SendOptions struct {
33+ // ReplyToMessageID is an optional adapter-owned message identifier to anchor replies.
34+ ReplyToMessageID string
35+}
36+
31 // BotAdapter is the transport layer a concrete protocol must implement.37 // BotAdapter is the transport layer a concrete protocol must implement.
32 // The Runner calls these methods; it never touches protocol types directly.38 // The Runner calls these methods; it never touches protocol types directly.
33 type BotAdapter interface {39 type BotAdapter interface {
@@ -39,7 +45,7 @@ type BotAdapter interface {
39 ) error45 ) error
40 46
41 // Send delivers a reply. The adapter chunks to protocol limits.47 // Send delivers a reply. The adapter chunks to protocol limits.
42- Send(ctx context.Context, channelID, text string) error48+ Send(ctx context.Context, channelID, text string, opts SendOptions) error
43 49
44 // IndicateWorking fires a "typing…" signal; returns a stop func.50 // IndicateWorking fires a "typing…" signal; returns a stop func.
45 // Return a no-op if the protocol doesn't support it.51 // Return a no-op if the protocol doesn't support it.
M packages/agent/modes/bot/runner.go +14 -10
@@ -19,6 +19,7 @@ var stderr = func() io.Writer { return os.Stderr }
1919 type Config struct {
2020 ZotHome string
2121 Provider string
22+ Model string
2223 AuthMethod string
2324 CWD string
2425 RefreshCreds func() error
@@ -57,12 +58,13 @@ func NewRunner(adapter BotAdapter, agent *core.Agent, cfg Config) *Runner {
5758 }
5859 }
5960
60-// UpdateRuntimeConfig updates provider/auth/cwd at runtime (e.g. after
61+// UpdateRuntimeConfig updates provider/model/auth/cwd at runtime (e.g. after
6162 // credential refresh). This is thread-safe.
62-func (r *Runner) UpdateRuntimeConfig(provider, authMethod, cwd string) {
63+func (r *Runner) UpdateRuntimeConfig(provider, model, authMethod, cwd string) {
6364 r.mu.Lock()
6465 defer r.mu.Unlock()
6566 r.cfg.Provider = provider
67+ r.cfg.Model = model
6668 r.cfg.AuthMethod = authMethod
6769 r.cfg.CWD = cwd
6870 }
@@ -105,9 +107,10 @@ func (r *Runner) handleCommand(cmd Command, msg InboundMessage) {
105107 switch cmd {
106108 case CmdStart, CmdHelp:
107109 _ = r.adapter.Send(context.Background(), msg.ChannelID,
108- "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop, or plain stop.")
110+ "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop, or plain stop.",
111+ SendOptions{ReplyToMessageID: msg.MessageID})
109112 case CmdStatus:
110- r.sendStatus(msg.ChannelID)
113+ r.sendStatus(msg.ChannelID, msg.MessageID)
111114 case CmdStop:
112115 r.cancelActiveTurn(msg.ChannelID, msg.MessageID)
113116 }
@@ -199,7 +202,7 @@ func (r *Runner) runTurn(ctx context.Context, t queuedTurn) {
199202 }
200203
201204 // Adapter.Send is responsible for chunking to protocol limits.
202- if err := r.adapter.Send(context.Background(), t.channelID, reply); err != nil {
205+ if err := r.adapter.Send(context.Background(), t.channelID, reply, SendOptions{}); err != nil {
203206 fmt.Fprintln(stderr(), "bot: send reply:", err)
204207 }
205208 }
@@ -209,26 +212,27 @@ func (r *Runner) cancelActiveTurn(channelID, messageID string) {
209212 r.mu.Lock()
210213 cancel := r.activeCtx
211214 r.mu.Unlock()
215+ opts := SendOptions{ReplyToMessageID: messageID}
212216 if cancel != nil {
213217 cancel()
214- _ = r.adapter.Send(context.Background(), channelID, "cancelled the current turn.")
218+ _ = r.adapter.Send(context.Background(), channelID, "cancelled the current turn.", opts)
215219 } else {
216- _ = r.adapter.Send(context.Background(), channelID, "nothing running.")
220+ _ = r.adapter.Send(context.Background(), channelID, "nothing running.", opts)
217221 }
218222 }
219223
220224 // sendStatus describes agent state to the user.
221-func (r *Runner) sendStatus(channelID string) {
225+func (r *Runner) sendStatus(channelID, messageID string) {
222226 r.mu.Lock()
223227 busy := r.busy
224228 queued := len(r.queue)
225229 ctxUsed := r.lastCtxInput
226230 providerName := r.cfg.Provider
231+ model := r.cfg.Model
227232 authMethod := r.cfg.AuthMethod
228233 cwd := r.cfg.CWD
229234 r.mu.Unlock()
230235
231- model := r.agent.Model
232236 ctxMax := 0
233237 if m, err := provider.FindModel(providerName, model); err == nil {
234238 ctxMax = m.ContextWindow
@@ -250,5 +254,5 @@ func (r *Runner) sendStatus(channelID string) {
250254 status += "\n" + extra
251255 }
252256
253- _ = r.adapter.Send(context.Background(), channelID, status)
257+ _ = r.adapter.Send(context.Background(), channelID, status, SendOptions{ReplyToMessageID: messageID})
254258 }
@@ -19,6 +19,7 @@ var stderr = func() io.Writer { return os.Stderr }
19 type Config struct {19 type Config struct {
20 ZotHome string20 ZotHome string
21 Provider string21 Provider string
22+ Model string
22 AuthMethod string23 AuthMethod string
23 CWD string24 CWD string
24 RefreshCreds func() error25 RefreshCreds func() error
@@ -57,12 +58,13 @@ func NewRunner(adapter BotAdapter, agent *core.Agent, cfg Config) *Runner {
57 }58 }
58 }59 }
59 60
60-// UpdateRuntimeConfig updates provider/auth/cwd at runtime (e.g. after61+// UpdateRuntimeConfig updates provider/model/auth/cwd at runtime (e.g. after
61 // credential refresh). This is thread-safe.62 // credential refresh). This is thread-safe.
62-func (r *Runner) UpdateRuntimeConfig(provider, authMethod, cwd string) {63+func (r *Runner) UpdateRuntimeConfig(provider, model, authMethod, cwd string) {
63 r.mu.Lock()64 r.mu.Lock()
64 defer r.mu.Unlock()65 defer r.mu.Unlock()
65 r.cfg.Provider = provider66 r.cfg.Provider = provider
67+ r.cfg.Model = model
66 r.cfg.AuthMethod = authMethod68 r.cfg.AuthMethod = authMethod
67 r.cfg.CWD = cwd69 r.cfg.CWD = cwd
68 }70 }
@@ -105,9 +107,10 @@ func (r *Runner) handleCommand(cmd Command, msg InboundMessage) {
105 switch cmd {107 switch cmd {
106 case CmdStart, CmdHelp:108 case CmdStart, CmdHelp:
107 _ = r.adapter.Send(context.Background(), msg.ChannelID,109 _ = r.adapter.Send(context.Background(), msg.ChannelID,
108- "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop, or plain stop.")110+ "send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop, or plain stop.",
111+ SendOptions{ReplyToMessageID: msg.MessageID})
109 case CmdStatus:112 case CmdStatus:
110- r.sendStatus(msg.ChannelID)113+ r.sendStatus(msg.ChannelID, msg.MessageID)
111 case CmdStop:114 case CmdStop:
112 r.cancelActiveTurn(msg.ChannelID, msg.MessageID)115 r.cancelActiveTurn(msg.ChannelID, msg.MessageID)
113 }116 }
@@ -199,7 +202,7 @@ func (r *Runner) runTurn(ctx context.Context, t queuedTurn) {
199 }202 }
200 203
201 // Adapter.Send is responsible for chunking to protocol limits.204 // Adapter.Send is responsible for chunking to protocol limits.
202- if err := r.adapter.Send(context.Background(), t.channelID, reply); err != nil {205+ if err := r.adapter.Send(context.Background(), t.channelID, reply, SendOptions{}); err != nil {
203 fmt.Fprintln(stderr(), "bot: send reply:", err)206 fmt.Fprintln(stderr(), "bot: send reply:", err)
204 }207 }
205 }208 }
@@ -209,26 +212,27 @@ func (r *Runner) cancelActiveTurn(channelID, messageID string) {
209 r.mu.Lock()212 r.mu.Lock()
210 cancel := r.activeCtx213 cancel := r.activeCtx
211 r.mu.Unlock()214 r.mu.Unlock()
215+ opts := SendOptions{ReplyToMessageID: messageID}
212 if cancel != nil {216 if cancel != nil {
213 cancel()217 cancel()
214- _ = r.adapter.Send(context.Background(), channelID, "cancelled the current turn.")218+ _ = r.adapter.Send(context.Background(), channelID, "cancelled the current turn.", opts)
215 } else {219 } else {
216- _ = r.adapter.Send(context.Background(), channelID, "nothing running.")220+ _ = r.adapter.Send(context.Background(), channelID, "nothing running.", opts)
217 }221 }
218 }222 }
219 223
220 // sendStatus describes agent state to the user.224 // sendStatus describes agent state to the user.
221-func (r *Runner) sendStatus(channelID string) {225+func (r *Runner) sendStatus(channelID, messageID string) {
222 r.mu.Lock()226 r.mu.Lock()
223 busy := r.busy227 busy := r.busy
224 queued := len(r.queue)228 queued := len(r.queue)
225 ctxUsed := r.lastCtxInput229 ctxUsed := r.lastCtxInput
226 providerName := r.cfg.Provider230 providerName := r.cfg.Provider
231+ model := r.cfg.Model
227 authMethod := r.cfg.AuthMethod232 authMethod := r.cfg.AuthMethod
228 cwd := r.cfg.CWD233 cwd := r.cfg.CWD
229 r.mu.Unlock()234 r.mu.Unlock()
230 235
231- model := r.agent.Model
232 ctxMax := 0236 ctxMax := 0
233 if m, err := provider.FindModel(providerName, model); err == nil {237 if m, err := provider.FindModel(providerName, model); err == nil {
234 ctxMax = m.ContextWindow238 ctxMax = m.ContextWindow
@@ -250,5 +254,5 @@ func (r *Runner) sendStatus(channelID string) {
250 status += "\n" + extra254 status += "\n" + extra
251 }255 }
252 256
253- _ = r.adapter.Send(context.Background(), channelID, status)257+ _ = r.adapter.Send(context.Background(), channelID, status, SendOptions{ReplyToMessageID: messageID})
254 }258 }
M packages/agent/modes/bot/runner_test.go +3 -1
@@ -15,7 +15,9 @@ type testAdapter struct{}
1515 func (testAdapter) Run(context.Context, func(InboundMessage), func(Command, InboundMessage)) error {
1616 return nil
1717 }
18-func (testAdapter) Send(context.Context, string, string) error { return nil }
18+func (testAdapter) Send(context.Context, string, string, SendOptions) error {
19+ return nil
20+}
1921 func (testAdapter) IndicateWorking(context.Context, string) func() { return func() {} }
2022 func (testAdapter) StatusText() string { return "" }
2123
@@ -15,7 +15,9 @@ type testAdapter struct{}
15 func (testAdapter) Run(context.Context, func(InboundMessage), func(Command, InboundMessage)) error {15 func (testAdapter) Run(context.Context, func(InboundMessage), func(Command, InboundMessage)) error {
16 return nil16 return nil
17 }17 }
18-func (testAdapter) Send(context.Context, string, string) error { return nil }18+func (testAdapter) Send(context.Context, string, string, SendOptions) error {
19+ return nil
20+}
19 func (testAdapter) IndicateWorking(context.Context, string) func() { return func() {} }21 func (testAdapter) IndicateWorking(context.Context, string) func() { return func() {} }
20 func (testAdapter) StatusText() string { return "" }22 func (testAdapter) StatusText() string { return "" }
21 23
M packages/agent/modes/telegram/adapter.go +10 -2
@@ -191,15 +191,23 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
191191
192192 // Send delivers a reply to a Telegram chat. channelID is parsed back
193193 // to int64. Messages are chunked to 4000 runes (Telegram limit 4096).
194-func (a *Adapter) Send(ctx context.Context, channelID, text string) error {
194+func (a *Adapter) Send(ctx context.Context, channelID, text string, opts bot.SendOptions) error {
195195 chatID, err := strconv.ParseInt(channelID, 10, 64)
196196 if err != nil {
197197 return fmt.Errorf("invalid channelID %q: %w", channelID, err)
198198 }
199+ replyTo := 0
200+ if opts.ReplyToMessageID != "" {
201+ if n, err := strconv.Atoi(opts.ReplyToMessageID); err == nil {
202+ replyTo = n
203+ }
204+ }
199205 for _, chunk := range chunkMessage(text, 4000) {
200- if err := a.Client.SendMessage(ctx, chatID, chunk, 0); err != nil {
206+ if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {
201207 return err
202208 }
209+ // Only the first chunk should be threaded under the original message.
210+ replyTo = 0
203211 }
204212 return nil
205213 }
@@ -191,15 +191,23 @@ func (a *Adapter) handleUpdate(ctx context.Context, u Update,
191 191
192 // Send delivers a reply to a Telegram chat. channelID is parsed back192 // Send delivers a reply to a Telegram chat. channelID is parsed back
193 // to int64. Messages are chunked to 4000 runes (Telegram limit 4096).193 // to int64. Messages are chunked to 4000 runes (Telegram limit 4096).
194-func (a *Adapter) Send(ctx context.Context, channelID, text string) error {194+func (a *Adapter) Send(ctx context.Context, channelID, text string, opts bot.SendOptions) error {
195 chatID, err := strconv.ParseInt(channelID, 10, 64)195 chatID, err := strconv.ParseInt(channelID, 10, 64)
196 if err != nil {196 if err != nil {
197 return fmt.Errorf("invalid channelID %q: %w", channelID, err)197 return fmt.Errorf("invalid channelID %q: %w", channelID, err)
198 }198 }
199+ replyTo := 0
200+ if opts.ReplyToMessageID != "" {
201+ if n, err := strconv.Atoi(opts.ReplyToMessageID); err == nil {
202+ replyTo = n
203+ }
204+ }
199 for _, chunk := range chunkMessage(text, 4000) {205 for _, chunk := range chunkMessage(text, 4000) {
200- if err := a.Client.SendMessage(ctx, chatID, chunk, 0); err != nil {206+ if err := a.Client.SendMessage(ctx, chatID, chunk, replyTo); err != nil {
201 return err207 return err
202 }208 }
209+ // Only the first chunk should be threaded under the original message.
210+ replyTo = 0
203 }211 }
204 return nil212 return nil
205 }213 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close