<!-- task=opencode_sse_stream plan=1 tag=REVIEW_OPENCODE_SSE -->

# Review Fix Plan - OpenCode SSE API Contract

## 이 파일을 읽는 구현 에이전트에게

**반드시 마지막에 `CODE_REVIEW-cloud-G07.md`의 모든 섹션을 실제 구현 내용과 검증 출력으로 채워야 한다. 이 파일 작성 전에는 작업이 완료된 것이 아니다.** 아래 체크리스트를 끝까지 수행하고, 중간/최종 검증 명령의 실제 stdout/stderr를 review 파일에 붙인다. review 파일의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 archive 지시(`*.log` rename, `complete.log` 작성)는 구현 에이전트가 실행하지 않는다.

## 배경

1차 구현은 테스트를 통과했지만 OpenCode server OpenAPI 계약과 맞지 않는 부분이 확인됐다. `/session/{sessionID}/prompt_async`의 `model` payload는 객체여야 하고, `session.status.status`는 문자열이 아니라 `SessionStatus` 객체다. 이 두 문제는 실서버에서 prompt 전송 실패나 stream completion 누락으로 이어질 수 있다.

## 분석 결과

### 읽은 파일

- `agent-task/opencode_sse_stream/plan_cloud_G07_0.log`
- `agent-task/opencode_sse_stream/code_review_cloud_G07_0.log`
- `apps/node/internal/adapters/cli/opencode_sse.go`
- `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`
- `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`
- `configs/edge.yaml`
- local OpenCode `1.14.46` `/doc` OpenAPI response

### 테스트 커버리지 공백

- `--model provider/model`이 `/prompt_async` body에서 `{providerID, modelID}` 객체로 전송되는지 검증하지 않는다.
- `session.status` 이벤트가 `{"status":{"type":"idle"}}` 형태로 올 때 complete 되는지 검증하지 않는다.

### 심볼 참조

- renamed/removed symbols: none.
- 새 helper 후보: `opencodeModelPayload`, `opencodeIdleStatus`.

### 범위 결정 근거

- `runtime.Adapter`, protobuf, Edge transport는 변경하지 않는다. 문제는 opencode SSE adapter 내부 HTTP payload/event parsing 계약에 한정된다.
- `configs/edge.yaml`의 opencode profile 이름과 args는 유지한다.
- 실제 opencode binary/model 통합 테스트는 제외한다. `httptest.Server`와 local OpenAPI 계약에 맞춘 unit/blackbox tests로 검증한다.

### 빌드 등급

- build=`cloud-G07`, review=`cloud-G07`: HTTP API 계약과 streaming completion 조건을 수정하므로 기존 등급을 유지한다.

### [REVIEW_OPENCODE_SSE-1] prompt_async model payload를 OpenCode schema에 맞춘다

#### 문제

```go
// apps/node/internal/adapters/cli/opencode_sse.go:562
if opts.Model != "" {
	payload["model"] = opts.Model
}
```

OpenCode `/session/{sessionID}/prompt_async` schema는 `model`을 `{providerID, modelID}` 객체로 요구한다. 현재 구현은 문자열을 보내므로 `--model ollama-dgx/qwen3.6:35b-a3b-bf16` 사용 시 실서버가 Bad Request를 반환할 수 있다.

#### 해결 방법

`--model provider/model`을 첫 `/` 기준으로 분리한다. provider 또는 model이 비어 있거나 `/`가 없으면 명확한 error를 반환하고 `RuntimeEvent error`를 emit한다.

```go
// after
if opts.Model != "" {
	model, err := opencodeModelPayload(opts.Model)
	if err != nil {
		return err
	}
	payload["model"] = model
}
```

#### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/adapters/cli/opencode_sse.go`: `opencodeModelPayload` helper 추가, `opencodePromptAsync`에서 객체 payload 사용.
- [ ] `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`: 정상 split, `/` 없는 model, provider 빈 값, model 빈 값 테스트.
- [ ] `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`: fake server의 `lastPromptBody`를 JSON decode해서 `model.providerID`, `model.modelID`, `parts[0].text`를 검증.

#### 테스트 작성

- 작성한다.
- `TestOpencodeModelPayload` 또는 동등한 이름으로 model parsing boundary를 검증한다.
- `TestCLIExecuteOpencodeSSE_StreamsTextDeltas`에 prompt body assertion을 추가한다.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/adapters/cli
```

예상 결과: package pass. Go test cache는 허용하지 않는다.

### [REVIEW_OPENCODE_SSE-2] session.status 객체 idle을 complete로 처리한다

#### 문제

```go
// apps/node/internal/adapters/cli/opencode_sse.go:401
case "session.status":
	if status, ok := stringFromProp(e.ev.Properties, "status"); ok && status == "idle" {
```

OpenCode `EventSessionStatus.properties.status`는 `SessionStatus` 객체다. `{"status":{"type":"idle"}}` 이벤트가 오면 현재 구현은 complete를 emit하지 않는다.

#### 해결 방법

문자열 `"idle"`과 객체 `{"type":"idle"}`를 모두 처리하는 helper를 추가한다.

```go
// after
case "session.status":
	if opencodeStatusIdle(e.ev.Properties) {
		return emitOpencodeComplete(ctx, spec.RunID, inputTokens, outputTokens, sink)
	}
```

complete event 중복 코드는 작게 helper로 묶어도 된다. 단, 이 refactor는 `opencode_sse.go` 안에 한정한다.

#### 수정 파일 및 체크리스트

- [ ] `apps/node/internal/adapters/cli/opencode_sse.go`: `opencodeStatusIdle` helper 추가, `session.status` branch 수정.
- [ ] `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`: string idle, object idle, busy/retry/non-map false 테스트.
- [ ] `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`: `session.idle` 없이 `session.status` object idle만 보내도 run이 complete 되는 regression test 추가.

#### 테스트 작성

- 작성한다.
- `TestCLIExecuteOpencodeSSE_SessionStatusObjectIdleCompletes`를 추가한다.

#### 중간 검증

```bash
go test -count=1 ./apps/node/internal/adapters/cli
```

예상 결과: package pass. Go test cache는 허용하지 않는다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/cli/opencode_sse.go` | REVIEW_OPENCODE_SSE-1, REVIEW_OPENCODE_SSE-2 |
| `apps/node/internal/adapters/cli/opencode_sse_internal_test.go` | REVIEW_OPENCODE_SSE-1, REVIEW_OPENCODE_SSE-2 |
| `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go` | REVIEW_OPENCODE_SSE-1, REVIEW_OPENCODE_SSE-2 |

## 최종 검증

```bash
go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/adapters ./apps/edge/internal/node ./apps/edge/internal/transport ./packages/config
```

예상 결과: package pass. Go test cache는 허용하지 않는다.

```bash
go test -count=1 ./...
```

예상 결과: repository-wide pass. Go test cache는 허용하지 않는다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 전체 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
