diff --git a/agent-task/opencode_json_stream/code_review_0.log b/agent-task/opencode_json_stream/code_review_0.log new file mode 100644 index 0000000..3094de9 --- /dev/null +++ b/agent-task/opencode_json_stream/code_review_0.log @@ -0,0 +1,93 @@ + + +# Code Review Reference - REFACTOR + +## 개요 + +date=2026-05-04 +task=opencode_json_stream, plan=0, tag=REFACTOR + +## 이 파일을 읽는 리뷰 에이전트에게 + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료 후 반드시 아래 순서로 아카이브하세요. + +1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수) +2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수) +3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Opencode 프로필을 JSON 이벤트 출력으로 전환 | [ ] | +| [REFACTOR-2] Opencode JSON 이벤트 스트림 파서를 실제 CLI 출력에 맞게 갱신 | [ ] | +| [REFACTOR-3] Opencode JSON 스트림 회귀 테스트와 수동 smoke test를 정리 | [ ] | + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 리뷰어를 위한 체크포인트 + +- `configs/edge.yaml`의 `opencode` 프로필이 `--format json`과 `output_format: "opencode-json"`을 함께 사용하고 있는지 확인하세요. +- `emitOpencodeJSON`가 더 이상 `io.ReadAll` 기반 단일 `{"response":"..."}` 파서를 쓰지 않고, line-by-line JSON 이벤트를 읽어 `type:"text"`의 `part.text`만 delta로 내보내는지 확인하세요. +- `emitOpencodeJSON`가 `step_start`/`step_finish` 같은 비텍스트 이벤트를 중복 출력하지 않고, 오류 이벤트만 `RuntimeEventError`로 반영하는지 확인하세요. +- `apps/node/internal/adapters/cli/oneshot/cli_test.go`의 `opencode-json` 회귀 테스트 fixture가 실제 이벤트 stream 형식(`step_start`/`text`/`step_finish`)으로 바뀌었는지 확인하세요. +- 구현 에이전트가 PATH 축소 환경 smoke test 또는 동등한 검증으로 `opencode` 실행 경로와 JSON 파서 동작을 함께 검증했는지 확인하세요. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +### REFACTOR-1 중간 검증 +```bash +$ rg -n 'opencode:|--format|output_format' configs/edge.yaml +(output) +``` + +### REFACTOR-2 중간 검증 +```bash +$ go test ./apps/node/internal/adapters/cli/oneshot -run TestCLIExecuteOneShotOpencodeJSON -count=1 +(output) +``` + +### REFACTOR-3 중간 검증 +```bash +$ go test ./apps/node/internal/adapters/cli/oneshot -run 'TestCLIExecuteOneShot(OpencodeJSON|StreamJSON|CodexJSON|ClaudeJSON)' -count=1 +(output) +``` + +### 최종 검증 +```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 +(output) +``` + +## 코드리뷰 결과 + +- 종합 판정: FAIL + +- 차원별 평가 + - correctness: Fail + - completeness: Warn + - test coverage: Warn + - API contract: Fail + - code quality: Pass + - plan deviation: Pass + - verification trust: Warn + +- 발견된 문제 + - Required: [apps/node/internal/adapters/cli/oneshot.go:361](/config/workspace/go-iop/apps/node/internal/adapters/cli/oneshot.go:361) 에서 `opencode`의 `error` 이벤트를 `string`으로 파싱하고 있어 실제 출력 형식인 `{"type":"error","error":{"name":"UnknownError","data":{"message":"..."}}}`를 해석하지 못합니다. 이 상태에서는 모델 선택 실패 같은 런타임 오류가 `RuntimeEventError` 없이 지나가고, 프로세스 exit code가 `0`이면 상위 로직이 그대로 `complete`를 보내므로 실패를 성공으로 오인할 수 있습니다. `error.data.message` 또는 동등한 중첩 필드를 파싱하도록 구조체와 테스트를 함께 보강해야 합니다. + - Suggested: [agent-task/opencode_json_stream/CODE_REVIEW.md:25](/config/workspace/go-iop/agent-task/opencode_json_stream/CODE_REVIEW.md:25) 이후 섹션이 구현 결과로 채워지지 않아 체크리스트와 검증 출력이 모두 placeholder 상태입니다. 후속 구현에서는 실제 검증 명령 출력과 설계 변경 사항을 문서에 반영해 리뷰 신뢰도를 복구하는 것이 좋습니다. + +- 다음 단계 + - FAIL: Required 이슈를 반영하는 새 `PLAN.md`와 `CODE_REVIEW.md` 스텁을 작성해 다음 구현 루프를 시작한다. diff --git a/agent-task/opencode_json_stream/code_review_1.log b/agent-task/opencode_json_stream/code_review_1.log new file mode 100644 index 0000000..808b48e --- /dev/null +++ b/agent-task/opencode_json_stream/code_review_1.log @@ -0,0 +1,109 @@ + + +# Code Review Reference - REVIEW_REFACTOR + +## 개요 + +date=2026-05-04 +task=opencode_json_stream, plan=1, tag=REVIEW_REFACTOR + +## 이 파일을 읽는 리뷰 에이전트에게 + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료 후 반드시 아래 순서로 아카이브하세요. + +1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수) +2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수) +3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_REFACTOR-1] Opencode 중첩 error 이벤트를 정확히 파싱하고 회귀 테스트 추가 | [x] | + +## 계획 대비 변경 사항 + +- 계획에는 `TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent` 한 개만 명시되어 있었으나, fallback 분기(`error.data.message`가 없을 때 `error.name` 사용) 회귀를 고정하기 위해 `TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName`을 추가로 작성했다. 코드 변경에 fallback 로직이 포함되어 있어, 동작을 별도 테스트로 잠그는 편이 안전하다고 판단했다. + +## 주요 설계 결정 + +- `error` 구조체는 실제 `opencode run --format json` 실패 출력 (`{"type":"error","error":{"name":"UnknownError","data":{"message":"..."}}}`)을 그대로 반영해 중첩 객체로 정의했다. 실제 CLI 호출(`/config/.npm-global/bin/opencode run --model definitely-invalid-provider/does-not-exist --format json ...`)에서 같은 구조의 JSON이 stdout 마지막 줄로 나오는 것을 확인했다. +- 메시지 추출 우선순위는 `error.data.message` → `error.name` → 무시(emit 안 함). `error.name`만 있는 경우에도 최소한의 단서를 노출해야 디버깅이 가능하다고 판단했다. +- `text` 이벤트 처리는 기존과 동일하게 유지해 직전 PLAN(REFACTOR-1~3)에서 고친 동작이 회귀하지 않도록 했다. +- malformed/empty line skip 동작도 그대로 유지된다(`json.Unmarshal` 실패 시 continue, 빈 message 시 emit 생략). + +## 리뷰어를 위한 체크포인트 + +- `emitOpencodeJSON`가 `error`를 문자열이 아니라 실제 중첩 객체 형식으로 파싱하는지 확인하세요. + → [apps/node/internal/adapters/cli/oneshot.go:361-374](apps/node/internal/adapters/cli/oneshot.go#L361-L374)에서 `Error` 필드가 `Name string` + `Data.Message string`을 가진 익명 구조체로 정의됨. +- `error.data.message`가 있으면 그 값을 `RuntimeEventError`로 보내고, 없을 때만 fallback 필드를 쓰는지 확인하세요. + → [apps/node/internal/adapters/cli/oneshot.go:387-393](apps/node/internal/adapters/cli/oneshot.go#L387-L393)의 `case "error"` 분기에서 `msg := ev.Error.Data.Message`로 우선순위를 잡고, 빈 경우 `ev.Error.Name`로 fallback. +- 기존 text 이벤트 delta 처리와 malformed/empty line skip 동작이 회귀되지 않았는지 확인하세요. + → `TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents`, `...SkipsMalformedAndEmpty` 두 테스트가 기존 fixture로 동일하게 PASS. +- `apps/node/internal/adapters/cli/oneshot/cli_test.go`에 중첩 error 이벤트 fixture 기반 회귀 테스트가 추가되었는지 확인하세요. + → [apps/node/internal/adapters/cli/oneshot/cli_test.go:205-247](apps/node/internal/adapters/cli/oneshot/cli_test.go#L205-L247)에 `TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent`, [같은 파일의 그 다음 함수](apps/node/internal/adapters/cli/oneshot/cli_test.go#L249)에 fallback 테스트 추가. +- 구현 에이전트가 실제 `opencode run --format json` 실패 샘플 또는 동등한 fixture를 근거로 검증 출력을 남겼는지 확인하세요. + → 아래 `최종 검증` 항목의 두 번째 명령에 실제 CLI 실패 출력 첨부. 마지막 줄 JSON이 fixture와 동일한 중첩 구조임을 확인 가능. + +## 검증 결과 + +### REVIEW_REFACTOR-1 중간 검증 +```bash +$ go test ./apps/node/internal/adapters/cli/oneshot -run 'TestCLIExecuteOneShotOpencodeJSON(ParsesStreamEvents|SkipsMalformedAndEmpty|ParsesNestedErrorEvent|FallsBackToErrorName)' -count=1 -v +=== RUN TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents +--- PASS: TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents (0.00s) +=== RUN TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent +--- PASS: TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent (0.00s) +=== RUN TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName +--- PASS: TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName (0.00s) +=== RUN TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty +--- PASS: TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty (0.00s) +PASS +ok iop/apps/node/internal/adapters/cli/oneshot 0.006s +``` + +### 최종 검증 +```bash +$ go test ./apps/node/internal/adapters/cli/oneshot -count=1 +ok iop/apps/node/internal/adapters/cli/oneshot 0.283s + +$ timeout 30s /config/.npm-global/bin/opencode run --title untitle --model definitely-invalid-provider/does-not-exist --format json --dangerously-skip-permissions hi +ProviderModelNotFoundError: ProviderModelNotFoundError + data: { + providerID: "definitely-invalid-provider", + modelID: "does-not-exist", + suggestions: [], +}, + + at (/$bunfs/root/chunk-gbjb6qet.js:565:72153) + at ~effect/Effect/successCont (/$bunfs/root/chunk-y3yhj99w.js:25:7738) + at runLoop (/$bunfs/root/chunk-y3yhj99w.js:25:2045) + ... + +{"type":"error","timestamp":1777860214976,"sessionID":"ses_20f45ffb4ffe3VBE4hSSGiTYQa","error":{"name":"UnknownError","data":{"message":"Model not found: definitely-invalid-provider/does-not-exist."}}} +EXIT=0 +``` + +→ 두 번째 명령이 stdout 마지막 줄에 `{"type":"error","error":{"name":"UnknownError","data":{"message":"Model not found: ..."}}}` 형식의 JSON을 출력함. 이 JSON은 새로 정의한 `ev.Error.Data.Message` 경로로 정확히 파싱되며, 회귀 테스트 fixture(`TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent`)와 동일한 구조이므로 코드와 fixture가 실제 CLI 출력과 일치함을 확인. + +## 코드리뷰 결과 + +- 종합 판정: PASS + +- 차원별 평가 + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass + +- 발견된 문제 + - 없음 + +- 다음 단계 + - PASS: 아카이브 후 `complete.log`를 작성하고 작업을 종료한다. diff --git a/agent-task/opencode_json_stream/complete.log b/agent-task/opencode_json_stream/complete.log new file mode 100644 index 0000000..b8e812a --- /dev/null +++ b/agent-task/opencode_json_stream/complete.log @@ -0,0 +1,17 @@ +완료 일시: 2026-05-04 + +요약: opencode JSON stream 전환과 중첩 error 이벤트 파싱 보강을 2회 루프로 완료했다. + +루프 이력: + +| plan log | code review log | verdict | +|----------|------------------|---------| +| `plan_0.log` | `code_review_0.log` | FAIL | +| `plan_1.log` | `code_review_1.log` | PASS | + +최종 리뷰 요약: + +- `configs/edge.yaml`의 `opencode` 프로필을 절대경로 command, `--format json`, `output_format: "opencode-json"` 조합으로 정리했다. +- `apps/node/internal/adapters/cli/oneshot.go`의 `emitOpencodeJSON`를 line-by-line JSON 이벤트 파서로 전환하고, `text` 이벤트 delta 전달을 유지하면서 중첩 `error.data.message`와 `error.name` fallback을 처리하도록 보강했다. +- `apps/node/internal/adapters/cli/oneshot/cli_test.go`에 stream event, nested error event, fallback error name, malformed/empty input 회귀 테스트를 추가/갱신했다. +- `go test ./apps/node/internal/adapters/cli/oneshot -count=1` 통과와 실제 `opencode run --format json` 실패 샘플 확인으로 구현과 검증 출력을 맞췄다. diff --git a/agent-task/opencode_json_stream/plan_0.log b/agent-task/opencode_json_stream/plan_0.log new file mode 100644 index 0000000..c618039 --- /dev/null +++ b/agent-task/opencode_json_stream/plan_0.log @@ -0,0 +1,305 @@ + + +# 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` 순서로 출력된다. diff --git a/agent-task/opencode_json_stream/plan_1.log b/agent-task/opencode_json_stream/plan_1.log new file mode 100644 index 0000000..9d0a456 --- /dev/null +++ b/agent-task/opencode_json_stream/plan_1.log @@ -0,0 +1,113 @@ + + +# Opencode Error Event 후속 수정 계획 + +## 이 파일을 읽는 구현 에이전트에게 + +아래 체크리스트를 순서대로 완료하고, 각 항목의 중간 검증과 최종 검증을 실제로 실행하세요. 구현이 끝나면 `agent-task/opencode_json_stream/CODE_REVIEW.md`의 모든 섹션을 실제 구현 내용과 명령 출력으로 채우세요. `CODE_REVIEW.md`의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 아카이브 지시(`*.log`로 이름 변경, `complete.log` 작성)는 구현 에이전트가 수행하면 안 되며, 리뷰 스킬 전용입니다. + +## 배경 + +직전 구현으로 `opencode`의 JSON text 이벤트는 읽을 수 있게 됐지만, 실패 경로의 `error` 이벤트 형식은 아직 제대로 해석하지 못합니다. 실제 `opencode run --format json`은 `error` 필드를 중첩 객체로 보내는데 현재 파서는 문자열만 기대하고 있어, 런타임 오류가 발생해도 node가 이를 실패 이벤트로 전달하지 못할 수 있습니다. + +### [REVIEW_REFACTOR-1] Opencode 중첩 error 이벤트를 정확히 파싱하고 회귀 테스트 추가 + +#### 문제 + +`apps/node/internal/adapters/cli/oneshot.go:361-381`은 `error` 필드를 `string`으로 선언하고 `ev.Error != ""`일 때만 `RuntimeEventError`를 emit합니다. 하지만 실제 `opencode run --format json` 실패 출력은 `{"type":"error","error":{"name":"UnknownError","data":{"message":"Model not found: ..."}}}` 형태여서 현재 구현은 에러 메시지를 읽지 못하고, 프로세스가 exit code `0`이면 상위 로직이 `complete`를 보내는 오동작으로 이어집니다. + +Before (`apps/node/internal/adapters/cli/oneshot.go:361-381`) + +```go +361 var ev struct { +362 Type string `json:"type"` +363 Error string `json:"error"` +364 Part struct { +365 Type string `json:"type"` +366 Text string `json:"text"` +367 } `json:"part"` +368 } +369 if err := json.Unmarshal(line, &ev); err != nil { +370 continue +371 } +372 +373 switch ev.Type { +374 case "text": +375 if ev.Part.Type == "text" && ev.Part.Text != "" { +376 outputTokens += len(strings.Fields(ev.Part.Text)) +377 _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeDelta, Delta: ev.Part.Text, Timestamp: time.Now()}) +378 } +379 case "error": +380 if ev.Error != "" { +381 _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: ev.Error, Timestamp: time.Now()}) +``` + +#### 해결 방법 + +`error`를 실제 출력에 맞는 중첩 구조체로 파싱하고, 우선순위는 `error.data.message`, 그다음 `error.name` 같은 fallback 순으로 메시지를 추출합니다. 동시에 테스트 fixture에 실제 에러 이벤트 샘플을 추가해, `RuntimeEventError`가 빠지지 않고 전달되는지 회귀를 고정합니다. + +After + +```go +var ev struct { + Type string `json:"type"` + Error struct { + Name string `json:"name"` + Data struct { + Message string `json:"message"` + } `json:"data"` + } `json:"error"` + Part struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"part"` +} +... +case "error": + msg := ev.Error.Data.Message + if msg == "" { + msg = ev.Error.Name + } + if msg != "" { + _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()}) + } +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/node/internal/adapters/cli/oneshot.go`에서 `opencode` error 이벤트 구조체를 실제 JSON 형식에 맞게 수정한다. +- [ ] `apps/node/internal/adapters/cli/oneshot.go`에서 `error.data.message`를 우선 사용하고 fallback 메시지 규칙을 정한다. +- [ ] `apps/node/internal/adapters/cli/oneshot.go`에서 text 이벤트 처리 동작은 유지하고 error 처리만 확장한다. +- [ ] `apps/node/internal/adapters/cli/oneshot/cli_test.go`에 중첩 error 이벤트 fixture 기반 회귀 테스트를 추가한다. +- [ ] 기존 `TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents`, malformed/empty 테스트와 충돌 없이 모두 통과하는지 확인한다. + +#### 테스트 작성 + +회귀 테스트를 작성한다. `apps/node/internal/adapters/cli/oneshot/cli_test.go`에 `TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent`를 추가하고, fixture는 `{"type":"error","error":{"name":"UnknownError","data":{"message":"Model not found"}}}` 형태를 사용한다. assertion은 `RuntimeEventError`가 정확한 메시지로 기록되는지, 그리고 텍스트 delta가 없을 때도 테스트가 실패를 감지하는지 확인하는 것이다. + +#### 중간 검증 + +```bash +go test ./apps/node/internal/adapters/cli/oneshot -run 'TestCLIExecuteOneShotOpencodeJSON(ParsesStreamEvents|SkipsMalformedAndEmpty|ParsesNestedErrorEvent)' -count=1 +``` + +예상 결과: `opencode-json` 관련 테스트가 모두 PASS하고, 새 error fixture에서 중첩 메시지가 정확히 검증된다. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/node/internal/adapters/cli/oneshot.go` | `REVIEW_REFACTOR-1` | +| `apps/node/internal/adapters/cli/oneshot/cli_test.go` | `REVIEW_REFACTOR-1` | + +## 최종 검증 + +```bash +go test ./apps/node/internal/adapters/cli/oneshot -count=1 +timeout 30s /config/.npm-global/bin/opencode run --title untitle --model definitely-invalid-provider/does-not-exist --format json --dangerously-skip-permissions hi +``` + +예상 결과: + +- 첫 번째 명령은 oneshot CLI 파서 테스트가 모두 PASS한다. +- 두 번째 명령의 실제 JSON 에러 샘플을 기준으로, 구현 코드가 같은 구조의 `error.data.message`를 파싱하도록 테스트와 코드가 일치한다. diff --git a/apps/node/internal/adapters/cli/oneshot.go b/apps/node/internal/adapters/cli/oneshot.go index 286ec62..82dcb8b 100644 --- a/apps/node/internal/adapters/cli/oneshot.go +++ b/apps/node/internal/adapters/cli/oneshot.go @@ -340,38 +340,61 @@ func emitCodexJSONLines(ctx context.Context, stdout io.Reader, sink runtime.Even return outputTokens, nil } -// emitOpencodeJSON parses `opencode -p ... -f json` output. The current -// opencode CLI wraps the final assistant text in a single JSON object: -// {"response":"..."}. +// emitOpencodeJSON parses `opencode run --format json` output. +// Each line is a JSON event: step_start, text (with part.text), step_finish, or error. +// Only type=="text" events with a non-empty part.text are emitted as deltas. func emitOpencodeJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) { - body, err := io.ReadAll(stdout) - if err != nil { - return 0, err - } - outBuf.Write(body) + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + outputTokens := 0 - trimmed := strings.TrimSpace(string(body)) - if trimmed == "" || trimmed[0] != '{' { - return 0, nil + 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 struct { + Name string `json:"name"` + Data struct { + Message string `json:"message"` + } `json:"data"` + } `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": + msg := ev.Error.Data.Message + if msg == "" { + msg = ev.Error.Name + } + if msg != "" { + _ = sink.Emit(ctx, runtime.RuntimeEvent{RunID: runID, Type: runtime.EventTypeError, Error: msg, Timestamp: time.Now()}) + } + } } - var resp struct { - Response string `json:"response"` + if err := scanner.Err(); err != nil { + return outputTokens, err } - if err := json.Unmarshal([]byte(trimmed), &resp); err != nil { - return 0, nil - } - if resp.Response == "" { - return 0, nil - } - - outputTokens := len(strings.Fields(resp.Response)) - _ = sink.Emit(ctx, runtime.RuntimeEvent{ - RunID: runID, - Type: runtime.EventTypeDelta, - Delta: resp.Response, - Timestamp: time.Now(), - }) return outputTokens, nil } diff --git a/apps/node/internal/adapters/cli/oneshot/cli_test.go b/apps/node/internal/adapters/cli/oneshot/cli_test.go index 21a3a40..efa49b3 100644 --- a/apps/node/internal/adapters/cli/oneshot/cli_test.go +++ b/apps/node/internal/adapters/cli/oneshot/cli_test.go @@ -168,13 +168,13 @@ EOF` } } -func TestCLIExecuteOneShotOpencodeJSONParsesResponse(t *testing.T) { +func TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents(t *testing.T) { testutil.RequireUnixShell(t) script := `cat <<'EOF' -{ - "response": "OpenCode says hello." -} +{"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` cfg := config.CLIConf{ Enabled: true, @@ -197,8 +197,123 @@ EOF` t.Fatalf("execute: %v", err) } combined := testutil.CollectDeltas(sink.Events()) - if combined != "OpenCode says hello." { - t.Fatalf("expected opencode-json deltas to be %q, got %q", "OpenCode says hello.", combined) + if combined != "Hi from opencode." { + t.Fatalf("expected opencode-json deltas to be %q, got %q", "Hi from opencode.", combined) + } +} + +func TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent(t *testing.T) { + testutil.RequireUnixShell(t) + + script := `cat <<'EOF' +{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}} +{"type":"error","sessionID":"ses_1","error":{"name":"UnknownError","data":{"message":"Model not found: definitely-invalid-provider/does-not-exist"}}} +EOF` + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "opencode": { + Command: "sh", + Args: []string{"-c", script, "sh"}, + OutputFormat: "opencode-json", + }, + }, + } + c := clipkg.New(cfg, zap.NewNop()) + sink := &testutil.FakeSink{} + + if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-oc-err", + Model: "opencode", + Input: map[string]any{"prompt": "hi"}, + }, sink); err != nil { + t.Fatalf("execute: %v", err) + } + if combined := testutil.CollectDeltas(sink.Events()); combined != "" { + t.Fatalf("expected no deltas for error-only stream, got %q", combined) + } + want := "Model not found: definitely-invalid-provider/does-not-exist" + var got string + for _, e := range sink.Events() { + if e.Type == noderuntime.EventTypeError { + got = e.Error + break + } + } + if got != want { + t.Fatalf("expected nested error message %q, got %q", want, got) + } +} + +func TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName(t *testing.T) { + testutil.RequireUnixShell(t) + + script := `cat <<'EOF' +{"type":"error","sessionID":"ses_1","error":{"name":"ProviderUnavailable"}} +EOF` + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "opencode": { + Command: "sh", + Args: []string{"-c", script, "sh"}, + OutputFormat: "opencode-json", + }, + }, + } + c := clipkg.New(cfg, zap.NewNop()) + sink := &testutil.FakeSink{} + + if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-oc-err2", + Model: "opencode", + Input: map[string]any{"prompt": "hi"}, + }, sink); err != nil { + t.Fatalf("execute: %v", err) + } + var got string + for _, e := range sink.Events() { + if e.Type == noderuntime.EventTypeError { + got = e.Error + break + } + } + if got != "ProviderUnavailable" { + t.Fatalf("expected fallback to error.name, got %q", got) + } +} + +func TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty(t *testing.T) { + testutil.RequireUnixShell(t) + + script := `cat <<'EOF' +not-json +{"type":"text","sessionID":"ses_1","part":{"type":"text","text":""}} +{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"valid"}} +EOF` + cfg := config.CLIConf{ + Enabled: true, + Profiles: map[string]config.CLIProfileConf{ + "opencode": { + Command: "sh", + Args: []string{"-c", script, "sh"}, + OutputFormat: "opencode-json", + }, + }, + } + c := clipkg.New(cfg, zap.NewNop()) + sink := &testutil.FakeSink{} + + if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{ + RunID: "run-oc2", + Model: "opencode", + Input: map[string]any{"prompt": "hi"}, + }, sink); err != nil { + t.Fatalf("execute: %v", err) + } + combined := testutil.CollectDeltas(sink.Events()) + if combined != "valid" { + t.Fatalf("expected only valid delta, got %q", combined) } } diff --git a/configs/edge.yaml b/configs/edge.yaml index 2c38486..79825be 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -16,7 +16,7 @@ console: agent: "opencode" session_id: "default" background: false - timeout_sec: 120 + timeout_sec: 300 nodes: - alias: "local-node" @@ -72,18 +72,20 @@ nodes: terminal: false output_format: "codex-json" opencode: - command: "opencode" + command: "/config/.npm-global/bin/opencode" args: - "run" + - "--title" + - "untitle" - "--model" - - "ollama-dgx/qwen3.6:35b-a3b-bf16" + - "ollama-m1/qwen3.6:27b-coding-mxfp8" - "--format" - - "default" + - "json" - "--dangerously-skip-permissions" env: [] persistent: false terminal: false - output_format: "" + output_format: "opencode-json" runtime: concurrency: 4 workspace_root: "/tmp/iop/workspace"