iop/apps/node/internal/adapters/cli/cli_internal_test.go
toki 24735a5914 feat: openai workspace agent execution contract implementation
- Update agent contract documentation for openai-compatible API
- Update automation-runtime-bridge milestone tracking
- Add edge smoke tests for openai CLI workspace
- Add node CLI adapters (codex, opencode, oneshot, persistent)
- Add e2e openai-cli-workspace script
- Add agent task tracking for execution contract
2026-06-13 23:12:07 +09:00

1199 lines
39 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"iop/apps/node/internal/adapters/cli/status"
"iop/apps/node/internal/runtime"
"iop/packages/go/config"
)
// --- driveJSONLines tests ---
type testSink struct {
events []runtime.RuntimeEvent
}
func (s *testSink) Emit(_ context.Context, e runtime.RuntimeEvent) error {
s.events = append(s.events, e)
return nil
}
type mockLineEmitter struct {
name string
emitFn func(line string) ([]runtime.RuntimeEvent, error)
}
func (m *mockLineEmitter) Name() string { return m.name }
func (m *mockLineEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) {
if m.emitFn != nil {
return m.emitFn(line)
}
return nil, nil
}
func TestDriveJSONLines_DispatchesEmitterEvents(t *testing.T) {
input := `{"type":"message","role":"assistant","content":"hello"}
not-json
{"type":"message","role":"assistant","content":"world"}`
outBuf := &strings.Builder{}
sink := &testSink{}
outReader := strings.NewReader(input + "\n")
mockEmitter := &mockLineEmitter{
name: "mock",
emitFn: func(line string) ([]runtime.RuntimeEvent, error) {
return []runtime.RuntimeEvent{
{Type: runtime.EventTypeDelta, Delta: "a:" + line},
{Type: runtime.EventTypeDelta, Delta: "b:" + line},
}, nil
},
}
outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-1", outBuf, mockEmitter, 4*1024*1024)
if err != nil {
t.Fatalf("driveJSONLines: %v", err)
}
if got := len(sink.events); got != 4 {
t.Fatalf("expected 4 events, got %d", got)
}
for i, ev := range sink.events {
if ev.RunID != "run-1" {
t.Errorf("event %d: RunID = %q, want %q", i, ev.RunID, "run-1")
}
if ev.Timestamp.IsZero() {
t.Errorf("event %d: Timestamp is zero", i)
}
if ev.Type == runtime.EventTypeDelta {
outputTokens += len(strings.Fields(ev.Delta))
}
}
raw := outBuf.String()
if !strings.Contains(raw, `{"type":"message"`) {
t.Fatalf("outBuf missing JSON line: %q", raw)
}
}
func TestDriveJSONLines_StopsOnEmitterError(t *testing.T) {
input := `{"type":"text"}
{"type":"error"}`
outBuf := &strings.Builder{}
sink := &testSink{}
outReader := strings.NewReader(input + "\n")
callCount := 0
mockEmitter := &mockLineEmitter{
name: "mock",
emitFn: func(line string) ([]runtime.RuntimeEvent, error) {
callCount++
if callCount == 2 {
return nil, errors.New("emitter failure")
}
return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: "ok"}}, nil
},
}
_, err := driveJSONLines(context.Background(), outReader, sink, "run-2", outBuf, mockEmitter, 4*1024*1024)
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Error() != "emitter failure" {
t.Fatalf("unexpected error: %v", err)
}
if callCount != 2 {
t.Fatalf("expected emitter called 2 times, got %d", callCount)
}
}
func TestDriveJSONLines_AccumulatesRawOutput(t *testing.T) {
lines := `{"type":"text","part":{"type":"text","text":"a"}}
{"type":"text","part":{"type":"text","text":"b"}}`
outBuf := &strings.Builder{}
sink := &testSink{}
outReader := strings.NewReader(lines + "\n")
outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-int", outBuf, opencodeJSONEmitter{}, 4*1024*1024)
if err != nil {
t.Fatalf("driveJSONLines: %v", err)
}
raw := outBuf.String()
if !strings.Contains(raw, `{"type":"text"`) {
t.Fatalf("outBuf missing expected line: %q", raw)
}
if newlineCount := strings.Count(raw, "\n"); newlineCount != 2 {
t.Fatalf("expected 2 newlines in outBuf, got %d", newlineCount)
}
if outputTokens != 2 {
t.Fatalf("expected 2 outputTokens, got %d", outputTokens)
}
}
func TestDriveJSONLines_ScannerBufferMax(t *testing.T) {
longLine := strings.Repeat("x", 100) + `{"type":"text"}`
outBuf := &strings.Builder{}
sink := &testSink{}
outReader := strings.NewReader(longLine + "\n")
outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-buf", outBuf, &mockLineEmitter{name: "buf"}, 50)
if err != nil {
t.Fatalf("driveJSONLines: %v", err)
}
if outputTokens != 0 {
t.Fatalf("expected 0 outputTokens for line too long for buffer, got %d", outputTokens)
}
}
func TestDriveJSONLines_SkipsEmptyAndNonJSONLines(t *testing.T) {
input := "\n\nnot json at all\n \n{\"type\":\"text\"}\n"
outBuf := &strings.Builder{}
sink := &testSink{}
outReader := strings.NewReader(input)
callCount := 0
mockEmitter := &mockLineEmitter{
name: "mock",
emitFn: func(line string) ([]runtime.RuntimeEvent, error) {
callCount++
return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: line}}, nil
},
}
_, err := driveJSONLines(context.Background(), outReader, sink, "run-skip", outBuf, mockEmitter, 4*1024*1024)
if err != nil {
t.Fatalf("driveJSONLines: %v", err)
}
if callCount != 1 {
t.Fatalf("expected emitter called 1 time, got %d", callCount)
}
}
func TestDriveJSONLines_OutputTokensCountedForDeltaOnly(t *testing.T) {
outBuf := &strings.Builder{}
sink := &testSink{}
outReader := strings.NewReader(`{"type":"error"}
{"type":"delta"}` + "\n")
mockEmitter := &mockLineEmitter{
name: "mock",
emitFn: func(line string) ([]runtime.RuntimeEvent, error) {
if strings.Contains(line, "error") {
return []runtime.RuntimeEvent{{Type: runtime.EventTypeError, Error: "bad"}}, nil
}
return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: "one two three"}}, nil
},
}
outputTokens, err := driveJSONLines(context.Background(), outReader, sink, "run-tokens", outBuf, mockEmitter, 4*1024*1024)
if err != nil {
t.Fatalf("driveJSONLines: %v", err)
}
if outputTokens != 3 {
t.Fatalf("expected 3 outputTokens, got %d", outputTokens)
}
}
// --- emitter edge case tests (not covered by blackbox tests) ---
func TestStreamJSONEmitter_SkipsNonAssistantRoles(t *testing.T) {
e := streamJSONEmitter{}
events, _ := e.Emit(`{"type":"message","role":"user","content":"hi"}`)
if len(events) != 0 {
t.Fatalf("expected 0 events for user role, got %d", len(events))
}
}
func TestStreamJSONEmitter_ErrorEvent(t *testing.T) {
e := streamJSONEmitter{}
events, _ := e.Emit(`{"type":"error","error":"something broke"}`)
if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "something broke" {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestStreamJSONEmitter_ToolUseBecomesVisibleDelta(t *testing.T) {
e := streamJSONEmitter{}
events, _ := e.Emit(`{"type":"tool_use","tool_name":"run_shell_command","tool_id":"tool-1","parameters":{"command":"go test ./..."}}`)
if len(events) != 1 || events[0].Type != runtime.EventTypeDelta {
t.Fatalf("unexpected events: %+v", events)
}
if events[0].Delta != "[tool] run_shell_command: go test ./...\n" {
t.Fatalf("unexpected delta: %q", events[0].Delta)
}
if events[0].Metadata["kind"] != "tool_use" || events[0].Metadata["tool_name"] != "run_shell_command" {
t.Fatalf("unexpected metadata: %+v", events[0].Metadata)
}
}
func TestStreamJSONEmitter_ToolResultBecomesVisibleDelta(t *testing.T) {
e := streamJSONEmitter{}
events, _ := e.Emit(`{"type":"tool_result","tool_id":"tool-1","status":"success","output":"ok\n"}`)
if len(events) != 1 || events[0].Type != runtime.EventTypeDelta {
t.Fatalf("unexpected events: %+v", events)
}
if events[0].Delta != "[tool-result] success: ok\n" {
t.Fatalf("unexpected delta: %q", events[0].Delta)
}
if events[0].Metadata["kind"] != "tool_result" {
t.Fatalf("unexpected metadata: %+v", events[0].Metadata)
}
}
func TestStreamJSONEmitter_ResultError(t *testing.T) {
e := streamJSONEmitter{}
events, _ := e.Emit(`{"type":"result","status":"error","error":{"message":"quota exceeded"}}`)
if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "quota exceeded" {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestStreamJSONEmitter_EmptyContentSkipped(t *testing.T) {
e := streamJSONEmitter{}
events, _ := e.Emit(`{"type":"message","role":"assistant","content":""}`)
if len(events) != 0 {
t.Fatalf("expected 0 events for empty content, got %d", len(events))
}
}
func TestClaudeJSONEmitter_ErrorResult(t *testing.T) {
e := claudeJSONEmitter{}
events, _ := e.Emit(`{"type":"result","is_error":true,"result":"API timeout"}`)
if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "API timeout" {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestClaudeJSONEmitter_NonTextDeltaSkipped(t *testing.T) {
e := claudeJSONEmitter{}
events, _ := e.Emit(`{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"image_delta","data":"base64"}}}`)
if len(events) != 0 {
t.Fatalf("expected 0 events for non-text delta, got %d", len(events))
}
}
func TestCodexJSONEmitter_ItemDeltaBecomesDelta(t *testing.T) {
e := codexJSONEmitter{}
events, _ := e.Emit(`{"type":"item.delta","item":{"type":"agent_message","delta":"Doing."}}`)
if len(events) != 1 || events[0].Delta != "Doing." {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestCodexJSONEmitter_OutputTextDeltaBecomesDelta(t *testing.T) {
e := codexJSONEmitter{}
events, _ := e.Emit(`{"type":"response.output_text.delta","delta":"chunk"}`)
if len(events) != 1 || events[0].Delta != "chunk" {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestCodexJSONEmitter_ContentOutputTextFallsBackToDelta(t *testing.T) {
e := codexJSONEmitter{}
events, _ := e.Emit(`{"type":"item.updated","item":{"type":"agent_message","message":{"content":[{"type":"output_text","text":"partial text"}]}}}`)
if len(events) != 1 || events[0].Delta != "partial text" {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestCodexJSONEmitter_TurnFailedBecomesError(t *testing.T) {
e := codexJSONEmitter{}
events, _ := e.Emit(`{"type":"turn.failed","error":{"message":"quota exceeded"}}`)
if len(events) != 1 || events[0].Type != runtime.EventTypeError || events[0].Error != "quota exceeded" {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestCodexJSONEmitter_StandardErrorEvent(t *testing.T) {
e := codexJSONEmitter{}
events, _ := e.Emit(`{"type":"error","message":"network timeout"}`)
if len(events) != 1 || events[0].Error != "network timeout" {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestCodexJSONEmitter_NonAgentMessageSkipped(t *testing.T) {
e := codexJSONEmitter{}
events, _ := e.Emit(`{"type":"item.completed","item":{"type":"tool_call","text":"ls -la"}}`)
if len(events) != 0 {
t.Fatalf("expected 0 events for non-agent_message item, got %d", len(events))
}
}
func TestClineJSONEmitter_CompletionError(t *testing.T) {
e := clineJSONEmitter{}
events, _ := e.Emit(`{"type":"completion","status":"error","error":"task failed"}`)
if len(events) != 1 || events[0].Error != "task failed" {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestClineJSONEmitter_SayErrorWithFallback(t *testing.T) {
e := clineJSONEmitter{}
events, _ := e.Emit(`{"type":"say","say":"error","message":"fallback msg"}`)
if len(events) != 1 || events[0].Error != "fallback msg" {
t.Fatalf("unexpected events: %+v", events)
}
}
func TestClineJSONEmitter_CompletionErrorWithoutErrorField(t *testing.T) {
e := clineJSONEmitter{}
events, _ := e.Emit(`{"type":"completion","status":"error"}`)
if len(events) != 1 || events[0].Error != "cline task failed" {
t.Fatalf("unexpected events: %+v", events)
}
}
// --- emitter registry tests ---
func TestEmitters_HaveDistinctNames(t *testing.T) {
testCases := []struct {
emitter lineEmitter
want string
}{
{streamJSONEmitter{}, "stream-json"},
{claudeJSONEmitter{}, "claude-json"},
{codexJSONEmitter{}, "codex-json"},
{opencodeJSONEmitter{}, "opencode-json"},
{clineJSONEmitter{}, "cline-json"},
}
for _, tc := range testCases {
if got := tc.emitter.Name(); got != tc.want {
t.Errorf("%T.Name() = %q, want %q", tc.emitter, got, tc.want)
}
}
}
func TestExecutorForMode(t *testing.T) {
c := New(config.CLIConf{}, nil)
testCases := []struct {
mode string
persistent bool
wantType string
}{
{mode: "codex-exec", wantType: "codex"},
{mode: "codex-app-server", wantType: "codex-app-server"},
{mode: "antigravity-print", wantType: "antigravity"},
{mode: "opencode-sse", wantType: "opencode"},
{mode: "persistent-lazy", persistent: true, wantType: "persistent"},
{mode: "", persistent: true, wantType: "persistent"},
{mode: "", persistent: false, wantType: "oneshot"},
{mode: "unknown-mode", persistent: false, wantType: "oneshot"},
}
for _, tc := range testCases {
profile := config.CLIProfileConf{
Mode: tc.mode,
Persistent: tc.persistent,
}
exec := c.executorFor(profile)
var ok bool
switch tc.wantType {
case "codex":
_, ok = exec.(*codexExecutor)
case "codex-app-server":
_, ok = exec.(*codexAppServerExecutor)
case "antigravity":
_, ok = exec.(*antigravityExecutor)
case "opencode":
_, ok = exec.(*opencodeExecutor)
case "persistent":
_, ok = exec.(*persistentExecutor)
case "oneshot":
_, ok = exec.(*oneshotExecutor)
}
if !ok {
t.Errorf("executorFor(mode=%q, persistent=%t) got type %T, want %s", tc.mode, tc.persistent, exec, tc.wantType)
}
}
}
func TestCLIStartSkipsCodexAppServerPersistentStartup(t *testing.T) {
// persistent=true + mode=codex-app-server must NOT be autostarted via Start().
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"codex-as": {
Mode: modeCodexAppServer,
Persistent: true,
Command: "false", // would fail if actually started
},
},
}, nil)
// shouldAutostartPersistentProfile must return false.
profile := c.profiles["codex-as"]
if shouldAutostartPersistentProfile(profile) {
t.Fatal("shouldAutostartPersistentProfile should return false for codex-app-server mode")
}
// Start() must not attempt to launch the process (no error from "false").
if err := c.Start(context.Background()); err != nil {
t.Fatalf("Start() returned error for codex-app-server profile: %v", err)
}
if len(c.persistentExecutor.sessions) != 0 {
t.Fatalf("expected no persistent sessions, got %d", len(c.persistentExecutor.sessions))
}
}
func TestHandleSessionList_PopulatedSnapshot(t *testing.T) {
c := New(config.CLIConf{}, nil)
c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{}
c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "alt"}] = &profileSession{}
c.codexExecutor.sessions[sessionKey{target: "codex", sessionID: "default"}] = &codexExecSession{}
c.codexAppServerExecutor.sessions[sessionKey{target: "codex-as", sessionID: "default"}] = &codexAppServerSession{}
c.antigravityExecutor.sessions[sessionKey{target: "antigravity", sessionID: "main"}] = &antigravitySession{}
c.opencodeExecutor.sessions[sessionKey{target: "opencode", sessionID: "main"}] = &opencodeSSESession{}
resp := c.handleSessionList(runtime.CommandRequest{
RequestID: "req-list",
Type: runtime.CommandTypeSessionList,
Adapter: "cli",
})
if resp.Result["count"] != "6" {
t.Fatalf("count: got %q want %q", resp.Result["count"], "6")
}
want := "antigravity-print:antigravity/main,codex-app-server:codex-as/default,codex-exec:codex/default,opencode-sse:opencode/main,persistent:claude/alt,persistent:claude/default"
if got := resp.Result["sessions"]; got != want {
t.Fatalf("sessions: got %q want %q", got, want)
}
if resp.RequestID != "req-list" {
t.Fatalf("request id not echoed: %+v", resp)
}
// Sorted order: antigravity-print:antigravity/main, codex-app-server:codex-as/default,
// codex-exec:codex/default, opencode-sse:opencode/main, persistent:claude/alt, persistent:claude/default
wantSessions := []struct{ mode, target, sessionID, label string }{
{"antigravity-print", "antigravity", "main", "antigravity-print:antigravity/main"},
{"codex-app-server", "codex-as", "default", "codex-app-server:codex-as/default"},
{"codex-exec", "codex", "default", "codex-exec:codex/default"},
{"opencode-sse", "opencode", "main", "opencode-sse:opencode/main"},
{"persistent", "claude", "alt", "persistent:claude/alt"},
{"persistent", "claude", "default", "persistent:claude/default"},
}
for i, ws := range wantSessions {
prefix := fmt.Sprintf("session.%d.", i)
if got := resp.Result[prefix+"label"]; got != ws.label {
t.Fatalf("%slabel: got %q want %q", prefix, got, ws.label)
}
if got := resp.Result[prefix+"mode"]; got != ws.mode {
t.Fatalf("%smode: got %q want %q", prefix, got, ws.mode)
}
if got := resp.Result[prefix+"target"]; got != ws.target {
t.Fatalf("%starget: got %q want %q", prefix, got, ws.target)
}
if got := resp.Result[prefix+"session_id"]; got != ws.sessionID {
t.Fatalf("%ssession_id: got %q want %q", prefix, got, ws.sessionID)
}
}
}
func TestHandleSessionList_WorkspaceVariantsDistinguished(t *testing.T) {
c := New(config.CLIConf{}, nil)
// Same target/sessionID, two different workspaces, plus a no-workspace session.
c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{}
c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default", workspace: "/ws/a"}] = &profileSession{}
c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default", workspace: "/ws/b"}] = &profileSession{}
resp := c.handleSessionList(runtime.CommandRequest{
RequestID: "req-ws",
Type: runtime.CommandTypeSessionList,
})
if resp.Result["count"] != "3" {
t.Fatalf("count: got %q want %q", resp.Result["count"], "3")
}
// Labels must be distinct so the three variants do not collide.
want := "persistent:claude/default,persistent:claude/default#/ws/a,persistent:claude/default#/ws/b"
if got := resp.Result["sessions"]; got != want {
t.Fatalf("sessions: got %q want %q", got, want)
}
// Sorted by label: no-workspace first, then #/ws/a, then #/ws/b.
wantEntries := []struct{ label, workspace string }{
{"persistent:claude/default", ""},
{"persistent:claude/default#/ws/a", "/ws/a"},
{"persistent:claude/default#/ws/b", "/ws/b"},
}
for i, we := range wantEntries {
prefix := fmt.Sprintf("session.%d.", i)
if got := resp.Result[prefix+"label"]; got != we.label {
t.Errorf("%slabel: got %q want %q", prefix, got, we.label)
}
if got := resp.Result[prefix+"workspace"]; got != we.workspace {
t.Errorf("%sworkspace: got %q want %q", prefix, got, we.workspace)
}
if got := resp.Result[prefix+"target"]; got != "claude" {
t.Errorf("%starget: got %q want %q", prefix, got, "claude")
}
if got := resp.Result[prefix+"session_id"]; got != "default" {
t.Errorf("%ssession_id: got %q want %q", prefix, got, "default")
}
}
}
func TestHandleSessionList_NoWorkspaceHasEmptyWorkspaceField(t *testing.T) {
c := New(config.CLIConf{}, nil)
c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{}
resp := c.handleSessionList(runtime.CommandRequest{Type: runtime.CommandTypeSessionList})
if got := resp.Result["session.0.label"]; got != "persistent:claude/default" {
t.Fatalf("label: got %q want %q", got, "persistent:claude/default")
}
if got, ok := resp.Result["session.0.workspace"]; !ok || got != "" {
t.Fatalf("workspace: got %q present=%v want empty string", got, ok)
}
}
func TestSessionKey_NormalizesWorkspaceAndSessionID(t *testing.T) {
key := newSessionKey(runtime.ExecutionSpec{
Target: "claude",
Workspace: " /ws/a ",
})
if key.target != "claude" {
t.Errorf("target: got %q want %q", key.target, "claude")
}
if key.sessionID != runtime.DefaultSessionID {
t.Errorf("sessionID: got %q want default %q", key.sessionID, runtime.DefaultSessionID)
}
if key.workspace != "/ws/a" {
t.Errorf("workspace: got %q want trimmed %q", key.workspace, "/ws/a")
}
empty := newSessionKey(runtime.ExecutionSpec{Target: "claude", Workspace: " "})
if empty.workspace != "" {
t.Errorf("blank workspace should normalize to empty, got %q", empty.workspace)
}
}
func TestHandleSessionList_SlashInSessionID(t *testing.T) {
c := New(config.CLIConf{}, nil)
c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "team/a/b"}] = &profileSession{}
resp := c.handleSessionList(runtime.CommandRequest{
RequestID: "req-slash",
Type: runtime.CommandTypeSessionList,
})
if resp.Result["count"] != "1" {
t.Fatalf("count: got %q want %q", resp.Result["count"], "1")
}
if got := resp.Result["session.0.label"]; got != "persistent:claude/team/a/b" {
t.Fatalf("session.0.label: got %q want %q", got, "persistent:claude/team/a/b")
}
if got := resp.Result["session.0.mode"]; got != "persistent" {
t.Fatalf("session.0.mode: got %q want %q", got, "persistent")
}
if got := resp.Result["session.0.target"]; got != "claude" {
t.Fatalf("session.0.target: got %q want %q", got, "claude")
}
if got := resp.Result["session.0.session_id"]; got != "team/a/b" {
t.Fatalf("session.0.session_id: got %q want %q", got, "team/a/b")
}
}
func TestJsonEmitters_RegistryMatchesImpls(t *testing.T) {
expectedKeys := []string{"stream-json", "claude-json", "codex-json", "opencode-json", "cline-json"}
for _, key := range expectedKeys {
reg, ok := jsonEmitters[key]
if !ok {
t.Fatalf("jsonEmitters[%q] not found in registry", key)
}
if reg.emitter.Name() != key {
t.Errorf("jsonEmitters[%q].emitter.Name() = %q, want %q", key, reg.emitter.Name(), key)
}
if reg.scanBufMax < 1024 {
t.Errorf("jsonEmitters[%q].scanBufMax = %d, expected at least 1024", key, reg.scanBufMax)
}
}
if len(jsonEmitters) != len(expectedKeys) {
t.Errorf("expected %d registered emitters, got %d", len(expectedKeys), len(jsonEmitters))
}
}
func TestHandleUsageStatus_EnvelopeAndParseMetadata(t *testing.T) {
t.Run("raw-only parse_status", func(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"claude": {},
},
}, nil)
c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) {
return &status.UsageStatus{RawOutput: "some raw text"}, nil
}
resp, err := c.HandleCommand(context.Background(), runtime.CommandRequest{
RequestID: "req-usage-1",
Type: runtime.CommandTypeUsageStatus,
Adapter: "cli",
Target: "claude",
SessionID: "sess-1",
})
if err != nil {
t.Fatalf("HandleCommand: %v", err)
}
if resp.RequestID != "req-usage-1" {
t.Fatalf("RequestID: got %q want %q", resp.RequestID, "req-usage-1")
}
if resp.Type != runtime.CommandTypeUsageStatus {
t.Fatalf("Type: got %q want %q", resp.Type, runtime.CommandTypeUsageStatus)
}
if resp.Adapter != "cli" {
t.Fatalf("Adapter: got %q want %q", resp.Adapter, "cli")
}
if resp.Target != "claude" {
t.Fatalf("Target: got %q want %q", resp.Target, "claude")
}
if resp.SessionID != "sess-1" {
t.Fatalf("SessionID: got %q want %q", resp.SessionID, "sess-1")
}
if resp.UsageStatus == nil {
t.Fatal("UsageStatus is nil")
}
if got := resp.UsageStatus.Metadata["parse_status"]; got != "raw_only" {
t.Fatalf("parse_status: got %q want %q", got, "raw_only")
}
})
t.Run("metadata-only no synthetic parse_status", func(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"claude": {},
},
}, nil)
c.StatusChecker = func(_ context.Context, _ string, _ config.CLIProfileConf) (*status.UsageStatus, error) {
return &status.UsageStatus{
Metadata: map[string]string{"source": "cli"},
}, nil
}
resp, err := c.HandleCommand(context.Background(), runtime.CommandRequest{
RequestID: "req-usage-2",
Type: runtime.CommandTypeUsageStatus,
Adapter: "cli",
Target: "claude",
SessionID: "sess-2",
})
if err != nil {
t.Fatalf("HandleCommand: %v", err)
}
if resp.UsageStatus == nil {
t.Fatal("UsageStatus is nil")
}
if _, ok := resp.UsageStatus.Metadata["parse_status"]; ok {
t.Fatalf("parse_status should not be set for metadata-only result, got %q", resp.UsageStatus.Metadata["parse_status"])
}
})
}
func TestHandleSessionList_CodexAppServerVisible(t *testing.T) {
c := New(config.CLIConf{}, nil)
c.codexAppServerExecutor.sessions[sessionKey{target: "codex-as", sessionID: "sess-1"}] = &codexAppServerSession{
threadID: "thread-abc",
}
resp := c.handleSessionList(runtime.CommandRequest{
RequestID: "req-cas",
Type: runtime.CommandTypeSessionList,
Adapter: "cli",
})
if resp.Result["count"] != "1" {
t.Fatalf("count: got %q want %q", resp.Result["count"], "1")
}
if got := resp.Result["session.0.mode"]; got != modeCodexAppServer {
t.Errorf("mode: got %q want %q", got, modeCodexAppServer)
}
if got := resp.Result["session.0.target"]; got != "codex-as" {
t.Errorf("target: got %q want %q", got, "codex-as")
}
if got := resp.Result["session.0.session_id"]; got != "sess-1" {
t.Errorf("session_id: got %q want %q", got, "sess-1")
}
}
func TestTerminateSession_CodexAppServerRemovesFromList(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"codex-as": {Mode: modeCodexAppServer},
},
}, nil)
key := sessionKey{target: "codex-as", sessionID: "sess-1"}
c.codexAppServerExecutor.sessions[key] = &codexAppServerSession{key: key}
if err := c.TerminateSession(context.Background(), "codex-as", "sess-1"); err != nil {
t.Fatalf("TerminateSession: %v", err)
}
resp := c.handleSessionList(runtime.CommandRequest{Type: runtime.CommandTypeSessionList})
if resp.Result["count"] != "0" {
t.Fatalf("expected session removed, count = %q", resp.Result["count"])
}
}
func TestTerminateSession_RemovesAllWorkspaceVariants(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"codex": {Mode: modeCodexExec},
},
}, nil)
// Same target/sessionID across no-workspace and two workspaces.
for _, ws := range []string{"", "/ws/a", "/ws/b"} {
key := sessionKey{target: "codex", sessionID: "sess-1", workspace: ws}
c.codexExecutor.sessions[key] = &codexExecSession{key: key}
}
// A different sessionID must survive termination.
other := sessionKey{target: "codex", sessionID: "sess-2", workspace: "/ws/a"}
c.codexExecutor.sessions[other] = &codexExecSession{key: other}
if err := c.TerminateSession(context.Background(), "codex", "sess-1"); err != nil {
t.Fatalf("TerminateSession: %v", err)
}
if len(c.codexExecutor.sessions) != 1 {
t.Fatalf("expected only sess-2 to remain, got %d sessions", len(c.codexExecutor.sessions))
}
if _, ok := c.codexExecutor.sessions[other]; !ok {
t.Fatal("sess-2 should not have been terminated")
}
// Terminating a target/sessionID with no sessions must report not-found.
err := c.TerminateSession(context.Background(), "codex", "missing")
if err == nil {
t.Fatal("expected error terminating unknown session")
}
}
func TestCloseProfileSession_PipeFallbackIdempotent(t *testing.T) {
var mockCloseCalled int
mockClose := func() error {
mockCloseCalled++
return os.ErrClosed
}
sess := &profileSession{
closeFn: mockClose,
}
err := closeProfileSession(context.Background(), sess)
if err != nil {
t.Fatalf("expected nil error on idempotent close of already closed session, got: %v", err)
}
if mockCloseCalled != 1 {
t.Errorf("expected mockClose to be called 1 time, got %d", mockCloseCalled)
}
}
func TestCLIOneshotExecutorCwd(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"oneshot-pwd": {
Command: "pwd",
},
},
}, nil)
tmpDir := t.TempDir()
spec := runtime.ExecutionSpec{
Target: "oneshot-pwd",
Workspace: tmpDir,
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
var output string
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeDelta {
output += ev.Delta
}
}
resolved, err := os.Readlink(tmpDir)
if err != nil {
resolved = tmpDir
}
if !strings.Contains(output, tmpDir) && !strings.Contains(output, resolved) {
t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output)
}
}
func TestCLIPersistentNonTerminalCwd(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"persistent-pwd": {
Command: "sh",
Persistent: true,
},
},
}, nil)
tmpDir := t.TempDir()
spec := runtime.ExecutionSpec{
Target: "persistent-pwd",
Workspace: tmpDir,
SessionID: "sess-cwd-nt",
SessionMode: runtime.SessionModeCreateIfMissing,
Input: map[string]any{"prompt": "pwd\n"},
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
defer func() {
_ = c.Stop(context.Background())
}()
var output string
success := false
resolved, err := os.Readlink(tmpDir)
if err != nil {
resolved = tmpDir
}
// Wait up to 2 seconds for output
for i := 0; i < 20; i++ {
output = ""
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeDelta {
output += ev.Delta
}
}
if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) {
success = true
break
}
time.Sleep(100 * time.Millisecond)
}
if !success {
t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output)
}
}
func TestCLIPersistentTerminalCwd(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"persistent-term-pwd": {
Command: "sh",
Persistent: true,
Terminal: true,
},
},
}, nil)
tmpDir := t.TempDir()
spec := runtime.ExecutionSpec{
Target: "persistent-term-pwd",
Workspace: tmpDir,
SessionID: "sess-cwd-term",
SessionMode: runtime.SessionModeCreateIfMissing,
Input: map[string]any{"prompt": "pwd"},
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
defer func() {
_ = c.Stop(context.Background())
}()
var output string
success := false
resolved, err := os.Readlink(tmpDir)
if err != nil {
resolved = tmpDir
}
// Wait up to 2 seconds for output
for i := 0; i < 20; i++ {
output = ""
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeDelta {
output += ev.Delta
}
}
if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) {
success = true
break
}
time.Sleep(100 * time.Millisecond)
}
if !success {
t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output)
}
}
func TestCLIWorkspacePreflightFailures(t *testing.T) {
// 1. prepareWorkspaceDir helper tests
t.Run("Helper", func(t *testing.T) {
// Blank workspace
dir, err := prepareWorkspaceDir("")
if err != nil || dir != "" {
t.Errorf("expected empty dir and nil error for blank workspace, got dir=%q, err=%v", dir, err)
}
dir, err = prepareWorkspaceDir(" ")
if err != nil || dir != "" {
t.Errorf("expected empty dir and nil error for whitespace workspace, got dir=%q, err=%v", dir, err)
}
// Not found directory
nonExistentPath := "/nonexistent/path/for/workspace/test"
dir, err = prepareWorkspaceDir(nonExistentPath)
if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace not found") {
t.Errorf("expected 'workspace not found' error, got dir=%q, err=%v", dir, err)
}
// File is not a directory
tmpFile, err := os.CreateTemp("", "test-workspace-file-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
_ = tmpFile.Close()
dir, err = prepareWorkspaceDir(tmpFile.Name())
if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace is not a directory") {
t.Errorf("expected 'workspace is not a directory' error, got dir=%q, err=%v", dir, err)
}
// Inaccessible directory
if os.Geteuid() != 0 {
// Create a directory and make it inaccessible
inaccessibleDir := t.TempDir() + "/inaccessible"
if err := os.Mkdir(inaccessibleDir, 0000); err != nil {
t.Fatalf("failed to create inaccessible dir: %v", err)
}
dir, err = prepareWorkspaceDir(inaccessibleDir)
if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace inaccessible") {
t.Errorf("expected 'workspace inaccessible' error, got dir=%q, err=%v", dir, err)
}
// Restore permission for cleanup
_ = os.Chmod(inaccessibleDir, 0700)
} else {
t.Log("running as root; skipping inaccessible workspace helper test")
}
})
// 2. Execute path preflight error propagation
t.Run("ExecuteOneshot", func(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"oneshot-pwd": {
Command: "pwd",
},
},
}, nil)
spec := runtime.ExecutionSpec{
RunID: "oneshot-run-id",
Target: "oneshot-pwd",
Workspace: "/nonexistent/path/for/workspace/test",
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err == nil {
t.Fatal("expected execute to fail for nonexistent workspace")
}
if !strings.Contains(err.Error(), "cli adapter: workspace not found") {
t.Errorf("expected 'workspace not found' error, got: %v", err)
}
// Verify sink EventTypeError event
hasErrorEvent := false
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeError {
hasErrorEvent = true
if ev.RunID != spec.RunID {
t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID)
}
if !strings.Contains(ev.Error, "cli adapter: workspace not found") {
t.Errorf("expected EventTypeError Error to contain 'workspace not found', got %q", ev.Error)
}
}
}
if !hasErrorEvent {
t.Errorf("expected sink to contain EventTypeError event, but it did not")
}
})
t.Run("ExecutePersistent", func(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"persistent-pwd": {
Command: "sh",
Persistent: true,
},
},
}, nil)
spec := runtime.ExecutionSpec{
RunID: "persistent-run-id",
Target: "persistent-pwd",
Workspace: "/nonexistent/path/for/workspace/test",
SessionID: "sess-preflight-fail",
SessionMode: runtime.SessionModeCreateIfMissing,
Input: map[string]any{"prompt": "pwd\n"},
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err == nil {
t.Fatal("expected execute to fail for nonexistent workspace")
}
if !strings.Contains(err.Error(), "cli adapter: workspace not found") {
t.Errorf("expected 'workspace not found' error, got: %v", err)
}
// Verify sink EventTypeError event
hasErrorEvent := false
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeError {
hasErrorEvent = true
if ev.RunID != spec.RunID {
t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID)
}
if !strings.Contains(ev.Error, "cli adapter: workspace not found") {
t.Errorf("expected EventTypeError Error to contain 'workspace not found', got %q", ev.Error)
}
}
}
if !hasErrorEvent {
t.Errorf("expected sink to contain EventTypeError event, but it did not")
}
})
t.Run("ExecuteCodexAppServer", func(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"codex-app": {
Mode: modeCodexAppServer,
Command: "codex",
Persistent: true,
Args: []string{"--app-server"},
},
},
}, nil)
spec := runtime.ExecutionSpec{
RunID: "codex-run-id",
Target: "codex-app",
Workspace: "/nonexistent/path/for/workspace/test",
SessionID: "sess-codex-preflight-fail",
SessionMode: runtime.SessionModeCreateIfMissing,
Input: map[string]any{"prompt": "some prompt"},
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err == nil {
t.Fatal("expected execute to fail for nonexistent workspace")
}
if !strings.Contains(err.Error(), "cli adapter: start codex app-server") ||
!strings.Contains(err.Error(), "cli adapter: workspace not found") {
t.Errorf("expected 'start codex app-server' and 'workspace not found' errors, got: %v", err)
}
// Verify sink EventTypeError event
hasErrorEvent := false
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeError {
hasErrorEvent = true
if ev.RunID != spec.RunID {
t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID)
}
if !strings.Contains(ev.Error, "cli adapter: start codex app-server") ||
!strings.Contains(ev.Error, "cli adapter: workspace not found") {
t.Errorf("expected EventTypeError Error to contain 'start codex app-server' and 'workspace not found', got %q", ev.Error)
}
}
}
if !hasErrorEvent {
t.Errorf("expected sink to contain EventTypeError event, but it did not")
}
})
t.Run("ExecuteOpencodeSSE", func(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"opencode-sse-target": {
Mode: modeOpencodeSSE,
Command: "opencode",
Persistent: true,
Args: []string{"serve"}, // triggers opencode-sse mode
},
},
}, nil)
spec := runtime.ExecutionSpec{
RunID: "opencode-run-id",
Target: "opencode-sse-target",
Workspace: "/nonexistent/path/for/workspace/test",
SessionID: "sess-opencode-preflight-fail",
SessionMode: runtime.SessionModeCreateIfMissing,
Input: map[string]any{"prompt": "some prompt"},
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err == nil {
t.Fatal("expected execute to fail for nonexistent workspace")
}
if !strings.Contains(err.Error(), "cli adapter: start opencode server") ||
!strings.Contains(err.Error(), "cli adapter: workspace not found") {
t.Errorf("expected 'start opencode server' and 'workspace not found' errors, got: %v", err)
}
// Verify sink EventTypeError event
hasErrorEvent := false
for _, ev := range sink.events {
if ev.Type == runtime.EventTypeError {
hasErrorEvent = true
if ev.RunID != spec.RunID {
t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID)
}
if !strings.Contains(ev.Error, "cli adapter: start opencode server") ||
!strings.Contains(ev.Error, "cli adapter: workspace not found") {
t.Errorf("expected EventTypeError Error to contain 'start opencode server' and 'workspace not found', got %q", ev.Error)
}
}
}
if !hasErrorEvent {
t.Errorf("expected sink to contain EventTypeError event, but it did not")
}
})
}
func TestCLIProcessExitFailureRemainsDistinct(t *testing.T) {
c := New(config.CLIConf{
Profiles: map[string]config.CLIProfileConf{
"fail-oneshot": {
Command: "sh",
Args: []string{"-c", "exit 7"},
},
},
}, nil)
tmpDir := t.TempDir()
spec := runtime.ExecutionSpec{
Target: "fail-oneshot",
Workspace: tmpDir,
}
sink := &testSink{}
err := c.Execute(context.Background(), spec, sink)
if err == nil {
t.Fatal("expected command execution to fail")
}
errStr := err.Error()
// Command failure should NOT look like a workspace error.
if strings.Contains(errStr, "workspace not found") ||
strings.Contains(errStr, "workspace is not a directory") ||
strings.Contains(errStr, "workspace inaccessible") {
t.Errorf("process exit failure should be distinct from workspace preflight error, got: %q", errStr)
}
if !strings.Contains(errStr, "exit status 7") && !strings.Contains(errStr, "exit code 7") {
t.Errorf("expected error to mention exit status/code 7, got: %q", errStr)
}
}