<!-- task=opencode_sse_stream plan=0 tag=OPENCODE_SSE -->

# 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 <url>`을 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 <prompt>`, `codex exec --dangerously-bypass-approvals-and-sandbox`, `cline -y --json --config /config/.cline/profiles/ollama-dgx <prompt>`처럼 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 파일을 작성하고 루프를 계속한다.
