update: oneshot adapter, cli test, edge config & opencode_json_stream task
This commit is contained in:
parent
03c02d738a
commit
428ac8b04c
8 changed files with 815 additions and 38 deletions
93
agent-task/opencode_json_stream/code_review_0.log
Normal file
93
agent-task/opencode_json_stream/code_review_0.log
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<!-- task=opencode_json_stream plan=0 tag=REFACTOR -->
|
||||
|
||||
# Code Review Reference - REFACTOR
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-05-04
|
||||
task=opencode_json_stream, plan=0, tag=REFACTOR
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
|
||||
|
||||
1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수)
|
||||
2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수)
|
||||
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REFACTOR-1] Opencode 프로필을 JSON 이벤트 출력으로 전환 | [ ] |
|
||||
| [REFACTOR-2] Opencode JSON 이벤트 스트림 파서를 실제 CLI 출력에 맞게 갱신 | [ ] |
|
||||
| [REFACTOR-3] Opencode JSON 스트림 회귀 테스트와 수동 smoke test를 정리 | [ ] |
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `configs/edge.yaml`의 `opencode` 프로필이 `--format json`과 `output_format: "opencode-json"`을 함께 사용하고 있는지 확인하세요.
|
||||
- `emitOpencodeJSON`가 더 이상 `io.ReadAll` 기반 단일 `{"response":"..."}` 파서를 쓰지 않고, line-by-line JSON 이벤트를 읽어 `type:"text"`의 `part.text`만 delta로 내보내는지 확인하세요.
|
||||
- `emitOpencodeJSON`가 `step_start`/`step_finish` 같은 비텍스트 이벤트를 중복 출력하지 않고, 오류 이벤트만 `RuntimeEventError`로 반영하는지 확인하세요.
|
||||
- `apps/node/internal/adapters/cli/oneshot/cli_test.go`의 `opencode-json` 회귀 테스트 fixture가 실제 이벤트 stream 형식(`step_start`/`text`/`step_finish`)으로 바뀌었는지 확인하세요.
|
||||
- 구현 에이전트가 PATH 축소 환경 smoke test 또는 동등한 검증으로 `opencode` 실행 경로와 JSON 파서 동작을 함께 검증했는지 확인하세요.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
### REFACTOR-1 중간 검증
|
||||
```bash
|
||||
$ rg -n 'opencode:|--format|output_format' configs/edge.yaml
|
||||
(output)
|
||||
```
|
||||
|
||||
### REFACTOR-2 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/node/internal/adapters/cli/oneshot -run TestCLIExecuteOneShotOpencodeJSON -count=1
|
||||
(output)
|
||||
```
|
||||
|
||||
### REFACTOR-3 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/node/internal/adapters/cli/oneshot -run 'TestCLIExecuteOneShot(OpencodeJSON|StreamJSON|CodexJSON|ClaudeJSON)' -count=1
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```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
|
||||
(output)
|
||||
```
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
|
||||
- 차원별 평가
|
||||
- correctness: Fail
|
||||
- completeness: Warn
|
||||
- test coverage: Warn
|
||||
- API contract: Fail
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Warn
|
||||
|
||||
- 발견된 문제
|
||||
- Required: [apps/node/internal/adapters/cli/oneshot.go:361](/config/workspace/go-iop/apps/node/internal/adapters/cli/oneshot.go:361) 에서 `opencode`의 `error` 이벤트를 `string`으로 파싱하고 있어 실제 출력 형식인 `{"type":"error","error":{"name":"UnknownError","data":{"message":"..."}}}`를 해석하지 못합니다. 이 상태에서는 모델 선택 실패 같은 런타임 오류가 `RuntimeEventError` 없이 지나가고, 프로세스 exit code가 `0`이면 상위 로직이 그대로 `complete`를 보내므로 실패를 성공으로 오인할 수 있습니다. `error.data.message` 또는 동등한 중첩 필드를 파싱하도록 구조체와 테스트를 함께 보강해야 합니다.
|
||||
- Suggested: [agent-task/opencode_json_stream/CODE_REVIEW.md:25](/config/workspace/go-iop/agent-task/opencode_json_stream/CODE_REVIEW.md:25) 이후 섹션이 구현 결과로 채워지지 않아 체크리스트와 검증 출력이 모두 placeholder 상태입니다. 후속 구현에서는 실제 검증 명령 출력과 설계 변경 사항을 문서에 반영해 리뷰 신뢰도를 복구하는 것이 좋습니다.
|
||||
|
||||
- 다음 단계
|
||||
- FAIL: Required 이슈를 반영하는 새 `PLAN.md`와 `CODE_REVIEW.md` 스텁을 작성해 다음 구현 루프를 시작한다.
|
||||
109
agent-task/opencode_json_stream/code_review_1.log
Normal file
109
agent-task/opencode_json_stream/code_review_1.log
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<!-- task=opencode_json_stream plan=1 tag=REVIEW_REFACTOR -->
|
||||
|
||||
# Code Review Reference - REVIEW_REFACTOR
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-05-04
|
||||
task=opencode_json_stream, plan=1, tag=REVIEW_REFACTOR
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
|
||||
|
||||
1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수)
|
||||
2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수)
|
||||
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_REFACTOR-1] Opencode 중첩 error 이벤트를 정확히 파싱하고 회귀 테스트 추가 | [x] |
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 계획에는 `TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent` 한 개만 명시되어 있었으나, fallback 분기(`error.data.message`가 없을 때 `error.name` 사용) 회귀를 고정하기 위해 `TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName`을 추가로 작성했다. 코드 변경에 fallback 로직이 포함되어 있어, 동작을 별도 테스트로 잠그는 편이 안전하다고 판단했다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- `error` 구조체는 실제 `opencode run --format json` 실패 출력 (`{"type":"error","error":{"name":"UnknownError","data":{"message":"..."}}}`)을 그대로 반영해 중첩 객체로 정의했다. 실제 CLI 호출(`/config/.npm-global/bin/opencode run --model definitely-invalid-provider/does-not-exist --format json ...`)에서 같은 구조의 JSON이 stdout 마지막 줄로 나오는 것을 확인했다.
|
||||
- 메시지 추출 우선순위는 `error.data.message` → `error.name` → 무시(emit 안 함). `error.name`만 있는 경우에도 최소한의 단서를 노출해야 디버깅이 가능하다고 판단했다.
|
||||
- `text` 이벤트 처리는 기존과 동일하게 유지해 직전 PLAN(REFACTOR-1~3)에서 고친 동작이 회귀하지 않도록 했다.
|
||||
- malformed/empty line skip 동작도 그대로 유지된다(`json.Unmarshal` 실패 시 continue, 빈 message 시 emit 생략).
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `emitOpencodeJSON`가 `error`를 문자열이 아니라 실제 중첩 객체 형식으로 파싱하는지 확인하세요.
|
||||
→ [apps/node/internal/adapters/cli/oneshot.go:361-374](apps/node/internal/adapters/cli/oneshot.go#L361-L374)에서 `Error` 필드가 `Name string` + `Data.Message string`을 가진 익명 구조체로 정의됨.
|
||||
- `error.data.message`가 있으면 그 값을 `RuntimeEventError`로 보내고, 없을 때만 fallback 필드를 쓰는지 확인하세요.
|
||||
→ [apps/node/internal/adapters/cli/oneshot.go:387-393](apps/node/internal/adapters/cli/oneshot.go#L387-L393)의 `case "error"` 분기에서 `msg := ev.Error.Data.Message`로 우선순위를 잡고, 빈 경우 `ev.Error.Name`로 fallback.
|
||||
- 기존 text 이벤트 delta 처리와 malformed/empty line skip 동작이 회귀되지 않았는지 확인하세요.
|
||||
→ `TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents`, `...SkipsMalformedAndEmpty` 두 테스트가 기존 fixture로 동일하게 PASS.
|
||||
- `apps/node/internal/adapters/cli/oneshot/cli_test.go`에 중첩 error 이벤트 fixture 기반 회귀 테스트가 추가되었는지 확인하세요.
|
||||
→ [apps/node/internal/adapters/cli/oneshot/cli_test.go:205-247](apps/node/internal/adapters/cli/oneshot/cli_test.go#L205-L247)에 `TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent`, [같은 파일의 그 다음 함수](apps/node/internal/adapters/cli/oneshot/cli_test.go#L249)에 fallback 테스트 추가.
|
||||
- 구현 에이전트가 실제 `opencode run --format json` 실패 샘플 또는 동등한 fixture를 근거로 검증 출력을 남겼는지 확인하세요.
|
||||
→ 아래 `최종 검증` 항목의 두 번째 명령에 실제 CLI 실패 출력 첨부. 마지막 줄 JSON이 fixture와 동일한 중첩 구조임을 확인 가능.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### REVIEW_REFACTOR-1 중간 검증
|
||||
```bash
|
||||
$ go test ./apps/node/internal/adapters/cli/oneshot -run 'TestCLIExecuteOneShotOpencodeJSON(ParsesStreamEvents|SkipsMalformedAndEmpty|ParsesNestedErrorEvent|FallsBackToErrorName)' -count=1 -v
|
||||
=== RUN TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents
|
||||
--- PASS: TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents (0.00s)
|
||||
=== RUN TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent
|
||||
--- PASS: TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent (0.00s)
|
||||
=== RUN TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName
|
||||
--- PASS: TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName (0.00s)
|
||||
=== RUN TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty
|
||||
--- PASS: TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli/oneshot 0.006s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ go test ./apps/node/internal/adapters/cli/oneshot -count=1
|
||||
ok iop/apps/node/internal/adapters/cli/oneshot 0.283s
|
||||
|
||||
$ timeout 30s /config/.npm-global/bin/opencode run --title untitle --model definitely-invalid-provider/does-not-exist --format json --dangerously-skip-permissions hi
|
||||
ProviderModelNotFoundError: ProviderModelNotFoundError
|
||||
data: {
|
||||
providerID: "definitely-invalid-provider",
|
||||
modelID: "does-not-exist",
|
||||
suggestions: [],
|
||||
},
|
||||
|
||||
at <anonymous> (/$bunfs/root/chunk-gbjb6qet.js:565:72153)
|
||||
at ~effect/Effect/successCont (/$bunfs/root/chunk-y3yhj99w.js:25:7738)
|
||||
at runLoop (/$bunfs/root/chunk-y3yhj99w.js:25:2045)
|
||||
...
|
||||
|
||||
{"type":"error","timestamp":1777860214976,"sessionID":"ses_20f45ffb4ffe3VBE4hSSGiTYQa","error":{"name":"UnknownError","data":{"message":"Model not found: definitely-invalid-provider/does-not-exist."}}}
|
||||
EXIT=0
|
||||
```
|
||||
|
||||
→ 두 번째 명령이 stdout 마지막 줄에 `{"type":"error","error":{"name":"UnknownError","data":{"message":"Model not found: ..."}}}` 형식의 JSON을 출력함. 이 JSON은 새로 정의한 `ev.Error.Data.Message` 경로로 정확히 파싱되며, 회귀 테스트 fixture(`TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent`)와 동일한 구조이므로 코드와 fixture가 실제 CLI 출력과 일치함을 확인.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
|
||||
- 차원별 평가
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
|
||||
- 발견된 문제
|
||||
- 없음
|
||||
|
||||
- 다음 단계
|
||||
- PASS: 아카이브 후 `complete.log`를 작성하고 작업을 종료한다.
|
||||
17
agent-task/opencode_json_stream/complete.log
Normal file
17
agent-task/opencode_json_stream/complete.log
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
완료 일시: 2026-05-04
|
||||
|
||||
요약: opencode JSON stream 전환과 중첩 error 이벤트 파싱 보강을 2회 루프로 완료했다.
|
||||
|
||||
루프 이력:
|
||||
|
||||
| plan log | code review log | verdict |
|
||||
|----------|------------------|---------|
|
||||
| `plan_0.log` | `code_review_0.log` | FAIL |
|
||||
| `plan_1.log` | `code_review_1.log` | PASS |
|
||||
|
||||
최종 리뷰 요약:
|
||||
|
||||
- `configs/edge.yaml`의 `opencode` 프로필을 절대경로 command, `--format json`, `output_format: "opencode-json"` 조합으로 정리했다.
|
||||
- `apps/node/internal/adapters/cli/oneshot.go`의 `emitOpencodeJSON`를 line-by-line JSON 이벤트 파서로 전환하고, `text` 이벤트 delta 전달을 유지하면서 중첩 `error.data.message`와 `error.name` fallback을 처리하도록 보강했다.
|
||||
- `apps/node/internal/adapters/cli/oneshot/cli_test.go`에 stream event, nested error event, fallback error name, malformed/empty input 회귀 테스트를 추가/갱신했다.
|
||||
- `go test ./apps/node/internal/adapters/cli/oneshot -count=1` 통과와 실제 `opencode run --format json` 실패 샘플 확인으로 구현과 검증 출력을 맞췄다.
|
||||
305
agent-task/opencode_json_stream/plan_0.log
Normal file
305
agent-task/opencode_json_stream/plan_0.log
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
<!-- 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` 순서로 출력된다.
|
||||
113
agent-task/opencode_json_stream/plan_1.log
Normal file
113
agent-task/opencode_json_stream/plan_1.log
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<!-- 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`를 파싱하도록 테스트와 코드가 일치한다.
|
||||
|
|
@ -340,38 +340,61 @@ func emitCodexJSONLines(ctx context.Context, stdout io.Reader, sink runtime.Even
|
|||
return outputTokens, nil
|
||||
}
|
||||
|
||||
// emitOpencodeJSON parses `opencode -p ... -f json` output. The current
|
||||
// opencode CLI wraps the final assistant text in a single JSON object:
|
||||
// {"response":"..."}.
|
||||
// emitOpencodeJSON parses `opencode run --format json` output.
|
||||
// Each line is a JSON event: step_start, text (with part.text), step_finish, or error.
|
||||
// Only type=="text" events with a non-empty part.text are emitted as deltas.
|
||||
func emitOpencodeJSON(ctx context.Context, stdout io.Reader, sink runtime.EventSink, runID string, outBuf *strings.Builder) (int, error) {
|
||||
body, err := io.ReadAll(stdout)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
outBuf.Write(body)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
outputTokens := 0
|
||||
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" || trimmed[0] != '{' {
|
||||
return 0, nil
|
||||
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 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"`
|
||||
}
|
||||
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":
|
||||
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()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Response string `json:"response"`
|
||||
if err := scanner.Err(); err != nil {
|
||||
return outputTokens, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(trimmed), &resp); err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
if resp.Response == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
outputTokens := len(strings.Fields(resp.Response))
|
||||
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: runID,
|
||||
Type: runtime.EventTypeDelta,
|
||||
Delta: resp.Response,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
return outputTokens, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -168,13 +168,13 @@ EOF`
|
|||
}
|
||||
}
|
||||
|
||||
func TestCLIExecuteOneShotOpencodeJSONParsesResponse(t *testing.T) {
|
||||
func TestCLIExecuteOneShotOpencodeJSONParsesStreamEvents(t *testing.T) {
|
||||
testutil.RequireUnixShell(t)
|
||||
|
||||
script := `cat <<'EOF'
|
||||
{
|
||||
"response": "OpenCode says hello."
|
||||
}
|
||||
{"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`
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
|
|
@ -197,8 +197,123 @@ EOF`
|
|||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
combined := testutil.CollectDeltas(sink.Events())
|
||||
if combined != "OpenCode says hello." {
|
||||
t.Fatalf("expected opencode-json deltas to be %q, got %q", "OpenCode says hello.", combined)
|
||||
if combined != "Hi from opencode." {
|
||||
t.Fatalf("expected opencode-json deltas to be %q, got %q", "Hi from opencode.", combined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExecuteOneShotOpencodeJSONParsesNestedErrorEvent(t *testing.T) {
|
||||
testutil.RequireUnixShell(t)
|
||||
|
||||
script := `cat <<'EOF'
|
||||
{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}
|
||||
{"type":"error","sessionID":"ses_1","error":{"name":"UnknownError","data":{"message":"Model not found: definitely-invalid-provider/does-not-exist"}}}
|
||||
EOF`
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"opencode": {
|
||||
Command: "sh",
|
||||
Args: []string{"-c", script, "sh"},
|
||||
OutputFormat: "opencode-json",
|
||||
},
|
||||
},
|
||||
}
|
||||
c := clipkg.New(cfg, zap.NewNop())
|
||||
sink := &testutil.FakeSink{}
|
||||
|
||||
if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
||||
RunID: "run-oc-err",
|
||||
Model: "opencode",
|
||||
Input: map[string]any{"prompt": "hi"},
|
||||
}, sink); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if combined := testutil.CollectDeltas(sink.Events()); combined != "" {
|
||||
t.Fatalf("expected no deltas for error-only stream, got %q", combined)
|
||||
}
|
||||
want := "Model not found: definitely-invalid-provider/does-not-exist"
|
||||
var got string
|
||||
for _, e := range sink.Events() {
|
||||
if e.Type == noderuntime.EventTypeError {
|
||||
got = e.Error
|
||||
break
|
||||
}
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("expected nested error message %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExecuteOneShotOpencodeJSONFallsBackToErrorName(t *testing.T) {
|
||||
testutil.RequireUnixShell(t)
|
||||
|
||||
script := `cat <<'EOF'
|
||||
{"type":"error","sessionID":"ses_1","error":{"name":"ProviderUnavailable"}}
|
||||
EOF`
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"opencode": {
|
||||
Command: "sh",
|
||||
Args: []string{"-c", script, "sh"},
|
||||
OutputFormat: "opencode-json",
|
||||
},
|
||||
},
|
||||
}
|
||||
c := clipkg.New(cfg, zap.NewNop())
|
||||
sink := &testutil.FakeSink{}
|
||||
|
||||
if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
||||
RunID: "run-oc-err2",
|
||||
Model: "opencode",
|
||||
Input: map[string]any{"prompt": "hi"},
|
||||
}, sink); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got string
|
||||
for _, e := range sink.Events() {
|
||||
if e.Type == noderuntime.EventTypeError {
|
||||
got = e.Error
|
||||
break
|
||||
}
|
||||
}
|
||||
if got != "ProviderUnavailable" {
|
||||
t.Fatalf("expected fallback to error.name, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExecuteOneShotOpencodeJSONSkipsMalformedAndEmpty(t *testing.T) {
|
||||
testutil.RequireUnixShell(t)
|
||||
|
||||
script := `cat <<'EOF'
|
||||
not-json
|
||||
{"type":"text","sessionID":"ses_1","part":{"type":"text","text":""}}
|
||||
{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"valid"}}
|
||||
EOF`
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"opencode": {
|
||||
Command: "sh",
|
||||
Args: []string{"-c", script, "sh"},
|
||||
OutputFormat: "opencode-json",
|
||||
},
|
||||
},
|
||||
}
|
||||
c := clipkg.New(cfg, zap.NewNop())
|
||||
sink := &testutil.FakeSink{}
|
||||
|
||||
if err := c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
||||
RunID: "run-oc2",
|
||||
Model: "opencode",
|
||||
Input: map[string]any{"prompt": "hi"},
|
||||
}, sink); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
combined := testutil.CollectDeltas(sink.Events())
|
||||
if combined != "valid" {
|
||||
t.Fatalf("expected only valid delta, got %q", combined)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ console:
|
|||
agent: "opencode"
|
||||
session_id: "default"
|
||||
background: false
|
||||
timeout_sec: 120
|
||||
timeout_sec: 300
|
||||
|
||||
nodes:
|
||||
- alias: "local-node"
|
||||
|
|
@ -72,18 +72,20 @@ nodes:
|
|||
terminal: false
|
||||
output_format: "codex-json"
|
||||
opencode:
|
||||
command: "opencode"
|
||||
command: "/config/.npm-global/bin/opencode"
|
||||
args:
|
||||
- "run"
|
||||
- "--title"
|
||||
- "untitle"
|
||||
- "--model"
|
||||
- "ollama-dgx/qwen3.6:35b-a3b-bf16"
|
||||
- "ollama-m1/qwen3.6:27b-coding-mxfp8"
|
||||
- "--format"
|
||||
- "default"
|
||||
- "json"
|
||||
- "--dangerously-skip-permissions"
|
||||
env: []
|
||||
persistent: false
|
||||
terminal: false
|
||||
output_format: ""
|
||||
output_format: "opencode-json"
|
||||
runtime:
|
||||
concurrency: 4
|
||||
workspace_root: "/tmp/iop/workspace"
|
||||
|
|
|
|||
Loading…
Reference in a new issue