# Code Review Reference - OPENCODE_SSE > **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** > The task is NOT complete until every section below is filled in. > Follow the ownership table at the bottom of this file for which sections you own. ## 개요 date=2026-05-11 task=opencode_sse_stream, plan=0, tag=OPENCODE_SSE ## 이 파일을 읽는 리뷰 에이전트에게 각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. 리뷰 완료 후 반드시 아래 순서로 아카이브하세요. 1. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log` (N = 기존 code_review_*.log 수) 2. `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log` (M = 기존 plan_*.log 수) 3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 routed plan + review 스텁 작성. --- ## 구현 항목별 완료 여부 | 항목 | 완료 여부 | |------|---------| | [OPENCODE_SSE-1] CLI adapter에 opencode SSE mode 추가 | [x] | | [OPENCODE_SSE-2] opencode profile 예시와 문서를 SSE mode로 정리 | [x] | ## 계획 대비 변경 사항 - 계획은 `executeOpencodeSSE`가 동기 scanner loop으로 SSE를 처리하는 구조를 암시했으나, 구현은 SSE 이벤트를 read하는 goroutine + `select { case <-ctx.Done(); case e := <-events }` 형태로 분리했다. 이유: `ctx.Done()` 즉시 응답성을 보장하면서도 abort 호출/cancelled emit/`ErrRunCancelled` 반환을 깔끔히 직렬화하기 위함이다. 외부 계약(`runtime.Adapter`/`RuntimeEvent`)은 그대로다. - 계획의 `permission.asked` 응답에서 `--dangerously-skip-permissions`가 없으면 `reject`로 답하도록 했고, 응답 호출은 background context + 5초 timeout으로 묶었다. SSE drain 도중 context 취소 시 reply가 ctx와 함께 죽지 않도록 하기 위함이다. - `opencodeAbort` 또한 cancel finalize 경로에서 별도 `context.WithTimeout(2s)`로 호출한다. 원래 ctx는 이미 done 상태이므로 abort 요청 자체가 즉시 실패하지 않게 하기 위해서다. - 검증 명령은 plan에 명시된 그대로 사용했다. 대체 없음. ## 주요 설계 결정 - `opencodeSSESession`은 `serverURL`, `sessionID`, `cmd`, `owned` 4개 필드만 보관한다. `owned=true`일 때만 local server process를 kill한다. `--attach`로 외부 server를 사용할 때는 process 소유권이 없으므로 cache 제거만 한다. - HTTP 라우팅 키: SSE 이벤트가 `properties.sessionID`를 포함할 때만 본 세션과 일치 여부를 검사하고, 누락된 경우는 통과시킨다. 이렇게 해서 서버측 이벤트 스키마 변화에 대한 호환성 여유를 둔다. - complete usage는 `session.next.step.ended`의 `tokens.input/output`에서 갱신하고 0이 아닌 값만 덮어쓴다. complete event는 `session.idle` 또는 `session.status.status=="idle"` 중 먼저 들어오는 쪽에서 emit한다. - `parseOpencodeRunArgs`는 `opencode run` 호환 옵션만 인식하고 알 수 없는 토큰은 무시한다. legacy `--format json` 등은 silent ignore 되어 사용자 설정 마이그레이션 시 잡음을 줄인다. - `closeOpencodeSession`은 attach 세션에 대해 process kill을 시도하지 않는다. abort 요청은 cancel 경로에서만 호출한다 — TerminateSession은 cache drop만 한다 (외부 server 정책에 맡긴다). ## 리뷰어를 위한 체크포인트 - `runtime.Adapter`, `runtime.EventSink`, protobuf `RunEvent` 계약이 변경되지 않았는지 확인한다. - `opencode-sse`는 `profile.Mode` 내부 분기로만 동작하고 기존 one-shot/persistent/codex-exec 경로를 깨지 않는지 확인한다. - SSE tests는 `httptest.Server`를 사용하고 실제 opencode binary나 실제 모델에 의존하지 않는지 확인한다. - `session.next.text.delta`가 즉시 `RuntimeEvent` delta로 emit되는지, complete usage는 `session.next.step.ended`에서 반영되는지 확인한다. - context cancel이 opencode abort 호출, cancelled event, `runtime.ErrRunCancelled`를 모두 만족하는지 확인한다. - config/docs 변경이 `mode: "opencode-sse"` 방향을 설명하고 legacy `opencode-json` stdout 경로와 혼동되지 않는지 확인한다. ## 검증 결과 _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ 필수 규칙: - 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. - 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. - `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. ### OPENCODE_SSE-1 중간 검증 ``` $ go test -count=1 ./apps/node/internal/adapters/cli ok iop/apps/node/internal/adapters/cli 87.356s ``` ### OPENCODE_SSE-2 중간 검증 ``` $ go test -count=1 ./packages/config ./apps/edge/internal/node ./apps/node/internal/adapters ok iop/packages/config 0.006s ok iop/apps/edge/internal/node 0.003s ok iop/apps/node/internal/adapters 0.004s ``` ### 최종 검증 ``` $ go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/adapters ./apps/edge/internal/node ./apps/edge/internal/transport ./packages/config ok iop/apps/node/internal/adapters/cli 87.406s ok iop/apps/node/internal/adapters 0.005s ok iop/apps/edge/internal/node 0.004s ok iop/apps/edge/internal/transport 0.007s ok iop/packages/config 0.006s ``` ``` $ go test -count=1 ./... ? iop/apps/control-plane/cmd/control-plane [no test files] ok iop/apps/edge/cmd/edge 0.006s ? iop/apps/edge/internal/bootstrap [no test files] ok iop/apps/edge/internal/node 0.006s ok iop/apps/edge/internal/transport 0.009s ? iop/apps/node/cmd/node [no test files] ok iop/apps/node/internal/adapters 0.006s ok iop/apps/node/internal/adapters/cli 87.369s ? iop/apps/node/internal/adapters/cli/internal/testutil [no test files] ok iop/apps/node/internal/adapters/cli/status 0.004s ? iop/apps/node/internal/adapters/mock [no test files] ? iop/apps/node/internal/adapters/ollama [no test files] ? iop/apps/node/internal/adapters/vllm [no test files] ok iop/apps/node/internal/bootstrap 0.162s ok iop/apps/node/internal/node 0.009s ok iop/apps/node/internal/router 0.004s ? iop/apps/node/internal/runtime [no test files] ok iop/apps/node/internal/store 0.049s ok iop/apps/node/internal/transport 0.006s ? iop/apps/worker/cmd/worker [no test files] ? iop/packages/auth [no test files] ok iop/packages/config 0.015s ? iop/packages/jobs [no test files] ? iop/packages/metadata [no test files] ? iop/packages/observability [no test files] ? iop/packages/policy [no test files] ? iop/packages/version [no test files] ? iop/proto/gen/iop [no test files] ``` ``` $ rg --sort path -n 'opencode-sse|opencode-json|opencode run --format json|session.next.text.delta' apps/node apps/edge configs packages agent-task/opencode_sse_stream apps/node/README.md:95:`opencode`는 profile `mode: "opencode-sse"`로 설정해 `opencode serve`의 HTTP/SSE 인터페이스를 사용한다. `profile.Command`가 가리키는 opencode 바이너리를 `serve --hostname 127.0.0.1 --port 0`으로 띄우고 `/event` SSE에서 `session.next.text.delta`를 즉시 `RuntimeEvent` delta로 relay한다. 외부에서 이미 실행 중인 server에 연결하려면 `--attach `을 args에 추가한다. legacy `output_format: "opencode-json"` stdout JSONL 경로는 `opencode run --format json` 사용 시에만 의미가 있다. apps/node/internal/adapters/cli/cli.go:29: modeOpencodeSSE = "opencode-sse" apps/node/internal/adapters/cli/cli_internal_test.go:320: {opencodeJSONEmitter{}, "opencode-json"}, apps/node/internal/adapters/cli/cli_internal_test.go:331: expectedKeys := []string{"stream-json", "claude-json", "codex-json", "opencode-json", "cline-json"} apps/node/internal/adapters/cli/emitters.go:33: "opencode-json": {emitter: opencodeJSONEmitter{}, scanBufMax: 4 * 1024 * 1024}, apps/node/internal/adapters/cli/emitters.go:282:// --- opencode-json emitter --- apps/node/internal/adapters/cli/emitters.go:286:func (opencodeJSONEmitter) Name() string { return "opencode-json" } apps/node/internal/adapters/cli/oneshot_blackbox_test.go:185: OutputFormat: "opencode-json", apps/node/internal/adapters/cli/oneshot_blackbox_test.go:201: t.Fatalf("expected opencode-json deltas to be %q, got %q", "Hi from opencode.", combined) apps/node/internal/adapters/cli/oneshot_blackbox_test.go:218: OutputFormat: "opencode-json", apps/node/internal/adapters/cli/oneshot_blackbox_test.go:260: OutputFormat: "opencode-json", apps/node/internal/adapters/cli/oneshot_blackbox_test.go:300: OutputFormat: "opencode-json", apps/node/internal/adapters/cli/opencode_sse.go:370: case "session.next.text.delta": apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go:130: Mode: "opencode-sse", apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go:165: "type": "session.next.text.delta", apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go:172: "type": "session.next.text.delta", apps/node/internal/adapters/factory_internal_test.go:169: Mode: "opencode-sse", apps/node/internal/adapters/factory_internal_test.go:178: if prof.Mode != "opencode-sse" { apps/node/internal/adapters/factory_internal_test.go:192: "mode": "opencode-sse", apps/node/internal/adapters/factory_internal_test.go:204: if prof.Mode != "opencode-sse" { apps/edge/README.md:18:같은 `cli` 어댑터 안에 `claude`, `gemini`, `codex`, `opencode`, `cline` profile을 포함할 수 있다. 예를 들어 `gemini --approval-mode yolo -p `, `codex exec --dangerously-bypass-approvals-and-sandbox`, `cline -y --json --config /config/.cline/profiles/ollama-dgx `처럼 headless 조합으로 설정한다. `opencode`는 `mode: "opencode-sse"` profile로 `opencode serve`의 HTTP/SSE 인터페이스를 사용하며, args의 `--model`, `--dangerously-skip-permissions`, `--title` 등은 그대로 유지하면 된다. apps/edge/internal/node/mapper_test.go:152: Mode: "opencode-sse", apps/edge/internal/node/mapper_test.go:178: if p.Mode != "opencode-sse" { apps/edge/internal/node/mapper_test.go:179: t.Errorf("mode: got %q, want %q", p.Mode, "opencode-sse") configs/edge.yaml:96: mode: "opencode-sse" packages/config/config_test.go:429: mode: "opencode-sse" packages/config/config_test.go:445: if prof.Mode != "opencode-sse" { agent-task/opencode_sse_stream/CODE_REVIEW-cloud-G07.md:43:- `opencode-sse`는 `profile.Mode` 내부 분기로만 동작하고 기존 one-shot/persistent/codex-exec 경로를 깨지 않는지 확인한다. agent-task/opencode_sse_stream/CODE_REVIEW-cloud-G07.md:45:- `session.next.text.delta`가 즉시 `RuntimeEvent` delta로 emit되는지, complete usage는 `session.next.step.ended`에서 반영되는지 확인한다. agent-task/opencode_sse_stream/CODE_REVIEW-cloud-G07.md:47:- config/docs 변경이 `mode: "opencode-sse"` 방향을 설명하고 legacy `opencode-json` stdout 경로와 혼동되지 않는지 확인한다. agent-task/opencode_sse_stream/CODE_REVIEW-cloud-G07.md:82:$ rg --sort path -n 'opencode-sse|opencode-json|opencode run --format json|session.next.text.delta' apps/node apps/edge configs packages agent-task/opencode_sse_stream agent-task/opencode_sse_stream/PLAN-cloud-G07.md:11:`opencode run --format json`은 IOP의 stdout JSONL 파이프라인에 연결되어 있지만, OpenCode CLI 구현상 완성된 text part 중심으로 출력되어 토큰 단위 delta stream을 안정적으로 얻기 어렵다. OpenCode server는 `/event` SSE와 `/session/{id}/prompt_async`를 제공하며, 이 경로에는 `session.next.text.delta` 이벤트가 있다. IOP 공통 인터페이스는 `runtime.Adapter.Execute(ctx, spec, sink)`와 `RuntimeEvent`이므로, opencode 내부 실행 방식만 SSE client로 바꾸면 Edge/Node transport 계약은 유지된다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:45:- `opencode-json` stdout JSONL parsing은 `apps/node/internal/adapters/cli/oneshot_blackbox_test.go`와 `cli_internal_test.go`에 있다. SSE 기반 `session.next.text.delta` relay는 테스트가 없다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:46:- `CLI.Execute`의 mode 분기는 `codex-exec`, `persistent`, one-shot만 검증되어 있다. `opencode-sse` 분기, session reuse, `REQUIRE_EXISTING` 실패는 테스트가 없다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:48:- edge YAML의 `mode` 로딩은 `codex-exec` 예시만 있다. `opencode-sse` profile 예시는 없다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:120:`executeOneShot`은 prompt를 subprocess argv 뒤에 붙이는 구조라 `opencode run --format json`의 stdout event만 읽을 수 있다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:142: modeOpencodeSSE = "opencode-sse" agent-task/opencode_sse_stream/PLAN-cloud-G07.md:165:`Execute`는 `opencode-sse`를 persistent/one-shot보다 먼저 처리한다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:185:- SSE `session.next.text.delta`는 `RuntimeEvent{Type: delta, Delta: properties.delta}`로 즉시 emit한다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:205: - `TestCLIExecuteOpencodeSSE_StreamsTextDeltas`: fake `/event`, `/session`, `/prompt_async`로 `session.next.text.delta` 2개와 idle을 보내고 delta concat, complete, usage를 검증한다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:243: output_format: "opencode-json" agent-task/opencode_sse_stream/PLAN-cloud-G07.md:267:`configs/edge.yaml`의 opencode profile을 `mode: "opencode-sse"`로 바꾸고 `args`는 prompt 전송 옵션만 남긴다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:282: mode: "opencode-sse" agent-task/opencode_sse_stream/PLAN-cloud-G07.md:285:`apps/node/README.md`와 `apps/edge/README.md`는 opencode 예시를 `opencode serve` + SSE mode로 설명한다. stdout JSONL `opencode-json` emitter는 legacy/run mode로 남긴다. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:289:- [ ] `configs/edge.yaml`: opencode profile `mode: "opencode-sse"` 적용, `run`/`--format json` 제거. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:290:- [ ] `apps/node/README.md`: CLI adapter 설명에 opencode는 `opencode-sse` mode에서 server SSE를 사용한다고 기록. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:300: - `TestLoadEdge_OpencodeSSEProfile`: YAML에서 `mode: "opencode-sse"`, `--model`, `--dangerously-skip-permissions`가 로드되는지 확인. agent-task/opencode_sse_stream/PLAN-cloud-G07.md:344:rg --sort path -n 'opencode-sse|opencode-json|opencode run --format json|session.next.text.delta' apps/node apps/edge configs packages agent-task/opencode_sse_stream agent-task/opencode_sse_stream/PLAN-cloud-G07.md:347:예상 결과: `opencode-sse`는 새 mode/문서/테스트에 나타난다. `opencode-json`과 `opencode run --format json`은 legacy stdout JSONL 경로로 의도적으로 남은 항목만 나타난다. ``` --- > **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every section: completion table, changes from plan, design decisions, and verification output?** > If anything is blank, go back and fill it in before saving this file. ## 코드리뷰 결과 ### 종합 판정 FAIL ### 차원별 평가 | 차원 | 평가 | |------|------| | correctness | Fail | | completeness | Fail | | test coverage | Fail | | API contract | Fail | | code quality | Pass | | plan deviation | Pass | | verification trust | Warn | ### 발견된 문제 - Required: `apps/node/internal/adapters/cli/opencode_sse.go:562`에서 `payload["model"] = opts.Model`로 문자열을 보내고 있다. OpenCode `1.14.46`의 `/session/{sessionID}/prompt_async` schema는 `model`을 `{providerID, modelID}` 객체로 요구한다. 현재 `configs/edge.yaml`의 `--model ollama-dgx/qwen3.6:35b-a3b-bf16` 조합은 실서버에서 400 응답을 낼 수 있다. `--model provider/model`을 첫 `/` 기준으로 분리해 `map[string]any{"providerID": provider, "modelID": model}`로 보내고, fake server test가 prompt body shape를 검증하도록 수정한다. - Required: `apps/node/internal/adapters/cli/opencode_sse.go:401`의 `session.status` 처리에서 `status`를 문자열 `"idle"`로만 읽는다. OpenCode schema의 `status`는 `SessionStatus` 객체이며 idle은 보통 `{"type":"idle"}` 형태다. 서버가 `session.status`로 완료 상태를 알리면 현재 adapter는 이를 무시하고 stream close나 timeout까지 대기할 수 있다. `status` 문자열과 객체를 모두 해석하는 helper를 추가하고 `session.status` 객체만으로 complete 되는 regression test를 추가한다. ### 다음 단계 FAIL: Required 문제를 해결하는 새 routed plan/review 파일을 작성하고 루프를 계속한다.