返回 DeepSeek-Reasonix
dispatch.go
根目录 / internal / acp / dispatch.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "sync"
11 "unicode/utf8"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/control"
15 "reasonix/internal/event"
16 "reasonix/internal/eventwire"
17 "reasonix/internal/permission"
18 "reasonix/internal/provider"
19 "reasonix/internal/shellparse"
20 )
21
22 // notifier is the slice of Conn the dispatch sink depends on: it pushes
23 // session/update notifications and, when a tool needs approval, makes a
24 // session/request_permission request. Narrowing to this interface keeps the sink
25 // unit-testable with a fake.
26 type notifier interface {
27 Notify(method string, params any) error
28 Request(ctx context.Context, method string, params any) (json.RawMessage, error)
29 }
30
31 // maxResultChars clips a tool result before it crosses the wire, matching main's
32 // dispatch.ts (the full result still goes to the model; this is display only).
33 const maxResultChars = 8000
34
35 // updateSink is an event.Sink bound to one session that maps the agent's typed
36 // event stream onto ACP session/update notifications. It is the v2 counterpart of
37 // main's dispatchKernelEvent: where main translated kernel events, we translate
38 // the event.Event the v2 agent already emits.
39 //
40 // v2 has no separate "tool intent" event — a call goes ToolDispatch → ToolResult,
41 // two states — so we emit a single pending tool_call on dispatch (already carrying
42 // rawInput, which main only had by the intent step) and a completed/failed
43 // tool_call_update on result. Message/Usage/Phase/TurnStarted/TurnDone have no
44 // place in main's update set and are dropped (TurnDone's outcome surfaces as the
45 // session/prompt stopReason instead).
46 //
47 // An ApprovalRequest is the controller asking the user to allow a gated tool
48 // call; the sink forwards it as a session/request_permission round-trip and feeds
49 // the answer back via approve (control.Controller.Approve), which the run loop is
50 // blocked on.
51 type updateSink struct {
52 conn notifier
53 sessionID string
54 // cwd resolves relative tool-arg paths for tool_call locations. Set once
55 // via bindCwd before the sink receives events.
56 cwd string
57 approve func(id string, allow, session, persist bool)
58 answer func(id string, answers []event.AskAnswer)
59 status func(event.Event)
60 // extensionSurface records the client's negotiated
61 // reasonix.extensionSurface support: structured surfaces go out as vendor
62 // session/update payloads on top of the always-sent text fallback.
63 extensionSurface bool
64 // speculativeToolIDs tracks parent-sampling tool IDs published under the
65 // active stream_attempt (attempt-scoped partials only). Guarded by mu —
66 // parent sampling and background sub-agents may Emit concurrently.
67 speculativeToolIDs map[string]struct{}
68 activeAttemptID string
69 mu sync.Mutex
70 turnCtx context.Context
71 }
72
73 func newUpdateSink(conn notifier, sessionID string) *updateSink {
74 return &updateSink{conn: conn, sessionID: sessionID}
75 }
76
77 // bindCwd installs the session root used to absolutize tool_call locations.
78 func (s *updateSink) bindCwd(cwd string) { s.cwd = cwd }
79
80 // bindApprove installs the controller's Approve callback, called by the service
81 // once the controller exists (the sink is built first, to hand to the Factory).
82 func (s *updateSink) bindApprove(fn func(id string, allow, session, persist bool)) {
83 if fn == nil {
84 s.approve = nil
85 return
86 }
87 s.approve = fn
88 }
89
90 // bindAnswer installs the controller's AnswerQuestion callback for AskRequest
91 // events.
92 func (s *updateSink) bindAnswer(fn func(id string, answers []event.AskAnswer)) {
93 s.answer = fn
94 }
95
96 // bindStatus installs the vendor-status observer. It receives typed events,
97 // never raw reasoning text or terminal transcripts.
98 func (s *updateSink) bindStatus(fn func(event.Event)) { s.status = fn }
99
100 // bindExtensionSurface records whether the client negotiated structured
101 // extension-surface support in the initialize handshake.
102 func (s *updateSink) bindExtensionSurface(supported bool) { s.extensionSurface = supported }
103
104 func (s *updateSink) setTurnContext(ctx context.Context) {
105 s.mu.Lock()
106 s.turnCtx = ctx
107 s.mu.Unlock()
108 }
109
110 func (s *updateSink) clearTurnContext() {
111 s.mu.Lock()
112 s.turnCtx = nil
113 s.mu.Unlock()
114 }
115
116 func (s *updateSink) currentTurnContext() context.Context {
117 s.mu.Lock()
118 ctx := s.turnCtx
119 s.mu.Unlock()
120 if ctx == nil {
121 return context.Background()
122 }
123 return ctx
124 }
125
126 // Emit implements event.Sink. The agent calls it serially (see event.Sink), so no
127 // locking is needed; write serialization lives in Conn.
128 func (s *updateSink) Emit(e event.Event) {
129 if s.status != nil {
130 s.status(e)
131 }
132 switch e.Kind {
133 case event.Reasoning:
134 if e.Text == "" {
135 return
136 }
137 s.send(messageChunk{SessionUpdate: "agent_thought_chunk", Content: textBlock(e.Text)})
138
139 case event.Text:
140 if e.Text == "" {
141 return
142 }
143 s.send(messageChunk{SessionUpdate: "agent_message_chunk", Content: textBlock(e.Text)})
144
145 case event.StreamAttempt:
146 // Attempt bookkeeping only. ACP still skips partial ToolDispatch (no
147 // pending card until full args arrive after commit), so discard must not
148 // invent failures for unpublished IDs. Full dispatches and parentId
149 // nested tools are real work and are never speculative.
150 s.mu.Lock()
151 switch e.StreamAttempt.Action {
152 case event.StreamAttemptBegin:
153 s.activeAttemptID = e.StreamAttempt.ID
154 s.speculativeToolIDs = nil
155 case event.StreamAttemptCommit, event.StreamAttemptDiscard:
156 s.activeAttemptID = ""
157 s.speculativeToolIDs = nil
158 }
159 s.mu.Unlock()
160
161 case event.ToolDispatch:
162 // Skip the early (Partial) dispatch and later same-ID preview refresh: ACP
163 // expects one pending tool_call and has no file-diff update payload.
164 if e.Tool.Partial || e.Tool.Refreshed {
165 return
166 }
167 // Full dispatches only arrive after a committed sampling attempt (or from
168 // nested sub-agents). Never mark them speculative.
169 // todo_write is the agent's task list; mirror it as an ACP plan update so
170 // the client renders structured progress alongside the tool_call.
171 if e.Tool.Name == "todo_write" {
172 if entries, ok := planEntriesFromTodoArgs(e.Tool.Args); ok {
173 s.send(planUpdate{SessionUpdate: "plan", Entries: entries})
174 }
175 }
176 s.send(toolCall{
177 SessionUpdate: "tool_call",
178 ToolCallID: e.Tool.ID,
179 Title: e.Tool.Name,
180 Kind: toolKindFor(e.Tool.Name),
181 Status: "pending",
182 RawInput: rawJSON(e.Tool.Args),
183 Locations: s.toolLocations(e.Tool.Name, e.Tool.Args),
184 })
185
186 case event.ToolResult:
187 status := "completed"
188 text := e.Tool.Output
189 if e.Tool.Err != "" {
190 status = "failed"
191 text = e.Tool.Err
192 }
193 if e.Tool.ID != "" {
194 s.mu.Lock()
195 delete(s.speculativeToolIDs, e.Tool.ID)
196 s.mu.Unlock()
197 }
198 s.send(toolCallUpdateMsg{
199 SessionUpdate: "tool_call_update",
200 ToolCallID: e.Tool.ID,
201 Status: status,
202 Content: []toolContent{{Type: "content", Content: textBlock(clip(text))}},
203 })
204
205 case event.Notice:
206 // Surface warnings to the host as a message chunk so they're not lost;
207 // info-level notices stay out of band.
208 if e.Level == event.LevelWarn && e.Text != "" {
209 s.send(messageChunk{
210 SessionUpdate: "agent_message_chunk",
211 Content: textBlock("\n\n[warning] " + e.Text),
212 })
213 }
214
215 case event.CompactionDone:
216 // ACP has no compaction-card concept; surface a one-line note so the host
217 // knows the context was summarized (an aborted pass has no summary).
218 if e.Compaction.Summary != "" {
219 s.send(messageChunk{
220 SessionUpdate: "agent_message_chunk",
221 Content: textBlock(fmt.Sprintf("\n\n[compacted %d earlier messages to save context]", e.Compaction.Messages)),
222 })
223 }
224
225 case event.ApprovalRequest:
226 // The run loop is now blocked awaiting Approve(id, …). Do the
227 // client round-trip off the emit goroutine so Emit returns at once
228 // (the agent emits serially); the answer unblocks the loop.
229 turnCtx := s.currentTurnContext()
230 go s.requestPermission(turnCtx, e.Approval)
231
232 case event.AskRequest:
233 // ACP has no separate "ask the user a business question" method. Reuse
234 // the standard permission round-trip with the question options as choices;
235 // clients such as Zed already know how to render this interaction.
236 turnCtx := s.currentTurnContext()
237 go s.requestAsk(turnCtx, e.Ask)
238
239 case event.ExtensionSurface, event.ExtensionStatus:
240 s.emitExtension(e)
241 }
242 }
243
244 // emitExtension maps one extension structured-UI event onto ACP updates. A
245 // client that negotiated reasonix.extensionSurface receives the structured DTO
246 // (the shared eventwire JSON contract) in a vendor session/update variant;
247 // every client — including that one, belt and suspenders — also receives the
248 // flattened text fallback as an ordinary agent_message_chunk. Blocking
249 // form/request prompts never arrive here: the hub routes those through
250 // AskRequest, which already rides the session/request_permission round-trip.
251 func (s *updateSink) emitExtension(e event.Event) {
252 p := e.Extension
253 if p == nil {
254 return
255 }
256 if s.extensionSurface {
257 if dto := eventwire.ToWireExtensionSurface(p); dto != nil {
258 s.send(extensionSurfaceUpdate{
259 SessionUpdate: extensionSurfaceUpdateKind,
260 Meta: map[string]any{
261 "reasonix.io": map[string]any{
262 "extensionSurface": dto,
263 },
264 },
265 })
266 }
267 }
268 text := extensionSurfaceText(p)
269 if text == "" {
270 return
271 }
272 prefix := "\n\n"
273 if extensionSeverityWarns(p) {
274 prefix += "[warning] "
275 }
276 s.send(messageChunk{SessionUpdate: "agent_message_chunk", Content: textBlock(prefix + text)})
277 }
278
279 // extensionSurfaceText flattens one extension surface payload to plain text
280 // for clients without structured-surface support: status →
281 // "[plugin] label: detail", card → title + body + fields, form → title +
282 // message, notification → title + body.
283 func extensionSurfaceText(p *event.ExtensionSurfacePayload) string {
284 var b strings.Builder
285 write := func(s string) {
286 if s == "" {
287 return
288 }
289 if b.Len() > 0 {
290 b.WriteString("\n")
291 }
292 b.WriteString(s)
293 }
294 switch {
295 case p.Status != nil:
296 line := "[" + p.PluginID + "] " + p.Status.Label
297 if p.Status.Detail != "" {
298 line += ": " + p.Status.Detail
299 }
300 write(line)
301 case p.Card != nil:
302 write(p.Card.Title)
303 body := p.Card.Text
304 if p.Card.Markdown != "" {
305 body = p.Card.Markdown
306 }
307 write(body)
308 for _, f := range p.Card.Fields {
309 write(f.Key + ": " + f.Value)
310 }
311 case p.Form != nil:
312 write(p.Form.Title)
313 write(p.Form.Message)
314 case p.Notification != nil:
315 write(p.Notification.Title)
316 write(p.Notification.Body)
317 }
318 return b.String()
319 }
320
321 // extensionSeverityWarns reports whether the payload carries a warn/error
322 // severity, which earns the same "[warning] " prefix as event.Notice.
323 func extensionSeverityWarns(p *event.ExtensionSurfacePayload) bool {
324 severity := ""
325 if p.Status != nil {
326 severity = p.Status.Severity
327 }
328 if p.Notification != nil {
329 severity = p.Notification.Severity
330 }
331 return severity == "warn" || severity == "error"
332 }
333
334 func (s *updateSink) send(update any) {
335 _ = s.conn.Notify("session/update", SessionUpdateParams{SessionID: s.sessionID, Update: update})
336 }
337
338 // replay streams a loaded conversation back to the client as session/update
339 // notifications so a resumed session reconstructs its transcript view. The
340 // system message is skipped (not user-visible); everything is reported as already
341 // completed since it is history, not a live turn.
342 func (s *updateSink) replay(msgs []provider.Message) {
343 for _, m := range msgs {
344 switch m.Role {
345 case provider.RoleUser:
346 // Replay the user-authored view, not the persisted wire form:
347 // UserMessageText strips injected transient blocks (<response-language>
348 // etc.) and unwraps memory-compiler contracts, same as every other
349 // surface (#6882). A turn that was pure injection replays as nothing.
350 text := m.Content
351 if steer, ok := agent.SteerText(text); ok {
352 text = steer
353 } else {
354 text = agent.UserMessageText(m)
355 }
356 if text != "" {
357 s.send(messageChunk{SessionUpdate: "user_message_chunk", Content: textBlock(text)})
358 }
359 case provider.RoleAssistant:
360 if m.ReasoningContent != "" {
361 s.send(messageChunk{SessionUpdate: "agent_thought_chunk", Content: textBlock(m.ReasoningContent)})
362 }
363 // Same display filter as live emission: goal markers and evidence
364 // blocks stay in history for parsing but never reach the client.
365 if display := agent.DisplayAssistantText(m.Content); display != "" {
366 s.send(messageChunk{SessionUpdate: "agent_message_chunk", Content: textBlock(display)})
367 }
368 for _, tc := range m.ToolCalls {
369 s.send(toolCall{
370 SessionUpdate: "tool_call",
371 ToolCallID: tc.ID,
372 Title: tc.Name,
373 Kind: toolKindFor(tc.Name),
374 Status: "completed",
375 RawInput: rawJSON(tc.Arguments),
376 Locations: s.toolLocations(tc.Name, tc.Arguments),
377 })
378 // Replaying the latest plan keeps the client's plan view in sync
379 // with the restored conversation; each update replaces the last.
380 if tc.Name == "todo_write" {
381 if entries, ok := planEntriesFromTodoArgs(tc.Arguments); ok {
382 s.send(planUpdate{SessionUpdate: "plan", Entries: entries})
383 }
384 }
385 }
386 case provider.RoleTool:
387 s.send(toolCallUpdateMsg{
388 SessionUpdate: "tool_call_update",
389 ToolCallID: m.ToolCallID,
390 Status: "completed",
391 Content: []toolContent{{Type: "content", Content: textBlock(clip(m.Content))}},
392 })
393 }
394 }
395 }
396
397 // requestPermission forwards an approval request to the client as a
398 // session/request_permission round-trip and feeds the outcome back through
399 // approve. Any transport failure or a cancelled/rejected outcome denies the call,
400 // so the model gets a blocked result rather than the turn hanging.
401 func (s *updateSink) requestPermission(ctx context.Context, a event.Approval) {
402 if s.approve == nil {
403 return
404 }
405 title := a.Tool
406 if a.Subject != "" {
407 title = a.Tool + " " + a.Subject
408 }
409 options := approvalOptions(a.Tool, a.Subject, a.Fresh)
410 params := PermissionRequestParams{
411 SessionID: s.sessionID,
412 ToolCall: PermissionToolCall{
413 ToolCallID: "gate-" + a.ID,
414 Title: title,
415 Kind: toolKindFor(a.Tool),
416 Status: "pending",
417 RawInput: rawJSON(string(a.RawInput)),
418 Locations: s.toolLocations(a.Tool, string(a.RawInput)),
419 Meta: s.permissionMeta(a),
420 },
421 Options: options,
422 }
423
424 allow, session, persist := false, false, false
425 if raw, err := s.conn.Request(ctx, "session/request_permission", params); err == nil {
426 var res PermissionRequestResult
427 if json.Unmarshal(raw, &res) == nil && res.Outcome.Outcome == "selected" {
428 switch PermissionOptionKind(res.Outcome.OptionID) {
429 case OptAllowOnce:
430 allow = true
431 case OptAllowAlways:
432 allow, session = true, true
433 }
434 }
435 }
436 s.approve(a.ID, allow, session, persist)
437 }
438
439 // permissionMeta carries Reasonix-owned structured data that an ACP supervisor
440 // may trust independently from model-supplied rawInput. A foreground bash call
441 // receives argv only when the command is a single static command: shell
442 // expansion, control operators, redirects, assignments, and background jobs all
443 // fail closed and remain interactive.
444 func (s *updateSink) permissionMeta(a event.Approval) map[string]any {
445 reasonix := map[string]any{
446 "approvalId": a.ID,
447 "tool": a.Tool,
448 "subject": a.Subject,
449 "fresh": a.Fresh,
450 }
451 if reason := strings.TrimSpace(a.Reason); reason != "" {
452 reasonix["reason"] = reason
453 }
454 if a.Tool == "bash" && strings.TrimSpace(s.cwd) != "" {
455 var input struct {
456 Command string `json:"command"`
457 RunInBackground bool `json:"run_in_background"`
458 PreserveBackgroundProcesses bool `json:"preserve_background_processes"`
459 }
460 if json.Unmarshal(a.RawInput, &input) == nil &&
461 !input.RunInBackground && !input.PreserveBackgroundProcesses {
462 cwd, cwdErr := filepath.Abs(s.cwd)
463 features, featureOK := shellparse.AnalyzeApprovalFeatures(input.Command)
464 command, commandErr := shellparse.ParseStaticCommand(input.Command, shellparse.StaticCommandPolicy{})
465 exact := featureOK && !features.DynamicCommandName && !features.NestedExecution &&
466 !features.Expansion && !features.Assignment && !features.Redirection &&
467 !shellparse.ContainsUnquotedGlob(input.Command)
468 for _, arg := range command.Argv {
469 // Tilde expansion is shell-dependent and therefore not exact argv.
470 if strings.HasPrefix(arg, "~") {
471 exact = false
472 }
473 }
474 if cwdErr == nil && commandErr == nil && exact && len(command.Argv) > 0 {
475 reasonix["commandSchemaVersion"] = 1
476 reasonix["argv"] = command.Argv
477 reasonix["cwd"] = cwd
478 }
479 }
480 }
481 return map[string]any{"reasonix.io": reasonix}
482 }
483
484 func (s *updateSink) requestAsk(ctx context.Context, a event.Ask) {
485 if s.answer == nil {
486 return
487 }
488 answers := make([]event.AskAnswer, 0, len(a.Questions))
489 for _, q := range a.Questions {
490 selected, ok := s.requestAskQuestion(ctx, a.ID, q)
491 if !ok {
492 s.answer(a.ID, nil)
493 return
494 }
495 answers = append(answers, event.AskAnswer{QuestionID: q.ID, Selected: []string{selected}})
496 }
497 s.answer(a.ID, answers)
498 }
499
500 func (s *updateSink) requestAskQuestion(ctx context.Context, askID string, q event.AskQuestion) (string, bool) {
501 title := strings.TrimSpace(q.Prompt)
502 if title == "" {
503 title = strings.TrimSpace(q.Header)
504 }
505 if title == "" {
506 title = "Question"
507 }
508 content := []toolContent(nil)
509 if q.Header != "" && q.Header != title {
510 content = append(content, toolContent{Type: "content", Content: textBlock(q.Header)})
511 }
512 options := make([]PermissionOption, 0, len(q.Options)+1)
513 labelsByID := make(map[string]string, len(q.Options))
514 for i, opt := range q.Options {
515 id := fmt.Sprintf("%s:%d", q.ID, i+1)
516 name := strings.TrimSpace(opt.Label)
517 if strings.TrimSpace(opt.Description) != "" {
518 name += " - " + strings.TrimSpace(opt.Description)
519 }
520 options = append(options, PermissionOption{OptionID: id, Name: name, Kind: OptAllowOnce})
521 labelsByID[id] = opt.Label
522 }
523 options = append(options, PermissionOption{OptionID: q.ID + ":cancel", Name: "Cancel", Kind: OptRejectOnce})
524
525 rawInput, _ := json.Marshal(map[string]any{
526 "id": q.ID,
527 "question": title,
528 "options": q.Options,
529 "multi": q.Multi,
530 })
531 params := PermissionRequestParams{
532 SessionID: s.sessionID,
533 ToolCall: PermissionToolCall{
534 ToolCallID: "ask-" + askID + "-" + q.ID,
535 Title: title,
536 Kind: "other",
537 Status: "pending",
538 Content: content,
539 RawInput: rawInput,
540 },
541 Options: options,
542 }
543
544 raw, err := s.conn.Request(ctx, "session/request_permission", params)
545 if err != nil {
546 return "", false
547 }
548 var res PermissionRequestResult
549 if json.Unmarshal(raw, &res) != nil || res.Outcome.Outcome != "selected" {
550 return "", false
551 }
552 label, ok := labelsByID[res.Outcome.OptionID]
553 return label, ok
554 }
555
556 func approvalSessionOptionName(tool, subject string) string {
557 if tool == control.SandboxEscapeApprovalTool {
558 return "Use real environment for this session"
559 }
560 sessionRule := permission.SessionGrantRuleForScope(tool, subject)
561 return "Allow " + sessionRule + " for this session"
562 }
563
564 func approvalOptions(tool, subject string, fresh bool) []PermissionOption {
565 if fresh || control.RequiresFreshHumanApprovalTool(tool) {
566 if tool == control.SandboxEscapeApprovalTool {
567 return []PermissionOption{
568 {OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
569 {OptionID: string(OptAllowAlways), Name: approvalSessionOptionName(tool, subject), Kind: OptAllowAlways},
570 {OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
571 }
572 }
573 return []PermissionOption{
574 {OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
575 {OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
576 }
577 }
578 allowSessionName := approvalSessionOptionName(tool, subject)
579 options := []PermissionOption{
580 {OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
581 {OptionID: string(OptAllowAlways), Name: allowSessionName, Kind: OptAllowAlways},
582 {OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
583 }
584 return options
585 }
586
587 // textBlock builds a text content block.
588 func textBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
589
590 // rawJSON returns args as a raw JSON value when it is valid JSON, else nil so the
591 // rawInput field is omitted rather than carrying a malformed payload.
592 func rawJSON(args string) json.RawMessage {
593 if args == "" || !json.Valid([]byte(args)) {
594 return nil
595 }
596 return json.RawMessage(args)
597 }
598
599 // clip truncates text to maxResultChars, appending a note, matching dispatch.ts.
600 func clip(text string) string {
601 if len(text) <= maxResultChars {
602 return text
603 }
604 end := maxResultChars
605 for end > 0 && !utf8.ValidString(text[:end]) {
606 end--
607 }
608 return text[:end] + "\n…(" +
609 strconv.Itoa(len(text)-end) + " more chars truncated)"
610 }
611
612 // toolKindFor maps a tool name to the ACP tool kind the host uses to categorize
613 // the call in its UI. The kinds match main's restricted set
614 // (read/edit/search/execute/other). Known v2 built-ins map explicitly; anything
615 // else (plugins, the task tool) falls back to a name heuristic, then "other".
616 func toolKindFor(name string) string {
617 switch name {
618 case "read_file", "ls", "glob":
619 return "read"
620 case "grep":
621 return "search"
622 case "edit_file", "move_file", "multiedit", "write_file":
623 return "edit"
624 case "bash":
625 return "execute"
626 case control.SandboxEscapeApprovalTool:
627 return "execute"
628 }
629 n := strings.ToLower(name)
630 switch {
631 case strings.Contains(n, "search") || strings.Contains(n, "grep") || strings.Contains(n, "find"):
632 return "search"
633 case strings.Contains(n, "edit") || strings.Contains(n, "write") || strings.Contains(n, "replace"):
634 return "edit"
635 case strings.Contains(n, "read") || strings.Contains(n, "cat") || strings.Contains(n, "view"):
636 return "read"
637 case strings.Contains(n, "bash") || strings.Contains(n, "exec") || strings.Contains(n, "shell") || strings.Contains(n, "run"):
638 return "execute"
639 default:
640 return "other"
641 }
642 }
643
644 // locationTools names the builtin tools whose "path" argument is a real file
645 // target worth a follow-along location. Search/list tools are excluded: their
646 // path is a directory scope, not a file the user would want opened.
647 var locationTools = map[string]bool{
648 "read_file": true,
649 "write_file": true,
650 "edit_file": true,
651 "multi_edit": true,
652 "notebook_edit": true,
653 "delete_range": true,
654 "delete_symbol": true,
655 "code_index": true,
656 }
657
658 // toolLocations derives the file location a tool call touches from its raw
659 // args, so the client can follow along in the editor. Unknown tools and
660 // path-less args yield nil.
661 func (s *updateSink) toolLocations(name, rawArgs string) []ToolCallLocation {
662 if !locationTools[name] {
663 return nil
664 }
665 var p struct {
666 Path string `json:"path"`
667 Offset int `json:"offset"`
668 }
669 if json.Unmarshal([]byte(rawArgs), &p) != nil || strings.TrimSpace(p.Path) == "" {
670 return nil
671 }
672 loc := ToolCallLocation{Path: s.absPath(p.Path)}
673 // read_file's offset is a 0-based start line; surface it so the editor can
674 // jump to the region being read.
675 if name == "read_file" && p.Offset > 0 {
676 line := p.Offset + 1
677 loc.Line = &line
678 }
679 return []ToolCallLocation{loc}
680 }
681
682 func (s *updateSink) absPath(p string) string {
683 if filepath.IsAbs(p) || s.cwd == "" {
684 return p
685 }
686 return filepath.Join(s.cwd, p)
687 }
688
689 // planEntriesFromTodoArgs maps a todo_write argument payload onto ACP plan
690 // entries. Phase items (level 0) rank high, sub-steps medium; unknown statuses
691 // degrade to pending so a malformed item cannot poison the whole update.
692 func planEntriesFromTodoArgs(rawArgs string) ([]PlanEntry, bool) {
693 var p struct {
694 Todos []struct {
695 Content string `json:"content"`
696 Status string `json:"status"`
697 Level int `json:"level"`
698 } `json:"todos"`
699 }
700 if json.Unmarshal([]byte(rawArgs), &p) != nil || len(p.Todos) == 0 {
701 return nil, false
702 }
703 entries := make([]PlanEntry, 0, len(p.Todos))
704 for _, t := range p.Todos {
705 if strings.TrimSpace(t.Content) == "" {
706 continue
707 }
708 status := t.Status
709 switch status {
710 case "pending", "in_progress", "completed":
711 default:
712 status = "pending"
713 }
714 priority := "medium"
715 if t.Level == 0 {
716 priority = "high"
717 }
718 entries = append(entries, PlanEntry{Content: t.Content, Priority: priority, Status: status})
719 }
720 if len(entries) == 0 {
721 return nil, false
722 }
723 return entries, true
724 }
725
725 lines GO