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