iop/agent-task/03_cli_emitter_interface/PLAN.md

9.6 KiB

CLI oneshot output emitter 인터페이스 추출

이 파일을 읽는 구현 에이전트에게

아래 체크리스트를 순서대로 완료하고, 각 항목의 중간 검증과 최종 검증을 실제로 실행하세요. 구현이 끝나면 agent-task/03_cli_emitter_interface/CODE_REVIEW.md의 모든 섹션을 실제 구현 내용과 명령 출력으로 채우세요. CODE_REVIEW.md이 파일을 읽는 리뷰 에이전트에게 섹션에 있는 아카이브 지시(*.log로 이름 변경, complete.log 작성)는 구현 에이전트가 수행하면 안 되며, 리뷰 스킬 전용입니다.

배경

apps/node/internal/adapters/cli/oneshot.go는 5개 JSONL 포맷별 emitter — emitStreamJSONLines, emitClaudeJSONLines, emitCodexJSONLines, emitOpencodeJSON, emitClineJSON — 와 raw emitStdoutChunks를 모두 포함해 481줄로 비대화되어 있습니다. 5개 함수 모두 동일한 보일러플레이트(scanner buffer setup, outBuf 누적, JSON unmarshal, sink emit, scanner err 검사)를 반복하고 포맷별로 라인 → RuntimeEvent 변환만 다릅니다. 인터페이스 도입 기준은 "구현체 두 개 이상"이며, 현재 6개이므로 충분히 추상화 대상입니다. 라인-기반 변환만 분리하고, scanner 루프와 공통 처리는 한 곳으로 묶습니다.

의존 관계 및 구현 순서

  1. [REFACTOR-1] lineEmitter 인터페이스와 공통 scanner 드라이버를 정의.
  2. [REFACTOR-2] 5개 JSON 포맷을 인터페이스 구현으로 이전.
  3. [REFACTOR-3] executeCommand의 switch가 emitter registry를 거치도록 단순화.

[REFACTOR-1] lineEmitter 인터페이스와 공통 드라이버 정의

문제

oneshot.go:135-469에 동일한 scanner 루프가 다섯 번 반복된다. 새 포맷이 들어오면 같은 50-60줄 보일러플레이트를 한 번 더 복사하게 된다.

해결 방법

새 파일 apps/node/internal/adapters/cli/emitters.go에 다음을 정의한다:

// lineEmitter parses one stdout line (already trimmed of trailing newline) and
// returns the resulting RuntimeEvents to push to the sink. Empty events are
// skipped. Returning a non-nil error aborts the run.
type lineEmitter interface {
    Name() string
    Emit(line string) ([]runtime.RuntimeEvent, error)
}

// driveJSONLines runs a shared scanner loop over stdout, accumulates raw output
// into outBuf, dispatches each line through the emitter, and forwards events
// to sink. Returns total OutputTokens approximation.
func driveJSONLines(
    ctx context.Context,
    stdout io.Reader,
    sink runtime.EventSink,
    runID string,
    outBuf *strings.Builder,
    emitter lineEmitter,
    scanBufMax int,
) (int, error) {
    scanner := bufio.NewScanner(stdout)
    scanner.Buffer(make([]byte, 64*1024), scanBufMax)
    outputTokens := 0
    for scanner.Scan() {
        line := scanner.Bytes()
        outBuf.Write(line)
        outBuf.WriteByte('\n')
        trimmed := strings.TrimSpace(string(line))
        if trimmed == "" || trimmed[0] != '{' {
            continue
        }
        events, err := emitter.Emit(trimmed)
        if err != nil {
            return outputTokens, err
        }
        for _, ev := range events {
            ev.RunID = runID
            if ev.Timestamp.IsZero() {
                ev.Timestamp = time.Now()
            }
            if ev.Type == runtime.EventTypeDelta {
                outputTokens += len(strings.Fields(ev.Delta))
            }
            _ = sink.Emit(ctx, ev)
        }
    }
    if err := scanner.Err(); err != nil {
        return outputTokens, err
    }
    return outputTokens, nil
}

raw emitStdoutChunks는 인터페이스 대상이 아니므로 그대로 둔다(JSON 라인 모델이 아님).

수정 파일 및 체크리스트

  • 새 파일 apps/node/internal/adapters/cli/emitters.go 생성, lineEmitter/driveJSONLines 정의.
  • apps/node/internal/adapters/cli/oneshot.goemitStdoutChunks는 그대로 둔다.

테스트 작성

신규 단위 테스트 apps/node/internal/adapters/cli/emitters_internal_test.go 또는 black-box 테스트:

  • TestDriveJSONLines_DispatchesEmitterEvents: 줄 두 개를 흘려보내고 emitter가 반환한 이벤트가 sink에 전달되며 RunID/Timestamp가 채워지는지 단언.
  • TestDriveJSONLines_StopsOnEmitterError: emitter가 error 반환 시 즉시 루프 종료.

중간 검증

go test ./apps/node/internal/adapters/cli/...

예상 결과: 신규 테스트 PASS.

[REFACTOR-2] 5개 포맷을 인터페이스 구현으로 이전

문제

oneshot.go의 5개 emit* 함수가 각자 scanner 루프 + JSON 디코드 + sink emit을 직접 한다.

해결 방법

각 포맷에 대해 line-only 변환 구현을 만든다 (예: streamJSONEmitter, claudeJSONEmitter, codexJSONEmitter, opencodeJSONEmitter, clineJSONEmitter). 각 구조체는 기존 anonymous JSON struct 정의를 그대로 들고, Emit(line string) ([]runtime.RuntimeEvent, error) 시그니처로 라인 한 줄을 처리해 0~N개의 RuntimeEvent를 반환한다.

예시 — codex:

type codexJSONEmitter struct{}
func (codexJSONEmitter) Name() string { return "codex-json" }
func (codexJSONEmitter) Emit(line string) ([]runtime.RuntimeEvent, error) {
    var ev struct{ ... }  // 기존 구조 재사용
    if err := json.Unmarshal([]byte(line), &ev); err != nil {
        return nil, nil
    }
    switch ev.Type {
    case "item.completed":
        if ev.Item.Type == "agent_message" && ev.Item.Text != "" {
            return []runtime.RuntimeEvent{{Type: runtime.EventTypeDelta, Delta: ev.Item.Text}}, nil
        }
    case "error":
        return []runtime.RuntimeEvent{{Type: runtime.EventTypeError, Error: ev.Message}}, nil
    case "turn.failed":
        return []runtime.RuntimeEvent{{Type: runtime.EventTypeError, Error: ev.Error.Message}}, nil
    }
    return nil, nil
}

기존 emitStreamJSONLines/emitClaudeJSONLines/... 함수는 모두 삭제한다. 일부 emitter는 더 큰 scanner buffer가 필요(claude 8MB, cline 8MB)하므로 lineEmitterMaxLineBytes() int 옵션 메서드를 추가하거나, 등록 시 scanBufMax를 함께 명시한다. 본 계획은 등록 형태로 처리:

type registeredEmitter struct {
    emitter    lineEmitter
    scanBufMax int
}

var jsonEmitters = map[string]registeredEmitter{
    "stream-json":   {streamJSONEmitter{}, 4 * 1024 * 1024},
    "claude-json":   {claudeJSONEmitter{}, 8 * 1024 * 1024},
    "codex-json":    {codexJSONEmitter{}, 4 * 1024 * 1024},
    "opencode-json": {opencodeJSONEmitter{}, 4 * 1024 * 1024},
    "cline-json":    {clineJSONEmitter{}, 8 * 1024 * 1024},
}

수정 파일 및 체크리스트

  • apps/node/internal/adapters/cli/emitters.go에 5개 emitter 구조체와 jsonEmitters registry를 정의한다.
  • apps/node/internal/adapters/cli/oneshot.go에서 emitStreamJSONLines, emitClaudeJSONLines, emitCodexJSONLines, emitOpencodeJSON, emitClineJSON을 삭제한다.
  • 더 이상 필요 없는 bufio, encoding/json, errors import는 oneshot.go에서 제거(사용처 확인 필요).

테스트 작성

기존 black-box 테스트(oneshot_blackbox_test.go 또는 현재 oneshot/cli_test.go)가 5개 포맷의 end-to-end 동작을 검증하므로 회귀 검증으로 충분. 추가로 emitter 단위 테스트:

  • TestStreamJSONEmitter_AssistantMessageBecomesDelta
  • TestClaudeJSONEmitter_TextDelta
  • TestCodexJSONEmitter_TurnFailedBecomesError
  • TestOpencodeJSONEmitter_TextPart
  • TestClineJSONEmitter_AskApiReqFailedBecomesError

각 테스트는 한 줄을 입력으로 주고 반환되는 RuntimeEvent 슬라이스의 타입/필드를 단언한다.

중간 검증

go test ./apps/node/internal/adapters/cli/...

예상 결과: 기존 케이스 + 신규 emitter 단위 테스트 PASS.

[REFACTOR-3] executeCommand의 switch를 registry 조회로 단순화

문제

oneshot.go:67-80switch profile.OutputFormat은 분기마다 emit 함수 호출 시그니처를 반복한다.

해결 방법

var outputTokens int
var readErr error
if reg, ok := jsonEmitters[profile.OutputFormat]; ok {
    outputTokens, readErr = driveJSONLines(ctx, stdout, sink, spec.RunID, &outBuf, reg.emitter, reg.scanBufMax)
} else {
    outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf)
}

수정 파일 및 체크리스트

  • apps/node/internal/adapters/cli/oneshot.goexecuteCommand에서 switch를 위 형태로 교체한다.
  • 알 수 없는 OutputFormat이 들어오면 emitStdoutChunks로 fallback (현 동작 유지) 또는 명시 에러 반환 중 하나로 결정해 코드와 docstring에 명시한다. 본 계획은 fallback 유지.

테스트 작성

추가 테스트 없음. oneshot_blackbox_test.go의 기존 케이스가 회귀 검증.

중간 검증

go test ./apps/node/internal/adapters/cli/...

예상 결과: PASS.

수정 파일 요약

파일 항목
apps/node/internal/adapters/cli/emitters.go (신규) REFACTOR-1, REFACTOR-2
apps/node/internal/adapters/cli/oneshot.go REFACTOR-2, REFACTOR-3
apps/node/internal/adapters/cli/emitters_internal_test.go (신규) 또는 외부 테스트 REFACTOR-1, REFACTOR-2

최종 검증

go build ./...
go test ./apps/node/...
wc -l apps/node/internal/adapters/cli/oneshot.go apps/node/internal/adapters/cli/emitters.go

예상 결과: 모든 테스트 PASS, oneshot.go가 200줄 이하로 축소되고 emitter 보일러플레이트가 한 곳으로 모인다.