| 1 | package acp |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/tool/builtin" |
| 12 | ) |
| 13 | |
| 14 | // requester is the slice of Conn that clientIO drives: agent → client requests. |
| 15 | type requester interface { |
| 16 | Request(ctx context.Context, method string, params any) (json.RawMessage, error) |
| 17 | } |
| 18 | |
| 19 | // clientIO implements builtin.FileOverlay and builtin.TerminalRunner over the |
| 20 | // ACP connection for one session, backed by the capabilities the client |
| 21 | // declared at initialize. Every method degrades to "not handled" (ok=false) on |
| 22 | // a missing capability or a transport/client error, so the tools fall back to |
| 23 | // their local implementations instead of failing the call. |
| 24 | type clientIO struct { |
| 25 | conn requester |
| 26 | sessionID string |
| 27 | caps ClientCapabilities |
| 28 | } |
| 29 | |
| 30 | func newClientIO(conn requester, sessionID string, caps ClientCapabilities) *clientIO { |
| 31 | return &clientIO{conn: conn, sessionID: sessionID, caps: caps} |
| 32 | } |
| 33 | |
| 34 | // hasAny reports whether the client offered anything clientIO can use; callers |
| 35 | // skip wiring the overlay/terminal entirely when it is false. |
| 36 | func (c *clientIO) hasAny() bool { |
| 37 | return c.caps.FS.ReadTextFile || c.caps.FS.WriteTextFile || c.caps.Terminal |
| 38 | } |
| 39 | |
| 40 | // fileOverlay returns c when the client offers an fs method, else nil. The nil |
| 41 | // is typed at the call site (assigned to the interface field only when |
| 42 | // non-nil), so a session without fs capability carries a nil interface. |
| 43 | func (c *clientIO) fileOverlay() *clientIO { |
| 44 | if c.caps.FS.ReadTextFile || c.caps.FS.WriteTextFile { |
| 45 | return c |
| 46 | } |
| 47 | return nil |
| 48 | } |
| 49 | |
| 50 | // terminalRunner returns c when the client offers terminals, else nil. |
| 51 | func (c *clientIO) terminalRunner() *clientIO { |
| 52 | if c.caps.Terminal { |
| 53 | return c |
| 54 | } |
| 55 | return nil |
| 56 | } |
| 57 | |
| 58 | // ReadTextFile implements builtin.FileOverlay: the client's view of the file, |
| 59 | // including unsaved editor buffers. ok=false on missing capability or any |
| 60 | // client/transport error — the tool then reads the local disk. |
| 61 | func (c *clientIO) ReadTextFile(ctx context.Context, path string) (string, bool) { |
| 62 | if !c.caps.FS.ReadTextFile { |
| 63 | return "", false |
| 64 | } |
| 65 | raw, err := c.conn.Request(ctx, "fs/read_text_file", FSReadTextFileParams{SessionID: c.sessionID, Path: path}) |
| 66 | if err != nil { |
| 67 | return "", false |
| 68 | } |
| 69 | var res FSReadTextFileResult |
| 70 | if json.Unmarshal(raw, &res) != nil { |
| 71 | return "", false |
| 72 | } |
| 73 | return res.Content, true |
| 74 | } |
| 75 | |
| 76 | // WriteTextFile implements builtin.FileOverlay: the client writes content to |
| 77 | // path and updates any open buffer. ok=false on missing capability so the tool |
| 78 | // writes the local disk; with the capability present, a client error is a real |
| 79 | // write failure and is surfaced (falling back could double-apply). |
| 80 | func (c *clientIO) WriteTextFile(ctx context.Context, path, content string) (bool, error) { |
| 81 | if !c.caps.FS.WriteTextFile { |
| 82 | return false, nil |
| 83 | } |
| 84 | if _, err := c.conn.Request(ctx, "fs/write_text_file", FSWriteTextFileParams{SessionID: c.sessionID, Path: path, Content: content}); err != nil { |
| 85 | return true, err |
| 86 | } |
| 87 | return true, nil |
| 88 | } |
| 89 | |
| 90 | // terminalOutputByteLimit bounds how much output a client terminal buffers for |
| 91 | // one command; matches the local bash tool's practical output scale. |
| 92 | const terminalOutputByteLimit = 1 << 20 |
| 93 | |
| 94 | // RunCommand implements builtin.TerminalRunner: run the command in a |
| 95 | // client-owned terminal (terminal/create → wait_for_exit → output → release) |
| 96 | // so the user watches it live. ok=false when the client has no terminal |
| 97 | // capability or creation fails — the bash tool then executes locally. A |
| 98 | // timeout kills the terminal and returns what it printed. |
| 99 | // |
| 100 | // envOverrides are standard temporary-directory variables (TMPDIR/TMP/TEMP) |
| 101 | // for the session-private temp directory. They are serialized as ACP v1 |
| 102 | // EnvVariable[] and never include the full host environment. |
| 103 | func (c *clientIO) RunCommand(ctx context.Context, command, cwd string, timeout time.Duration, envOverrides map[string]string) (string, bool, error) { |
| 104 | if !c.caps.Terminal { |
| 105 | return "", false, nil |
| 106 | } |
| 107 | raw, err := c.conn.Request(ctx, "terminal/create", TerminalCreateParams{ |
| 108 | SessionID: c.sessionID, |
| 109 | Command: command, |
| 110 | Cwd: cwd, |
| 111 | Env: envMapToVariables(envOverrides), |
| 112 | OutputByteLimit: terminalOutputByteLimit, |
| 113 | }) |
| 114 | if err != nil { |
| 115 | return "", false, nil |
| 116 | } |
| 117 | var created TerminalCreateResult |
| 118 | if json.Unmarshal(raw, &created) != nil || strings.TrimSpace(created.TerminalID) == "" { |
| 119 | return "", false, nil |
| 120 | } |
| 121 | id := TerminalIDParams{SessionID: c.sessionID, TerminalID: created.TerminalID} |
| 122 | defer func() { _, _ = c.conn.Request(context.WithoutCancel(ctx), "terminal/release", id) }() |
| 123 | |
| 124 | waitCtx := ctx |
| 125 | var cancel context.CancelFunc |
| 126 | if timeout > 0 { |
| 127 | waitCtx, cancel = context.WithTimeout(ctx, timeout) |
| 128 | defer cancel() |
| 129 | } |
| 130 | _, waitErr := c.conn.Request(waitCtx, "terminal/wait_for_exit", id) |
| 131 | timedOut := waitErr != nil && waitCtx.Err() != nil && ctx.Err() == nil |
| 132 | if timedOut { |
| 133 | _, _ = c.conn.Request(context.WithoutCancel(ctx), "terminal/kill", id) |
| 134 | } |
| 135 | |
| 136 | output, exit := c.terminalOutput(context.WithoutCancel(ctx), id) |
| 137 | switch { |
| 138 | case ctx.Err() != nil: |
| 139 | return output, true, ctx.Err() |
| 140 | case timedOut: |
| 141 | // Typed timeout so bash.ExecuteDetailed can set state=timed_out / |
| 142 | // failurePhase=timeout instead of a generic failed/execution. |
| 143 | return output, true, builtin.TerminalTimeoutError{Timeout: timeout} |
| 144 | case waitErr != nil: |
| 145 | return output, true, waitErr |
| 146 | case exit != nil && exit.ExitCode != nil && *exit.ExitCode != 0: |
| 147 | // Preserve the real exit code on ShellExecution via TerminalExitError. |
| 148 | return output, true, builtin.TerminalExitError{Code: *exit.ExitCode} |
| 149 | case exit != nil && exit.Signal != nil && *exit.Signal != "": |
| 150 | return output, true, fmt.Errorf("terminated by signal %s", *exit.Signal) |
| 151 | } |
| 152 | return output, true, nil |
| 153 | } |
| 154 | |
| 155 | // envMapToVariables converts a small override map into ACP EnvVariable entries. |
| 156 | // Empty or nil maps yield nil (omitted from JSON). |
| 157 | func envMapToVariables(env map[string]string) []EnvVariable { |
| 158 | if len(env) == 0 { |
| 159 | return nil |
| 160 | } |
| 161 | // Stable order for tests and logs. |
| 162 | keys := make([]string, 0, len(env)) |
| 163 | for k := range env { |
| 164 | if strings.TrimSpace(k) == "" { |
| 165 | continue |
| 166 | } |
| 167 | keys = append(keys, k) |
| 168 | } |
| 169 | sort.Strings(keys) |
| 170 | out := make([]EnvVariable, 0, len(keys)) |
| 171 | for _, k := range keys { |
| 172 | out = append(out, EnvVariable{Name: k, Value: env[k]}) |
| 173 | } |
| 174 | return out |
| 175 | } |
| 176 | |
| 177 | func (c *clientIO) terminalOutput(ctx context.Context, id TerminalIDParams) (string, *TerminalExitStatus) { |
| 178 | raw, err := c.conn.Request(ctx, "terminal/output", id) |
| 179 | if err != nil { |
| 180 | return "", nil |
| 181 | } |
| 182 | var res TerminalOutputResult |
| 183 | if json.Unmarshal(raw, &res) != nil { |
| 184 | return "", nil |
| 185 | } |
| 186 | out := res.Output |
| 187 | if res.Truncated { |
| 188 | out += "\n…(output truncated by the client terminal)" |
| 189 | } |
| 190 | return out, res.ExitStatus |
| 191 | } |
| 192 |