iop/agent-task/archive/2026/05/opencode_json_stream/plan_0.log
toki 690498453e 정리: 작업 로그 아카이브 구조를 정리한다
AI-first 작업 이력을 보존하면서 기본 작업 컨텍스트에서 과거 로그를 분리하기 위해 agent-task 완료 로그를 월별 archive로 이동하고 에이전트 진입/ignore 규칙을 추가한다.
2026-05-17 18:34:27 +09:00

305 lines
13 KiB
Text

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