- runtimeSupervisor 단일 goroutine으로 연결·재연결 수명주기 관리 - 초기 연결 실패 시 fx OnStart 후크 중단 방지 (SDD S16) - finite 재연결 시도 고갈 시 노드 종료 - CLI adaptors: oneshot, opencode_sse, persistent 보강 및 테스트 확장
956 lines
26 KiB
Go
956 lines
26 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
clipkg "iop/apps/node/internal/adapters/cli"
|
|
"iop/apps/node/internal/adapters/cli/internal/testutil"
|
|
noderuntime "iop/apps/node/internal/runtime"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
// opencodeFakeServer is a minimal stand-in for the OpenCode HTTP/SSE server.
|
|
// Tests push SSE events through ch; the SSE handler forwards them as
|
|
// `data: <json>\n\n` lines until the channel closes.
|
|
type opencodeFakeServer struct {
|
|
mu sync.Mutex
|
|
events chan map[string]any
|
|
abortCalls atomic.Int32
|
|
promptCalls atomic.Int32
|
|
createCalls atomic.Int32
|
|
lastPromptBody []byte
|
|
createdID string
|
|
|
|
// promptReady is signalled once the prompt_async handler has fully read the
|
|
// request body. Tests wait on this before issuing ordering decisions.
|
|
promptReady chan struct{}
|
|
// promptBlock, when non-nil, is drained once to unblock the prompt_async
|
|
// response write. nil means the handler replies immediately.
|
|
promptBlock *promptGate
|
|
}
|
|
|
|
// promptGate is a one-shot response gate for the prompt_async handler.
|
|
type promptGate struct {
|
|
mu sync.Mutex
|
|
closed bool
|
|
release chan struct{} // closed to unblock the handler
|
|
blocked chan struct{} // closed when the handler enters the blocked wait
|
|
}
|
|
|
|
func (g *promptGate) signalBlocked() {
|
|
select {
|
|
case <-g.blocked:
|
|
default:
|
|
close(g.blocked)
|
|
}
|
|
}
|
|
|
|
func newOpencodeFakeServer(t *testing.T, sessionID string) (*opencodeFakeServer, *httptest.Server) {
|
|
t.Helper()
|
|
s := &opencodeFakeServer{
|
|
events: make(chan map[string]any, 32),
|
|
createdID: sessionID,
|
|
promptReady: make(chan struct{}, 1),
|
|
}
|
|
mux := http.NewServeMux()
|
|
eventHandler := func(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
events := s.events
|
|
s.mu.Unlock()
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.WriteHeader(http.StatusOK)
|
|
flusher, _ := w.(http.Flusher)
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case ev, ok := <-events:
|
|
if !ok {
|
|
return
|
|
}
|
|
b, err := json.Marshal(ev)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
fmt.Fprintf(w, "data: %s\n\n", string(b))
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
mux.HandleFunc("/event", eventHandler)
|
|
mux.HandleFunc("/global/event", eventHandler)
|
|
mux.HandleFunc("/session", func(w http.ResponseWriter, r *http.Request) {
|
|
s.createCalls.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": s.createdID})
|
|
})
|
|
mux.HandleFunc("/session/", func(w http.ResponseWriter, r *http.Request) {
|
|
// Routes: /session/{id}/prompt_async or /session/{id}/abort
|
|
path := strings.TrimPrefix(r.URL.Path, "/session/")
|
|
parts := strings.SplitN(path, "/", 2)
|
|
if len(parts) != 2 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
switch parts[1] {
|
|
case "prompt_async":
|
|
s.promptCalls.Add(1)
|
|
buf := make([]byte, 4096)
|
|
n, _ := r.Body.Read(buf)
|
|
s.mu.Lock()
|
|
s.lastPromptBody = append([]byte(nil), buf[:n]...)
|
|
s.mu.Unlock()
|
|
// Signal that the prompt body has been received so callers can
|
|
// observe the acceptance event before the response is written.
|
|
select {
|
|
case s.promptReady <- struct{}{}:
|
|
default:
|
|
}
|
|
// If a response gate is armed, wait for release or request cancellation.
|
|
gate := s.currentPromptGate()
|
|
if gate != nil {
|
|
gate.signalBlocked()
|
|
select {
|
|
case <-gate.release:
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusAccepted)
|
|
case "abort":
|
|
s.abortCalls.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
mux.HandleFunc("/permission/", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(func() {
|
|
s.mu.Lock()
|
|
if s.events != nil {
|
|
close(s.events)
|
|
s.events = nil
|
|
}
|
|
s.mu.Unlock()
|
|
srv.Close()
|
|
})
|
|
return s, srv
|
|
}
|
|
|
|
func (s *opencodeFakeServer) push(ev map[string]any) {
|
|
s.mu.Lock()
|
|
ch := s.events
|
|
s.mu.Unlock()
|
|
if ch != nil {
|
|
ch <- ev
|
|
}
|
|
}
|
|
|
|
// BlockPromptResponse arms the prompt response gate. The prompt_async handler
|
|
// will not reply with 202 until UnblockPromptResponse is called or the server
|
|
// closes. Safe to call multiple times; only the most recent gate is in effect.
|
|
// Returns the gate reference for handler-block ordering checks.
|
|
func (s *opencodeFakeServer) BlockPromptResponse() *promptGate {
|
|
s.mu.Lock()
|
|
g := &promptGate{release: make(chan struct{}), blocked: make(chan struct{})}
|
|
s.promptBlock = g
|
|
s.mu.Unlock()
|
|
return g
|
|
}
|
|
|
|
// UnblockPromptResponse releases the most recent prompt response gate. If no
|
|
// gate is armed, this is a no-op. Safe to call multiple times; only the
|
|
// first call closes the release channel.
|
|
func (s *opencodeFakeServer) UnblockPromptResponse() {
|
|
s.mu.Lock()
|
|
block := s.promptBlock
|
|
s.promptBlock = nil
|
|
s.mu.Unlock()
|
|
if block == nil {
|
|
return
|
|
}
|
|
block.mu.Lock()
|
|
if block.closed {
|
|
block.mu.Unlock()
|
|
return
|
|
}
|
|
block.closed = true
|
|
block.mu.Unlock()
|
|
close(block.release)
|
|
}
|
|
|
|
// currentPromptGate returns the currently armed gate, or nil.
|
|
func (s *opencodeFakeServer) currentPromptGate() *promptGate {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.promptBlock
|
|
}
|
|
|
|
// waitPromptBodyRead waits until the handler has fully read the prompt body,
|
|
// or times out.
|
|
func waitPromptBodyRead(t *testing.T, fake *opencodeFakeServer) {
|
|
t.Helper()
|
|
select {
|
|
case <-fake.promptReady:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("prompt body was not read by handler")
|
|
}
|
|
}
|
|
|
|
// waitPromptResponseBlocked waits until the handler has entered the blocked
|
|
// wait on the response gate, or times out.
|
|
func waitPromptResponseBlocked(t *testing.T, g *promptGate) {
|
|
t.Helper()
|
|
select {
|
|
case <-g.blocked:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("handler did not enter blocked wait on response gate")
|
|
}
|
|
}
|
|
|
|
func opencodeGlobalEvent(eventType string, props map[string]any) map[string]any {
|
|
return map[string]any{
|
|
"directory": "/tmp/iop-test",
|
|
"payload": map[string]any{
|
|
"id": "evt_test",
|
|
"type": eventType,
|
|
"properties": props,
|
|
},
|
|
}
|
|
}
|
|
|
|
func opencodeSSEProfile(attachURL string, opts ...string) config.CLIProfileConf {
|
|
args := []string{"--attach", attachURL, "--title", "test"}
|
|
args = append(args, opts...)
|
|
return config.CLIProfileConf{
|
|
Command: "/bin/true",
|
|
Args: args,
|
|
Mode: "opencode-sse",
|
|
}
|
|
}
|
|
|
|
func TestCLIExecuteOpencodeSSE_StreamsTextDeltas(t *testing.T) {
|
|
fake, srv := newOpencodeFakeServer(t, "ses_1")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL, "--model", "prov/m1", "--dangerously-skip-permissions"),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
sink := &testutil.FakeSink{}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
|
RunID: "run-1",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "hello"},
|
|
}, sink)
|
|
}()
|
|
|
|
// Wait until prompt_async has been received, then push events.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for fake.promptCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if fake.promptCalls.Load() == 0 {
|
|
t.Fatal("prompt_async was not called")
|
|
}
|
|
|
|
fake.push(map[string]any{
|
|
"type": "session.next.text.delta",
|
|
"properties": map[string]any{
|
|
"sessionID": "ses_1",
|
|
"delta": "Hello ",
|
|
},
|
|
})
|
|
fake.push(map[string]any{
|
|
"type": "session.next.text.delta",
|
|
"properties": map[string]any{
|
|
"sessionID": "ses_1",
|
|
"delta": "world",
|
|
},
|
|
})
|
|
fake.push(map[string]any{
|
|
"type": "session.next.step.ended",
|
|
"properties": map[string]any{
|
|
"sessionID": "ses_1",
|
|
"tokens": map[string]any{"input": 5.0, "output": 2.0},
|
|
},
|
|
})
|
|
fake.push(map[string]any{
|
|
"type": "session.idle",
|
|
"properties": map[string]any{
|
|
"sessionID": "ses_1",
|
|
},
|
|
})
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("execute did not return")
|
|
}
|
|
|
|
events := sink.Events()
|
|
if combined := testutil.CollectDeltas(events); combined != "Hello world" {
|
|
t.Fatalf("deltas: got %q, want %q", combined, "Hello world")
|
|
}
|
|
var complete *noderuntime.RuntimeEvent
|
|
for i := range events {
|
|
if events[i].Type == noderuntime.EventTypeComplete {
|
|
complete = &events[i]
|
|
}
|
|
}
|
|
if complete == nil {
|
|
t.Fatal("expected complete event")
|
|
}
|
|
if complete.Usage == nil || complete.Usage.InputTokens != 5 || complete.Usage.OutputTokens != 2 {
|
|
t.Fatalf("expected usage 5/2, got %+v", complete.Usage)
|
|
}
|
|
if fake.createCalls.Load() != 1 {
|
|
t.Errorf("expected 1 create session call, got %d", fake.createCalls.Load())
|
|
}
|
|
|
|
fake.mu.Lock()
|
|
body := append([]byte(nil), fake.lastPromptBody...)
|
|
fake.mu.Unlock()
|
|
var decoded struct {
|
|
Parts []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"parts"`
|
|
Model struct {
|
|
ProviderID string `json:"providerID"`
|
|
ModelID string `json:"modelID"`
|
|
} `json:"model"`
|
|
}
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
t.Fatalf("decode prompt body: %v (body=%q)", err, string(body))
|
|
}
|
|
if len(decoded.Parts) != 1 || decoded.Parts[0].Type != "text" || decoded.Parts[0].Text != "hello" {
|
|
t.Errorf("parts: got %+v", decoded.Parts)
|
|
}
|
|
if decoded.Model.ProviderID != "prov" || decoded.Model.ModelID != "m1" {
|
|
t.Errorf("model: got %+v", decoded.Model)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecuteOpencodeSSE_GlobalMessagePartDeltaStreamsTextOnly(t *testing.T) {
|
|
fake, srv := newOpencodeFakeServer(t, "ses_global")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
sink := &testutil.FakeSink{}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
|
RunID: "run-global",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "hello"},
|
|
}, sink)
|
|
}()
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for fake.promptCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if fake.promptCalls.Load() == 0 {
|
|
t.Fatal("prompt_async was not called")
|
|
}
|
|
|
|
fake.push(opencodeGlobalEvent("message.part.updated", map[string]any{
|
|
"sessionID": "ses_global",
|
|
"part": map[string]any{"id": "prt_reason", "type": "reasoning", "text": ""},
|
|
}))
|
|
fake.push(opencodeGlobalEvent("message.part.delta", map[string]any{
|
|
"sessionID": "ses_global",
|
|
"partID": "prt_reason",
|
|
"field": "text",
|
|
"delta": "thinking",
|
|
}))
|
|
fake.push(opencodeGlobalEvent("message.part.updated", map[string]any{
|
|
"sessionID": "ses_global",
|
|
"part": map[string]any{"id": "prt_text", "type": "text", "text": ""},
|
|
}))
|
|
fake.push(opencodeGlobalEvent("message.part.delta", map[string]any{
|
|
"sessionID": "ses_global",
|
|
"partID": "prt_text",
|
|
"field": "text",
|
|
"delta": "OK",
|
|
}))
|
|
fake.push(opencodeGlobalEvent("message.part.updated", map[string]any{
|
|
"sessionID": "ses_global",
|
|
"part": map[string]any{
|
|
"id": "prt_finish",
|
|
"type": "step-finish",
|
|
"tokens": map[string]any{"input": 7.0, "output": 1.0},
|
|
},
|
|
}))
|
|
fake.push(opencodeGlobalEvent("session.status", map[string]any{
|
|
"sessionID": "ses_global",
|
|
"status": map[string]any{"type": "idle"},
|
|
}))
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("execute did not return")
|
|
}
|
|
|
|
events := sink.Events()
|
|
if combined := testutil.CollectDeltas(events); combined != "OK" {
|
|
t.Fatalf("deltas: got %q, want %q", combined, "OK")
|
|
}
|
|
var complete *noderuntime.RuntimeEvent
|
|
for i := range events {
|
|
if events[i].Type == noderuntime.EventTypeComplete {
|
|
complete = &events[i]
|
|
}
|
|
}
|
|
if complete == nil {
|
|
t.Fatal("expected complete event")
|
|
}
|
|
if complete.Usage == nil || complete.Usage.InputTokens != 7 || complete.Usage.OutputTokens != 1 {
|
|
t.Fatalf("expected usage 7/1, got %+v", complete.Usage)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecuteOpencodeSSE_PromptAsyncSendsModelObject(t *testing.T) {
|
|
fake, srv := newOpencodeFakeServer(t, "ses_m")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL, "--model", "ollama-dgx/qwen3.6:35b-a3b-bf16"),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
sink := &testutil.FakeSink{}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
|
RunID: "run-m",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "hello"},
|
|
}, sink)
|
|
}()
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for fake.promptCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if fake.promptCalls.Load() == 0 {
|
|
t.Fatal("prompt_async was not called")
|
|
}
|
|
fake.push(map[string]any{
|
|
"type": "session.idle",
|
|
"properties": map[string]any{"sessionID": "ses_m"},
|
|
})
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("execute did not return")
|
|
}
|
|
|
|
fake.mu.Lock()
|
|
body := append([]byte(nil), fake.lastPromptBody...)
|
|
fake.mu.Unlock()
|
|
var decoded struct {
|
|
Model map[string]any `json:"model"`
|
|
}
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
t.Fatalf("decode body: %v (body=%q)", err, string(body))
|
|
}
|
|
if decoded.Model["providerID"] != "ollama-dgx" {
|
|
t.Errorf("providerID: got %v", decoded.Model["providerID"])
|
|
}
|
|
if decoded.Model["modelID"] != "qwen3.6:35b-a3b-bf16" {
|
|
t.Errorf("modelID: got %v", decoded.Model["modelID"])
|
|
}
|
|
}
|
|
|
|
func TestCLIExecuteOpencodeSSE_InvalidModelEmitsError(t *testing.T) {
|
|
_, srv := newOpencodeFakeServer(t, "ses_bad")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL, "--model", "missing-slash"),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
sink := &testutil.FakeSink{}
|
|
|
|
err := c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
|
RunID: "run-bad",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "hi"},
|
|
}, sink)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid model, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "missing provider/model separator") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var sawErr bool
|
|
for _, e := range sink.Events() {
|
|
if e.Type == noderuntime.EventTypeError && strings.Contains(e.Error, "missing provider/model separator") {
|
|
sawErr = true
|
|
}
|
|
}
|
|
if !sawErr {
|
|
t.Fatal("expected error event with model parse failure")
|
|
}
|
|
}
|
|
|
|
func TestCLIExecuteOpencodeSSE_SessionStatusObjectIdleCompletes(t *testing.T) {
|
|
fake, srv := newOpencodeFakeServer(t, "ses_obj")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
sink := &testutil.FakeSink{}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
|
RunID: "run-obj",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "hi"},
|
|
}, sink)
|
|
}()
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for fake.promptCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
fake.push(map[string]any{
|
|
"type": "session.next.text.delta",
|
|
"properties": map[string]any{
|
|
"sessionID": "ses_obj",
|
|
"delta": "done",
|
|
},
|
|
})
|
|
fake.push(map[string]any{
|
|
"type": "session.status",
|
|
"properties": map[string]any{
|
|
"sessionID": "ses_obj",
|
|
"status": map[string]any{"type": "idle"},
|
|
},
|
|
})
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("execute did not return")
|
|
}
|
|
var sawComplete bool
|
|
for _, e := range sink.Events() {
|
|
if e.Type == noderuntime.EventTypeComplete {
|
|
sawComplete = true
|
|
}
|
|
}
|
|
if !sawComplete {
|
|
t.Fatal("expected complete event after object-idle session.status")
|
|
}
|
|
}
|
|
|
|
func TestCLIExecuteOpencodeSSE_RequireExistingWithoutSessionErrors(t *testing.T) {
|
|
_, srv := newOpencodeFakeServer(t, "ses_1")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
sink := &testutil.FakeSink{}
|
|
|
|
err := c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
|
RunID: "run-x",
|
|
Target: "opencode",
|
|
SessionID: "abs",
|
|
SessionMode: noderuntime.SessionModeRequireExisting,
|
|
Input: map[string]any{"prompt": "hi"},
|
|
}, sink)
|
|
if err == nil {
|
|
t.Fatal("expected error when SessionModeRequireExisting and no cached session")
|
|
}
|
|
if !strings.Contains(err.Error(), "no persistent session") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecuteOpencodeSSE_SessionErrorEmitsRuntimeError(t *testing.T) {
|
|
fake, srv := newOpencodeFakeServer(t, "ses_1")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
sink := &testutil.FakeSink{}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
|
RunID: "run-err",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "hi"},
|
|
}, sink)
|
|
}()
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for fake.promptCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
fake.push(map[string]any{
|
|
"type": "session.error",
|
|
"properties": map[string]any{
|
|
"sessionID": "ses_1",
|
|
"error": map[string]any{
|
|
"name": "ModelError",
|
|
"data": map[string]any{"message": "Model not found: foo/bar"},
|
|
},
|
|
},
|
|
})
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "Model not found: foo/bar") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("execute did not return")
|
|
}
|
|
|
|
var gotErr string
|
|
for _, e := range sink.Events() {
|
|
if e.Type == noderuntime.EventTypeError {
|
|
gotErr = e.Error
|
|
}
|
|
}
|
|
if gotErr != "Model not found: foo/bar" {
|
|
t.Fatalf("expected runtime error event, got %q", gotErr)
|
|
}
|
|
}
|
|
|
|
func TestCLIExecuteOpencodeSSE_ConsecutiveExecutesReuseSession(t *testing.T) {
|
|
fake, srv := newOpencodeFakeServer(t, "ses_reuse")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
|
|
waitPromptCalls := func(t *testing.T, want int32) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for fake.promptCalls.Load() < want && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if fake.promptCalls.Load() < want {
|
|
t.Fatalf("prompt_async call count: got %d, want >= %d", fake.promptCalls.Load(), want)
|
|
}
|
|
}
|
|
|
|
// --- First Execute ---
|
|
sink1 := &testutil.FakeSink{}
|
|
done1 := make(chan error, 1)
|
|
go func() {
|
|
done1 <- c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
|
RunID: "run-seq1",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "first"},
|
|
}, sink1)
|
|
}()
|
|
|
|
waitPromptCalls(t, 1)
|
|
fake.push(map[string]any{
|
|
"type": "session.next.text.delta",
|
|
"properties": map[string]any{
|
|
"sessionID": "ses_reuse",
|
|
"delta": "IOP_E2E_OPENCODE_ONE",
|
|
},
|
|
})
|
|
fake.push(map[string]any{
|
|
"type": "session.idle",
|
|
"properties": map[string]any{"sessionID": "ses_reuse"},
|
|
})
|
|
|
|
select {
|
|
case err := <-done1:
|
|
if err != nil {
|
|
t.Fatalf("first execute: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("first execute did not return")
|
|
}
|
|
|
|
if combined := testutil.CollectDeltas(sink1.Events()); combined != "IOP_E2E_OPENCODE_ONE" {
|
|
t.Errorf("run1 deltas: got %q, want %q", combined, "IOP_E2E_OPENCODE_ONE")
|
|
}
|
|
if fake.createCalls.Load() != 1 {
|
|
t.Errorf("after run1: expected 1 createSession call, got %d", fake.createCalls.Load())
|
|
}
|
|
|
|
// --- Second Execute on same CLI / same session ---
|
|
sink2 := &testutil.FakeSink{}
|
|
done2 := make(chan error, 1)
|
|
go func() {
|
|
done2 <- c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
|
RunID: "run-seq2",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "second"},
|
|
}, sink2)
|
|
}()
|
|
|
|
waitPromptCalls(t, 2)
|
|
fake.push(map[string]any{
|
|
"type": "session.next.text.delta",
|
|
"properties": map[string]any{
|
|
"sessionID": "ses_reuse",
|
|
"delta": "IOP_E2E_OPENCODE_TWO",
|
|
},
|
|
})
|
|
fake.push(map[string]any{
|
|
"type": "session.idle",
|
|
"properties": map[string]any{"sessionID": "ses_reuse"},
|
|
})
|
|
|
|
select {
|
|
case err := <-done2:
|
|
if err != nil {
|
|
t.Fatalf("second execute: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("second execute did not return")
|
|
}
|
|
|
|
// Session was reused: still 1 create call, 2 prompt calls.
|
|
if fake.createCalls.Load() != 1 {
|
|
t.Errorf("after run2: expected 1 createSession call (reused), got %d", fake.createCalls.Load())
|
|
}
|
|
if fake.promptCalls.Load() != 2 {
|
|
t.Errorf("expected 2 prompt_async calls, got %d", fake.promptCalls.Load())
|
|
}
|
|
|
|
// run2 sink must have the second delta and a complete, not the first delta.
|
|
if combined := testutil.CollectDeltas(sink2.Events()); combined != "IOP_E2E_OPENCODE_TWO" {
|
|
t.Errorf("run2 deltas: got %q, want %q", combined, "IOP_E2E_OPENCODE_TWO")
|
|
}
|
|
for _, e := range sink2.Events() {
|
|
if e.Type == noderuntime.EventTypeDelta && e.Delta == "IOP_E2E_OPENCODE_ONE" {
|
|
t.Error("run2 sink must not contain run1 delta")
|
|
}
|
|
}
|
|
var run1Complete, run2Complete bool
|
|
for _, e := range sink1.Events() {
|
|
if e.Type == noderuntime.EventTypeComplete {
|
|
run1Complete = true
|
|
}
|
|
}
|
|
for _, e := range sink2.Events() {
|
|
if e.Type == noderuntime.EventTypeComplete {
|
|
run2Complete = true
|
|
}
|
|
}
|
|
if !run1Complete {
|
|
t.Error("run1: expected complete event")
|
|
}
|
|
if !run2Complete {
|
|
t.Error("run2: expected complete event")
|
|
}
|
|
}
|
|
|
|
func TestCLIExecuteOpencodeSSE_ContextCancelAbortsSession(t *testing.T) {
|
|
fake, srv := newOpencodeFakeServer(t, "ses_1")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
sink := &testutil.FakeSink{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- c.Execute(ctx, noderuntime.ExecutionSpec{
|
|
RunID: "run-cancel",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "hi"},
|
|
}, sink)
|
|
}()
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for fake.promptCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != noderuntime.ErrRunCancelled {
|
|
t.Fatalf("expected ErrRunCancelled, got %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("execute did not return after cancel")
|
|
}
|
|
|
|
var cancelEvent bool
|
|
var msg string
|
|
for _, e := range sink.Events() {
|
|
if e.Type == noderuntime.EventTypeCancelled {
|
|
cancelEvent = true
|
|
msg = e.Message
|
|
}
|
|
}
|
|
if !cancelEvent {
|
|
t.Fatal("expected cancelled event")
|
|
}
|
|
if msg != "user-cancel" {
|
|
t.Fatalf("expected user-cancel, got %q", msg)
|
|
}
|
|
|
|
// Allow a short window for the abort POST to arrive.
|
|
deadline = time.Now().Add(1 * time.Second)
|
|
for fake.abortCalls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if fake.abortCalls.Load() == 0 {
|
|
t.Fatal("expected /session/{id}/abort to be called")
|
|
}
|
|
}
|
|
|
|
// TestCLIExecuteOpencodeSSE_ContextCancelAfterPromptAcceptedAbortsSession
|
|
// verifies the regression: when prompt_async POST reaches the server (202) but
|
|
// the response is blocked, cancelling the run context must abort the remote
|
|
// session exactly once via /abort, emit exactly one cancelled event, and
|
|
// return ErrRunCancelled with no error event.
|
|
func TestCLIExecuteOpencodeSSE_ContextCancelAfterPromptAcceptedAbortsSession(t *testing.T) {
|
|
fake, srv := newOpencodeFakeServer(t, "ses_abort")
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode": opencodeSSEProfile(srv.URL),
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
sink := &testutil.FakeSink{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Arm the response gate before Execute so the handler blocks on it.
|
|
blocked := fake.BlockPromptResponse()
|
|
defer fake.UnblockPromptResponse()
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- c.Execute(ctx, noderuntime.ExecutionSpec{
|
|
RunID: "run-abort-after-accept",
|
|
Target: "opencode",
|
|
Input: map[string]any{"prompt": "abort-test"},
|
|
}, sink)
|
|
}()
|
|
|
|
// Wait for the handler to confirm it has the body and is blocked on the gate.
|
|
waitPromptBodyRead(t, fake)
|
|
waitPromptResponseBlocked(t, blocked)
|
|
|
|
// Cancel the run context. The adapter must handle this by sending a
|
|
// bounded /abort, emitting one cancelled event, and returning ErrRunCancelled.
|
|
cancel()
|
|
|
|
// The execute call must return ErrRunCancelled within a bounded window.
|
|
select {
|
|
case err := <-done:
|
|
if err != noderuntime.ErrRunCancelled {
|
|
t.Fatalf("expected ErrRunCancelled, got %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("execute did not return after prompt-accepted cancellation")
|
|
}
|
|
|
|
// Execute returns only after the in-flight cancellation path completes /abort.
|
|
if got := fake.abortCalls.Load(); got != 1 {
|
|
t.Fatalf("expected /abort call count == 1 when Execute returned, got %d", got)
|
|
}
|
|
|
|
// Exactly one cancelled event, no error event.
|
|
var cancelCount, errCount int
|
|
for _, e := range sink.Events() {
|
|
switch e.Type {
|
|
case noderuntime.EventTypeCancelled:
|
|
cancelCount++
|
|
case noderuntime.EventTypeError:
|
|
errCount++
|
|
}
|
|
}
|
|
if cancelCount != 1 {
|
|
t.Fatalf("expected exactly 1 cancelled event, got %d", cancelCount)
|
|
}
|
|
if errCount != 0 {
|
|
t.Fatalf("expected 0 error events, got %d", errCount)
|
|
}
|
|
}
|