<!-- task=opencode_json_stream plan=0 tag=REFACTOR -->

# 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` 순서로 출력된다.
