iop/agent-task/opencode_sse_stream/plan_cloud_G07_0.log
toki 554e7ef269 feat: opencode SSE 스트림 어댑터 및 관련 개선사항 적용
- opencode SSE 스트림 파서 어댑터 추가 (opencode_sse.go)
- 블랙박스/내부 테스트 파일 추가
- node/edge 애플리케이션의 CLI 어댑터 개선
- 설정 파일 및 README 업데이트
- agent-task opencode_sse_stream 추가
2026-05-12 07:31:09 +09:00

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`의 전체 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.