터미널 세션 core와 CLI mode executor 경계를 분리해 후속 remote terminal bridge가 재사용할 내부 실행 기반을 만든다. adapter 기본값과 vLLM experimental surface도 명시해 production 실행 경로가 mock fallback이나 미구현 실행으로 숨지 않게 한다.
602 lines
20 KiB
Go
602 lines
20 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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: "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 "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 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.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"] != "5" {
|
|
t.Fatalf("count: got %q want %q", resp.Result["count"], "5")
|
|
}
|
|
want := "antigravity-print:antigravity/main,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-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-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_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 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)
|
|
}
|
|
}
|