mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +08:00
* fix(agent): deliver the OpenCode prompt on stdin, not argv (#6538) The daemon appended the whole task prompt as an argv element, so on Windows every OpenCode task whose command line cleared CreateProcess's 32,767 character lpCommandLine limit failed to start at all. Go surfaces the resulting ERROR_FILENAME_EXCED_RANGE (206) as "The filename or extension is too long", which reads as an exe-path problem and is not one. A workspace with a realistic set of models and skills clears that ceiling on the prompt alone (39,629 bytes in the report), leaving no usable in-app workaround. `opencode run` merges its variadic [message..] positional with whatever is piped in, so passing no positional makes the piped text the entire run message. That path is present in the reported v1.18.14 and back to v1.4.10. Write the prompt from its own goroutine, as the Pi and Cursor backends already do: a prompt larger than the pipe buffer blocks mid-write until OpenCode drains it, and OpenCode cannot drain while nobody consumes its stdout. OpenCode reads stdin to EOF, so stdin is closed exactly once as the end-of-prompt signal — including on the cancellation path, where closing it releases a writer still blocked on a full pipe. The existing process-group SIGTERM -> SIGKILL ordering from #4533 is unchanged. Keeping the prompt off argv also stops it being echoed into the "agent command" log line; prompt_bytes replaces it there. Scoped to OpenCode. DevEco, OpenClaw (#6032), Qwen, Copilot and Antigravity inline their prompts the same way, but each CLI's input contract needs verifying on its own before it moves. Co-authored-by: multica-agent <[email protected]> * fix(agent): do not fail a completed run on a benign prompt-write EPIPE The backend CI run for this PR failed in TestPiExecuteRetainsOnlyLastTurnOutput with "pi prompt write failed: write |1: broken pipe". The cause is a latent race shared by pi and the OpenCode code added in the previous commit, which copied pi's error handling. A child that exits without draining stdin closes the read end while the parent is still writing the prompt, so the parent's write returns EPIPE. Both backends converted that into a failed result even when the agent had already emitted a complete, successful stream — making the outcome depend on whether the write landed before or after the child exited, i.e. on machine load. OpenCode reads stdin to EOF before it does any work, so a run that produced a complete stream necessarily received the whole prompt; an EPIPE recorded after that only means the pipe closed on the way out. Report the write error only for a run that did NOT complete, where it can actually explain the failure, and append rather than overwrite so the stream's own diagnosis survives. cursor.go already guards this correctly by ignoring writeErr once a result was seen. Test fakes now drain stdin, matching what the real CLIs do. A fake that exits without reading is what manufactures the spurious EPIPE, and that is what broke CI — pi's piEventStreamScript is the fixture that failed. Adds a deterministic regression test: a child that emits a full successful stream and never reads a 1.2 MB prompt. Against the previous commit it reproduces the CI signature exactly (status "failed", "broken pipe"); with this change it passes. pi.go's production handling has the same defect and still needs the equivalent fix, but that belongs to whoever owns #6485 — this change keeps to OpenCode plus the test fixture that was breaking the build. Co-authored-by: multica-agent <[email protected]> * fix(agent): gate prompt-write suppression on a positive terminal signal Review caught a false-success path opened by the previous commit. It suppressed a failed prompt write whenever status was "completed", on the reasoning that a completed run must have received the whole prompt. That reasoning does not hold: processEvents starts finalStatus at "completed" and only fails closed on structural evidence — an open step, or a step awaiting a continuation. A child that emits NOTHING and exits 0 sets neither, so the default survives. The full path: the parent writes a large prompt, the child exits 0 without reading a byte, the write returns EPIPE, stdout hits EOF with no events, and the run is reported as completed with empty output and no error. The prompt never reached the agent and nothing said so. Reproduced deterministically before the fix: status="completed" output="" error="". Absence of a failure signal is not proof of success, so record the positive one instead. eventResult.sawTerminalSignal is set only when a step_finish closed the last step with no continuation pending, and the write error is suppressed only on that evidence. It is deliberately not the negation of noTerminalSignal: an empty stream sets neither, because there is nothing to fail closed on and nothing proving completion either. When the signal is absent the write error now also flips the default "completed" to "failed". Adds the empty-stream regression alongside the existing complete-stream one, so both directions of the branch are pinned. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
1862 lines
62 KiB
Go
1862 lines
62 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewReturnsOpencodeBackend(t *testing.T) {
|
|
t.Parallel()
|
|
b, err := New("opencode", Config{ExecutablePath: "/nonexistent/opencode"})
|
|
if err != nil {
|
|
t.Fatalf("New(opencode) error: %v", err)
|
|
}
|
|
if _, ok := b.(*opencodeBackend); !ok {
|
|
t.Fatalf("expected *opencodeBackend, got %T", b)
|
|
}
|
|
}
|
|
|
|
// ── Text event tests ──
|
|
|
|
func TestOpencodeHandleTextEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{}
|
|
ch := make(chan Message, 10)
|
|
var output strings.Builder
|
|
|
|
event := opencodeEvent{
|
|
Type: "text",
|
|
SessionID: "ses_abc",
|
|
Part: opencodeEventPart{
|
|
Type: "text",
|
|
Text: "Hello from opencode",
|
|
},
|
|
}
|
|
|
|
b.handleTextEvent(event, ch, &output)
|
|
|
|
if output.String() != "Hello from opencode" {
|
|
t.Errorf("output: got %q, want %q", output.String(), "Hello from opencode")
|
|
}
|
|
msg := <-ch
|
|
if msg.Type != MessageText {
|
|
t.Errorf("type: got %v, want MessageText", msg.Type)
|
|
}
|
|
if msg.Content != "Hello from opencode" {
|
|
t.Errorf("content: got %q, want %q", msg.Content, "Hello from opencode")
|
|
}
|
|
}
|
|
|
|
func TestOpencodeHandleTextEventEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{}
|
|
ch := make(chan Message, 10)
|
|
var output strings.Builder
|
|
|
|
event := opencodeEvent{
|
|
Type: "text",
|
|
Part: opencodeEventPart{Type: "text", Text: ""},
|
|
}
|
|
|
|
b.handleTextEvent(event, ch, &output)
|
|
|
|
if output.String() != "" {
|
|
t.Errorf("expected empty output, got %q", output.String())
|
|
}
|
|
if len(ch) != 0 {
|
|
t.Errorf("expected no messages, got %d", len(ch))
|
|
}
|
|
}
|
|
|
|
// ── Tool use event tests (real opencode schema) ──
|
|
|
|
func TestOpencodeHandleToolUseEventCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{}
|
|
ch := make(chan Message, 10)
|
|
|
|
// Real opencode tool_use event: single event with state containing both
|
|
// call parameters and result.
|
|
event := opencodeEvent{
|
|
Type: "tool_use",
|
|
Part: opencodeEventPart{
|
|
Tool: "bash",
|
|
CallID: "call_BHA1",
|
|
State: &opencodeToolState{
|
|
Status: "completed",
|
|
Input: json.RawMessage(`{"command":"pwd","description":"Prints current working directory path"}`),
|
|
Output: "/tmp/multica\n",
|
|
},
|
|
},
|
|
}
|
|
|
|
b.handleToolUseEvent(event, ch)
|
|
|
|
// Should emit both a tool-use and a tool-result message.
|
|
if len(ch) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(ch))
|
|
}
|
|
|
|
// First: tool-use
|
|
msg := <-ch
|
|
if msg.Type != MessageToolUse {
|
|
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
|
|
}
|
|
if msg.Tool != "bash" {
|
|
t.Errorf("tool: got %q, want %q", msg.Tool, "bash")
|
|
}
|
|
if msg.CallID != "call_BHA1" {
|
|
t.Errorf("callID: got %q, want %q", msg.CallID, "call_BHA1")
|
|
}
|
|
if cmd, ok := msg.Input["command"].(string); !ok || cmd != "pwd" {
|
|
t.Errorf("input.command: got %v", msg.Input["command"])
|
|
}
|
|
|
|
// Second: tool-result
|
|
msg = <-ch
|
|
if msg.Type != MessageToolResult {
|
|
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
|
|
}
|
|
if msg.CallID != "call_BHA1" {
|
|
t.Errorf("callID: got %q, want %q", msg.CallID, "call_BHA1")
|
|
}
|
|
if msg.Output != "/tmp/multica\n" {
|
|
t.Errorf("output: got %q", msg.Output)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeHandleToolUseEventPending(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{}
|
|
ch := make(chan Message, 10)
|
|
|
|
// Tool use with pending status — only emit tool-use, no result.
|
|
event := opencodeEvent{
|
|
Type: "tool_use",
|
|
Part: opencodeEventPart{
|
|
Tool: "read",
|
|
CallID: "call_ABC",
|
|
State: &opencodeToolState{
|
|
Status: "pending",
|
|
Input: json.RawMessage(`{"filePath":"/tmp/test.go"}`),
|
|
},
|
|
},
|
|
}
|
|
|
|
b.handleToolUseEvent(event, ch)
|
|
|
|
if len(ch) != 1 {
|
|
t.Fatalf("expected 1 message for pending tool, got %d", len(ch))
|
|
}
|
|
msg := <-ch
|
|
if msg.Type != MessageToolUse {
|
|
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
|
|
}
|
|
if msg.Tool != "read" {
|
|
t.Errorf("tool: got %q, want %q", msg.Tool, "read")
|
|
}
|
|
}
|
|
|
|
func TestOpencodeHandleToolUseEventStructuredOutput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{}
|
|
ch := make(chan Message, 10)
|
|
|
|
// Tool with structured (non-string) output.
|
|
event := opencodeEvent{
|
|
Type: "tool_use",
|
|
Part: opencodeEventPart{
|
|
Tool: "glob",
|
|
CallID: "call_XYZ",
|
|
State: &opencodeToolState{
|
|
Status: "completed",
|
|
Input: json.RawMessage(`{"pattern":"*.go"}`),
|
|
Output: map[string]any{"files": []any{"main.go", "main_test.go"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
b.handleToolUseEvent(event, ch)
|
|
|
|
// tool-use + tool-result
|
|
if len(ch) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(ch))
|
|
}
|
|
<-ch // skip tool-use
|
|
msg := <-ch
|
|
if msg.Type != MessageToolResult {
|
|
t.Errorf("type: got %v, want MessageToolResult", msg.Type)
|
|
}
|
|
if !strings.Contains(msg.Output, "main.go") {
|
|
t.Errorf("output should contain 'main.go', got %q", msg.Output)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeHandleToolUseEventNilState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{}
|
|
ch := make(chan Message, 10)
|
|
|
|
// Tool use with no state at all — should emit tool-use with no crash.
|
|
event := opencodeEvent{
|
|
Type: "tool_use",
|
|
Part: opencodeEventPart{
|
|
Tool: "write",
|
|
CallID: "call_NUL",
|
|
},
|
|
}
|
|
|
|
b.handleToolUseEvent(event, ch)
|
|
|
|
if len(ch) != 1 {
|
|
t.Fatalf("expected 1 message, got %d", len(ch))
|
|
}
|
|
msg := <-ch
|
|
if msg.Type != MessageToolUse {
|
|
t.Errorf("type: got %v, want MessageToolUse", msg.Type)
|
|
}
|
|
}
|
|
|
|
// ── Error event tests ──
|
|
|
|
func TestOpencodeHandleErrorEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 10)
|
|
status := "completed"
|
|
errMsg := ""
|
|
|
|
event := opencodeEvent{
|
|
Type: "error",
|
|
SessionID: "ses_abc",
|
|
Error: &opencodeError{
|
|
Name: "UnknownError",
|
|
Data: &opencodeErrData{
|
|
Message: "Model not found: definitely/not-a-model.",
|
|
},
|
|
},
|
|
}
|
|
|
|
b.handleErrorEvent(event, ch, &status, &errMsg)
|
|
|
|
if status != "failed" {
|
|
t.Errorf("status: got %q, want %q", status, "failed")
|
|
}
|
|
if errMsg != "Model not found: definitely/not-a-model." {
|
|
t.Errorf("error: got %q", errMsg)
|
|
}
|
|
msg := <-ch
|
|
if msg.Type != MessageError {
|
|
t.Errorf("type: got %v, want MessageError", msg.Type)
|
|
}
|
|
if msg.Content != "Model not found: definitely/not-a-model." {
|
|
t.Errorf("content: got %q", msg.Content)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeHandleErrorEventNameOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 10)
|
|
status := "completed"
|
|
errMsg := ""
|
|
|
|
// Error with name but no data.message — should fall back to name.
|
|
event := opencodeEvent{
|
|
Type: "error",
|
|
Error: &opencodeError{
|
|
Name: "RateLimitError",
|
|
},
|
|
}
|
|
|
|
b.handleErrorEvent(event, ch, &status, &errMsg)
|
|
|
|
if errMsg != "RateLimitError" {
|
|
t.Errorf("error: got %q, want %q", errMsg, "RateLimitError")
|
|
}
|
|
}
|
|
|
|
func TestOpencodeHandleErrorEventNilError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 10)
|
|
status := "completed"
|
|
errMsg := ""
|
|
|
|
event := opencodeEvent{Type: "error"}
|
|
|
|
b.handleErrorEvent(event, ch, &status, &errMsg)
|
|
|
|
if errMsg != "unknown opencode error" {
|
|
t.Errorf("error: got %q, want %q", errMsg, "unknown opencode error")
|
|
}
|
|
}
|
|
|
|
// ── JSON parsing tests with real fixtures ──
|
|
|
|
func TestOpencodeEventParsingTextFixture(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
line := `{"type":"text","timestamp":1775116675833,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","type":"text","text":"pong","time":{"start":1775116675833,"end":1775116675833}}}`
|
|
|
|
var event opencodeEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if event.Type != "text" {
|
|
t.Errorf("type: got %q, want %q", event.Type, "text")
|
|
}
|
|
if event.SessionID != "ses_abc" {
|
|
t.Errorf("sessionID: got %q, want %q", event.SessionID, "ses_abc")
|
|
}
|
|
if event.Part.Text != "pong" {
|
|
t.Errorf("part.text: got %q, want %q", event.Part.Text, "pong")
|
|
}
|
|
}
|
|
|
|
func TestOpencodeEventParsingToolUseFixture(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Real `tool_use` JSON from live `opencode run --format json` output.
|
|
line := `{"type":"tool_use","timestamp":1775117187163,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","type":"tool","tool":"bash","callID":"call_BHA1","state":{"status":"completed","input":{"command":"pwd","description":"Prints current working directory path"},"output":"/tmp/multica\n","metadata":{"exit":0},"time":{"start":1775117187092,"end":1775117187162}}}}`
|
|
|
|
var event opencodeEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if event.Type != "tool_use" {
|
|
t.Errorf("type: got %q, want %q", event.Type, "tool_use")
|
|
}
|
|
if event.Part.Tool != "bash" {
|
|
t.Errorf("part.tool: got %q, want %q", event.Part.Tool, "bash")
|
|
}
|
|
if event.Part.CallID != "call_BHA1" {
|
|
t.Errorf("part.callID: got %q, want %q", event.Part.CallID, "call_BHA1")
|
|
}
|
|
if event.Part.State == nil {
|
|
t.Fatal("part.state is nil")
|
|
}
|
|
if event.Part.State.Status != "completed" {
|
|
t.Errorf("state.status: got %q, want %q", event.Part.State.Status, "completed")
|
|
}
|
|
|
|
// Parse state.input
|
|
var input map[string]any
|
|
if err := json.Unmarshal(event.Part.State.Input, &input); err != nil {
|
|
t.Fatalf("unmarshal state.input: %v", err)
|
|
}
|
|
if input["command"] != "pwd" {
|
|
t.Errorf("state.input.command: got %v, want %q", input["command"], "pwd")
|
|
}
|
|
|
|
// state.output should be a string
|
|
if output, ok := event.Part.State.Output.(string); !ok || output != "/tmp/multica\n" {
|
|
t.Errorf("state.output: got %v (%T)", event.Part.State.Output, event.Part.State.Output)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeEventParsingErrorFixture(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
line := `{"type":"error","timestamp":1775117233612,"sessionID":"ses_abc","error":{"name":"UnknownError","data":{"message":"Model not found: definitely/not-a-model."}}}`
|
|
|
|
var event opencodeEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if event.Type != "error" {
|
|
t.Errorf("type: got %q, want %q", event.Type, "error")
|
|
}
|
|
if event.Error == nil {
|
|
t.Fatal("error field is nil")
|
|
}
|
|
if event.Error.Name != "UnknownError" {
|
|
t.Errorf("error.name: got %q", event.Error.Name)
|
|
}
|
|
if got := event.Error.Message(); got != "Model not found: definitely/not-a-model." {
|
|
t.Errorf("error.Message(): got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeEventParsingStepStartFixture(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
line := `{"type":"step_start","timestamp":1775116675819,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","snapshot":"abc123","type":"step-start"}}`
|
|
|
|
var event opencodeEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if event.Type != "step_start" {
|
|
t.Errorf("type: got %q, want %q", event.Type, "step_start")
|
|
}
|
|
if event.SessionID != "ses_abc" {
|
|
t.Errorf("sessionID: got %q", event.SessionID)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeStepFinishParsing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
line := `{"type":"step_finish","timestamp":1775116676180,"sessionID":"ses_abc","part":{"id":"prt_789","reason":"stop","snapshot":"abc123","messageID":"msg_456","sessionID":"ses_abc","type":"step-finish","tokens":{"total":14674,"input":14585,"output":89,"reasoning":82,"cache":{"write":0,"read":0}},"cost":0}}`
|
|
|
|
var event opencodeEvent
|
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if event.Type != "step_finish" {
|
|
t.Errorf("type: got %q, want %q", event.Type, "step_finish")
|
|
}
|
|
if event.SessionID != "ses_abc" {
|
|
t.Errorf("sessionID: got %q", event.SessionID)
|
|
}
|
|
}
|
|
|
|
// ── extractToolOutput tests ──
|
|
|
|
func TestExtractToolOutputString(t *testing.T) {
|
|
t.Parallel()
|
|
if got := extractToolOutput("hello\n"); got != "hello\n" {
|
|
t.Errorf("got %q, want %q", got, "hello\n")
|
|
}
|
|
}
|
|
|
|
func TestExtractToolOutputNil(t *testing.T) {
|
|
t.Parallel()
|
|
if got := extractToolOutput(nil); got != "" {
|
|
t.Errorf("got %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractToolOutputStructured(t *testing.T) {
|
|
t.Parallel()
|
|
obj := map[string]any{"key": "value"}
|
|
got := extractToolOutput(obj)
|
|
if !strings.Contains(got, `"key"`) || !strings.Contains(got, `"value"`) {
|
|
t.Errorf("got %q, expected JSON containing key/value", got)
|
|
}
|
|
}
|
|
|
|
// ── opencodeError.Message() tests ──
|
|
|
|
func TestOpencodeErrorMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
err *opencodeError
|
|
want string
|
|
}{
|
|
{
|
|
name: "data message",
|
|
err: &opencodeError{Name: "Err", Data: &opencodeErrData{Message: "details"}},
|
|
want: "details",
|
|
},
|
|
{
|
|
name: "name only",
|
|
err: &opencodeError{Name: "RateLimitError"},
|
|
want: "RateLimitError",
|
|
},
|
|
{
|
|
name: "empty",
|
|
err: &opencodeError{},
|
|
want: "",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tt.err.Message(); got != tt.want {
|
|
t.Errorf("Message() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Integration-level tests: processEvents ──
|
|
//
|
|
// These feed multiple JSON lines through processEvents and verify the
|
|
// accumulated result (status, output, sessionID, error) and emitted messages.
|
|
|
|
func TestOpencodeProcessEventsHappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Simulate a successful run: step_start → text → tool_use → text → step_finish
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_happy","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1001,"sessionID":"ses_happy","part":{"type":"text","text":"Analyzing the issue..."}}`,
|
|
`{"type":"tool_use","timestamp":1002,"sessionID":"ses_happy","part":{"tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"ls"},"output":"file1.go\nfile2.go\n"}}}`,
|
|
`{"type":"text","timestamp":1003,"sessionID":"ses_happy","part":{"type":"text","text":" Done."}}`,
|
|
`{"type":"step_finish","timestamp":1004,"sessionID":"ses_happy","part":{"type":"step-finish"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
// Verify result.
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "completed")
|
|
}
|
|
if result.sessionID != "ses_happy" {
|
|
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_happy")
|
|
}
|
|
if result.output != "Analyzing the issue... Done." {
|
|
t.Errorf("output: got %q, want %q", result.output, "Analyzing the issue... Done.")
|
|
}
|
|
if result.errMsg != "" {
|
|
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
|
}
|
|
|
|
// Drain and verify messages.
|
|
close(ch)
|
|
var msgs []Message
|
|
for m := range ch {
|
|
msgs = append(msgs, m)
|
|
}
|
|
|
|
// Expected: status(running), text, tool-use, tool-result, text, = 5 messages
|
|
if len(msgs) != 5 {
|
|
t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
|
|
}
|
|
if msgs[0].Type != MessageStatus || msgs[0].Status != "running" {
|
|
t.Errorf("msg[0]: got %+v, want status=running", msgs[0])
|
|
}
|
|
if msgs[1].Type != MessageText || msgs[1].Content != "Analyzing the issue..." {
|
|
t.Errorf("msg[1]: got %+v", msgs[1])
|
|
}
|
|
if msgs[2].Type != MessageToolUse || msgs[2].Tool != "bash" {
|
|
t.Errorf("msg[2]: got %+v, want tool-use(bash)", msgs[2])
|
|
}
|
|
if msgs[3].Type != MessageToolResult || msgs[3].Output != "file1.go\nfile2.go\n" {
|
|
t.Errorf("msg[3]: got %+v, want tool-result", msgs[3])
|
|
}
|
|
if msgs[4].Type != MessageText || msgs[4].Content != " Done." {
|
|
t.Errorf("msg[4]: got %+v", msgs[4])
|
|
}
|
|
}
|
|
|
|
func TestOpencodeProcessEventsErrorCausesFailedStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Simulate: step_start → error (model not found) → step_finish.
|
|
// OpenCode exits RC=0 on error events, so the error event is the only
|
|
// signal that something went wrong.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_err","part":{"type":"step-start"}}`,
|
|
`{"type":"error","timestamp":1001,"sessionID":"ses_err","error":{"name":"UnknownError","data":{"message":"Model not found: bad/model"}}}`,
|
|
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_err","part":{"type":"step-finish"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "failed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "failed")
|
|
}
|
|
if result.errMsg != "Model not found: bad/model" {
|
|
t.Errorf("errMsg: got %q", result.errMsg)
|
|
}
|
|
if result.sessionID != "ses_err" {
|
|
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_err")
|
|
}
|
|
|
|
close(ch)
|
|
var errorMsgs int
|
|
for m := range ch {
|
|
if m.Type == MessageError {
|
|
errorMsgs++
|
|
}
|
|
}
|
|
if errorMsgs != 1 {
|
|
t.Errorf("expected 1 error message, got %d", errorMsgs)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeProcessEventsSessionIDExtracted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Session ID should be captured from the last event that has one.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_first","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1001,"sessionID":"ses_updated","part":{"type":"text","text":"hi"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.sessionID != "ses_updated" {
|
|
t.Errorf("sessionID: got %q, want %q (should use last seen)", result.sessionID, "ses_updated")
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsScannerError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Use an ioErrReader that returns valid data then an I/O error, which
|
|
// triggers scanner.Err() and should set status to "failed".
|
|
result := b.processEvents(&ioErrReader{
|
|
data: `{"type":"text","sessionID":"ses_scan","part":{"text":"before error"}}` + "\n",
|
|
}, ch)
|
|
|
|
if result.status != "failed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "failed")
|
|
}
|
|
if !strings.Contains(result.errMsg, "stdout read error") {
|
|
t.Errorf("errMsg: got %q, want it to contain 'stdout read error'", result.errMsg)
|
|
}
|
|
// The text event before the error should still be captured.
|
|
if result.output != "before error" {
|
|
t.Errorf("output: got %q, want %q", result.output, "before error")
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
// ioErrReader delivers data on the first Read, then returns an error on the second.
|
|
type ioErrReader struct {
|
|
data string
|
|
read bool
|
|
}
|
|
|
|
func (r *ioErrReader) Read(p []byte) (int, error) {
|
|
if !r.read {
|
|
r.read = true
|
|
n := copy(p, r.data)
|
|
return n, nil
|
|
}
|
|
return 0, fmt.Errorf("simulated I/O error")
|
|
}
|
|
|
|
func TestOpencodeProcessEventsEmptyLines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Empty lines and invalid JSON should be skipped without error.
|
|
lines := strings.Join([]string{
|
|
"",
|
|
" ",
|
|
"not json at all",
|
|
`{"type":"text","sessionID":"ses_ok","part":{"text":"valid"}}`,
|
|
"",
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "completed")
|
|
}
|
|
if result.output != "valid" {
|
|
t.Errorf("output: got %q, want %q", result.output, "valid")
|
|
}
|
|
if result.sessionID != "ses_ok" {
|
|
t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_ok")
|
|
}
|
|
|
|
close(ch)
|
|
var msgs []Message
|
|
for m := range ch {
|
|
msgs = append(msgs, m)
|
|
}
|
|
if len(msgs) != 1 || msgs[0].Type != MessageText {
|
|
t.Errorf("expected 1 text message, got %d: %+v", len(msgs), msgs)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Error event followed by more text — status should remain "failed".
|
|
lines := strings.Join([]string{
|
|
`{"type":"error","sessionID":"ses_x","error":{"name":"RateLimitError"}}`,
|
|
`{"type":"text","sessionID":"ses_x","part":{"text":"recovered?"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "failed" {
|
|
t.Errorf("status: got %q, want %q (error should stick)", result.status, "failed")
|
|
}
|
|
if result.errMsg != "RateLimitError" {
|
|
t.Errorf("errMsg: got %q, want %q", result.errMsg, "RateLimitError")
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsStreamEndsMidStep(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Regression for production run B: the provider stream died right after a
|
|
// mid-step planning message. opencode reaches a clean EOF and exits 0 with
|
|
// the step still open (no step_finish, no error event), which previously
|
|
// reported "completed" — a false-green with all work lost.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_midstep","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1001,"sessionID":"ses_midstep","part":{"type":"text","text":"Let me plan the work..."}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "failed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "failed")
|
|
}
|
|
if !strings.Contains(result.errMsg, "terminal signal") {
|
|
t.Errorf("errMsg: got %q, want it to mention the missing terminal signal", result.errMsg)
|
|
}
|
|
// Text emitted before the stream died must still be captured.
|
|
if result.output != "Let me plan the work..." {
|
|
t.Errorf("output: got %q", result.output)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsStreamEndsAfterToolBeforeStepFinish(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Regression for production run A: the stream died after a tool ran but
|
|
// before the step closed. opencode emits tool_use only on terminal states
|
|
// (here completed), so unfinished work manifests as an unclosed step, not a
|
|
// pending tool — the open step at EOF is what fails the run.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_tool","part":{"type":"step-start"}}`,
|
|
`{"type":"tool_use","timestamp":1001,"sessionID":"ses_tool","part":{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"go test ./..."},"output":"ok\n"}}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "failed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "failed")
|
|
}
|
|
if !strings.Contains(result.errMsg, "terminal signal") {
|
|
t.Errorf("errMsg: got %q, want it to mention the missing terminal signal", result.errMsg)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsMultiStepHappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// A full tool loop as it appears on the real wire (live-probed against
|
|
// opencode 1.17.16): the tool step closes with reason "tool-calls", the
|
|
// continuation step opens and closes with the final reason "stop". Must
|
|
// stay "completed" — "stop" is the run-level terminal signal.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_multi","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1001,"sessionID":"ses_multi","part":{"type":"text","text":"step one"}}`,
|
|
`{"type":"tool_use","timestamp":1002,"sessionID":"ses_multi","part":{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"echo ok"},"output":"ok\n"}}}`,
|
|
`{"type":"step_finish","timestamp":1003,"sessionID":"ses_multi","part":{"type":"step-finish","reason":"tool-calls"}}`,
|
|
`{"type":"step_start","timestamp":1004,"sessionID":"ses_multi","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1005,"sessionID":"ses_multi","part":{"type":"text","text":" step two"}}`,
|
|
`{"type":"step_finish","timestamp":1006,"sessionID":"ses_multi","part":{"type":"step-finish","reason":"stop"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "completed")
|
|
}
|
|
if result.output != "step one step two" {
|
|
t.Errorf("output: got %q", result.output)
|
|
}
|
|
if result.errMsg != "" {
|
|
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsStreamEndsAfterToolCallsStepFinish(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// A step_finish with reason "tool-calls" is not a run-level terminal
|
|
// signal: tool results still have to be fed back in a continuation step.
|
|
// If the stream dies in the gap between that finish and the next
|
|
// step_start, the run did not complete — even though no step is open.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_toolcalls","part":{"type":"step-start"}}`,
|
|
`{"type":"tool_use","timestamp":1001,"sessionID":"ses_toolcalls","part":{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"go test ./..."},"output":"ok\n"}}}`,
|
|
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_toolcalls","part":{"type":"step-finish","reason":"tool-calls"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "failed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "failed")
|
|
}
|
|
if !strings.Contains(result.errMsg, "terminal signal") {
|
|
t.Errorf("errMsg: got %q, want it to mention the missing terminal signal", result.errMsg)
|
|
}
|
|
if !result.noTerminalSignal {
|
|
t.Error("noTerminalSignal: got false, want true")
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsStreamEndsAfterToolWithStopFinish(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Some providers return reason "stop" even when the assistant step contains
|
|
// tool calls. OpenCode still continues the run so it can feed the tool result
|
|
// back to the model. EOF before that continuation step is therefore not a
|
|
// successful run-level terminal signal.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_stop_tool","part":{"type":"step-start"}}`,
|
|
`{"type":"tool_use","timestamp":1001,"sessionID":"ses_stop_tool","part":{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"go test ./..."},"output":"ok\n"}}}`,
|
|
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_stop_tool","part":{"type":"step-finish","reason":"stop"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "failed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "failed")
|
|
}
|
|
if !strings.Contains(result.errMsg, "terminal signal") {
|
|
t.Errorf("errMsg: got %q, want it to mention the missing terminal signal", result.errMsg)
|
|
}
|
|
if !result.noTerminalSignal {
|
|
t.Error("noTerminalSignal: got false, want true")
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsToolWithStopFinishThenContinuation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// The provider-specific "stop" quirk must not false-fail a healthy run once
|
|
// OpenCode starts the continuation step and reaches a real terminal finish.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_stop_continue","part":{"type":"step-start"}}`,
|
|
`{"type":"tool_use","timestamp":1001,"sessionID":"ses_stop_continue","part":{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"echo ok"},"output":"ok\n"}}}`,
|
|
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_stop_continue","part":{"type":"step-finish","reason":"stop"}}`,
|
|
`{"type":"step_start","timestamp":1003,"sessionID":"ses_stop_continue","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1004,"sessionID":"ses_stop_continue","part":{"type":"text","text":"done"}}`,
|
|
`{"type":"step_finish","timestamp":1005,"sessionID":"ses_stop_continue","part":{"type":"step-finish","reason":"stop"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "completed")
|
|
}
|
|
if result.errMsg != "" {
|
|
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsProviderExecutedToolWithStopFinish(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Provider-executed tools do not require OpenCode to feed a local tool
|
|
// result back to the model, so a "stop" finish remains terminal.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_provider_tool","part":{"type":"step-start"}}`,
|
|
`{"type":"tool_use","timestamp":1001,"sessionID":"ses_provider_tool","part":{"type":"tool","tool":"web_search","callID":"call_1","metadata":{"providerExecuted":true},"state":{"status":"completed","input":{"query":"weather"},"output":"sunny"}}}`,
|
|
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_provider_tool","part":{"type":"step-finish","reason":"stop"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "completed")
|
|
}
|
|
if result.errMsg != "" {
|
|
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsStepFinishWithoutReasonBackcompat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Older opencode versions emit step-finish parts without a reason field.
|
|
// A missing (or unknown) reason must be treated as terminal so healthy
|
|
// runs on old protocols are not mass-failed; only an explicit
|
|
// "tool-calls" keeps the run non-terminal.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_noreason","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1001,"sessionID":"ses_noreason","part":{"type":"text","text":"legacy run"}}`,
|
|
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_noreason","part":{"type":"step-finish"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q", result.status, "completed")
|
|
}
|
|
if result.errMsg != "" {
|
|
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsToolErrorThenCleanFinish(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// A recovered tool error is normal in a healthy run: opencode emits tool_use
|
|
// only on terminal states, and a failed tool arrives with state.status=="error"
|
|
// (real wire shape from `opencode run --format json`). The run continues and
|
|
// closes every step with step_finish, so status must stay "completed". The
|
|
// earlier pending-tool heuristic recorded the error tool's callID, never
|
|
// drained it, and wrongly reported this healthy run as failed.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_toolerr","part":{"type":"step-start"}}`,
|
|
`{"type":"tool_use","timestamp":1001,"sessionID":"ses_toolerr","part":{"type":"tool","tool":"read","callID":"functions.read:1","state":{"status":"error","input":{"filePath":"/nonexistent-path-xyz/also-missing.md"},"error":"File not found: /nonexistent-path-xyz/also-missing.md"}}}`,
|
|
`{"type":"tool_use","timestamp":1002,"sessionID":"ses_toolerr","part":{"type":"tool","tool":"bash","callID":"functions.bash:0","state":{"status":"completed","input":{"command":"echo hi"},"output":"hi\n"}}}`,
|
|
`{"type":"step_finish","timestamp":1003,"sessionID":"ses_toolerr","part":{"type":"step-finish"}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q (recovered tool error must not fail a healthy run)", result.status, "completed")
|
|
}
|
|
if result.errMsg != "" {
|
|
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
|
}
|
|
|
|
close(ch)
|
|
var msgs []Message
|
|
for msg := range ch {
|
|
msgs = append(msgs, msg)
|
|
}
|
|
|
|
var toolUses, toolResults int
|
|
var failedToolResult *Message
|
|
for i := range msgs {
|
|
switch msgs[i].Type {
|
|
case MessageToolUse:
|
|
toolUses++
|
|
case MessageToolResult:
|
|
toolResults++
|
|
if msgs[i].CallID == "functions.read:1" {
|
|
failedToolResult = &msgs[i]
|
|
}
|
|
}
|
|
}
|
|
if toolUses != 2 || toolResults != 2 {
|
|
t.Fatalf("tool messages are not paired: tool_use=%d tool_result=%d (%+v)", toolUses, toolResults, msgs)
|
|
}
|
|
if failedToolResult == nil {
|
|
t.Fatal("missing tool_result for failed read tool")
|
|
}
|
|
if failedToolResult.Output != "File not found: /nonexistent-path-xyz/also-missing.md" {
|
|
t.Errorf("failed tool output: got %q", failedToolResult.Output)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeProcessEventsEmptyFinalStepFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Regression for #6522. The run did real work, then the provider stream
|
|
// died: the final step opened, emitted a reasoning part (which carries no
|
|
// deliverable and no tokens), and closed with reason "unknown" and an
|
|
// all-zero token block. `opencode run` exited 0, so the daemon reported
|
|
// `completed` with an empty agent_error and the task stalled with no
|
|
// deliverable. Zero input tokens means the provider round-trip never
|
|
// happened — that is a dead stream, not a completion.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_void","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1001,"sessionID":"ses_void","part":{"type":"text","text":"All context gathered. Let me set up and begin."}}`,
|
|
`{"type":"tool_use","timestamp":1002,"sessionID":"ses_void","part":{"type":"tool","tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"ls"},"output":"ok\n"}}}`,
|
|
`{"type":"step_finish","timestamp":1003,"sessionID":"ses_void","part":{"type":"step-finish","reason":"tool-calls","tokens":{"input":14585,"output":89,"cache":{"write":0,"read":0}}}}`,
|
|
`{"type":"step_start","timestamp":1004,"sessionID":"ses_void","part":{"type":"step-start"}}`,
|
|
`{"type":"reasoning","timestamp":1005,"sessionID":"ses_void","part":{"type":"reasoning","text":"thinking..."}}`,
|
|
`{"type":"step_finish","timestamp":1006,"sessionID":"ses_void","part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0,"cache":{"write":0,"read":0}},"cost":0}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "failed" {
|
|
t.Errorf("status: got %q, want %q (an empty final step must not complete the run)", result.status, "failed")
|
|
}
|
|
if !strings.Contains(result.errMsg, "empty step") {
|
|
t.Errorf("errMsg: got %q, want it to name the empty step", result.errMsg)
|
|
}
|
|
// The error text is the input to taskfailure.Classify, which routes this
|
|
// failure to the retryable provider_network bucket — see the matching
|
|
// cases in pkg/taskfailure/classify_test.go. Keep the two in sync.
|
|
if !strings.HasPrefix(result.errMsg, "opencode stream ended") {
|
|
t.Errorf("errMsg: got %q, want the classifier's opencode stream-ended prefix", result.errMsg)
|
|
}
|
|
if !result.noTerminalSignal {
|
|
t.Error("noTerminalSignal: got false, want true")
|
|
}
|
|
// Work done before the stream died must still be reported, so the failure
|
|
// stays diagnosable and the usage already paid for is not lost.
|
|
if result.output != "All context gathered. Let me set up and begin." {
|
|
t.Errorf("output: got %q, want the text emitted before the empty step", result.output)
|
|
}
|
|
if result.usage.InputTokens != 14585 || result.usage.OutputTokens != 89 {
|
|
t.Errorf("usage: got %+v, want the first step's tokens preserved", result.usage)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsEmptyStepMidRunRecovers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// Only the step the run *ends* on matters. A void step that the run
|
|
// recovers from — the next step produces real output and closes cleanly —
|
|
// is not evidence of a dead stream and must stay "completed".
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_recover","part":{"type":"step-start"}}`,
|
|
`{"type":"step_finish","timestamp":1001,"sessionID":"ses_recover","part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"cache":{"write":0,"read":0}}}}`,
|
|
`{"type":"step_start","timestamp":1002,"sessionID":"ses_recover","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1003,"sessionID":"ses_recover","part":{"type":"text","text":"recovered and done"}}`,
|
|
`{"type":"step_finish","timestamp":1004,"sessionID":"ses_recover","part":{"type":"step-finish","reason":"stop","tokens":{"input":120,"output":8,"cache":{"write":0,"read":0}}}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q (a recovered void step must not fail the run)", result.status, "completed")
|
|
}
|
|
if result.errMsg != "" {
|
|
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsToolOnlyFinalStepStaysCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
// The guard must key on "this step produced nothing", not "the run produced
|
|
// no text". A task whose only deliverable is a tool side effect — here a
|
|
// provider-executed tool, so no continuation step is owed — legitimately
|
|
// ends without any assistant prose and must stay green.
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_toolonly","part":{"type":"step-start"}}`,
|
|
`{"type":"tool_use","timestamp":1001,"sessionID":"ses_toolonly","part":{"type":"tool","tool":"web_search","callID":"call_1","metadata":{"providerExecuted":true},"state":{"status":"completed","input":{"query":"weather"},"output":"sunny"}}}`,
|
|
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_toolonly","part":{"type":"step-finish","reason":"stop","tokens":{"input":0,"output":0,"cache":{"write":0,"read":0}}}}`,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q (a tool call is a productive step)", result.status, "completed")
|
|
}
|
|
if result.errMsg != "" {
|
|
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
|
}
|
|
|
|
close(ch)
|
|
}
|
|
|
|
func TestOpencodeProcessEventsUsageOnlyFinalStepStaysCompleted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A step that emitted neither text nor a tool call but reports usage did
|
|
// reach the provider, so it is not the zero-round-trip shape the guard
|
|
// targets and must stay green. OpenCode reports reasoning and the
|
|
// aggregate total in their own fields and cost as a sibling of the token
|
|
// block, so each of them alone has to be enough — a guard that only looked
|
|
// at input/output would fail these healthy runs.
|
|
usageCases := []struct {
|
|
name string
|
|
final string
|
|
}{
|
|
{
|
|
name: "input only",
|
|
final: `{"type":"step_finish","timestamp":1005,"sessionID":"ses_usage","part":{"type":"step-finish","reason":"stop","tokens":{"input":950,"output":0,"cache":{"write":0,"read":0}}}}`,
|
|
},
|
|
{
|
|
name: "reasoning only",
|
|
final: `{"type":"step_finish","timestamp":1005,"sessionID":"ses_usage","part":{"type":"step-finish","reason":"stop","tokens":{"input":0,"output":0,"reasoning":82,"cache":{"write":0,"read":0}}}}`,
|
|
},
|
|
{
|
|
name: "aggregate total only",
|
|
final: `{"type":"step_finish","timestamp":1005,"sessionID":"ses_usage","part":{"type":"step-finish","reason":"stop","tokens":{"total":14674,"input":0,"output":0,"cache":{"write":0,"read":0}}}}`,
|
|
},
|
|
{
|
|
name: "cost only",
|
|
final: `{"type":"step_finish","timestamp":1005,"sessionID":"ses_usage","part":{"type":"step-finish","reason":"stop","tokens":{"input":0,"output":0,"cache":{"write":0,"read":0}},"cost":0.0021}}`,
|
|
},
|
|
{
|
|
name: "cache read only",
|
|
final: `{"type":"step_finish","timestamp":1005,"sessionID":"ses_usage","part":{"type":"step-finish","reason":"stop","tokens":{"input":0,"output":0,"cache":{"write":0,"read":512}}}}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range usageCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
|
|
ch := make(chan Message, 256)
|
|
|
|
lines := strings.Join([]string{
|
|
`{"type":"step_start","timestamp":1000,"sessionID":"ses_usage","part":{"type":"step-start"}}`,
|
|
`{"type":"text","timestamp":1001,"sessionID":"ses_usage","part":{"type":"text","text":"answer"}}`,
|
|
`{"type":"step_finish","timestamp":1002,"sessionID":"ses_usage","part":{"type":"step-finish","reason":"stop","tokens":{"input":900,"output":40,"cache":{"write":0,"read":0}}}}`,
|
|
`{"type":"step_start","timestamp":1003,"sessionID":"ses_usage","part":{"type":"step-start"}}`,
|
|
`{"type":"reasoning","timestamp":1004,"sessionID":"ses_usage","part":{"type":"reasoning","text":"thinking..."}}`,
|
|
tc.final,
|
|
}, "\n")
|
|
|
|
result := b.processEvents(strings.NewReader(lines), ch)
|
|
|
|
if result.status != "completed" {
|
|
t.Errorf("status: got %q, want %q (reported usage proves the provider round-trip happened)", result.status, "completed")
|
|
}
|
|
if result.errMsg != "" {
|
|
t.Errorf("errMsg: got %q, want empty", result.errMsg)
|
|
}
|
|
|
|
close(ch)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Windows native-binary resolution tests ──
|
|
|
|
// fakeStat returns a statFn that reports any path in `present` as existing
|
|
// and every other path as not-found. The returned os.FileInfo is a stub
|
|
// because resolveOpenCodeNativeFromShim only inspects the error.
|
|
func fakeStat(present ...string) func(string) (os.FileInfo, error) {
|
|
set := make(map[string]struct{}, len(present))
|
|
for _, p := range present {
|
|
set[p] = struct{}{}
|
|
}
|
|
return func(path string) (os.FileInfo, error) {
|
|
if _, ok := set[path]; ok {
|
|
return nil, nil
|
|
}
|
|
return nil, errors.New("not found")
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenCodeNativeFromShimResolvesNpmShim(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Reporter's exact layout from multica#1717.
|
|
shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd")
|
|
native := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin", "opencode.exe")
|
|
|
|
got := resolveOpenCodeNativeFromShim(shim, fakeStat(native))
|
|
if got != native {
|
|
t.Errorf("got %q, want %q", got, native)
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenCodeNativeFromShimReturnsEmptyWhenNativeMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Shim ends in .cmd but the bundled native binary isn't present (e.g.
|
|
// platform package didn't install or layout changed). Caller must keep
|
|
// the original shim path so PATH lookup still wins.
|
|
shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd")
|
|
|
|
got := resolveOpenCodeNativeFromShim(shim, fakeStat())
|
|
if got != "" {
|
|
t.Errorf("got %q, want empty (missing native binary)", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenCodeNativeFromShimSkipsNonCmdPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// On macOS/Linux the path returned by exec.LookPath is the native
|
|
// binary itself, with no .cmd extension. Helper should signal "no
|
|
// rewrite needed" by returning empty.
|
|
cases := []string{
|
|
"/usr/local/bin/opencode",
|
|
"C:\\nvm4w\\nodejs\\opencode.exe",
|
|
"",
|
|
}
|
|
for _, p := range cases {
|
|
if got := resolveOpenCodeNativeFromShim(p, fakeStat("anything")); got != "" {
|
|
t.Errorf("path %q: got %q, want empty", p, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenCodeNativeFromShimAcceptsUppercaseExtension(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Windows is case-insensitive on filesystem extensions. PATHEXT tokens
|
|
// are commonly uppercase, and exec.LookPath can return either case.
|
|
shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.CMD")
|
|
native := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin", "opencode.exe")
|
|
|
|
got := resolveOpenCodeNativeFromShim(shim, fakeStat(native))
|
|
if got != native {
|
|
t.Errorf("got %q, want %q", got, native)
|
|
}
|
|
}
|
|
|
|
func TestResolveOpenCodeNativeFromShimFallsBackToBaseline(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Older CPUs without AVX2 get `opencode-windows-x64-baseline` instead of
|
|
// the default x64 build. Resolver should fall through and find it when
|
|
// the primary x64 package isn't installed.
|
|
shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd")
|
|
baseline := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe")
|
|
|
|
got := resolveOpenCodeNativeFromShim(shim, fakeStat(baseline))
|
|
if got != baseline {
|
|
t.Errorf("got %q, want %q", got, baseline)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeWindowsPackageCandidatesArm64(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// ARM64 hosts (Surface, Copilot+ PC) should try arm64 first so the
|
|
// resolver doesn't accidentally pick up a leftover x64 install when
|
|
// the matching arm64 package is present.
|
|
got := opencodeWindowsPackageCandidates("arm64")
|
|
want := []string{"opencode-windows-arm64", "opencode-windows-x64", "opencode-windows-x64-baseline"}
|
|
if !equalStringSlice(got, want) {
|
|
t.Errorf("got %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeWindowsPackageCandidatesAmd64(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// amd64 (and any non-arm64) hosts try x64 → baseline → arm64. The arm64
|
|
// fallback at the end covers the unusual case where only the arm64
|
|
// package is installed; resolution still succeeds.
|
|
got := opencodeWindowsPackageCandidates("amd64")
|
|
want := []string{"opencode-windows-x64", "opencode-windows-x64-baseline", "opencode-windows-arm64"}
|
|
if !equalStringSlice(got, want) {
|
|
t.Errorf("got %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// fakeOpencodeScript returns a POSIX-sh script that impersonates `opencode`
|
|
// for argv / env capture. It writes the argv (one per line) to
|
|
// $OPENCODE_ARGS_FILE, the resolved PWD to $OPENCODE_PWD_FILE, and the
|
|
// permission config to $OPENCODE_PERMISSION_FILE. It emits a minimal completed
|
|
// step on stdout so the daemon's event loop terminates, then exits.
|
|
func fakeOpencodeScript() string {
|
|
return `#!/bin/sh
|
|
if [ -n "$OPENCODE_ARGS_FILE" ]; then
|
|
for arg in "$@"; do
|
|
printf '%s\n' "$arg" >> "$OPENCODE_ARGS_FILE"
|
|
done
|
|
fi
|
|
|
|
# Real OpenCode reads stdin to EOF (await Bun.stdin.text()) before it does any
|
|
# work, so the fake must drain it too. A fake that exits without reading closes
|
|
# the read end under the daemon's concurrent prompt write, which surfaces as a
|
|
# spurious EPIPE whose timing depends on machine load.
|
|
if [ -n "$OPENCODE_STDIN_FILE" ]; then
|
|
cat > "$OPENCODE_STDIN_FILE"
|
|
else
|
|
cat > /dev/null
|
|
fi
|
|
if [ -n "$OPENCODE_PWD_FILE" ]; then
|
|
printf '%s\n' "$PWD" > "$OPENCODE_PWD_FILE"
|
|
fi
|
|
if [ -n "$OPENCODE_PERMISSION_FILE" ]; then
|
|
printf '%s\n' "$OPENCODE_PERMISSION" > "$OPENCODE_PERMISSION_FILE"
|
|
fi
|
|
if [ -f "$PWD/opencode.json" ] && grep -Eq '"question"[[:space:]]*:[[:space:]]*"allow"' "$PWD/opencode.json"; then
|
|
if [ "$OPENCODE_PERMISSION" = '{"*":"allow","question":"deny"}' ]; then
|
|
printf '{"type":"error","timestamp":1,"sessionID":"ses_fake","error":{"name":"PermissionBypass","data":{"message":"question permission bypassed by env wildcard order"}}}\n'
|
|
exit 0
|
|
fi
|
|
fi
|
|
printf '{"type":"step_start","timestamp":1,"sessionID":"ses_fake","part":{"type":"step-start"}}\n'
|
|
printf '{"type":"text","timestamp":2,"sessionID":"ses_fake","part":{"type":"text","text":"ok"}}\n'
|
|
printf '{"type":"step_finish","timestamp":3,"sessionID":"ses_fake","part":{"type":"step-finish"}}\n'
|
|
`
|
|
}
|
|
|
|
func containsString(items []string, want string) bool {
|
|
for _, item := range items {
|
|
if item == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestOpencodeBackendAnchorsDirAndPWD pins the discovery-root fix from
|
|
// MUL-2416: OpenCode resolves its AGENTS.md walk-up and .opencode/skills
|
|
// project config scan from `--dir` and PWD. cmd.Dir alone is not enough
|
|
// because OpenCode reads PWD (inherited from the daemon) before falling
|
|
// back to process.cwd(). Without this anchor, skills written into the
|
|
// task workdir are silently invisible and the agent runs against the
|
|
// daemon's shell working directory.
|
|
func TestOpencodeBackendAnchorsDirAndPWD(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,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{
|
|
"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,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
<-session.Result
|
|
|
|
// argv should include `--dir <workDir>` immediately after the `run` /
|
|
// `--format json` prefix and nowhere else.
|
|
raw, err := os.ReadFile(argsFile)
|
|
if err != nil {
|
|
t.Fatalf("read args file: %v", err)
|
|
}
|
|
args := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
|
if len(args) < 2 || args[0] != "run" {
|
|
t.Fatalf("expected first arg to be 'run', got %q", args)
|
|
}
|
|
dirIdx := -1
|
|
for i, a := range args {
|
|
if a == "--dir" {
|
|
dirIdx = i
|
|
break
|
|
}
|
|
}
|
|
if dirIdx == -1 {
|
|
t.Fatalf("expected --dir flag in argv, got %q", args)
|
|
}
|
|
if dirIdx+1 >= len(args) || args[dirIdx+1] != workDir {
|
|
t.Fatalf("expected --dir %q, got args=%q", workDir, args)
|
|
}
|
|
|
|
// PWD inside the child process must resolve to the task workdir,
|
|
// otherwise OpenCode's project discovery walk starts in the wrong
|
|
// directory and silently misses .opencode/skills + AGENTS.md.
|
|
gotPWD, err := os.ReadFile(pwdFile)
|
|
if err != nil {
|
|
t.Fatalf("read PWD file: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(string(gotPWD)); got != workDir {
|
|
t.Errorf("child PWD = %q, want %q", got, workDir)
|
|
}
|
|
}
|
|
|
|
// TestOpencodeBackendNeverEmitsPromptFlag pins MUL-5392: `opencode run` has no
|
|
// --prompt flag, so emitting one makes the real CLI exit 1 with a usage dump
|
|
// before a single token is sent. The backend carried that dead branch from the
|
|
// day OpenCode was added (#341); it never fired because the daemon leaves
|
|
// SystemPrompt empty for opencode, which is the correct delivery path — the
|
|
// runtime brief reaches the agent through the workdir AGENTS.md instead. This
|
|
// test exists so re-adding the flag fails here rather than in production.
|
|
func TestOpencodeBackendNeverEmitsPromptFlag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
argsFile := filepath.Join(tempDir, "argv.txt")
|
|
stdinFile := filepath.Join(tempDir, "stdin.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
|
|
|
|
workDir := t.TempDir()
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{
|
|
"OPENCODE_ARGS_FILE": argsFile,
|
|
"OPENCODE_STDIN_FILE": stdinFile,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new opencode backend: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// SystemPrompt is set deliberately; the backend must NOT forward it.
|
|
const brief = "the entire multica runtime brief"
|
|
session, err := backend.Execute(ctx, "do the thing", ExecOptions{
|
|
Cwd: workDir,
|
|
SystemPrompt: brief,
|
|
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, "--prompt") {
|
|
t.Errorf("argv must NOT contain --prompt (OpenCode's run has no such flag): %v", args)
|
|
}
|
|
if containsString(args, brief) {
|
|
t.Errorf("SystemPrompt leaked into argv: %v", args)
|
|
}
|
|
// The user prompt travels on stdin, not argv (#6538).
|
|
if containsString(args, "do the thing") {
|
|
t.Errorf("user prompt leaked into argv: %v", args)
|
|
}
|
|
stdinRaw, err := os.ReadFile(stdinFile)
|
|
if err != nil {
|
|
t.Fatalf("read stdin file: %v", err)
|
|
}
|
|
if string(stdinRaw) != "do the thing" {
|
|
t.Errorf("prompt did not arrive on stdin: got %q", string(stdinRaw))
|
|
}
|
|
}
|
|
|
|
func TestOpencodeBackendInjectsThinkingVariant(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
argsFile := filepath.Join(tempDir, "argv.txt")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{
|
|
"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{
|
|
Model: "opencode/deepseek-v4",
|
|
ThinkingLevel: "max",
|
|
CustomArgs: []string{"--variant", "low", "--keep-me"},
|
|
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 := splitNonEmptyLines(string(raw))
|
|
if !containsAdjacent(args, "--model", "opencode/deepseek-v4") {
|
|
t.Fatalf("expected --model opencode/deepseek-v4 in args: %v", args)
|
|
}
|
|
if !containsAdjacent(args, "--variant", "max") {
|
|
t.Fatalf("expected daemon-injected --variant max in args: %v", args)
|
|
}
|
|
if argIndexOf(args, "--model") > argIndexOf(args, "--variant") {
|
|
t.Errorf("expected --variant after --model for readability: %v", args)
|
|
}
|
|
count := 0
|
|
for _, arg := range args {
|
|
if arg == "--variant" {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected exactly one --variant, got %d: %v", count, args)
|
|
}
|
|
if argIndexOf(args, "low") >= 0 {
|
|
t.Errorf("filtered user --variant value still appears: %v", args)
|
|
}
|
|
if argIndexOf(args, "--keep-me") < 0 {
|
|
t.Errorf("non-blocked custom arg was dropped: %v", args)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeBackendDoesNotUsePermissionEnvOverride(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
permissionFile := filepath.Join(tempDir, "permission.json")
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{
|
|
"OPENCODE_PERMISSION_FILE": permissionFile,
|
|
},
|
|
})
|
|
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{
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
<-session.Result
|
|
|
|
raw, err := os.ReadFile(permissionFile)
|
|
if err != nil {
|
|
t.Fatalf("read permission file: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(string(raw)); got != "" {
|
|
t.Fatalf("OPENCODE_PERMISSION = %q, want empty env override", got)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeBackendQuestionDenySurvivesUserConfig(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()
|
|
if err := os.WriteFile(
|
|
filepath.Join(workDir, "opencode.json"),
|
|
[]byte(`{"permission":{"question":"allow"}}`),
|
|
0o644,
|
|
); err != nil {
|
|
t.Fatalf("write opencode config: %v", err)
|
|
}
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{
|
|
"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,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
if result.Status != "completed" {
|
|
t.Fatalf("result status = %q, error = %q; want completed", result.Status, result.Error)
|
|
}
|
|
|
|
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, "--dangerously-skip-permissions") {
|
|
t.Fatalf("expected daemon-mode argv to include --dangerously-skip-permissions, got %q", args)
|
|
}
|
|
}
|
|
|
|
// TestOpencodeBackendBlocksDirOverride ensures user-supplied custom args
|
|
// cannot replace the daemon-managed `--dir` anchor. Letting custom args
|
|
// override it would re-introduce the MUL-2416 regression.
|
|
func TestOpencodeBackendBlocksDirOverride(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()
|
|
bogusDir := t.TempDir()
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
Logger: slog.Default(),
|
|
Env: map[string]string{
|
|
"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,
|
|
Timeout: 5 * time.Second,
|
|
CustomArgs: []string{"--dir", bogusDir},
|
|
})
|
|
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")
|
|
for i, a := range args {
|
|
if a == "--dir" {
|
|
if i+1 >= len(args) || args[i+1] != workDir {
|
|
t.Errorf("--dir was overridden by custom args: got %q", args)
|
|
}
|
|
}
|
|
if a == bogusDir {
|
|
t.Errorf("custom --dir value %q leaked into argv: %q", bogusDir, args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fakeOpencodeMidToolScript impersonates an `opencode run` whose provider
|
|
// stream dies mid-step: it prints a step_start and a terminal-error tool_use,
|
|
// then exits 0 on a clean EOF with no step_finish and no error event. This is
|
|
// the false-green shape from production — the recovered tool error does not
|
|
// rescue the run; the unclosed step is what fails it.
|
|
func fakeOpencodeMidToolScript() string {
|
|
return `#!/bin/sh
|
|
cat > /dev/null
|
|
printf '{"type":"step_start","timestamp":1,"sessionID":"ses_fake","part":{"type":"step-start"}}\n'
|
|
printf '{"type":"tool_use","timestamp":2,"sessionID":"ses_fake","part":{"type":"tool","tool":"read","callID":"functions.read:1","state":{"status":"error","input":{"filePath":"/nope.md"},"error":"File not found"}}}\n'
|
|
exit 0
|
|
`
|
|
}
|
|
|
|
// TestOpencodeBackendFailsOnStreamEndingMidTool proves the terminal-signal
|
|
// guard over the full clean-EOF/exit-0 path, not just the scanner loop: a run
|
|
// whose stream ends with an open step must surface as Result.Status == "failed"
|
|
// even though opencode exited 0 with no error event and its last tool merely
|
|
// reported a recovered error.
|
|
func TestOpencodeBackendFailsOnStreamEndingMidTool(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte(fakeOpencodeMidToolScript()))
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
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{
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
|
|
if result.Status != "failed" {
|
|
t.Fatalf("result status = %q, error = %q; want failed", result.Status, result.Error)
|
|
}
|
|
if !strings.Contains(result.Error, "terminal signal") {
|
|
t.Errorf("result error = %q, want it to mention the missing terminal signal", result.Error)
|
|
}
|
|
}
|
|
|
|
// fakeOpencodeStepThenExit1Script impersonates an `opencode run` that crashes
|
|
// mid-step: it opens a step, then the process itself dies nonzero with no
|
|
// step_finish and no error event.
|
|
func fakeOpencodeStepThenExit1Script() string {
|
|
return `#!/bin/sh
|
|
cat > /dev/null
|
|
printf '{"type":"step_start","timestamp":1,"sessionID":"ses_fake","part":{"type":"step-start"}}\n'
|
|
exit 1
|
|
`
|
|
}
|
|
|
|
// TestOpencodeBackendAppendsExitDetailOnMidStepCrash pins the second fix: when
|
|
// the terminal-signal guard has already failed the run AND the process exited
|
|
// nonzero, the exit detail is appended so the crash signal / exit code is not
|
|
// lost — the errMsg carries both the missing-terminal-signal reason and the
|
|
// process exit status.
|
|
func TestOpencodeBackendAppendsExitDetailOnMidStepCrash(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tempDir := t.TempDir()
|
|
fakePath := filepath.Join(tempDir, "opencode")
|
|
writeTestExecutable(t, fakePath, []byte(fakeOpencodeStepThenExit1Script()))
|
|
|
|
backend, err := New("opencode", Config{
|
|
ExecutablePath: fakePath,
|
|
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{
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
go func() {
|
|
for range session.Messages {
|
|
}
|
|
}()
|
|
result := <-session.Result
|
|
|
|
if result.Status != "failed" {
|
|
t.Fatalf("result status = %q, error = %q; want failed", result.Status, result.Error)
|
|
}
|
|
if !strings.Contains(result.Error, "terminal signal") {
|
|
t.Errorf("result error = %q, want it to mention the missing terminal signal", result.Error)
|
|
}
|
|
if !strings.Contains(result.Error, "exit status 1") {
|
|
t.Errorf("result error = %q, want it to include the process exit status", result.Error)
|
|
}
|
|
}
|
|
|
|
func equalStringSlice(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|