- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동 - 새 테스트 파일 추가 (edge, node, client, config, readability) - readability_audit 스크립트 및 baseline 추가 - roadmap/SDD 문서 갱신 - agent-client/pi/extensions/openai-sampling-parameters 추가
415 lines
15 KiB
Go
415 lines
15 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"iop/apps/node/internal/adapters/cli/status"
|
|
"iop/apps/node/internal/runtime"
|
|
"iop/packages/go/config"
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
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 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)
|
|
}
|
|
}
|