# Opencode JSON Stream 전환 계획 ## 이 파일을 읽는 구현 에이전트에게 아래 체크리스트를 순서대로 완료하고, 각 항목의 중간 검증과 최종 검증을 실제로 실행하세요. 구현이 끝나면 `agent-task/opencode_json_stream/CODE_REVIEW.md`의 모든 섹션을 실제 구현 내용과 명령 출력으로 채우세요. `CODE_REVIEW.md`의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 아카이브 지시(`*.log`로 이름 변경, `complete.log` 작성)는 구현 에이전트가 수행하면 안 되며, 리뷰 스킬 전용입니다. ## 배경 현재 `opencode` 프로필은 기본 stdout 형식으로 동작하고 있어, `opencode run --format json`이 제공하는 이벤트 스트림을 사용하지 못합니다. 반면 node CLI 어댑터의 `emitOpencodeJSON`는 더 이상 실제 CLI 출력과 맞지 않는 단일 `{"response":"..."}` 포맷만 가정하고 있어, JSON 모드로 전환해도 응답을 제대로 전달하지 못할 가능성이 큽니다. 이번 작업은 `opencode`를 JSON 이벤트 기반 출력으로 전환하고, 현재 CLI가 실제로 내보내는 `step_start`/`text`/`step_finish` 흐름을 node one-shot 파서에서 안전하게 처리하도록 맞추는 데 목적이 있습니다. ## 의존 관계 및 구현 순서 1. `[REFACTOR-1]`에서 `opencode` 프로필을 JSON 이벤트 출력으로 전환한다. 2. `[REFACTOR-2]`에서 one-shot 파서를 실제 `opencode` JSON 이벤트 형식에 맞게 바꾼다. 3. `[REFACTOR-3]`에서 회귀 테스트를 새 포맷 기준으로 갱신하고 검증 명령을 실행한다. ### [REFACTOR-1] Opencode 프로필을 JSON 이벤트 출력으로 전환 #### 문제 `configs/edge.yaml:74-88`의 `opencode` 프로필은 `--format default`와 빈 `output_format`을 사용합니다. 이 설정에서는 node CLI 어댑터가 raw stdout chunk만 전달하므로, `opencode run --format json`이 제공하는 구조화된 이벤트 스트림을 활용할 수 없습니다. Before (`configs/edge.yaml:74-88`) ```yaml 74 opencode: 75 command: "/config/.npm-global/bin/opencode" 76 args: 77 - "run" 78 - "--title" 79 - "untitle" 80 - "--model" 81 - "ollama-m1/qwen3.6:27b-coding-mxfp8" 82 - "--format" 83 - "default" 84 - "--dangerously-skip-permissions" 85 env: [] 86 persistent: false 87 terminal: false 88 output_format: "" ``` #### 해결 방법 `opencode` 프로필의 CLI 인자를 `--format json`으로 바꾸고, 파서 선택이 `emitOpencodeJSON`으로 연결되도록 `output_format: "opencode-json"`을 지정합니다. 절대 경로 `command`는 유지해서 앞서 수정한 PATH 독립성은 깨지지 않게 합니다. After ```yaml opencode: command: "/config/.npm-global/bin/opencode" args: - "run" - "--title" - "untitle" - "--model" - "ollama-m1/qwen3.6:27b-coding-mxfp8" - "--format" - "json" - "--dangerously-skip-permissions" env: [] persistent: false terminal: false output_format: "opencode-json" ``` #### 수정 파일 및 체크리스트 - [ ] `configs/edge.yaml`에서 `opencode` 프로필의 `--format` 값을 `json`으로 변경한다. - [ ] `configs/edge.yaml`에서 `output_format`을 `opencode-json`으로 변경한다. - [ ] `command` 절대경로와 기존 모델/permission 옵션은 그대로 유지한다. #### 테스트 작성 테스트 코드는 추가하지 않는다. 이 항목은 설정 변경만 수행하며, 동작 회귀는 `[REFACTOR-3]`의 Go 테스트와 수동 smoke test에서 함께 검증한다. #### 중간 검증 ```bash rg -n 'opencode:|--format|output_format' configs/edge.yaml ``` 예상 결과: `opencode` 프로필 아래에 `--format`, `json`, `output_format: "opencode-json"`이 함께 보인다. ### [REFACTOR-2] Opencode JSON 이벤트 스트림 파서를 실제 CLI 출력에 맞게 갱신 #### 문제 `apps/node/internal/adapters/cli/oneshot.go:343-375`의 `emitOpencodeJSON`는 stdout 전체를 `io.ReadAll`로 읽은 뒤 단일 `{"response":"..."}` JSON만 파싱합니다. 실제 `opencode run --format json`은 줄 단위 JSON 이벤트를 여러 개 내보내며, 확인된 예시도 `step_start`, `text`, `step_finish` 객체 순서입니다. 현재 구현으로는 실시간에 가까운 이벤트 전달이 불가능하고, CLI 형식 변경에 의해 응답이 완전히 누락될 수 있습니다. Before (`apps/node/internal/adapters/cli/oneshot.go:343-375`) ```go 343 // emitOpencodeJSON parses `opencode -p ... -f json` output. The current 344 // opencode CLI wraps the final assistant text in a single JSON object: 345 // {"response":"..."}. 346 func emitOpencodeJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { 347 body, err := io.ReadAll(stdout) 348 if err != nil { 349 return 0, err 350 } 351 outBuf.Write(body) 352 353 trimmed := strings.TrimSpace(string(body)) 354 if trimmed == "" || trimmed[0] != '{' { 355 return 0, nil 356 } 357 358 var resp struct { 359 Response string `json:"response"` 360 } 361 if err := json.Unmarshal([]byte(trimmed), &resp); err != nil { 362 return 0, nil 363 } 364 if resp.Response == "" { 365 return 0, nil 366 } 367 368 outputTokens := len(strings.Fields(resp.Response)) 369 _ = sink.Emit(ctx, runtime.RuntimeEvent{ 370 RunID: runID, 371 Type: runtime.EventTypeDelta, 372 Delta: resp.Response, 373 Timestamp: time.Now(), 374 }) 375 return outputTokens, nil ``` #### 해결 방법 `emitStreamJSONLines`/`emitCodexJSONLines`와 같은 패턴으로 `bufio.Scanner` 기반 줄 단위 파서를 사용합니다. 각 줄을 `outBuf`에 그대로 남기면서 JSON 이벤트를 해석하고, `type=="text"` 이벤트의 `part.text`를 `RuntimeEventDelta`로 emit합니다. `step_start`/`step_finish`는 진단용으로만 보존하고, 에러 이벤트가 있으면 `RuntimeEventError`로 전달합니다. 함수 주석도 실제 `opencode run --format json` 출력 계약에 맞게 갱신합니다. After ```go func emitOpencodeJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) outputTokens := 0 for scanner.Scan() { line := scanner.Bytes() outBuf.Write(line) outBuf.WriteByte('\n') trimmed := strings.TrimSpace(string(line)) if trimmed == "" || trimmed[0] != '{' { continue } var ev struct { Type string `json:"type"` Error string `json:"error"` Part struct { Type string `json:"type"` Text string `json:"text"` } `json:"part"` } if err := json.Unmarshal(line, &ev); err != nil { continue } switch ev.Type { case "text": if ev.Part.Type == "text" && ev.Part.Text != "" { outputTokens += len(strings.Fields(ev.Part.Text)) _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeDelta, Delta: ev.Part.Text, Timestamp: time.Now()}) } case "error": if ev.Error != "" { _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: ev.Error, Timestamp: time.Now()}) } } } if err := scanner.Err(); err != nil { return outputTokens, err } return outputTokens, nil } ``` #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/adapters/cli/oneshot.go`의 `emitOpencodeJSON` 구현을 `io.ReadAll` 기반 단일 응답 파서에서 line-by-line 이벤트 파서로 교체한다. - [ ] `apps/node/internal/adapters/cli/oneshot.go`의 주석을 실제 `opencode run --format json` 출력 예시에 맞게 갱신한다. - [ ] `apps/node/internal/adapters/cli/oneshot.go`에서 `text` 이벤트만 delta로 전달하고, `step_start`/`step_finish`는 무시하는지 확인한다. - [ ] `apps/node/internal/adapters/cli/oneshot.go`에서 JSON parse 실패 line은 다른 파서들과 동일하게 건너뛰도록 유지한다. - [ ] renamed/removed symbol call site 점검 결과: 외부 호출부는 `emitOpencodeJSON` 1곳(`executeCommand` switch의 `case "opencode-json"`)뿐이며 이름 변경은 하지 않는다. #### 테스트 작성 회귀 테스트를 작성한다. `apps/node/internal/adapters/cli/oneshot/cli_test.go`에 `TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents`를 추가하거나 기존 `TestCLIExecuteOneShotOpencodeJSONParsesResponse`를 새 포맷 기준으로 교체한다. 검증 목표는 `step_start`/`step_finish`는 무시하고 `type:"text"` 이벤트의 `part.text`만 delta로 이어 붙여 전달하는 것이다. 필요하다면 별도 테스트 하나를 더 추가해 malformed line과 빈 text 이벤트를 건너뛰는지 확인한다. #### 중간 검증 ```bash go test ./apps/node/internal/adapters/cli/oneshot -run TestCLIExecuteOneShotOpencodeJSON -count=1 ``` 예상 결과: `opencode-json` 관련 테스트가 PASS하며, 실제 JSON 이벤트 fixture에서 기대 문자열이 delta로 수집된다. ### [REFACTOR-3] Opencode JSON 스트림 회귀 테스트와 수동 smoke test를 정리 #### 문제 현재 `apps/node/internal/adapters/cli/oneshot/cli_test.go:171-203`의 `TestCLIExecuteOneShotOpencodeJSONParsesResponse`는 더 이상 실제 CLI 출력과 맞지 않는 단일 `{"response":"..."}` fixture만 검증합니다. 이 테스트만 남겨두면 구현이 현실 CLI 형식에서 깨져도 회귀를 잡아내지 못합니다. Before (`apps/node/internal/adapters/cli/oneshot/cli_test.go:171-203`) ```go 171 func TestCLIExecuteOneShotOpencodeJSONParsesResponse(t *testing.T) { 172 testutil.RequireUnixShell(t) 173 174 script := `cat <<'EOF' 175 { 176 "response": "OpenCode says hello." 177 } 178 EOF` 179 cfg := config.CLIConf{ 180 Enabled: true, 181 Profiles: map[string]config.CLIProfileConf{ 182 "opencode": { 183 Command: "sh", 184 Args: []string{"-c", script, "sh"}, 185 OutputFormat: "opencode-json", 186 }, 187 }, 188 } 189 c := clipkg.New(cfg, zap.NewNop()) 190 sink := &testutil.FakeSink{} 191 192 if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ 193 RunID: "run-oc", 194 Model: "opencode", 195 Input: map[string]any{"prompt": "hi"}, 196 }, sink); err != nil { 197 t.Fatalf("execute: %v", err) 198 } 199 combined := testutil.CollectDeltas(sink.Events()) 200 if combined != "OpenCode says hello." { 201 t.Fatalf("expected opencode-json deltas to be %q, got %q", "OpenCode says hello.", combined) 202 } 203 } ``` #### 해결 방법 테스트 fixture를 실제 관찰된 `opencode run --format json` 이벤트 흐름으로 바꿉니다. 최소한 `step_start`, `text`, `step_finish` 3줄을 포함하고, assertion은 `part.text`만 합쳐진 결과를 기대하도록 바꿉니다. 필요 시 `type:"error"` 또는 비JSON line을 포함한 보조 테스트를 추가해 파서의 내결함성도 고정합니다. After ```go script := `cat <<'EOF' {"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}} {"type":"text","sessionID":"ses_1","part":{"type":"text","text":"Hi from opencode."}} {"type":"step_finish","sessionID":"ses_1","part":{"type":"step-finish","reason":"stop"}} EOF` ... if combined != "Hi from opencode." { t.Fatalf("expected opencode-json deltas to be %q, got %q", "Hi from opencode.", combined) } ``` #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/adapters/cli/oneshot/cli_test.go`의 기존 `opencode-json` 테스트 fixture를 실제 이벤트 stream 형식으로 교체한다. - [ ] 필요 시 `error` 이벤트 또는 malformed line을 다루는 보조 테스트를 추가한다. - [ ] 기존 `stream-json`, `codex-json`, `claude-json` 테스트와 naming/fixture 스타일을 맞춘다. - [ ] 수동 smoke test 명령을 계획의 최종 검증에 반영한다. #### 테스트 작성 회귀 테스트를 작성한다. 기본 테스트명은 `TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents`로 권장한다. assertion은 `CollectDeltas`가 `part.text`만 연결한 문자열과 일치하는지 확인하고, event 수집 순서상 `start`와 `complete` 이벤트가 유지되는지 함께 검증해도 좋다. #### 중간 검증 ```bash go test ./apps/node/internal/adapters/cli/oneshot -run 'TestCLIExecuteOneShot(OpencodeJSON|StreamJSON|CodexJSON|ClaudeJSON)' -count=1 ``` 예상 결과: JSON 파서 관련 oneshot 테스트가 모두 PASS하고, 새 `opencode` fixture가 회귀 없이 통과한다. ## 수정 파일 요약 | 파일 | 항목 | |------|------| | `configs/edge.yaml` | `REFACTOR-1` | | `apps/node/internal/adapters/cli/oneshot.go` | `REFACTOR-2` | | `apps/node/internal/adapters/cli/oneshot/cli_test.go` | `REFACTOR-3` | ## 최종 검증 ```bash go test ./apps/node/internal/adapters/cli/oneshot -count=1 env -i HOME="$HOME" PATH=/config/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin go run ./apps/edge/cmd/edge console --config configs/edge.yaml env -i HOME="$HOME" PATH=/config/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ./bin/node.sh ``` 예상 결과: - 첫 번째 명령은 oneshot CLI 파서 테스트가 모두 PASS한다. - 두 번째와 세 번째 명령으로 띄운 console/node에서 `hi` 입력 시 `agent=opencode` 요청이 `start -> message -> complete` 순서로 출력된다.