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]>
161 lines
6.8 KiB
Go
161 lines
6.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestOpencodeUsesACP(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
cfg Config
|
|
opts ExecOptions
|
|
want bool
|
|
}{
|
|
{"1.x current", Config{BuiltinRuntime: true, CLIVersion: "1.19.4"}, ExecOptions{}, true},
|
|
{"1.x first with effort option", Config{BuiltinRuntime: true, CLIVersion: "1.15.0"}, ExecOptions{}, true},
|
|
{"1.x too old", Config{BuiltinRuntime: true, CLIVersion: "1.14.9"}, ExecOptions{}, false},
|
|
{"2.x as reported", Config{BuiltinRuntime: true, CLIVersion: "opencode v2.2.0"}, ExecOptions{}, true},
|
|
{"2.0.0", Config{BuiltinRuntime: true, CLIVersion: "opencode v2.0.0"}, ExecOptions{}, true},
|
|
{"unknown version", Config{BuiltinRuntime: true}, ExecOptions{}, false},
|
|
{"custom profile", Config{BuiltinRuntime: false, CLIVersion: "1.19.4"}, ExecOptions{}, false},
|
|
{"launch prefix", Config{BuiltinRuntime: true, CLIVersion: "1.19.4", LaunchPrefix: []string{"--x"}}, ExecOptions{}, false},
|
|
{"custom_args are run flags", Config{BuiltinRuntime: true, CLIVersion: "1.19.4"}, ExecOptions{CustomArgs: []string{"--agent", "plan"}}, false},
|
|
{"forced run", Config{BuiltinRuntime: true, CLIVersion: "1.19.4", Env: map[string]string{opencodeTransportEnv: "RUN"}}, ExecOptions{}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got, reason := opencodeUsesACP(tc.cfg, tc.opts); got != tc.want {
|
|
t.Fatalf("opencodeUsesACP = %v (%s), want %v", got, reason, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpencodeACPSplitModel(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct{ model, level, wantModel, wantLevel string }{
|
|
{"openai/gpt-5", "high", "openai/gpt-5", "high"},
|
|
{"openai/gpt-5#max", "high", "openai/gpt-5", "max"},
|
|
{"openai/gpt-5#", "high", "openai/gpt-5", "high"},
|
|
{"", "high", "", "high"},
|
|
}
|
|
for _, tc := range cases {
|
|
m, l := opencodeACPSplitModel(tc.model, tc.level)
|
|
if m != tc.wantModel || l != tc.wantLevel {
|
|
t.Fatalf("split(%q,%q) = %q,%q want %q,%q", tc.model, tc.level, m, l, tc.wantModel, tc.wantLevel)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpencodeACPMcpServers(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := Config{Logger: slog.Default()}
|
|
raw := json.RawMessage(`{"mcpServers":{
|
|
"local":{"command":"node","args":["s.js"],"env":{"B":"2","A":"1"}},
|
|
"remote":{"url":"https://example.invalid/mcp","headers":{"Authorization":"Bearer x"}},
|
|
"off":{"command":"x","enabled":false}
|
|
}}`)
|
|
got, err := opencodeACPMcpServers(raw, cfg)
|
|
if err != nil {
|
|
t.Fatalf("translate: %v", err)
|
|
}
|
|
data, _ := json.Marshal(got)
|
|
want := `[{"args":["s.js"],"command":"node","env":[{"name":"A","value":"1"},{"name":"B","value":"2"}],"name":"local"},` +
|
|
`{"headers":[{"name":"Authorization","value":"Bearer x"}],"name":"remote","type":"http","url":"https://example.invalid/mcp"}]`
|
|
if string(data) != want {
|
|
t.Fatalf("servers =\n%s\nwant\n%s", data, want)
|
|
}
|
|
|
|
if _, err := opencodeACPMcpServers(json.RawMessage(`{"mcp":{"r":{"type":"remote","url":"https://x","oauth":{"clientId":"c"}}}}`), cfg); err == nil || !strings.Contains(err.Error(), "OAuth") {
|
|
t.Fatalf("OAuth server must be refused, got %v", err)
|
|
}
|
|
if _, err := opencodeACPMcpServers(json.RawMessage(`{"mcp":{"inherited":{"enabled":false}}}`), cfg); err == nil {
|
|
t.Fatal("bare enabled override must be refused")
|
|
}
|
|
if got, err := opencodeACPMcpServers(nil, cfg); err != nil || len(got) != 0 {
|
|
t.Fatalf("empty config = %v, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeExportTurnUsage(t *testing.T) {
|
|
t.Parallel()
|
|
v1 := `{"info":{"id":"ses"},"messages":[
|
|
{"info":{"role":"user","time":{"created":200}}},
|
|
{"info":{"role":"assistant","time":{"created":100},"tokens":{"input":1000,"output":1000,"reasoning":0,"cache":{"read":0,"write":0}}}},
|
|
{"info":{"role":"assistant","time":{"created":201},"tokens":{"input":10,"output":5,"reasoning":3,"cache":{"read":7,"write":1}}}},
|
|
{"info":{"role":"assistant","time":{"created":300},"tokens":{"input":20,"output":6,"reasoning":4,"cache":{"read":8,"write":0}}}}]}`
|
|
got, err := opencodeExportTurnUsage([]byte(v1), 200, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := (TokenUsage{InputTokens: 30, OutputTokens: 18, CacheReadTokens: 15, CacheWriteTokens: 1}); got != want {
|
|
t.Fatalf("1.x usage = %+v want %+v", got, want)
|
|
}
|
|
|
|
// 2.x flattens message metadata and may print a banner before the JSON.
|
|
v2 := "warning: banner\n" + `{"info":{"id":"ses"},"messages":[
|
|
{"type":"model-switched","time":{"created":250}},
|
|
{"type":"assistant","time":{"created":250},"tokens":{"input":9182,"output":55,"reasoning":256,"cache":{"read":3,"write":0}}},
|
|
{"type":"assistant","time":{"created":260},"tokens":{"input":115,"output":8,"reasoning":17,"cache":{"read":9187,"write":0}}}]}`
|
|
got, err = opencodeExportTurnUsage([]byte(v2), 200, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := (TokenUsage{InputTokens: 9297, OutputTokens: 63, CacheReadTokens: 9190}); got != want {
|
|
t.Fatalf("2.x usage = %+v want %+v", got, want)
|
|
}
|
|
|
|
if _, err := opencodeExportTurnUsage([]byte("not json"), 0, false); err == nil {
|
|
t.Fatal("expected a parse error")
|
|
}
|
|
}
|
|
|
|
// The run transport's 2.x tool_use part carries the call id as `id` (with the
|
|
// part id moved to `partID`); 1.x uses `callID`. Both must reach CallID.
|
|
func TestOpencodeToolUseCallIDBothMajors(t *testing.T) {
|
|
t.Parallel()
|
|
lines := map[string]string{
|
|
"1.x": `{"type":"tool_use","sessionID":"s","part":{"id":"prt_1","type":"tool","tool":"task","callID":"call-v1","state":{"status":"completed","input":{},"output":"ok"}}}`,
|
|
"2.x": `{"type":"tool_use","sessionID":"s","part":{"partID":"prt_2","type":"tool","id":"call-v2","tool":"subagent","state":{"status":"completed","input":{},"output":"ok"}}}`,
|
|
}
|
|
want := map[string]string{"1.x": "call-v1", "2.x": "call-v2"}
|
|
for name, line := range lines {
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 8)
|
|
b.processEvents(strings.NewReader(line+"\n"), ch)
|
|
close(ch)
|
|
var got []Message
|
|
for m := range ch {
|
|
got = append(got, m)
|
|
}
|
|
if len(got) != 2 || got[0].Type != MessageToolUse || got[1].Type != MessageToolResult {
|
|
t.Fatalf("%s: messages = %+v", name, got)
|
|
}
|
|
if got[0].CallID != want[name] || got[1].CallID != want[name] {
|
|
t.Fatalf("%s: call ids = %q/%q, want %q", name, got[0].CallID, got[1].CallID, want[name])
|
|
}
|
|
}
|
|
}
|
|
|
|
// A non-tool part's `id` is a part id, never a call id.
|
|
func TestOpencodeToolCallIDIgnoresNonToolParts(t *testing.T) {
|
|
t.Parallel()
|
|
if got := (opencodeEventPart{ID: "prt_x", Type: "text"}).toolCallID(); got != "" {
|
|
t.Fatalf("toolCallID = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeACPChildUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
if !opencodeACPChildUpdate(json.RawMessage(`{"sessionUpdate":"agent_message_chunk","_meta":{"opencode/child-session":{"id":"ses_c"}}}`)) {
|
|
t.Fatal("child update not recognised")
|
|
}
|
|
if opencodeACPChildUpdate(json.RawMessage(`{"sessionUpdate":"agent_message_chunk","_meta":{}}`)) {
|
|
t.Fatal("parent update recognised as child")
|
|
}
|
|
}
|