# 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`에 다음을 정의한다: ```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.go`의 `emitStdoutChunks`는 그대로 둔다. #### 테스트 작성 신규 단위 테스트 `apps/node/internal/adapters/cli/emitters_internal_test.go` 또는 black-box 테스트: - `TestDriveJSONLines_DispatchesEmitterEvents`: 줄 두 개를 흘려보내고 emitter가 반환한 이벤트가 sink에 전달되며 RunID/Timestamp가 채워지는지 단언. - `TestDriveJSONLines_StopsOnEmitterError`: emitter가 error 반환 시 즉시 루프 종료. #### 중간 검증 ```bash 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: ```go 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)하므로 `lineEmitter`에 `MaxLineBytes() int` 옵션 메서드를 추가하거나, 등록 시 `scanBufMax`를 함께 명시한다. 본 계획은 등록 형태로 처리: ```go 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` 슬라이스의 타입/필드를 단언한다. #### 중간 검증 ```bash go test ./apps/node/internal/adapters/cli/... ``` 예상 결과: 기존 케이스 + 신규 emitter 단위 테스트 PASS. ### [REFACTOR-3] executeCommand의 switch를 registry 조회로 단순화 #### 문제 `oneshot.go:67-80`의 `switch profile.OutputFormat`은 분기마다 emit 함수 호출 시그니처를 반복한다. #### 해결 방법 ```go 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.go`의 `executeCommand`에서 switch를 위 형태로 교체한다. - [ ] 알 수 없는 `OutputFormat`이 들어오면 `emitStdoutChunks`로 fallback (현 동작 유지) 또는 명시 에러 반환 중 하나로 결정해 코드와 docstring에 명시한다. 본 계획은 fallback 유지. #### 테스트 작성 추가 테스트 없음. `oneshot_blackbox_test.go`의 기존 케이스가 회귀 검증. #### 중간 검증 ```bash 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 | ## 최종 검증 ```bash 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 보일러플레이트가 한 곳으로 모인다.