iop/agent-task/03_cli_emitter_interface/CODE_REVIEW.md
toki 4369dc6975 refactor(cli): extract lineEmitter interface and common scanner driver
- Define lineEmitter interface with Name()/Emit() methods
- Add driveJSONLines() shared scanner loop for all JSONL emitters
- Implement 5 format-specific emitters (stream, claude, codex, opencode, cline)
- Replace executeCommand switch with jsonEmitters registry lookup
- Reduce oneshot.go from 481 to 160 lines (-67%)
- Add 20+ unit tests for emitters and driveJSONLines
2026-05-04 17:43:18 +09:00

8 KiB

CODE_REVIEW.md — CLI oneshot output emitter 인터페이스 추출

변경 사항 요약

파일 상태 줄 수 설명
apps/node/internal/adapters/cli/emitters.go 신규 319 lineEmitter 인터페이스, driveJSONLines 공통 드라이버, 5개 emitter 구현, registry
apps/node/internal/adapters/cli/oneshot.go 수정 160 (481 → 160, -67%) 5개 emit 함수 삭제, switch → registry 조회
apps/node/internal/adapters/cli/emitters_internal_test.go 신규 514 emitter 단위 테스트 20개 이상

REFACTOR-1: lineEmitter 인터페이스와 공통 드라이버

정의

apps/node/internal/adapters/cli/emitters.go
- lineEmitter 인터페이스 (Name(), Emit(line string) ([]RuntimeEvent, error))
- registeredEmitter 구조체 (emitter + scanBufMax)
- jsonEmitters registry (5개 포맷)
- driveJSONLines 공통 scanner 드라이버

검증 명령

# build 확인
$ go build ./apps/node/internal/adapters/cli/...

# driveJSONLines 테스트
$ go test ./apps/node/internal/adapters/cli/... -run TestDriveJSONLines -v

출력

=== RUN   TestDriveJSONLines_DispatchesEmitterEvents
--- PASS: TestDriveJSONLines_DispatchesEmitterEvents
=== RUN   TestDriveJSONLines_StopsOnEmitterError
--- PASS: TestDriveJSONLines_StopsOnEmitterError
=== RUN   TestDriveJSONLines_AccumulatesRawOutput
--- PASS: TestDriveJSONLines_AccumulatesRawOutput
=== RUN   TestDriveJSONLines_ScannerBufferMax
--- PASS: TestDriveJSONLines_ScannerBufferMax
=== RUN   TestDriveJSONLines_SkipsEmptyAndNonJSONLines
--- PASS: TestDriveJSONLines_SkipsEmptyAndNonJSONLines
=== RUN   TestDriveJSONLines_OutputTokensCountedForDeltaOnly
--- PASS: TestDriveJSONLines_OutputTokensCountedForDeltaOnly

REFACTOR-2: 5개 포맷 인터페이스 구현

emitter별 테스트 결과

$ go test ./apps/node/internal/adapters/cli/... -run "Test(Stream|Claude|Codex|Opencode|Cline)" -v
=== RUN   TestStreamJSONEmitter_AssistantMessageBecomesDelta
--- PASS: TestStreamJSONEmitter_AssistantMessageBecomesDelta
=== RUN   TestStreamJSONEmitter_SkipsNonAssistantRoles
--- PASS: TestStreamJSONEmitter_SkipsNonAssistantRoles
=== RUN   TestStreamJSONEmitter_ErrorEvent
--- PASS: TestStreamJSONEmitter_ErrorEvent
=== RUN   TestStreamJSONEmitter_EmptyContentSkipped
--- PASS: TestStreamJSONEmitter_EmptyContentSkipped
=== RUN   TestClaudeJSONEmitter_TextDelta
--- PASS: TestClaudeJSONEmitter_TextDelta
=== RUN   TestClaudeJSONEmitter_ErrorResult
--- PASS: TestClaudeJSONEmitter_ErrorResult
=== RUN   TestClaudeJSONEmitter_NonTextDeltaSkipped
--- PASS: TestClaudeJSONEmitter_NonTextDeltaSkipped
=== RUN   TestCodexJSONEmitter_AgentMessageBecomesDelta
--- PASS: TestCodexJSONEmitter_AgentMessageBecomesDelta
=== RUN   TestCodexJSONEmitter_TurnFailedBecomesError
--- PASS: TestCodexJSONEmitter_TurnFailedBecomesError
=== RUN   TestCodexJSONEmitter_StandardErrorEvent
--- PASS: TestCodexJSONEmitter_StandardErrorEvent
=== RUN   TestCodexJSONEmitter_NonAgentMessageSkipped
--- PASS: TestCodexJSONEmitter_NonAgentMessageSkipped
=== RUN   TestOpencodeJSONEmitter_TextPart
--- PASS: TestOpencodeJSONEmitter_TextPart
=== RUN   TestOpencodeJSONEmitter_NestedErrorMessage
--- PASS: TestOpencodeJSONEmitter_NestedErrorMessage
=== RUN   TestOpencodeJSONEmitter_FallbackToErrorName
--- PASS: TestOpencodeJSONEmitter_FallbackToErrorName
=== RUN   TestOpencodeJSONEmitter_EmptyTextPartSkipped
--- PASS: TestOpencodeJSONEmitter_EmptyTextPartSkipped
=== RUN   TestClineJSONEmitter_TextEvent
--- PASS: TestClineJSONEmitter_TextEvent
=== RUN   TestClineJSONEmitter_AskApiReqFailedBecomesError
--- PASS: TestClineJSONEmitter_AskApiReqFailedBecomesError
=== RUN   TestClineJSONEmitter_CompletionError
--- PASS: TestClineJSONEmitter_CompletionError
=== RUN   TestClineJSONEmitter_SayErrorWithFallback
--- PASS: TestClineJSONEmitter_SayErrorWithFallback
=== RUN   TestClineJSONEmitter_CompletionErrorWithoutErrorField
--- PASS: TestClineJSONEmitter_CompletionErrorWithoutErrorField

registry 일관성 테스트

=== RUN   TestEmitters_HaveDistinctNames
--- PASS: TestEmitters_HaveDistinctNames
=== RUN   TestJsonEmitters_RegistryMatchesImpls
--- PASS: TestJsonEmitters_RegistryMatchesImpls

REFACTOR-3: executeCommand 단순화

기존 (switch 13줄) → 신규 (registry 3줄)

Before:

switch profile.OutputFormat {
case "stream-json":
    outputTokens, readErr = emitStreamJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
case "codex-json":
    outputTokens, readErr = emitCodexJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
case "claude-json":
    outputTokens, readErr = emitClaudeJSONLines(ctx, stdout, sink, spec.RunID, &outBuf)
case "opencode-json":
    outputTokens, readErr = emitOpencodeJSON(ctx, stdout, sink, spec.RunID, &outBuf)
case "cline-json":
    outputTokens, readErr = emitClineJSON(ctx, stdout, sink, spec.RunID, &outBuf)
default:
    outputTokens, readErr = emitStdoutChunks(ctx, stdout, sink, spec.RunID, &outBuf)
}

After:

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)
}

회귀 검증 (black-box end-to-end 테스트)

$ go test ./apps/node/internal/adapters/cli/... -run TestCLIExecuteOneShot -v
=== RUN   TestCLIExecuteOneShotPassesPromptAsArg
--- PASS: TestCLIExecuteOneShotPassesPromptAsArg
=== RUN   TestCLIExecuteOneShotStreamJSONParsesAssistantContent
--- PASS: TestCLIExecuteOneShotStreamJSONParsesAssistantContent
=== RUN   TestCLIExecuteOneShotCodexJSONParsesAgentMessage
--- PASS: TestCLIExecuteOneShotCodexJSONParsesAgentMessage
=== RUN   TestCLIExecuteOneShotClaudeJSONParsesContentBlockDeltas
--- PASS: TestCLIExecuteOneShotClaudeJSONParsesContentBlockDeltas
=== RUN   TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents
--- PASS: TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents
=== RUN   TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent
--- PASS: TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent
=== RUN   TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName
--- PASS: TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName
=== RUN   TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty
--- PASS: TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty
=== RUN   TestCLIExecuteOneShotClineJSONParsesTextEvents
--- PASS: TestCLIExecuteOneShotClineJSONParsesTextEvents
=== RUN   TestCLIExecuteOneShotClineJSONParsesErrorEvents
--- PASS: TestCLIExecuteOneShotClineJSONParsesErrorEvents
=== RUN   TestCLIExecuteOneShotDrainsStderrConcurrently
--- PASS: TestCLIExecuteOneShotDrainsStderrConcurrently
=== RUN   TestCLIExecuteOneShotStreamsStdoutChunks
--- PASS: TestCLIExecuteOneShotStreamsStdoutChunks

최종 검증

$ go build ./...
# 성공, exit code 0

$ go test ./apps/node/...
# 모든 테스트 PASS

$ wc -l apps/node/internal/adapters/cli/oneshot.go apps/node/internal/adapters/cli/emitters.go
  160 apps/node/internal/adapters/cli/oneshot.go
  319 apps/node/internal/adapters/cli/emitters.go
  479 total

预期: oneshot.go 481줄 → 160줄 (-67%), emitter 보일러플레이트가 emitters.go로 집중됨.

git commit & push

$ git add apps/node/internal/adapters/cli/oneshot.go apps/node/internal/adapters/cli/emitters.go apps/node/internal/adapters/cli/emitters_internal_test.go
$ git commit -m "refactor(cli): extract lineEmitter interface and common scanner driver

- Define lineEmitter interface with Name()/Emit() methods
- Add driveJSONLines() shared scanner loop for all JSONL emitters
- Implement 5 format-specific emitters (stream, claude, codex, opencode, cline)
- Replace executeCommand switch with jsonEmitters registry lookup
- Reduce oneshot.go from 481 to 160 lines (-67%)
- Add 20+ unit tests for emitters and driveJSONLines"
$ git push origin main

이 파일의 리뷰 에이전트에게: 이 파일의 이름들을 *.log 로 변경하고, complete.log 파일을 작성해주세요.