mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(agent): drive OpenCode over ACP so long tools and subagents are not force-stopped `opencode run --format json` reports a tool only when it finishes, with the call and its result in one event. The daemon never saw a tool in flight, so a long silent tool or a delegated subagent was judged by the 10-minute OpenCode idle window instead of the tool budget and force-stopped (GH #9614). Built-in OpenCode 1.15+ and 2.x runtimes now run `opencode acp`, which on both majors announces a tool when it starts executing. MessageToolUse is emitted from that in_progress frame, so the daemon applies AgentToolWatchdog for the whole tool. One code path covers both majors: - model via session/set_config_option (2.x has no session/set_model); effort via the advertised `effort` option, splitting 2.x `model#variant` - resume via session/load; a missing session is reported as ResumeRejected - cancel via session/cancel, which aborts the running tool on both majors - MCP: 1.x keeps OPENCODE_CONFIG_CONTENT; 2.x gets servers in session/new, which lifts the 2.x MCP refusal on this transport - 2.x subagent text (`_meta["opencode/child-session"]`) stays out of the parent's output; its tool calls are kept - usage summed from the session export over this turn's assistant messages, since the prompt result's usage is not per-turn on either major The run transport stays as the fallback for custom profiles, older or unknown versions, agents with custom_args, and MULTICA_OPENCODE_TRANSPORT=run. Also fix the run transport on 2.x: tool_use parts carry the call id as `id` (1.x: `callID`), so every 2.x tool message had an empty CallID. Co-authored-by: multica-agent <[email protected]> * fix(agent): bound OpenCode ACP teardown when the runtime stops reading A runtime that stops reading stdin mid-prompt left the prompt write blocked holding the client's write lock. The cancel path then waited on that lock to send session/cancel, and the cancel grace and the process-group kill only ran after the write, so the process could outlive the task indefinitely. - session/cancel is sent only once the prompt is fully delivered, from its own goroutine, so the grace starts immediately and a stalled pipe cannot hold it - a turn OpenCode never answered (or a cancelled/timed-out setup) is torn down forcibly: stdin closed first to release a blocked write, then SIGTERM to the group and SIGKILL after the grace; an answered turn still exits on EOF Regression tests cover a stalled prompt write under a 1s timeout and a runtime that ignores session/cancel. Co-authored-by: multica-agent <[email protected]> * fix(agent): bound OpenCode ACP teardown when a setup write stalls Setup requests (initialize, session/new, session/load, set_config_option) are synchronous stdin writes that check their context only after the write returns. A runtime that stops reading after initialize, with a large MCP payload in session/new, left the run goroutine blocked in that write, so the deferred teardown never ran and the process outlived the timeout. A watcher now force-stops the process as soon as the run ends before the prompt is fully delivered; closing stdin releases the blocked write. Once the prompt is delivered the existing session/cancel path owns shutdown. Regression test: 256 KiB MCP env, runtime stalls after initialize, 1s timeout; returns in ~1s (60s before the fix). Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Multica Eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
617 lines
20 KiB
Go
617 lines
20 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestOpencodeUsesV2Contract pins which version strings switch the backend onto
|
|
// the 2.x argv. The shapes matter: extractVersionLine keeps the whole matched
|
|
// line, and 2.x reports `opencode v2.0.10` where 1.x reports a bare `1.18.31`,
|
|
// so a prefix test against "2." would miss every real 2.x runtime.
|
|
func TestOpencodeUsesV2Contract(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
version string
|
|
builtin bool
|
|
want bool
|
|
}{
|
|
{"v2 as reported by the real CLI", "opencode v2.0.10", true, true},
|
|
{"v2 bare semver", "2.0.10", true, true},
|
|
{"v2 with leading v", "v2.1.0", true, true},
|
|
{"future major", "opencode v3.0.0", true, true},
|
|
{"v1 bare semver as reported by the real CLI", "1.18.31", true, false},
|
|
{"v1 oldest supported", "1.1.54", true, false},
|
|
{"unknown version fails closed onto 1.x", "", true, false},
|
|
{"unparsable version fails closed onto 1.x", "opencode nightly", true, false},
|
|
// A custom runtime profile wraps a binary this package did not choose,
|
|
// so its version string does not establish the usage convention. Acting
|
|
// on it would break working 1.x profiles on a guess.
|
|
{"custom runtime never switches", "opencode v2.0.10", false, false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := Config{CLIVersion: tc.version, BuiltinRuntime: tc.builtin}
|
|
if got := opencodeUsesV2Contract(cfg); got != tc.want {
|
|
t.Fatalf("opencodeUsesV2Contract(%q, builtin=%v) = %v, want %v",
|
|
tc.version, tc.builtin, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpencodeModelArg covers folding the thinking level into the model string,
|
|
// which is how 2.x expresses what 1.x passed as `--variant`.
|
|
func TestOpencodeModelArg(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
model string
|
|
thinkingLevel string
|
|
wantModel string
|
|
wantOK bool
|
|
}{
|
|
{"no thinking level leaves the model alone", "anthropic/claude-sonnet-4-5", "", "anthropic/claude-sonnet-4-5", true},
|
|
{"level folds onto the model", "anthropic/claude-sonnet-4-5", "high", "anthropic/claude-sonnet-4-5#high", true},
|
|
{"neither set", "", "", "", true},
|
|
// 2.x resolves the default model server-side, so there is no model
|
|
// string to attach the variant to and the level cannot be expressed.
|
|
{"level without a model is not representable", "", "high", "", false},
|
|
// An explicitly pinned variant wins rather than growing a second '#',
|
|
// which OpenCode would reject.
|
|
{"explicit variant on the model wins", "anthropic/claude-sonnet-4-5#max", "high", "anthropic/claude-sonnet-4-5#max", true},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
gotModel, gotOK := opencodeModelArg(tc.model, tc.thinkingLevel)
|
|
if gotModel != tc.wantModel || gotOK != tc.wantOK {
|
|
t.Fatalf("opencodeModelArg(%q, %q) = (%q, %v), want (%q, %v)",
|
|
tc.model, tc.thinkingLevel, gotModel, gotOK, tc.wantModel, tc.wantOK)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const testMCPConfig = `{"mcpServers":{"probe":{"command":"node","args":["probe.js"]}}}`
|
|
|
|
// TestOpencodeCheckMCPSupport pins the refusal. 2.x has no channel that can
|
|
// carry MCP credentials without putting them where the agent can commit them,
|
|
// so a configured run fails instead of quietly starting without its servers.
|
|
func TestOpencodeCheckMCPSupport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
wantErr bool
|
|
}{
|
|
{"no mcp config", "", false},
|
|
{"empty object asks for nothing", `{"mcpServers":{}}`, false},
|
|
{"configured servers are refused", testMCPConfig, true},
|
|
{"native opencode shape is refused too", `{"mcp":{"probe":{"type":"local","command":["x"]}}}`, true},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var raw json.RawMessage
|
|
if tc.raw != "" {
|
|
raw = json.RawMessage(tc.raw)
|
|
}
|
|
err := opencodeCheckMCPSupport(raw)
|
|
if tc.wantErr {
|
|
if !errors.Is(err, ErrOpenCodeV2MCPUnsupported) {
|
|
t.Fatalf("expected ErrOpenCodeV2MCPUnsupported, got %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpencodeV2ExecuteRefusesMCPConfig is the end-to-end half: the run must
|
|
// fail before anything starts, and nothing may be written into the workdir —
|
|
// that file is the whole reason MCP is refused here.
|
|
func TestOpencodeV2ExecuteRefusesMCPConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
marker := filepath.Join(tempDir, "started.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte("#!/bin/sh\ntouch \""+marker+"\"\n"))
|
|
|
|
workDir := t.TempDir()
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
CLIVersion: "opencode v2.0.10",
|
|
BuiltinRuntime: true,
|
|
Env: map[string]string{opencodeTransportEnv: "run"},
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new opencode backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
_, execErr := backend.Execute(ctx, "prompt-ignored", ExecOptions{
|
|
Cwd: workDir,
|
|
McpConfig: json.RawMessage(testMCPConfig),
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if !errors.Is(execErr, ErrOpenCodeV2MCPUnsupported) {
|
|
t.Fatalf("expected the run to be refused, got %v", execErr)
|
|
}
|
|
|
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
t.Fatalf("refused run still started the CLI, stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workDir, "opencode.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("refused run still wrote a config into the workdir, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestOpencodeV1StillDeliversMCPConfig guards the other side of the refusal:
|
|
// 1.x is unaffected and still projects mcp_config through its env channel.
|
|
func TestOpencodeV1StillDeliversMCPConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
envFile := filepath.Join(tempDir, "env.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
script := "#!/bin/sh\ncat > /dev/null\nprintf '%s\\n' \"$OPENCODE_CONFIG_CONTENT\" > \"" + envFile + "\"\n" +
|
|
"printf '{\"type\":\"step_start\",\"sessionID\":\"ses\",\"part\":{}}\\n'\n" +
|
|
"printf '{\"type\":\"step_finish\",\"sessionID\":\"ses\",\"part\":{}}\\n'\n"
|
|
writeTestExecutable(t, fakePath, []byte(script))
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
CLIVersion: "1.18.31",
|
|
BuiltinRuntime: true,
|
|
Env: map[string]string{opencodeTransportEnv: "run"},
|
|
Logger: slog.Default(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new opencode backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
|
|
Cwd: t.TempDir(),
|
|
McpConfig: json.RawMessage(testMCPConfig),
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("1.x must still accept mcp_config: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
<-session.Result
|
|
|
|
got, err := os.ReadFile(envFile)
|
|
if err != nil {
|
|
t.Fatalf("read env capture: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "probe") {
|
|
t.Fatalf("1.x lost its MCP env injection: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestOpencodeV2ArgvOmitsDirAndFoldsVariant is the end-to-end regression for
|
|
// GH #8586: on a 2.x runtime the daemon must not pass `--dir` (the CLI rejects
|
|
// the unknown flag and the run dies before it starts) and must express the
|
|
// thinking level through the model string instead of `--variant`.
|
|
func TestOpencodeV2ArgvOmitsDirAndFoldsVariant(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
argsFile := filepath.Join(tempDir, "argv.txt")
|
|
pwdFile := filepath.Join(tempDir, "pwd.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
|
|
|
|
workDir := t.TempDir()
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
CLIVersion: "opencode v2.0.10",
|
|
BuiltinRuntime: true,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{opencodeTransportEnv: "run",
|
|
"OPENCODE_ARGS_FILE": argsFile,
|
|
"OPENCODE_PWD_FILE": pwdFile,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new opencode backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
|
|
Cwd: workDir,
|
|
Model: "anthropic/claude-sonnet-4-5",
|
|
ThinkingLevel: "high",
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
<-session.Result
|
|
|
|
raw, err := os.ReadFile(argsFile)
|
|
if err != nil {
|
|
t.Fatalf("read args file: %v", err)
|
|
}
|
|
args := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
|
|
|
if containsString(args, "--dir") {
|
|
t.Fatalf("2.x argv must not carry --dir, got %q", args)
|
|
}
|
|
if containsString(args, "--variant") {
|
|
t.Fatalf("2.x argv must not carry --variant, got %q", args)
|
|
}
|
|
if !containsString(args, "anthropic/claude-sonnet-4-5#high") {
|
|
t.Fatalf("expected the thinking level folded into the model string, got %q", args)
|
|
}
|
|
|
|
// Dropping --dir is only safe because cwd still anchors discovery, so the
|
|
// PWD the child sees must remain the task workdir.
|
|
gotPWD, err := os.ReadFile(pwdFile)
|
|
if err != nil {
|
|
t.Fatalf("read PWD file: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(gotPWD)) != workDir {
|
|
t.Fatalf("expected PWD %q, got %q", workDir, strings.TrimSpace(string(gotPWD)))
|
|
}
|
|
}
|
|
|
|
// TestOpencodeV1ArgvKeepsDirAndVariant is the other half of the contract: the
|
|
// 1.x path is what every existing runtime uses and must not shift.
|
|
func TestOpencodeV1ArgvKeepsDirAndVariant(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
argsFile := filepath.Join(tempDir, "argv.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
|
|
|
|
workDir := t.TempDir()
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
CLIVersion: "1.18.31",
|
|
BuiltinRuntime: true,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{opencodeTransportEnv: "run", "OPENCODE_ARGS_FILE": argsFile},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new opencode backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
|
|
Cwd: workDir,
|
|
Model: "anthropic/claude-sonnet-4-5",
|
|
ThinkingLevel: "high",
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
<-session.Result
|
|
|
|
raw, err := os.ReadFile(argsFile)
|
|
if err != nil {
|
|
t.Fatalf("read args file: %v", err)
|
|
}
|
|
args := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
|
|
|
if !containsString(args, "--dir") {
|
|
t.Fatalf("1.x argv must still carry --dir, got %q", args)
|
|
}
|
|
if !containsString(args, "--variant") {
|
|
t.Fatalf("1.x argv must still carry --variant, got %q", args)
|
|
}
|
|
if containsString(args, "anthropic/claude-sonnet-4-5#high") {
|
|
t.Fatalf("1.x must not fold the variant into the model, got %q", args)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeConnectionFromArgs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
wantServer string
|
|
wantStandalone bool
|
|
}{
|
|
{"nothing", []string{"run", "--format", "json"}, "", false},
|
|
{"separate value", []string{"run", "--server", "http://127.0.0.1:4096"}, "http://127.0.0.1:4096", false},
|
|
{"inline value", []string{"run", "--server=http://127.0.0.1:4096"}, "http://127.0.0.1:4096", false},
|
|
{"standalone", []string{"run", "--standalone"}, "", true},
|
|
{"trailing --server with no value is ignored", []string{"run", "--server"}, "", false},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
server, standalone := opencodeConnectionFromArgs(tc.args)
|
|
if server != tc.wantServer || standalone != tc.wantStandalone {
|
|
t.Fatalf("opencodeConnectionFromArgs(%q) = (%q, %v), want (%q, %v)",
|
|
tc.args, server, standalone, tc.wantServer, tc.wantStandalone)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpencodeSessionTracker covers the handoff the cancellation path depends
|
|
// on, including the nil receiver every direct-processEvents test relies on.
|
|
func TestOpencodeSessionTracker(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var nilTracker *opencodeSessionTracker
|
|
nilTracker.set("ses_ignored") // must not panic
|
|
if got := nilTracker.get(); got != "" {
|
|
t.Fatalf("nil tracker should report no session, got %q", got)
|
|
}
|
|
|
|
tracker := &opencodeSessionTracker{}
|
|
tracker.set("")
|
|
if got := tracker.get(); got != "" {
|
|
t.Fatalf("empty session id must be ignored, got %q", got)
|
|
}
|
|
tracker.set("ses_first")
|
|
tracker.set("ses_second")
|
|
if got := tracker.get(); got != "ses_first" {
|
|
t.Fatalf("tracker should keep the first session id, got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestOpencodeProcessEventsPublishesSession pins that the scanner hands the
|
|
// session id to the tracker. Without it the cancellation path has no id to
|
|
// interrupt and a cancelled 2.x run keeps going server-side.
|
|
func TestOpencodeProcessEventsPublishesSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}, session: &opencodeSessionTracker{}}
|
|
ch := make(chan Message, 8)
|
|
lines := `{"type":"step_start","timestamp":1,"sessionID":"ses_abc","part":{}}` + "\n" +
|
|
`{"type":"step_finish","timestamp":2,"sessionID":"ses_abc","part":{}}` + "\n"
|
|
|
|
go func() {
|
|
b.processEvents(strings.NewReader(lines), ch)
|
|
close(ch)
|
|
}()
|
|
for range ch {
|
|
}
|
|
|
|
if got := b.session.get(); got != "ses_abc" {
|
|
t.Fatalf("expected the scanner to publish the session id, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeInterruptSessionWithoutSessionIsNoop(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
marker := filepath.Join(tempDir, "called.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte("#!/bin/sh\ntouch \""+marker+"\"\n"))
|
|
|
|
opencodeInterruptSession(opencodeRunConnection{cmd: NewCommand(fakePath, nil)}, "", slog.Default())
|
|
|
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
t.Fatalf("interrupt must not run the CLI without a session id, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestOpencodeInterruptSessionStandaloneIsNoop: a private server is a child of
|
|
// the client, so the process-group signalling already stops it. Interrupting the
|
|
// default service here would signal an unrelated session.
|
|
func TestOpencodeInterruptSessionStandaloneIsNoop(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
marker := filepath.Join(tempDir, "called.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte("#!/bin/sh\ntouch \""+marker+"\"\n"))
|
|
|
|
conn := opencodeRunConnection{cmd: NewCommand(fakePath, nil), standalone: true}
|
|
opencodeInterruptSession(conn, "ses_abc", slog.Default())
|
|
|
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
t.Fatalf("interrupt must not run for a standalone run, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestOpencodeInterruptSessionUsesRunConnection pins that the interrupt reaches
|
|
// the same service the run used. A run launched against `--server <url>` whose
|
|
// interrupt goes to the default background service reports success while the
|
|
// real session keeps working.
|
|
func TestOpencodeInterruptSessionUsesRunConnection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
capture := filepath.Join(tempDir, "capture.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
script := "#!/bin/sh\n" +
|
|
"{ printf 'env=%s\\n' \"$MULTICA_TEST_ENV\"; printf 'cwd=%s\\n' \"$PWD\"; " +
|
|
"for arg in \"$@\"; do printf '%s\\n' \"$arg\"; done; } > \"" + capture + "\"\n"
|
|
writeTestExecutable(t, fakePath, []byte(script))
|
|
|
|
workDir := t.TempDir()
|
|
conn := opencodeRunConnection{
|
|
cmd: NewCommand(fakePath, nil),
|
|
server: "http://127.0.0.1:54321",
|
|
env: []string{"MULTICA_TEST_ENV=task-marker"},
|
|
dir: workDir,
|
|
}
|
|
opencodeInterruptSession(conn, "ses_abc", slog.Default())
|
|
|
|
raw, err := os.ReadFile(capture)
|
|
if err != nil {
|
|
t.Fatalf("read capture: %v", err)
|
|
}
|
|
got := string(raw)
|
|
|
|
// The shell reports $PWD with symlinks resolved, which on macOS turns
|
|
// /tmp into /private/tmp.
|
|
wantCwd := workDir
|
|
if resolved, err := filepath.EvalSymlinks(workDir); err == nil {
|
|
wantCwd = resolved
|
|
}
|
|
|
|
for _, want := range []string{
|
|
"api", "POST", "/api/session/ses_abc/interrupt",
|
|
"--server", "http://127.0.0.1:54321",
|
|
"env=task-marker",
|
|
"cwd=" + wantCwd,
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("interrupt lost %q from the run connection:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOpencodeInterruptSessionBoundsPipeWait: CombinedOutput waits for EOF on
|
|
// the output pipes, so an `api` process that exits while a descendant still
|
|
// holds them would block the termination path behind this call indefinitely.
|
|
func TestOpencodeInterruptSessionBoundsPipeWait(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
// Exits immediately but leaves a descendant holding stdout/stderr.
|
|
writeTestExecutable(t, fakePath, []byte("#!/bin/sh\nsleep 7 &\nexit 0\n"))
|
|
|
|
start := time.Now()
|
|
opencodeInterruptSession(opencodeRunConnection{cmd: NewCommand(fakePath, nil)}, "ses_abc", slog.Default())
|
|
if elapsed := time.Since(start); elapsed > opencodeInterruptTimeout+time.Second {
|
|
t.Fatalf("interrupt exceeded its bound waiting for descendant pipe EOF: %s", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestOpencodeInterruptSessionSurvivesFailure keeps the call best-effort: a
|
|
// service that is gone or answers non-zero must not stop the caller, because
|
|
// the process-group signalling behind it is what the 1.x path always relied on.
|
|
func TestOpencodeInterruptSessionSurvivesFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte("#!/bin/sh\necho 'connection refused' >&2\nexit 1\n"))
|
|
|
|
opencodeInterruptSession(opencodeRunConnection{cmd: NewCommand(fakePath, nil)}, "ses_abc", slog.Default())
|
|
}
|
|
|
|
// TestOpencodeV2CancelInterruptsThroughRunConnection is the end-to-end version
|
|
// of the connection test: it proves Execute actually threads the run's server
|
|
// selection, environment and workdir into the interrupt. A unit test on
|
|
// opencodeInterruptSession alone would keep passing if that wiring regressed,
|
|
// and the failure mode is quiet — the interrupt reports success against the
|
|
// default service while the real session keeps working.
|
|
func TestOpencodeV2CancelInterruptsThroughRunConnection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
capture := filepath.Join(tempDir, "interrupt.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
// `api` invocations record their context; `run` streams one event and then
|
|
// blocks so the test can cancel it mid-flight.
|
|
script := "#!/bin/sh\n" +
|
|
"if [ \"$1\" = \"api\" ]; then\n" +
|
|
" { printf 'env=%s\\n' \"$MULTICA_TEST_ENV\"; for arg in \"$@\"; do printf '%s\\n' \"$arg\"; done; } > \"" + capture + "\"\n" +
|
|
" exit 0\n" +
|
|
"fi\n" +
|
|
"cat > /dev/null\n" +
|
|
"printf '{\"type\":\"text\",\"sessionID\":\"ses_live\",\"part\":{\"type\":\"text\",\"text\":\"ready\"}}\\n'\n" +
|
|
"while :; do sleep 1; done\n"
|
|
writeTestExecutable(t, fakePath, []byte(script))
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
CLIVersion: "opencode v2.0.10",
|
|
BuiltinRuntime: true,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{opencodeTransportEnv: "run", "MULTICA_TEST_ENV": "task-marker"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new opencode backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
workDir := t.TempDir()
|
|
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
|
|
Cwd: workDir,
|
|
Timeout: 20 * time.Second,
|
|
CustomArgs: []string{"--server", "http://127.0.0.1:54321"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-session.Messages:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("fake CLI never became ready")
|
|
}
|
|
|
|
cancel()
|
|
for range session.Messages {
|
|
}
|
|
<-session.Result
|
|
|
|
raw, err := os.ReadFile(capture)
|
|
if err != nil {
|
|
t.Fatalf("cancelling a 2.x run did not interrupt the session: %v", err)
|
|
}
|
|
got := string(raw)
|
|
for _, want := range []string{
|
|
"/api/session/ses_live/interrupt",
|
|
"--server", "http://127.0.0.1:54321",
|
|
"env=task-marker",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("cancel lost %q from the run connection:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|