305 lines
13 KiB
Text
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` 순서로 출력된다.
|