- opencode SSE 스트림 파서 어댑터 추가 (opencode_sse.go) - 블랙박스/내부 테스트 파일 추가 - node/edge 애플리케이션의 CLI 어댑터 개선 - 설정 파일 및 README 업데이트 - agent-task opencode_sse_stream 추가
349 lines
16 KiB
Text
349 lines
16 KiB
Text
<!-- task=opencode_sse_stream plan=0 tag=OPENCODE_SSE -->
|
|
|
|
# Opencode SSE Stream Plan
|
|
|
|
## 이 파일을 읽는 구현 에이전트에게
|
|
|
|
**반드시 마지막에 `CODE_REVIEW-cloud-G07.md`의 모든 섹션을 실제 구현 내용과 검증 출력으로 채워야 한다. 이 파일 작성 전에는 작업이 완료된 것이 아니다.** 아래 체크리스트를 끝까지 수행하고, 중간/최종 검증 명령의 실제 stdout/stderr를 review 파일에 붙인다. review 파일의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 archive 지시(`*.log` rename, `complete.log` 작성)는 구현 에이전트가 실행하지 않는다.
|
|
|
|
## 배경
|
|
|
|
`opencode run --format json`은 IOP의 stdout JSONL 파이프라인에 연결되어 있지만, OpenCode CLI 구현상 완성된 text part 중심으로 출력되어 토큰 단위 delta stream을 안정적으로 얻기 어렵다. OpenCode server는 `/event` SSE와 `/session/{id}/prompt_async`를 제공하며, 이 경로에는 `session.next.text.delta` 이벤트가 있다. IOP 공통 인터페이스는 `runtime.Adapter.Execute(ctx, spec, sink)`와 `RuntimeEvent`이므로, opencode 내부 실행 방식만 SSE client로 바꾸면 Edge/Node transport 계약은 유지된다.
|
|
|
|
## 분석 결과
|
|
|
|
### 읽은 파일
|
|
|
|
- `agent-ops/rules/project/rules.md`
|
|
- `agent-ops/rules/project/domain/node/rules.md`
|
|
- `agent-ops/rules/project/domain/edge/rules.md`
|
|
- `agent-ops/rules/project/domain/platform-common/rules.md`
|
|
- `agent-ops/skills/common/router.md`
|
|
- `agent-ops/skills/common/plan/SKILL.md`
|
|
- `go.mod`
|
|
- `apps/node/internal/adapters/cli/cli.go`
|
|
- `apps/node/internal/adapters/cli/oneshot.go`
|
|
- `apps/node/internal/adapters/cli/emitters.go`
|
|
- `apps/node/internal/adapters/cli/codex_exec.go`
|
|
- `apps/node/internal/adapters/cli/persistent.go`
|
|
- `apps/node/internal/adapters/factory.go`
|
|
- `apps/edge/internal/node/mapper.go`
|
|
- `packages/config/config.go`
|
|
- `proto/iop/runtime.proto`
|
|
- `configs/edge.yaml`
|
|
- `apps/node/README.md`
|
|
- `apps/edge/README.md`
|
|
- `apps/node/internal/adapters/cli/oneshot_blackbox_test.go`
|
|
- `apps/node/internal/adapters/cli/cli_internal_test.go`
|
|
- `apps/node/internal/adapters/factory_internal_test.go`
|
|
- `apps/edge/internal/node/mapper_test.go`
|
|
- `apps/edge/internal/transport/server_test.go`
|
|
- `packages/config/config_test.go`
|
|
|
|
### 테스트 커버리지 공백
|
|
|
|
- `opencode-json` stdout JSONL parsing은 `apps/node/internal/adapters/cli/oneshot_blackbox_test.go`와 `cli_internal_test.go`에 있다. SSE 기반 `session.next.text.delta` relay는 테스트가 없다.
|
|
- `CLI.Execute`의 mode 분기는 `codex-exec`, `persistent`, one-shot만 검증되어 있다. `opencode-sse` 분기, session reuse, `REQUIRE_EXISTING` 실패는 테스트가 없다.
|
|
- cancel 시 opencode `/session/{id}/abort` 호출과 IOP cancelled event 변환은 테스트가 없다.
|
|
- edge YAML의 `mode` 로딩은 `codex-exec` 예시만 있다. `opencode-sse` profile 예시는 없다.
|
|
|
|
### 심볼 참조
|
|
|
|
- renamed/removed symbols: none.
|
|
- 새 symbol 후보: `modeOpencodeSSE`, `opencodeSSESession`, `executeOpencodeSSE`, `parseOpencodeRunArgs`. 새 symbol이므로 기존 call site는 없다.
|
|
|
|
### 범위 결정 근거
|
|
|
|
- `runtime.Adapter`, `runtime.EventSink`, `RunEvent`, Edge-Node TCP/protobuf transport는 변경하지 않는다. 공통 인터페이스가 이미 stream relay 계약을 제공한다.
|
|
- `proto/iop/runtime.proto`는 변경하지 않는다. `CLIProfileConfig.mode`, `args`, `env`가 이미 edge에서 node로 전달된다.
|
|
- OpenCode 실제 binary나 실제 모델을 호출하는 통합 테스트는 제외한다. `httptest.Server`로 SSE/HTTP 계약을 검증하면 결정적이다.
|
|
- `ollama`, `vllm`, `mock`, `edge console` 실행 로직은 제외한다. opencode profile 내부 실행 방식만 바꾼다.
|
|
|
|
### 빌드 등급
|
|
|
|
- build=`cloud-G07`, review=`cloud-G07`: HTTP/SSE protocol parsing, session lifecycle, cancellation, cross-package config examples가 포함되어 local-only 검증보다 높은 리뷰가 필요하다.
|
|
|
|
## 의존 관계 및 구현 순서
|
|
|
|
1. `OPENCODE_SSE-1`: CLI adapter 내부 mode/session/SSE 실행기 구현.
|
|
2. `OPENCODE_SSE-2`: 설정 예시와 문서 갱신.
|
|
3. 각 항목 중간 검증 후 최종 전체 테스트.
|
|
|
|
### [OPENCODE_SSE-1] CLI adapter에 opencode SSE mode 추가
|
|
|
|
#### 문제
|
|
|
|
`CLI`는 persistent session map과 codex exec session map만 가진다.
|
|
|
|
```go
|
|
// apps/node/internal/adapters/cli/cli.go:55
|
|
type CLI struct {
|
|
mu sync.Mutex
|
|
profiles map[string]config.CLIProfileConf
|
|
sessions map[sessionKey]*profileSession
|
|
codexSessions map[sessionKey]*codexExecSession
|
|
logger *zap.Logger
|
|
```
|
|
|
|
`Start`는 `codex-exec`만 persistent startup에서 제외한다.
|
|
|
|
```go
|
|
// apps/node/internal/adapters/cli/cli.go:93
|
|
names := make([]string, 0, len(c.profiles))
|
|
for name := range c.profiles {
|
|
if c.profiles[name].Persistent && c.profiles[name].Mode != "codex-exec" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
```
|
|
|
|
`Execute`는 `codex-exec`, persistent, one-shot만 분기한다.
|
|
|
|
```go
|
|
// apps/node/internal/adapters/cli/cli.go:153
|
|
func (c *CLI) Execute(ctx context.Context, spec runtime.ExecutionSpec, sink runtime.EventSink) error {
|
|
agent := cliAgentName(spec)
|
|
profile, ok := c.profiles[agent]
|
|
if !ok {
|
|
return fmt.Errorf("cli adapter: unknown agent %q", agent)
|
|
}
|
|
if profile.Mode == "codex-exec" {
|
|
return c.executeCodexExec(ctx, spec, profile, sink)
|
|
}
|
|
if profile.Persistent {
|
|
return c.executePersistent(ctx, spec, profile, sink)
|
|
}
|
|
return c.executeOneShot(ctx, spec, profile, sink)
|
|
}
|
|
```
|
|
|
|
`executeOneShot`은 prompt를 subprocess argv 뒤에 붙이는 구조라 `opencode run --format json`의 stdout event만 읽을 수 있다.
|
|
|
|
```go
|
|
// apps/node/internal/adapters/cli/oneshot.go:17
|
|
func (c *CLI) executeOneShot(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
|
|
prompt := extractPrompt(spec.Input)
|
|
args := append(append([]string{}, profile.Args...), prompt)
|
|
_, err := c.executeCommand(ctx, spec, profile, args, prompt, sink)
|
|
return err
|
|
}
|
|
```
|
|
|
|
#### 해결 방법
|
|
|
|
`apps/node/internal/adapters/cli/opencode_sse.go`를 새로 추가한다. mode별 파일을 둔 `codex_exec.go` 패턴을 따른다.
|
|
|
|
`cli.go`의 session state와 dispatch를 다음 형태로 확장한다.
|
|
|
|
```go
|
|
// after
|
|
const (
|
|
modeCodexExec = "codex-exec"
|
|
modeOpencodeSSE = "opencode-sse"
|
|
)
|
|
|
|
type opencodeSSESession struct {
|
|
key sessionKey
|
|
serverURL string
|
|
sessionID string
|
|
cmd *exec.Cmd
|
|
owned bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
type CLI struct {
|
|
mu sync.Mutex
|
|
profiles map[string]config.CLIProfileConf
|
|
sessions map[sessionKey]*profileSession
|
|
codexSessions map[sessionKey]*codexExecSession
|
|
opencodeSessions map[sessionKey]*opencodeSSESession
|
|
logger *zap.Logger
|
|
// ...
|
|
}
|
|
```
|
|
|
|
`Execute`는 `opencode-sse`를 persistent/one-shot보다 먼저 처리한다.
|
|
|
|
```go
|
|
// after
|
|
if profile.Mode == modeCodexExec {
|
|
return c.executeCodexExec(ctx, spec, profile, sink)
|
|
}
|
|
if profile.Mode == modeOpencodeSSE {
|
|
return c.executeOpencodeSSE(ctx, spec, profile, sink)
|
|
}
|
|
```
|
|
|
|
`executeOpencodeSSE`는 다음 계약을 지킨다.
|
|
|
|
- `profile.Args`는 `opencode run` 호환 옵션으로 해석한다: `--attach`, `--model`, `--agent`, `--variant`, `--title`, `--dangerously-skip-permissions`, `--dir`.
|
|
- `--attach <url>`이 있으면 외부 server를 사용하고 process를 띄우지 않는다.
|
|
- `--attach`가 없으면 `profile.Command serve --hostname 127.0.0.1 --port 0`을 실행하고 stdout/stderr에서 `opencode server listening on http://...`를 파싱한다.
|
|
- `SessionModeRequireExisting`이고 `(target, session_id)`에 cached opencode session이 없으면 codex/persistent와 같은 형태의 error를 반환한다.
|
|
- `GET /event` SSE를 먼저 열고, session이 없으면 `POST /session`으로 생성한다.
|
|
- `POST /session/{id}/prompt_async` body는 `{parts:[{type:"text",text:prompt}], model?, agent?, variant?}`로 보낸다.
|
|
- SSE `session.next.text.delta`는 `RuntimeEvent{Type: delta, Delta: properties.delta}`로 즉시 emit한다.
|
|
- SSE `session.next.step.ended`의 `tokens.input/output`은 complete event usage에 보관한다.
|
|
- SSE `session.status` idle 또는 `session.idle`에서 `RuntimeEvent{Type: complete, Message:"opencode sse execution complete"}`를 emit하고 종료한다.
|
|
- SSE `session.error`는 error event emit 후 error return한다.
|
|
- `permission.asked`는 `--dangerously-skip-permissions`가 있으면 `/permission/{id}/reply` with `{"reply":"once"}`, 없으면 `{"reply":"reject"}`로 답한다.
|
|
- `ctx.Done()`이면 `/session/{id}/abort`를 best-effort로 호출하고 cancelled event를 emit한 뒤 `runtime.ErrRunCancelled`를 반환한다.
|
|
|
|
`Stop`과 `TerminateSession`은 `opencodeSessions`도 닫는다. owned local server process는 kill하고, attach session은 process kill 없이 cache만 제거하며 가능하면 abort를 호출한다.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `apps/node/internal/adapters/cli/cli.go`: mode constants, `opencodeSessions` map, `Start` skip, `Execute` branch, `Stop`, `TerminateSession` 확장.
|
|
- [ ] `apps/node/internal/adapters/cli/opencode_sse.go`: HTTP/SSE client, option parser, local server startup, session resolve, prompt send, event loop, abort/close helpers 구현.
|
|
- [ ] `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`: httptest 기반 execute/cancel/error/session tests 추가.
|
|
- [ ] `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`: args parser, SSE data parser unit tests 추가.
|
|
|
|
#### 테스트 작성
|
|
|
|
- 작성한다.
|
|
- `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`
|
|
- `TestCLIExecuteOpencodeSSE_StreamsTextDeltas`: fake `/event`, `/session`, `/prompt_async`로 `session.next.text.delta` 2개와 idle을 보내고 delta concat, complete, usage를 검증한다.
|
|
- `TestCLIExecuteOpencodeSSE_RequireExistingWithoutSessionErrors`: cached session이 없고 `SessionModeRequireExisting`이면 error.
|
|
- `TestCLIExecuteOpencodeSSE_SessionErrorEmitsRuntimeError`: `session.error`를 error event와 error return으로 검증한다.
|
|
- `TestCLIExecuteOpencodeSSE_ContextCancelAbortsSession`: context cancel 시 `/session/{id}/abort` 호출, cancelled event, `runtime.ErrRunCancelled`.
|
|
- `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`
|
|
- `TestParseOpencodeRunArgs`: `--attach`, `--model provider/model`, `--agent`, `--variant`, `--dangerously-skip-permissions` parsing.
|
|
- `TestDecodeSSEDataLine`: `data: {...}` parsing과 non-data skip.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test -count=1 ./apps/node/internal/adapters/cli
|
|
```
|
|
|
|
예상 결과: package pass. Go test cache는 허용하지 않는다.
|
|
|
|
### [OPENCODE_SSE-2] opencode profile 예시와 문서를 SSE mode로 정리
|
|
|
|
#### 문제
|
|
|
|
예시 config는 opencode를 `run --format json` stdout JSONL mode로 둔다.
|
|
|
|
```yaml
|
|
# configs/edge.yaml:85
|
|
opencode:
|
|
command: "/config/.npm-global/bin/opencode"
|
|
args:
|
|
- "run"
|
|
- "--title"
|
|
- "untitle"
|
|
- "--model"
|
|
- "ollama-dgx/qwen3.6:35b-a3b-bf16"
|
|
- "--format"
|
|
- "json"
|
|
- "--dangerously-skip-permissions"
|
|
env: []
|
|
persistent: false
|
|
terminal: false
|
|
output_format: "opencode-json"
|
|
```
|
|
|
|
`CLIProfileConf`에는 이미 mode string이 있어 새 config field 없이 opencode 전용 구현을 선택할 수 있다.
|
|
|
|
```go
|
|
// packages/config/config.go:124
|
|
type CLIProfileConf struct {
|
|
Command string `mapstructure:"command" yaml:"command"`
|
|
Args []string `mapstructure:"args" yaml:"args"`
|
|
Env []string `mapstructure:"env" yaml:"env"`
|
|
Persistent bool `mapstructure:"persistent" yaml:"persistent"`
|
|
Terminal bool `mapstructure:"terminal" yaml:"terminal"`
|
|
ResponseIdleTimeoutMS int `mapstructure:"response_idle_timeout_ms" yaml:"response_idle_timeout_ms"`
|
|
StartupIdleTimeoutMS int `mapstructure:"startup_idle_timeout_ms" yaml:"startup_idle_timeout_ms"`
|
|
OutputFormat string `mapstructure:"output_format" yaml:"output_format"`
|
|
CompletionMarker CompletionMarkerConf `mapstructure:"completion_marker" yaml:"completion_marker"`
|
|
Mode string `mapstructure:"mode" yaml:"mode"`
|
|
ResumeArgs []string `mapstructure:"resume_args" yaml:"resume_args"`
|
|
}
|
|
```
|
|
|
|
#### 해결 방법
|
|
|
|
`configs/edge.yaml`의 opencode profile을 `mode: "opencode-sse"`로 바꾸고 `args`는 prompt 전송 옵션만 남긴다.
|
|
|
|
```yaml
|
|
# after
|
|
opencode:
|
|
command: "/config/.npm-global/bin/opencode"
|
|
args:
|
|
- "--title"
|
|
- "untitle"
|
|
- "--model"
|
|
- "ollama-dgx/qwen3.6:35b-a3b-bf16"
|
|
- "--dangerously-skip-permissions"
|
|
env: []
|
|
persistent: false
|
|
terminal: false
|
|
mode: "opencode-sse"
|
|
```
|
|
|
|
`apps/node/README.md`와 `apps/edge/README.md`는 opencode 예시를 `opencode serve` + SSE mode로 설명한다. stdout JSONL `opencode-json` emitter는 legacy/run mode로 남긴다.
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [ ] `configs/edge.yaml`: opencode profile `mode: "opencode-sse"` 적용, `run`/`--format json` 제거.
|
|
- [ ] `apps/node/README.md`: CLI adapter 설명에 opencode는 `opencode-sse` mode에서 server SSE를 사용한다고 기록.
|
|
- [ ] `apps/edge/README.md`: 수동 테스트 예시의 opencode 명령 설명 갱신.
|
|
- [ ] `packages/config/config_test.go`: `TestLoadEdge_OpencodeSSEProfile` 추가.
|
|
- [ ] `apps/edge/internal/node/mapper_test.go`: `TestBuildConfigPayload_CLIProfiles` 또는 새 테스트에서 `Mode` 전달 검증.
|
|
- [ ] `apps/node/internal/adapters/factory_internal_test.go`: proto/legacy settings에서 `mode`가 node CLI config로 들어오는지 검증.
|
|
|
|
#### 테스트 작성
|
|
|
|
- 작성한다.
|
|
- `packages/config/config_test.go`
|
|
- `TestLoadEdge_OpencodeSSEProfile`: YAML에서 `mode: "opencode-sse"`, `--model`, `--dangerously-skip-permissions`가 로드되는지 확인.
|
|
- `apps/edge/internal/node/mapper_test.go`
|
|
- `Mode`와 `ResumeArgs`가 `CLIProfileConfig`로 전달되는지 확인.
|
|
- `apps/node/internal/adapters/factory_internal_test.go`
|
|
- typed proto와 legacy struct fallback 모두 `mode` 값을 유지하는지 확인.
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test -count=1 ./packages/config ./apps/edge/internal/node ./apps/node/internal/adapters
|
|
```
|
|
|
|
예상 결과: package pass. Go test cache는 허용하지 않는다.
|
|
|
|
## 수정 파일 요약
|
|
|
|
| 파일 | 항목 |
|
|
|------|------|
|
|
| `apps/node/internal/adapters/cli/cli.go` | OPENCODE_SSE-1 |
|
|
| `apps/node/internal/adapters/cli/opencode_sse.go` | OPENCODE_SSE-1 |
|
|
| `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go` | OPENCODE_SSE-1 |
|
|
| `apps/node/internal/adapters/cli/opencode_sse_internal_test.go` | OPENCODE_SSE-1 |
|
|
| `configs/edge.yaml` | OPENCODE_SSE-2 |
|
|
| `apps/node/README.md` | OPENCODE_SSE-2 |
|
|
| `apps/edge/README.md` | OPENCODE_SSE-2 |
|
|
| `packages/config/config_test.go` | OPENCODE_SSE-2 |
|
|
| `apps/edge/internal/node/mapper_test.go` | OPENCODE_SSE-2 |
|
|
| `apps/node/internal/adapters/factory_internal_test.go` | OPENCODE_SSE-2 |
|
|
|
|
## 최종 검증
|
|
|
|
```bash
|
|
go test -count=1 ./apps/node/internal/adapters/cli ./apps/node/internal/adapters ./apps/edge/internal/node ./apps/edge/internal/transport ./packages/config
|
|
```
|
|
|
|
예상 결과: package pass. Go test cache는 허용하지 않는다.
|
|
|
|
```bash
|
|
go test -count=1 ./...
|
|
```
|
|
|
|
예상 결과: repository-wide pass. Go test cache는 허용하지 않는다.
|
|
|
|
```bash
|
|
rg --sort path -n 'opencode-sse|opencode-json|opencode run --format json|session.next.text.delta' apps/node apps/edge configs packages agent-task/opencode_sse_stream
|
|
```
|
|
|
|
예상 결과: `opencode-sse`는 새 mode/문서/테스트에 나타난다. `opencode-json`과 `opencode run --format json`은 legacy stdout JSONL 경로로 의도적으로 남은 항목만 나타난다.
|
|
|
|
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 전체 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|