<!-- task=opencode_json_stream plan=1 tag=REVIEW_REFACTOR -->

# 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`를 파싱하도록 테스트와 코드가 일치한다.
