feat: codex-app-server streaming migration and node CLI improvements
- Add codex_app_server adapter for streaming migration - Update node CLI adapters with persistent execution support - Add output filter for blackbox filtering - Update agent-roadmap with codex-app-server-streaming-migration milestone - Add agent-task for codex-app-server-streaming-migration - Update edge.yaml configuration
This commit is contained in:
parent
1a90b7fe09
commit
77ab8c294c
29 changed files with 4322 additions and 25 deletions
|
|
@ -66,6 +66,10 @@ CLI 실행, specialized agent 등록, bootstrap/enrollment, 원격 터미널 브
|
|||
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
|
||||
- 요약: 원격 터미널 브리지 POC 전에 남은 호환성/소유권 리스크를 Client HTTP lifecycle, Edge run surface, Node terminal core, typed adapter config 계약으로 고정한다.
|
||||
|
||||
- [계획] Codex App Server 스트리밍 전환
|
||||
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- 요약: Codex CLI target의 기본 `codex` profile을 app-server 기반으로 전환해 `item/agentMessage/delta`를 IOP delta stream으로 전달한다.
|
||||
|
||||
- [계획] 원격 터미널 브리지 POC
|
||||
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/remote-terminal-bridge-poc.md`
|
||||
- 요약: Agent를 설치하기 어려운 host/device를 위해 Edge broker와 Node terminal transport 기반 원격 터미널 브리지 POC를 만든다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
# Milestone: Codex App Server 스트리밍 전환
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-roadmap/ROADMAP.md`
|
||||
- Phase: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
Codex CLI adapter의 기본 `codex` target/profile을 `codex app-server` 기반 실행 경로로 전환한다.
|
||||
`codex exec --json`의 최종 메시지 중심 출력 대신 app-server의 `item/agentMessage/delta`를 IOP `delta` event로 전달해 사용자 레벨에서 실시간 스트림 UX를 제공한다.
|
||||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
- 상태: 해제
|
||||
- 결정 필요: 없음
|
||||
|
||||
## 범위
|
||||
|
||||
- CLI adapter에 `codex-app-server` 실행 mode 추가
|
||||
- `codex app-server --stdio` JSON-RPC client lifecycle 구현
|
||||
- IOP logical `session_id`와 app-server thread 재사용 매핑
|
||||
- app-server `item/agentMessage/delta`, `item/completed`, `turn/completed`, error event를 IOP runtime event로 변환
|
||||
- 기본 target/profile 이름은 `codex`로 유지하고, app-server mode를 `codex`의 기본 실행 경로로 전환
|
||||
- `codex-exec` 경로는 fallback 또는 별도 명시 profile로 남길 수 있게 호환성 유지
|
||||
- `scripts/dev/edge.sh`와 `scripts/dev/node.sh` 기준 2-message 사용자 레벨 stream smoke 검증
|
||||
|
||||
## 기능
|
||||
|
||||
### Epic: [appserver-runtime] Codex app-server runtime
|
||||
|
||||
Codex app-server를 장기 JSON-RPC 실행기로 붙여 IOP CLI adapter의 세션/스트림 계약에 맞춘다.
|
||||
|
||||
- [ ] [mode-router] CLI adapter에 `codex-app-server` mode를 추가하고 executor/session reporter 라우팅을 분리한다.
|
||||
- [ ] [rpc-lifecycle] `codex app-server --stdio` 프로세스를 시작한 뒤 `initialize`, `initialized`, `thread/start`, `turn/start` 흐름을 안정적으로 처리한다.
|
||||
- [ ] [session-thread] IOP `session_id`별 app-server thread를 재사용하고 `/terminate-session`에서 해당 세션을 정리한다.
|
||||
- [ ] [event-map] `item/agentMessage/delta`를 `RuntimeEvent{Type: delta}`로 전달하고, `turn/completed`는 complete, app-server error와 failed turn은 error로 매핑한다.
|
||||
- [ ] [default-profile] `configs/edge.yaml`의 기본 `codex` profile을 app-server mode로 전환한다. 검증: `console.target`과 CLI profile key가 모두 `codex`인 상태에서 기본 Codex 실행이 app-server 경로를 사용한다.
|
||||
- [ ] [exec-fallback] 기존 `codex-exec` 실행 경로는 회귀 테스트와 fallback profile 후보로 유지한다.
|
||||
- [ ] [stream-smoke] `./scripts/dev/edge.sh`와 `./scripts/dev/node.sh` 시작 후 `codex` target으로 2-message 왕복에서 델타 스트림이 관찰된다. 검증: 두 메시지가 같은 logical session에서 완료되고, 적어도 한 turn에서 app-server delta event가 IOP delta로 relay된다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- Codex app-server의 전체 rich client 기능 구현
|
||||
- WebSocket transport 우선 도입
|
||||
- OpenAI-compatible API 또는 A2A payload에 Codex app-server 제어 기능을 노출
|
||||
- app-server approval UI의 전체 제품화
|
||||
- `codex exec --json` 삭제
|
||||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 관련 경로: `apps/node/internal/adapters/cli/`, `apps/node/internal/runtime/`, `configs/edge.yaml`, `scripts/dev/edge.sh`, `scripts/dev/node.sh`, `agent-test/local/`
|
||||
- 표준선(선택): 사용자가 원하는 기본 target/profile 이름은 `codex`다. app-server mode는 새 target 이름이 아니라 기존 `codex` profile의 기본 실행 방식으로 들어간다.
|
||||
- 선행 작업: `codex exec --json` 델타 미지원 확인, `codex app-server`의 ChatGPT Plus 인증 및 2-turn delta stream probe 확인
|
||||
- 후속 작업: app-server approval, rate-limit/account 상태, rich client 기능을 별도 운영 표면으로 확장할지 검토
|
||||
- 확인 필요: 없음
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/01_appserver_rpc_client plan=0 tag=API -->
|
||||
# Code Review Reference - API
|
||||
|
||||
> 구현 에이전트 경고: 이 파일은 구현 마지막 단계에서 반드시 갱신한다. `구현 완료 기록`, `구현 체크리스트`, `검증 출력`을 실제 내용으로 채우기 전에는 작업 완료를 선언하지 않는다.
|
||||
|
||||
## 개요
|
||||
- 날짜: 2026-06-12
|
||||
- Task: `m-codex-app-server-streaming-migration/01_appserver_rpc_client`
|
||||
- Plan: `PLAN-cloud-G07.md`
|
||||
- Tag: `API`
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `mode-router`: CLI adapter에 `codex-app-server` mode를 추가하고 executor/session reporter 라우팅을 분리한다.
|
||||
- `rpc-lifecycle`: `codex app-server --stdio` 프로세스를 시작한 뒤 `initialize`, `initialized`, `thread/start`, `turn/start` 흐름을 안정적으로 처리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 리뷰 에이전트 지침
|
||||
- `PLAN-cloud-G07.md`를 먼저 읽고 구현 범위가 plan을 넘지 않았는지 확인한다.
|
||||
- app-server RPC가 newline JSON `{id, method, params}` 형태인지 확인한다.
|
||||
- process lifecycle, context cancel, duplicate close, request id response matching을 우선 리뷰한다.
|
||||
- archive는 명시 요청 없이 읽지 않는다.
|
||||
|
||||
## 구현 완료 기록
|
||||
| 항목 | 구현 에이전트 기록 |
|
||||
| --- | --- |
|
||||
| 변경 요약 | `modeCodexAppServer` 상수 추가, `codexAppServerExecutor`/`codexAppServerSession`/`codexAppServerProc` 신규 구현, CLI router/reporter 연결, `shouldAutostartPersistentProfile` 제외 처리, newline-delimited JSON RPC (id/method/params, jsonrpc 없음) stdin/stdout 구현, initialize→initialized→thread/start 시퀀스, sync.Once 기반 중복 close 방어 |
|
||||
| 주요 파일 | `apps/node/internal/adapters/cli/cli.go` (API-1), `apps/node/internal/adapters/cli/codex_app_server.go` (API-2, API-3, 신규), `apps/node/internal/adapters/cli/cli_internal_test.go` (API-1 테스트), `apps/node/internal/adapters/cli/codex_app_server_internal_test.go` (API-2, API-3 테스트, 신규) |
|
||||
| 범위 외 변경 여부 | 없음. `handleSessionList` 스냅샷 테스트 기댓값만 새 executor 반영으로 업데이트 |
|
||||
| 남은 리스크 | `codexAppServerInit`의 `thread/start` 응답에서 threadId 필드명이 실제 Codex CLI 버전에 따라 다를 수 있음 (`threadId` vs `id` 둘 다 처리함). 실제 CLI 연결 검증은 cloud-G07 등급으로 local/cloud runner 필요 |
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
| 항목 | 완료 여부 |
|
||||
| --- | --- |
|
||||
| API-1 새 mode를 CLI adapter 라우터에 연결 | 완료 |
|
||||
| API-2 app-server stdio 프로세스와 RPC lifecycle 구현 | 완료 |
|
||||
| API-3 세션 시작의 최소 골격까지 연결 | 완료 |
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] `codex app-server --stdio`용 mode 상수와 executor/session 타입을 추가한다.
|
||||
- [x] `CLI.New`, `executorFor`, `sessionReporterFor`, `shouldAutostartPersistentProfile`, `Stop`에 새 executor를 연결한다.
|
||||
- [x] newline JSON RPC request writer와 response/notification reader를 구현한다.
|
||||
- [x] `initialize` request와 `initialized` notification을 startup sequence로 검증한다.
|
||||
- [x] process close가 중복 호출과 context cancel에서 안전하게 동작하게 한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드 리뷰 전용 체크리스트
|
||||
- [ ] `codex-exec` 기존 경로가 회귀하지 않았다.
|
||||
- [ ] app-server mode가 legacy persistent startup으로 들어가지 않는다.
|
||||
- [ ] request/notification reader goroutine 누수가 없다.
|
||||
- [ ] 실패 시 runtime error 또는 Go error가 호출자에게 관측 가능하다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 검증 출력
|
||||
```text
|
||||
$ go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s
|
||||
ok iop/apps/node/internal/adapters/cli 42.513s
|
||||
|
||||
$ go test ./apps/node/... -count=1 -timeout=120s
|
||||
ok iop/apps/node/cmd/node 0.009s
|
||||
ok iop/apps/node/internal/adapters 0.008s
|
||||
ok iop/apps/node/internal/adapters/cli 42.541s
|
||||
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.840s
|
||||
? iop/apps/node/internal/adapters/mock [no test files]
|
||||
ok iop/apps/node/internal/adapters/ollama 0.007s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.007s
|
||||
ok iop/apps/node/internal/bootstrap 0.262s
|
||||
ok iop/apps/node/internal/node 0.118s
|
||||
ok iop/apps/node/internal/router 0.005s
|
||||
? iop/apps/node/internal/runtime [no test files]
|
||||
ok iop/apps/node/internal/store 0.045s
|
||||
ok iop/apps/node/internal/terminal 0.444s
|
||||
ok iop/apps/node/internal/transport 5.133s
|
||||
```
|
||||
|
||||
## 리뷰어 체크포인트
|
||||
- `go test ./apps/node/internal/adapters/cli -count=1`
|
||||
- `go test ./apps/node/... -count=1`
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- Correctness: Fail
|
||||
- Completeness: Fail
|
||||
- Test coverage: Fail
|
||||
- API contract: Fail
|
||||
- Code quality: Pass
|
||||
- Plan deviation: Fail
|
||||
- Verification trust: Fail
|
||||
- 발견된 문제:
|
||||
- Required - `apps/node/internal/adapters/cli/codex_app_server.go:285`: `send`가 response channel과 `p.done`을 같은 `select`에서 기다리기 때문에, 서버가 정상 response를 쓴 직후 stdout을 닫으면 response가 이미 도착했어도 `process exited`가 선택될 수 있습니다. 실제로 `go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s`가 `send 2: codex app-server process exited`로 실패했습니다. `p.done` 분기에서 먼저 pending response를 비차단으로 확인하거나, response dispatch와 process-exit close 순서를 조정해 이미 받은 response를 우선 반환하도록 고치고 회귀 테스트를 안정화하세요.
|
||||
- Required - `apps/node/internal/adapters/cli/codex_app_server.go:341`: `thread/start` 응답에서 `threadId`/`id`가 모두 비어도 `codexAppServerInit`이 빈 thread id를 성공으로 반환합니다. 계획은 `thread/start`까지 호출해 Codex thread id를 session에 저장하는 것이므로, thread id가 없으면 명시적 error를 반환하고 missing-id 테스트를 추가하세요.
|
||||
- 다음 단계: WARN/FAIL 후속 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다.
|
||||
|
||||
### 리뷰 중 실행한 검증
|
||||
|
||||
```text
|
||||
$ go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer|TestExecutorForMode|TestCLIStartSkipsCodexAppServerPersistentStartup|TestHandleSessionList' -count=1 -timeout=60s
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
|
||||
$ go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
FAIL
|
||||
FAIL iop/apps/node/internal/adapters/cli 42.498s
|
||||
FAIL
|
||||
|
||||
$ go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
FAIL
|
||||
FAIL iop/apps/node/internal/adapters/cli 0.004s
|
||||
FAIL
|
||||
|
||||
$ go test ./apps/node/... -count=1 -timeout=120s
|
||||
ok iop/apps/node/cmd/node 0.009s
|
||||
ok iop/apps/node/internal/adapters 0.008s
|
||||
ok iop/apps/node/internal/adapters/cli 42.540s
|
||||
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.878s
|
||||
? iop/apps/node/internal/adapters/mock [no test files]
|
||||
ok iop/apps/node/internal/adapters/ollama 0.007s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.007s
|
||||
ok iop/apps/node/internal/bootstrap 0.265s
|
||||
ok iop/apps/node/internal/node 0.118s
|
||||
ok iop/apps/node/internal/router 0.005s
|
||||
? iop/apps/node/internal/runtime [no test files]
|
||||
ok iop/apps/node/internal/store 0.040s
|
||||
ok iop/apps/node/internal/terminal 0.436s
|
||||
ok iop/apps/node/internal/transport 5.144s
|
||||
```
|
||||
|
||||
## 코드리뷰 전용 종결 체크리스트
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append했다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-cloud-G07.md`를 `code_review_cloud_G07_0.log`로 아카이브했다.
|
||||
- [x] active `PLAN-cloud-G07.md`를 `plan_cloud_G07_0.log`로 아카이브했다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인했다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으므로 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않았다.
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/01_appserver_rpc_client plan=1 tag=REVIEW_API -->
|
||||
|
||||
# Code Review Reference - REVIEW_API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-12
|
||||
task=m-codex-app-server-streaming-migration/01_appserver_rpc_client, plan=1, tag=REVIEW_API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `mode-router`: CLI adapter에 `codex-app-server` mode를 추가하고 executor/session reporter 라우팅을 분리한다.
|
||||
- `rpc-lifecycle`: `codex app-server --stdio` 프로세스를 시작한 뒤 `initialize`, `initialized`, `thread/start`, `turn/start` 흐름을 안정적으로 처리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-codex-app-server-streaming-migration/01_appserver_rpc_client/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-codex-app-server-streaming-migration`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_API-1] `send` response/process-exit 경합 수정 | 완료 |
|
||||
| [REVIEW_API-2] `thread/start` thread id 필수 검증 | 완료 |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `send`가 response와 `p.done` 경합에서 이미 도착한 response를 우선 반환하도록 수정한다.
|
||||
- [x] `codexAppServerInit`이 `thread/start` 응답의 thread id 누락을 error로 반환하고 missing-id 회귀 테스트를 추가한다.
|
||||
- [x] `go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s`, `go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s`, `go test ./apps/node/... -count=1 -timeout=120s` 결과를 `CODE_REVIEW-cloud-G07.md`에 실제 stdout/stderr로 기록한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/`를 `agent-task/archive/YYYY/MM/m-codex-app-server-streaming-migration/01_appserver_rpc_client/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-codex-app-server-streaming-migration`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-codex-app-server-streaming-migration/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- `send()`의 두 번째 select에 response 우선 처리 패턴(`non-blocking ch` → blocking multi-select → non-blocking ch again)을 추가했다. PLAN에는 명시되지 않았으나 리뷰 지적(REVIEW_API-1)에서 요구한 경합 처리로, 동작 범위 내 수정이다.
|
||||
- `codexAppServerInit`에서 thread id가 비어 있을 때 `("", nil)` 반환을 `error` 반환으로 변경했다. PLAN에는 "저장" 수준만 명시됐으나 리뷰 지적(REVIEW_API-2)에서 필수 검증으로 요구했다.
|
||||
- `TestCodexAppServerSend_ResponsePriorityOverDone` 테스트는 `send()` 경합 경로를 직접 호출하기보다 pending 채널 pre-load + done 채널 수동 확인 방식으로 구현했다. `send()`를 우회하지 않으면 id 생성 흐름이 연동되어 pipe write 블록이 발생할 수 있어서 핵심 select 로직만 직접 검증한다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **response 우선 select 패턴**: `select { case resp := <-ch: ... default: }` → full multi-select → `select { case resp := <-ch: ... default: }` 3단계. `ch`가 이미 ready이면 첫 번째 non-blocking select에서 즉시 반환하고, 두 채널이 동시에 ready인 경우 두 번째 full select 이후 마지막 non-blocking select에서 경합을 해소한다.
|
||||
- **thread id 누락 오류**: `threadId` 필드명과 `id` 필드명 둘 다 fallback으로 확인한 뒤 둘 다 비어 있으면 `fmt.Errorf`로 오류 반환. 실제 Codex CLI 버전별 응답 차이를 흡수하면서도 누락을 숨기지 않는다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `send`가 이미 dispatch된 response를 process-exit보다 우선 처리하는지 확인한다.
|
||||
- `pending` cleanup, context cancel, duplicate close 동작이 깨지지 않았는지 확인한다.
|
||||
- `thread/start` 응답의 thread id 누락이 성공으로 묻히지 않는지 확인한다.
|
||||
- 구현자가 기록한 검증 출력이 실제 명령과 일치하는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### REVIEW_API-1 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s
|
||||
```
|
||||
```text
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
```
|
||||
|
||||
### REVIEW_API-2 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerRPC|TestCodexAppServerThread' -count=1 -timeout=30s
|
||||
```
|
||||
```text
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s
|
||||
go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s
|
||||
go test ./apps/node/... -count=1 -timeout=120s
|
||||
```
|
||||
```text
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
|
||||
ok iop/apps/node/internal/adapters/cli 42.493s
|
||||
|
||||
ok iop/apps/node/cmd/node 0.014s
|
||||
ok iop/apps/node/internal/adapters 0.008s
|
||||
ok iop/apps/node/internal/adapters/cli 42.551s
|
||||
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.819s
|
||||
? iop/apps/node/internal/adapters/mock [no test files]
|
||||
ok iop/apps/node/internal/adapters/ollama 0.010s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.009s
|
||||
ok iop/apps/node/internal/bootstrap 0.263s
|
||||
ok iop/apps/node/internal/node 0.119s
|
||||
ok iop/apps/node/internal/router 0.005s
|
||||
? iop/apps/node/internal/runtime [no test files]
|
||||
ok iop/apps/node/internal/store 0.056s
|
||||
ok iop/apps/node/internal/terminal 0.449s
|
||||
ok iop/apps/node/internal/transport 5.163s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- Correctness: Fail
|
||||
- Completeness: Fail
|
||||
- Test coverage: Fail
|
||||
- API contract: Pass
|
||||
- Code quality: Pass
|
||||
- Plan deviation: Fail
|
||||
- Verification trust: Fail
|
||||
- 발견된 문제:
|
||||
- Required - `apps/node/internal/adapters/cli/codex_app_server.go:274`: `send()`의 첫 번째 `select`는 `writeErr`, `p.done`, `ctx.Done()` 중 `p.done`이 선택되면 response channel을 확인하지 않고 즉시 `process exited`를 반환합니다. 서버가 요청을 읽고 response를 쓴 뒤 바로 stdout을 닫는 기존 재현 케이스에서 `writeErr`, response dispatch, `p.done`이 가까운 시점에 ready가 되며, 현재 코드에서도 `go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s`가 `send 2: codex app-server process exited`로 실패합니다. 첫 번째 `p.done` 분기에서도 이미 dispatch된 response를 우선 확인하거나, write 완료와 response wait를 분리해 `p.done`이 final response를 가로채지 못하도록 고치세요.
|
||||
- Required - `apps/node/internal/adapters/cli/codex_app_server_internal_test.go:406`: 새 `TestCodexAppServerSend_ResponsePriorityOverDone`은 `send()`를 호출하지 않고 preloaded channel을 직접 읽기만 해서, 실제 `send`의 `writeErr`/`p.done`/response select 경합을 검증하지 못합니다. 기존 `TestCodexAppServerSend_RequestIDMonotone` 또는 새 테스트가 실제 `send()` 호출로 response-priority 경로를 안정적으로 검증하게 수정하세요.
|
||||
- 다음 단계: WARN/FAIL 후속 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다.
|
||||
|
||||
### 리뷰 중 실행한 검증
|
||||
|
||||
```text
|
||||
$ go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
FAIL
|
||||
FAIL iop/apps/node/internal/adapters/cli 0.023s
|
||||
FAIL
|
||||
|
||||
$ go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s
|
||||
--- FAIL: TestCodexAppServerSend_RequestIDMonotone (0.00s)
|
||||
codex_app_server_internal_test.go:341: send 2: codex app-server process exited
|
||||
FAIL
|
||||
FAIL iop/apps/node/internal/adapters/cli 0.004s
|
||||
FAIL
|
||||
|
||||
$ go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s
|
||||
ok iop/apps/node/internal/adapters/cli 42.497s
|
||||
|
||||
$ go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerRPC|TestCodexAppServerThread' -count=1 -timeout=30s
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
```
|
||||
|
||||
## 코드리뷰 전용 종결 체크리스트
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append했다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-cloud-G07.md`를 `code_review_cloud_G07_1.log`로 아카이브했다.
|
||||
- [x] active `PLAN-cloud-G07.md`를 `plan_cloud_G07_1.log`로 아카이브했다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인했다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으므로 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않았다.
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/01_appserver_rpc_client plan=2 tag=REVIEW_REVIEW_API -->
|
||||
|
||||
# Code Review Reference - REVIEW_REVIEW_API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-13
|
||||
task=m-codex-app-server-streaming-migration/01_appserver_rpc_client, plan=2, tag=REVIEW_REVIEW_API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `mode-router`: CLI adapter에 `codex-app-server` mode를 추가하고 executor/session reporter 라우팅을 분리한다.
|
||||
- `rpc-lifecycle`: `codex app-server --stdio` 프로세스를 시작한 뒤 `initialize`, `initialized`, `thread/start`, `turn/start` 흐름을 안정적으로 처리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-codex-app-server-streaming-migration/01_appserver_rpc_client/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-codex-app-server-streaming-migration`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_REVIEW_API-1] `send()` write 단계의 response/process-exit 경합 수정 | 완료 |
|
||||
| [REVIEW_REVIEW_API-2] 실제 경합을 검증하지 않는 보조 테스트 교체 | 완료 |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `send()`의 write 단계에서도 process exit보다 이미 도착한 response를 우선 반환하도록 수정한다.
|
||||
- [x] `TestCodexAppServerSend_ResponsePriorityOverDone`를 실제 `send()` 경로를 검증하는 테스트로 바꾸거나 제거하고, 기존 반복 테스트가 경합 회귀를 잡도록 정리한다.
|
||||
- [x] `go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s`, `go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s`, `go test ./apps/node/... -count=1 -timeout=120s` 결과를 `CODE_REVIEW-cloud-G07.md`에 실제 stdout/stderr로 기록한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_2.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_2.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/`를 `agent-task/archive/YYYY/MM/m-codex-app-server-streaming-migration/01_appserver_rpc_client/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-codex-app-server-streaming-migration`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-codex-app-server-streaming-migration/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- `send()` write 단계 select에도 response 우선 처리를 적용했다. 이전 구현은 write 완료 대기 중 `p.done`이 선택되면 응답이 이미 수신된 경우에도 오류를 반환했다.
|
||||
- `recvResponse()` 헬퍼를 추출해 fast-path(non-blocking ch 확인) → slow-path(multi-select) → drain(done 선택 후 한 번 더 ch 확인) 패턴을 응답 수신 경로에서 재사용했다.
|
||||
- `TestCodexAppServerSend_ResponsePriorityOverDone`을 pending 채널 수동 조작에서 실제 `send()` 호출 방식으로 완전 교체했다. fake server 고루틴이 응답 전송 직후 `stdoutW.Close()`로 프로세스 종료를 시뮬레이션하고, `send()`가 error 없이 응답을 반환하는지 검증한다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **write 단계 response 우선**: write 대기 select에 `case resp := <-ch`를 추가해 write가 완료되기 전에 응답이 도착해도 바로 반환한다. 이는 매우 빠른 서버(응답 후 즉시 종료)에서 write 고루틴이 아직 `writeErr`를 전달하지 않은 상태에서 `p.done`이 먼저 닫힐 때 응답을 놓치지 않기 위한 처리다.
|
||||
- **recvResponse 헬퍼**: 두 대기 지점(write 완료 후, recvResponse 내부)에서 동일한 우선순위 패턴이 필요해 중복을 제거했다. `recvResponse`는 fast-path non-blocking → blocking multi-select → done 분기 후 drain의 3단계를 가진다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `send()`의 두 wait 지점 모두 response 우선 규칙을 적용하는지 확인한다.
|
||||
- `ctx.Done()` 취소, RPC error response, pending cleanup이 유지되는지 확인한다.
|
||||
- `TestCodexAppServerSend_ResponsePriorityOverDone`가 실제 `send()`를 호출하거나 제거됐는지 확인한다.
|
||||
- 구현자가 기록한 focused 반복 검증이 실제로 통과하는지 재실행한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### REVIEW_REVIEW_API-1 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s
|
||||
```
|
||||
```text
|
||||
ok iop/apps/node/internal/adapters/cli 0.025s
|
||||
```
|
||||
|
||||
### REVIEW_REVIEW_API-2 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerSend' -count=50 -timeout=60s
|
||||
```
|
||||
```text
|
||||
ok iop/apps/node/internal/adapters/cli 0.012s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerSend' -count=50 -timeout=60s
|
||||
go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s
|
||||
go test ./apps/node/... -count=1 -timeout=120s
|
||||
```
|
||||
```text
|
||||
ok iop/apps/node/internal/adapters/cli 0.025s
|
||||
|
||||
ok iop/apps/node/internal/adapters/cli 0.012s
|
||||
|
||||
ok iop/apps/node/internal/adapters/cli 42.676s
|
||||
|
||||
ok iop/apps/node/cmd/node 0.012s
|
||||
ok iop/apps/node/internal/adapters 0.014s
|
||||
ok iop/apps/node/internal/adapters/cli 42.551s
|
||||
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.817s
|
||||
? iop/apps/node/internal/adapters/mock [no test files]
|
||||
ok iop/apps/node/internal/adapters/ollama 0.012s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.009s
|
||||
ok iop/apps/node/internal/bootstrap 0.264s
|
||||
ok iop/apps/node/internal/node 0.121s
|
||||
ok iop/apps/node/internal/router 0.004s
|
||||
? iop/apps/node/internal/runtime [no test files]
|
||||
ok iop/apps/node/internal/store 0.038s
|
||||
ok iop/apps/node/internal/terminal 0.435s
|
||||
ok iop/apps/node/internal/transport 5.146s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- Correctness: Pass
|
||||
- Completeness: Pass
|
||||
- Test coverage: Pass
|
||||
- API contract: Pass
|
||||
- Code quality: Pass
|
||||
- Plan deviation: Pass
|
||||
- Verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계: PASS 종결로 `complete.log`를 작성하고 task directory를 archive로 이동한다.
|
||||
|
||||
### 리뷰 중 실행한 검증
|
||||
|
||||
```text
|
||||
$ go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s
|
||||
ok iop/apps/node/internal/adapters/cli 0.021s
|
||||
|
||||
$ go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerSend' -count=50 -timeout=60s
|
||||
ok iop/apps/node/internal/adapters/cli 0.011s
|
||||
|
||||
$ go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s
|
||||
ok iop/apps/node/internal/adapters/cli 42.438s
|
||||
|
||||
$ go test ./apps/node/... -count=1 -timeout=120s
|
||||
ok iop/apps/node/cmd/node 0.009s
|
||||
ok iop/apps/node/internal/adapters 0.008s
|
||||
ok iop/apps/node/internal/adapters/cli 42.614s
|
||||
? iop/apps/node/internal/adapters/cli/internal/testutil [no test files]
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.777s
|
||||
? iop/apps/node/internal/adapters/mock [no test files]
|
||||
ok iop/apps/node/internal/adapters/ollama 0.009s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.008s
|
||||
ok iop/apps/node/internal/bootstrap 0.264s
|
||||
ok iop/apps/node/internal/node 0.119s
|
||||
ok iop/apps/node/internal/router 0.004s
|
||||
? iop/apps/node/internal/runtime [no test files]
|
||||
ok iop/apps/node/internal/store 0.032s
|
||||
ok iop/apps/node/internal/terminal 0.434s
|
||||
ok iop/apps/node/internal/transport 5.143s
|
||||
```
|
||||
|
||||
### 리뷰 중 정리한 비동작 artifact
|
||||
|
||||
- `PLAN-cloud-G07.md` 파일 첫 글자에 남아 있던 stray `ㄹ` 문자를 제거했다. 구현/검증/계약 변경은 없다.
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# Complete - m-codex-app-server-streaming-migration/01_appserver_rpc_client
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-13
|
||||
|
||||
## 요약
|
||||
|
||||
Codex app-server RPC client follow-up review loop completed after 3 reviews; final verdict PASS.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | `send` response/process-exit race and missing `thread/start` thread id validation required follow-up |
|
||||
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | write-stage `p.done` race and non-representative response-priority test required follow-up |
|
||||
| `plan_cloud_G07_2.log` | `code_review_cloud_G07_2.log` | PASS | response priority fixed across `send` wait points, thread id validation retained, focused and node-wide tests passed |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- Added `codex-app-server` mode routing and session reporter integration for the CLI adapter.
|
||||
- Implemented newline-delimited Codex app-server request/response handling for `initialize`, `initialized`, and `thread/start`.
|
||||
- Added app-server session lifecycle handling, duplicate close protection, and `SessionModeRequireExisting` behavior.
|
||||
- Fixed `send()` response/process-exit races so already delivered responses are preferred over process exit.
|
||||
- Added thread id missing-response validation and focused send/thread tests.
|
||||
- Removed a stray non-behavioral character from the active follow-up plan before archiving.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s` - PASS; `ok iop/apps/node/internal/adapters/cli 0.021s`
|
||||
- `go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerSend' -count=50 -timeout=60s` - PASS; `ok iop/apps/node/internal/adapters/cli 0.011s`
|
||||
- `go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s` - PASS; `ok iop/apps/node/internal/adapters/cli 42.438s`
|
||||
- `go test ./apps/node/... -count=1 -timeout=120s` - PASS; node packages passed, including `apps/node/internal/adapters/cli`, `status`, `bootstrap`, `node`, `router`, `store`, `terminal`, and `transport`
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Completed task ids:
|
||||
- `mode-router`: PASS; evidence=`agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_2.log`, `agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_2.log`; verification=`go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s`
|
||||
- `rpc-lifecycle`: PASS; evidence=`agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_2.log`, `agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_2.log`; verification=`go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s`, `go test ./apps/node/... -count=1 -timeout=120s`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/01_appserver_rpc_client plan=0 tag=API -->
|
||||
# Codex App Server RPC Client Plan
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
이 PLAN을 끝까지 읽은 뒤 구현한다. 범위 밖 리팩터링, archive 읽기, 사용자 변경 되돌리기는 금지한다. 코드 변경 전 관련 domain rule과 local test rule을 확인하고, 구현 완료 전 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다.
|
||||
|
||||
## 배경
|
||||
현재 CLI adapter는 `codex exec --json`을 `modeCodexExec`로 실행한다. 이번 작업은 기본 프로필 전환의 선행 작업으로, `codex app-server --stdio`를 별도 mode로 라우팅하고 프로세스 생명주기 및 newline JSON RPC 초기화까지 안전하게 붙이는 단계다. 실제 turn 이벤트 매핑과 세션 노출은 후속 `02+01_session_events`가 맡는다.
|
||||
|
||||
임시 프로토콜 확인 결과, Codex CLI `0.139.0`의 app-server는 `--stdio`를 지원하고 `Content-Length` 프레이밍이 아니라 newline-delimited JSON을 받는다. 요청은 `jsonrpc` 필드 없이 `{id, method, params}` 형태이며, `initialize`, `initialized`, `thread/start`까지 확인됐다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
구현 중 사용자 소유 인증, 외부 계정 상태, 또는 범위 변경 없이는 안전하게 진행할 수 없을 때만 `CODE_REVIEW-cloud-G07.md`의 사용자 리뷰 요청 섹션을 실제 내용으로 교체하고 중단한다. 단순 검증 로그 부족은 구현 에이전트가 재실행으로 해소한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `mode-router`: CLI adapter에 `codex-app-server` mode를 추가하고 executor/session reporter 라우팅을 분리한다.
|
||||
- `rpc-lifecycle`: `codex app-server --stdio` 프로세스를 시작한 뒤 `initialize`, `initialized`, `thread/start`, `turn/start` 흐름을 안정적으로 처리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
### 읽은 파일
|
||||
- Agent-Ops: `agent-ops/rules/project/rules.md`, `agent-ops/rules/common/rules-roadmap.md`, `agent-ops/skills/common/router.md`, `agent-ops/skills/common/plan/SKILL.md`
|
||||
- Roadmap: `agent-roadmap/current.md`, `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`, `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Domain/test rules: `agent-ops/rules/project/domain/node/rules.md`, `agent-ops/rules/project/domain/platform-common/rules.md`, `agent-ops/rules/project/domain/testing/rules.md`, `agent-test/local/rules.md`, `agent-test/local/node-smoke.md`, `agent-test/local/platform-common-smoke.md`, `agent-test/local/testing-smoke.md`
|
||||
- 코드: `apps/node/internal/adapters/cli/cli.go`, `apps/node/internal/adapters/cli/codex_exec.go`, `apps/node/internal/adapters/cli/opencode_sse.go`, `apps/node/internal/adapters/cli/oneshot.go`, `apps/node/internal/adapters/cli/emitters.go`, `apps/node/internal/runtime/types.go`, `apps/node/internal/adapters/cli/internal/testutil/testutil.go`
|
||||
- 테스트: `apps/node/internal/adapters/cli/cli_internal_test.go`, `apps/node/internal/adapters/cli/codex_exec_blackbox_test.go`, `apps/node/internal/adapters/cli/opencode_sse_internal_test.go`, `apps/node/internal/adapters/cli/opencode_sse_blackbox_test.go`
|
||||
- 임시 생성 타입: `/tmp/iop-codex-appserver-ts/ClientRequest.ts`, `/tmp/iop-codex-appserver-ts/ServerNotification.ts`, `/tmp/iop-codex-appserver-ts/v2/*`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
- 환경 미지정은 local로 본다.
|
||||
- 신선한 Go 검증은 필요한 경우 `-count=1`을 사용한다.
|
||||
- 기본 검증 후보: `go test ./apps/node/internal/adapters/cli -count=1`, `go test ./apps/node/... -count=1`
|
||||
- 외부 Codex CLI와 계정 상태가 필요한 검증은 local runner에서 실행하고, 사용자 소유 계정 문제일 때만 사용자 리뷰 요청으로 남긴다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
- 현재 app-server RPC client가 없으므로 mode routing, 초기화 request shape, stdin/stdout newline framing, 프로세스 종료/중복 종료 테스트가 없다.
|
||||
- `Content-Length`와 JSON-RPC envelope를 쓰지 않는다는 회귀 테스트가 필요하다.
|
||||
|
||||
### 심볼 참조
|
||||
- 기존 심볼명 변경 없음.
|
||||
- 추가 예정: `modeCodexAppServer`, `codexAppServerExecutor`, `codexAppServerSession`, app-server request/notification helper.
|
||||
- 호출 지점: `CLI.New`, `CLI.Start`, `shouldAutostartPersistentProfile`, `executorFor`, `sessionReporterFor`, `Stop`.
|
||||
|
||||
### 분할 판단
|
||||
분할 정책을 평가했고 공유 task group은 `agent-task/m-codex-app-server-streaming-migration/`이다. sibling subtask는 `01_appserver_rpc_client`(선행 없음), `02+01_session_events`(`01` 의존), `03+02_default_profile`(`02` 의존), `04+03_stream_smoke`(`03` 의존)이다. 현재 작업은 `mode-router`와 `rpc-lifecycle`만 닫으며, 후속 작업은 각 선행 `complete.log`가 없으면 구현하지 않는다.
|
||||
|
||||
### 범위 결정 근거
|
||||
새 mode와 프로세스/RPC lifecycle은 모든 후속 작업의 기반이다. event map까지 동시에 넣으면 adapter 내부 상태와 runtime 이벤트 계약이 섞여 리뷰 단위가 커지므로 분리한다.
|
||||
|
||||
### 빌드 등급
|
||||
`cloud-G07`: 외부 Codex CLI protocol과 프로세스 lifecycle을 다루므로 단위 테스트만으로는 부족하고, CLI가 설치된 local/cloud runner 검증이 필요하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] `codex app-server --stdio`용 mode 상수와 executor/session 타입을 추가한다.
|
||||
- [ ] `CLI.New`, `executorFor`, `sessionReporterFor`, `shouldAutostartPersistentProfile`, `Stop`에 새 executor를 연결한다.
|
||||
- [ ] newline JSON RPC request writer와 response/notification reader를 구현한다.
|
||||
- [ ] `initialize` request와 `initialized` notification을 startup sequence로 검증한다.
|
||||
- [ ] process close가 중복 호출과 context cancel에서 안전하게 동작하게 한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [API-1] 새 mode를 CLI adapter 라우터에 연결
|
||||
문제: `apps/node/internal/adapters/cli/cli.go:31`의 mode 목록에는 app-server mode가 없고, `executorFor`는 `codex-exec`만 Codex 전용 executor로 라우팅한다.
|
||||
|
||||
해결 방법: `modeCodexAppServer = "codex-app-server"`를 추가하고 `codexAppServerExecutor`를 `CLI` 필드, 생성자, reporter 목록에 연결한다. `shouldAutostartPersistentProfile`에서 persistent auto-start 대상에서 제외한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/cli.go`
|
||||
- [ ] `apps/node/internal/adapters/cli/cli_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] app-server profile이 새 executor로 라우팅되는 단위 테스트
|
||||
- [ ] persistent=true인 app-server profile이 legacy persistent startup으로 들어가지 않는 테스트
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestExecutorForMode|TestCLIStartSkipsCodexAppServerPersistentStartup' -count=1
|
||||
```
|
||||
|
||||
### [API-2] app-server stdio 프로세스와 RPC lifecycle 구현
|
||||
문제: `opencode_sse.go`는 HTTP/SSE lifecycle을, `codex_exec.go`는 one-shot exec resume을 다룬다. app-server는 stdio process와 request id 기반 response matching이 필요하다.
|
||||
|
||||
해결 방법: 새 파일 `codex_app_server.go`를 추가해 profile command/args에서 `codex app-server --stdio`를 실행한다. stdout은 line scanner로 읽고, request id별 response channel과 notification channel을 분리한다. startup은 `initialize` response 수신 후 `initialized` notification 전송까지 완료해야 성공으로 본다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] fake process pipe로 `initialize` request shape 검증
|
||||
- [ ] `jsonrpc` 필드가 붙지 않는지 검증
|
||||
- [ ] server stderr/process exit가 adapter error로 surfaced 되는지 검증
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerRPC|TestCodexAppServerProcessLifecycle' -count=1
|
||||
```
|
||||
|
||||
### [API-3] 세션 시작의 최소 골격까지 연결
|
||||
문제: 후속 작업이 turn/event mapping을 붙이려면 app-server process가 논리 session key에 묶여 있어야 한다.
|
||||
|
||||
해결 방법: `resolveCodexAppServerSession`을 구현해 `sessionKey{target, sessionID}` 단위로 process를 재사용한다. 최초 생성 시 `thread/start`까지 호출해 Codex thread id를 session에 저장하되, turn 실행과 event mapping은 후속 plan에서 구현한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] 같은 target/sessionID가 같은 app-server session을 재사용하는 테스트
|
||||
- [ ] `SessionModeRequireExisting`에서 없으면 에러가 나는 테스트
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerSessionResolve|TestCodexAppServerRequireExisting' -count=1
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
| 파일 | 항목 |
|
||||
| --- | --- |
|
||||
| `apps/node/internal/adapters/cli/cli.go` | API-1 |
|
||||
| `apps/node/internal/adapters/cli/codex_app_server.go` | API-2, API-3 |
|
||||
| `apps/node/internal/adapters/cli/codex_app_server_internal_test.go` | API-2, API-3 |
|
||||
| `apps/node/internal/adapters/cli/cli_internal_test.go` | API-1 |
|
||||
|
||||
## 최종 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -count=1
|
||||
go test ./apps/node/... -count=1
|
||||
```
|
||||
|
||||
**모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.**
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/01_appserver_rpc_client plan=1 tag=REVIEW_API -->
|
||||
# Codex App Server RPC Client Follow-up Plan
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
이 PLAN을 끝까지 읽은 뒤 구현한다. 범위 밖 리팩터링, archive 읽기, 사용자 변경 되돌리기는 금지한다. 코드 변경 전 관련 domain rule과 local test rule을 확인하고, 구현 완료 전 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다.
|
||||
|
||||
구현 중 사용자 소유 인증, 외부 계정 상태, 또는 범위 변경 없이는 안전하게 진행할 수 없을 때만 `CODE_REVIEW-cloud-G07.md`의 사용자 리뷰 요청 섹션을 실제 내용으로 교체하고 중단한다. 직접 사용자에게 질문하거나 선택지를 제시하지 않는다. 단순 검증 로그 부족은 구현 에이전트가 재실행으로 해소한다.
|
||||
|
||||
## 배경
|
||||
이 follow-up은 `code_review_cloud_G07_0.log`의 FAIL 판정에서 나온 Required 이슈만 처리한다. app-server mode 라우팅 자체는 유지하되, newline RPC response와 process exit의 경합, 그리고 `thread/start` 결과 검증 누락을 수정한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `mode-router`: CLI adapter에 `codex-app-server` mode를 추가하고 executor/session reporter 라우팅을 분리한다.
|
||||
- `rpc-lifecycle`: `codex app-server --stdio` 프로세스를 시작한 뒤 `initialize`, `initialized`, `thread/start`, `turn/start` 흐름을 안정적으로 처리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이전 리뷰 요약
|
||||
- Archived plan: `agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_0.log`
|
||||
- Archived review: `agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_0.log`
|
||||
- Verdict: FAIL
|
||||
- Required:
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go:285`에서 response와 `p.done`이 동시에 ready일 때 valid response 대신 `process exited`가 반환될 수 있다.
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go:341`에서 `thread/start` response의 thread id가 비어도 초기화 성공으로 처리한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] `send`가 response와 `p.done` 경합에서 이미 도착한 response를 우선 반환하도록 수정한다.
|
||||
- [ ] `codexAppServerInit`이 `thread/start` 응답의 thread id 누락을 error로 반환하고 missing-id 회귀 테스트를 추가한다.
|
||||
- [ ] `go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s`, `go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s`, `go test ./apps/node/... -count=1 -timeout=120s` 결과를 `CODE_REVIEW-cloud-G07.md`에 실제 stdout/stderr로 기록한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_API-1] `send` response/process-exit 경합 수정
|
||||
문제: `codexAppServerProc.send`가 response channel과 `p.done`을 같은 `select`에서 기다린다. 서버가 response를 쓴 직후 stdout을 닫으면 둘 다 ready가 되고, Go select가 `p.done`을 고르면 이미 받은 response가 무시된다. 이 때문에 `TestCodexAppServerSend_RequestIDMonotone`가 반복 실행에서 `send 2: codex app-server process exited`로 실패한다.
|
||||
|
||||
해결 방법: response를 우선 반환하도록 `p.done` 분기를 조정한다. 예를 들어 `p.done`을 받았을 때 pending response channel을 비차단으로 한 번 확인하고, response가 있으면 error 처리 규칙까지 동일하게 적용해 반환한다. 더 나은 구조가 있으면 적용하되 pending map 삭제와 context cancel 동작은 유지한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] process stdout close와 response delivery가 붙어 있어도 response가 우선 처리되는 회귀 테스트를 안정화한다.
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s
|
||||
```
|
||||
|
||||
### [REVIEW_API-2] `thread/start` thread id 필수 검증
|
||||
문제: `codexAppServerInit`이 `threadId`와 `id`를 모두 찾지 못해도 빈 string을 성공으로 반환한다. 이후 session에는 빈 `threadID`가 저장되어 후속 turn 실행이 올바른 thread에 붙을 수 없다.
|
||||
|
||||
해결 방법: `thread/start` 응답에서 `threadId` 또는 fallback `id`가 비어 있으면 명시적 error를 반환한다. error 메시지는 response shape를 진단할 수 있도록 `thread/start`와 thread id 누락을 포함한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] `thread/start` 응답에 thread id가 없을 때 `codexAppServerInit`이 error를 반환하는 테스트를 추가한다.
|
||||
- [ ] 기존 `threadId`와 fallback `id` 성공 경로가 유지되는지 확인한다.
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerRPC|TestCodexAppServerThread' -count=1 -timeout=30s
|
||||
```
|
||||
|
||||
## 범위 결정 근거
|
||||
라우터 등록, session reporter 연결, legacy persistent startup 제외는 첫 리뷰에서 Required로 지적되지 않았다. 이번 follow-up은 실패 재현 가능한 RPC lifecycle 이슈와 thread id 계약 보완만 다룬다.
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
- `send`가 이미 dispatch된 response를 process-exit보다 우선 처리하는지 확인한다.
|
||||
- `pending` cleanup, context cancel, duplicate close 동작이 깨지지 않았는지 확인한다.
|
||||
- `thread/start` 응답의 thread id 누락이 성공으로 묻히지 않는지 확인한다.
|
||||
- 구현자가 기록한 검증 출력이 실제 명령과 일치하는지 확인한다.
|
||||
|
||||
## 최종 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=20 -timeout=30s
|
||||
go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s
|
||||
go test ./apps/node/... -count=1 -timeout=120s
|
||||
```
|
||||
|
||||
**모든 코드 변경 완료 후 반드시 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.**
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/01_appserver_rpc_client plan=2 tag=REVIEW_REVIEW_API -->
|
||||
# Codex App Server RPC Client Follow-up Plan 2
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
이 PLAN을 끝까지 읽은 뒤 구현한다. 범위 밖 리팩터링, archive 읽기, 사용자 변경 되돌리기는 금지한다. 코드 변경 전 관련 domain rule과 local test rule을 확인하고, 구현 완료 전 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다.
|
||||
|
||||
구현 중 사용자 소유 인증, 외부 계정 상태, 또는 범위 변경 없이는 안전하게 진행할 수 없을 때만 `CODE_REVIEW-cloud-G07.md`의 사용자 리뷰 요청 섹션을 실제 내용으로 교체하고 중단한다. 직접 사용자에게 질문하거나 선택지를 제시하지 않는다. 단순 검증 로그 부족은 구현 에이전트가 재실행으로 해소한다.
|
||||
|
||||
## 배경
|
||||
이 follow-up은 `code_review_cloud_G07_1.log`의 FAIL 판정에서 나온 Required 이슈만 처리한다. `thread/start` thread id 누락 검증은 통과했으므로 다시 넓히지 않는다. 남은 범위는 `send()`의 첫 번째 `writeErr`/`p.done` 경합과, 실제 `send()` 경로를 검증하지 않는 보조 테스트 수정이다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `mode-router`: CLI adapter에 `codex-app-server` mode를 추가하고 executor/session reporter 라우팅을 분리한다.
|
||||
- `rpc-lifecycle`: `codex app-server --stdio` 프로세스를 시작한 뒤 `initialize`, `initialized`, `thread/start`, `turn/start` 흐름을 안정적으로 처리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이전 리뷰 요약
|
||||
- Archived plan: `agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_1.log`
|
||||
- Archived review: `agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_1.log`
|
||||
- Verdict: FAIL
|
||||
- Required:
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go:274`에서 첫 번째 `select`가 `p.done`을 선택하면 response channel 확인 없이 `process exited`를 반환한다.
|
||||
- `apps/node/internal/adapters/cli/codex_app_server_internal_test.go:406`의 새 테스트가 `send()`를 호출하지 않아 실제 경합을 검증하지 못한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] `send()`의 write 단계에서도 process exit보다 이미 도착한 response를 우선 반환하도록 수정한다.
|
||||
- [ ] `TestCodexAppServerSend_ResponsePriorityOverDone`를 실제 `send()` 경로를 검증하는 테스트로 바꾸거나 제거하고, 기존 반복 테스트가 경합 회귀를 잡도록 정리한다.
|
||||
- [ ] `go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s`, `go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s`, `go test ./apps/node/... -count=1 -timeout=120s` 결과를 `CODE_REVIEW-cloud-G07.md`에 실제 stdout/stderr로 기록한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_REVIEW_API-1] `send()` write 단계의 response/process-exit 경합 수정
|
||||
문제: 현재 `send()`는 stdin write 완료를 기다리는 첫 번째 `select`에서 `p.done`이 선택되면 바로 `process exited`를 반환한다. 서버가 요청을 읽고 response를 쓴 직후 stdout을 닫으면 `writeErr`, response dispatch, `p.done`이 가까운 시점에 ready가 되어 여전히 valid response가 묻힐 수 있다.
|
||||
|
||||
해결 방법: 첫 번째 `p.done` 분기에서도 response channel을 비차단으로 확인하고, 이미 dispatch된 response가 있으면 기존 RPC error 처리 규칙까지 동일하게 적용해 반환한다. 더 안전한 구조가 필요하면 response 처리 helper를 분리해 첫 번째/두 번째 wait 모두 같은 우선순위 규칙을 쓰게 한다. `ctx.Done()`은 기존처럼 우선 취소 semantics를 유지한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] `TestCodexAppServerSend_RequestIDMonotone`가 `-count=200`에서도 안정적으로 통과하도록 실제 `send()` 경로를 수정한다.
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s
|
||||
```
|
||||
|
||||
### [REVIEW_REVIEW_API-2] 실제 경합을 검증하지 않는 보조 테스트 교체
|
||||
문제: `TestCodexAppServerSend_ResponsePriorityOverDone`는 `send()`를 호출하지 않고 preloaded channel을 직접 읽는다. 이 테스트는 `send()`의 select 구조가 바뀌어도 실패하지 않으므로 회귀 방지 가치가 없다.
|
||||
|
||||
해결 방법: 해당 테스트를 실제 `send()` 호출 기반으로 바꾸거나, 기존 `TestCodexAppServerSend_RequestIDMonotone` 반복 검증이 충분히 실제 경합을 재현한다면 제거한다. 남기는 테스트는 stdin write, response dispatch, done close가 모두 실제 `send()` 흐름을 통과해야 한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] `send()`를 우회하는 select-only 테스트를 남기지 않는다.
|
||||
- [ ] 새/수정 테스트가 실패 시 `send()` 실제 반환값으로 실패하도록 만든다.
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerSend' -count=50 -timeout=60s
|
||||
```
|
||||
|
||||
## 범위 결정 근거
|
||||
`thread/start` thread id 누락 error와 테스트는 이번 리뷰에서 통과했다. 이번 follow-up은 남은 RPC send 경합과 테스트 신뢰성만 닫는다.
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
- `send()`의 두 wait 지점 모두 response 우선 규칙을 적용하는지 확인한다.
|
||||
- `ctx.Done()` 취소, RPC error response, pending cleanup이 유지되는지 확인한다.
|
||||
- `TestCodexAppServerSend_ResponsePriorityOverDone`가 실제 `send()`를 호출하거나 제거됐는지 확인한다.
|
||||
- 구현자가 기록한 focused 반복 검증이 실제로 통과하는지 재실행한다.
|
||||
|
||||
## 최종 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run '^TestCodexAppServerSend_RequestIDMonotone$' -count=200 -timeout=60s
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerSend' -count=50 -timeout=60s
|
||||
go test ./apps/node/internal/adapters/cli -count=1 -timeout=60s
|
||||
go test ./apps/node/... -count=1 -timeout=120s
|
||||
```
|
||||
|
||||
**모든 코드 변경 완료 후 반드시 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.**
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/02+01_session_events plan=0 tag=REFACTOR -->
|
||||
# Code Review Reference - REFACTOR
|
||||
|
||||
> 구현 에이전트 경고: 이 파일은 구현 마지막 단계에서 반드시 갱신한다. `구현 완료 기록`, `구현 체크리스트`, `검증 출력`을 실제 내용으로 채우기 전에는 작업 완료를 선언하지 않는다.
|
||||
|
||||
## 개요
|
||||
- 날짜: 2026-06-12
|
||||
- Task: `m-codex-app-server-streaming-migration/02+01_session_events`
|
||||
- Plan: `PLAN-cloud-G07.md`
|
||||
- Tag: `REFACTOR`
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `session-thread`: IOP `session_id`별 app-server thread를 재사용하고 `/terminate-session`에서 해당 세션을 정리한다.
|
||||
- `event-map`: `item/agentMessage/delta`를 `RuntimeEvent{Type: delta}`로 전달하고, `turn/completed`는 complete, app-server error와 failed turn은 error로 매핑한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 리뷰 에이전트 지침
|
||||
- 선행 `01_appserver_rpc_client/complete.log`가 있는지 확인한다.
|
||||
- IOP session id와 Codex thread id를 혼동하지 않았는지 확인한다.
|
||||
- delta/completed/error event가 중복 emit 되지 않는지 우선 리뷰한다.
|
||||
- archive는 명시 요청 없이 읽지 않는다.
|
||||
|
||||
## 구현 완료 기록
|
||||
| 항목 | 구현 에이전트 기록 |
|
||||
| --- | --- |
|
||||
| 변경 요약 | `codex_app_server.go`의 `Execute` stub을 실제 turn 실행으로 교체. `turn/start` 전송, notification drain, `RuntimeEvent` 변환 구현. session list/terminate는 기존 `Sessions()`/`Terminate()` 구현을 그대로 활용. |
|
||||
| 주요 파일 | `apps/node/internal/adapters/cli/codex_app_server.go`, `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`, `apps/node/internal/adapters/cli/cli_internal_test.go` |
|
||||
| 범위 외 변경 여부 | 없음. `extractPrompt` 중복 선언 제거(기존 `oneshot.go` 함수 재사용)만 추가됨 |
|
||||
| 남은 리스크 | 실제 codex app-server 프로세스 smoke 검증은 `04+03_stream_smoke` subtask에서 수행 |
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
| 항목 | 완료 여부 |
|
||||
| --- | --- |
|
||||
| REFACTOR-1 IOP session과 Codex thread 매핑 | 완료 - `resolveCodexAppServerSession`이 `(target, sessionID)` key로 session을 재사용. threadID는 session struct에만 보관 |
|
||||
| REFACTOR-2 app-server notification을 RuntimeEvent로 변환 | 완료 - `decodeAppServerNotification`: delta/turn-completed/error/turn-failed 매핑. metadata에 source, turn_id 포함 |
|
||||
| REFACTOR-3 session list와 terminate 연결 | 완료 - 기존 `Sessions()`/`Terminate()`가 reporter chain에 이미 연결되어 있음. 신규 테스트로 검증 |
|
||||
|
||||
## 구현 체크리스트
|
||||
- [x] 선행 `01_appserver_rpc_client/complete.log`를 확인한다. (archive에 존재 확인)
|
||||
- [x] `SessionID`를 Codex thread id에 매핑하고 재사용한다.
|
||||
- [x] `turn/start` request를 `ExecutionSpec.Input` 기반 prompt로 보낸다.
|
||||
- [x] `item/agentMessage/delta`, `item/completed`, `turn/completed`, `error` notification을 runtime event로 변환한다.
|
||||
- [x] runtime event metadata에 app-server source와 thread/turn id를 남긴다.
|
||||
- [x] session list와 terminate 동작을 app-server session에 맞게 구현한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
|
||||
|
||||
## 코드 리뷰 전용 체크리스트
|
||||
- [ ] response delta가 빈 문자열을 emit 하지 않는다.
|
||||
- [ ] completed event가 정확히 한 번 emit 된다.
|
||||
- [ ] error notification에서 session lock이 풀린다.
|
||||
- [ ] `/sessions`와 `/terminate-session`이 app-server session을 다룬다.
|
||||
|
||||
### 리뷰 루프 종결 체크리스트
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-cloud-G07.md`를 `code_review_cloud_G07_0.log`로 아카이브한다.
|
||||
- [x] active `PLAN-cloud-G07.md`를 `plan_cloud_G07_0.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] FAIL이고 user-review gate가 트리거되지 않았으므로 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] PASS 전용 `complete.log` 작성과 task directory archive 이동은 적용하지 않는다.
|
||||
- [ ] USER_REVIEW 작성은 적용하지 않는다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 검증 출력
|
||||
```text
|
||||
$ go test ./apps/node/internal/adapters/cli -count=1 -run 'TestCodexAppServerSessionThreadMapping|TestCodexAppServerEventMap|TestHandleSessionList_CodexAppServerVisible|TestTerminateSession_CodexAppServerRemovesFromList' -v
|
||||
=== RUN TestHandleSessionList_CodexAppServerVisible
|
||||
--- PASS: TestHandleSessionList_CodexAppServerVisible (0.00s)
|
||||
=== RUN TestTerminateSession_CodexAppServerRemovesFromList
|
||||
--- PASS: TestTerminateSession_CodexAppServerRemovesFromList (0.00s)
|
||||
=== RUN TestCodexAppServerSessionThreadMapping
|
||||
--- PASS: TestCodexAppServerSessionThreadMapping (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_Delta
|
||||
--- PASS: TestCodexAppServerEventMap_Delta (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_TurnCompleted
|
||||
--- PASS: TestCodexAppServerEventMap_TurnCompleted (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_Error
|
||||
--- PASS: TestCodexAppServerEventMap_Error (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_TurnFailed
|
||||
--- PASS: TestCodexAppServerEventMap_TurnFailed (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.006s
|
||||
|
||||
$ go test ./apps/node/internal/adapters/cli -count=1
|
||||
ok iop/apps/node/internal/adapters/cli 42.559s
|
||||
|
||||
$ go test ./apps/node/... -count=1
|
||||
ok iop/apps/node/cmd/node 0.009s
|
||||
ok iop/apps/node/internal/adapters 0.009s
|
||||
ok iop/apps/node/internal/adapters/cli 42.535s
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.856s
|
||||
ok iop/apps/node/internal/adapters/ollama 0.008s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.007s
|
||||
ok iop/apps/node/internal/bootstrap 0.263s
|
||||
ok iop/apps/node/internal/node 0.118s
|
||||
ok iop/apps/node/internal/router 0.004s
|
||||
ok iop/apps/node/internal/store 0.045s
|
||||
ok iop/apps/node/internal/terminal 0.420s
|
||||
ok iop/apps/node/internal/transport 5.138s
|
||||
```
|
||||
|
||||
## 리뷰어 체크포인트
|
||||
- `test -f agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log`
|
||||
- `go test ./apps/node/internal/adapters/cli -count=1`
|
||||
- `go test ./apps/node/... -count=1`
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Fail
|
||||
- code quality: Pass
|
||||
- plan deviation: Fail
|
||||
- verification trust: Fail
|
||||
- 발견된 문제:
|
||||
- Required: `apps/node/internal/adapters/cli/codex_app_server.go:527`의 `thread/start` 응답 파싱이 실제 app-server 계약과 다릅니다. `/tmp/iop-codex-appserver-ts/v2/ThreadStartResponse.ts`는 응답이 `result.thread.id` 형태임을 보여주는데, 구현은 `result.threadId` 또는 top-level `result.id`만 읽습니다. 실제 app-server에서는 세션 초기화가 `server did not return a thread id`로 실패할 수 있습니다. `thread.id` nested 값을 파싱하고, 테스트 fixture도 실제 `ThreadStartResponse` shape로 바꾸세요.
|
||||
- Required: `apps/node/internal/adapters/cli/codex_app_server.go:111`의 `turn/start` 응답 파싱도 실제 계약과 다릅니다. `/tmp/iop-codex-appserver-ts/v2/TurnStartResponse.ts`는 `result.turn.id`인데 구현은 `turnId`/`id`만 읽어 `turnID`가 빈 값으로 남습니다. 이 때문에 plan이 요구한 turn id metadata가 빠지고, 후속 이벤트 상관관계가 깨집니다. `turn.id`를 파싱하고 누락 시 error를 반환하는 테스트를 추가하세요.
|
||||
- Required: `apps/node/internal/adapters/cli/codex_app_server.go:148`의 event metadata 생성은 plan의 `source와 thread/turn id` 요구 중 thread id를 전혀 싣지 않습니다. `Execute`/`codexAppServerDrainNotifications`/`decodeAppServerNotification`에 thread id를 전달하거나 notification params의 `threadId`를 읽어서 `metadata["thread_id"]`를 채우고, delta/complete/error 테스트에서 `thread_id`와 `turn_id`를 함께 검증하세요.
|
||||
- Required: `apps/node/internal/adapters/cli/codex_app_server.go:172`는 모든 `turn/completed`를 성공 complete로 처리합니다. 실제 `TurnCompletedNotification`은 `params.turn.status`가 `failed`일 수 있고 `turn.error.message`를 포함합니다. failed status는 `RuntimeEvent{Type: error}`와 Go error로 surfaced 되어야 하며, 성공 complete로 저장되면 실패 turn이 정상 완료로 보입니다.
|
||||
- Required: `apps/node/internal/adapters/cli/codex_app_server.go:181`은 `error` notification에서 `params.message`만 읽습니다. 실제 `ErrorNotification`은 `params.error.message` 형태라 실제 오류 메시지가 `codex app-server error`로 뭉개집니다. `error.message`를 우선 파싱하고 `threadId`/`turnId` metadata도 함께 보존하세요.
|
||||
- Required: `apps/node/internal/adapters/cli/codex_app_server.go:377`에서 notification channel이 가득 차면 `default`로 notification을 조용히 버립니다. delta 손실뿐 아니라 `turn/completed`/`error`까지 버려져 실행이 context timeout까지 멈출 수 있습니다. read loop가 terminal notification을 drop하지 않도록 backpressure, larger drain path, 또는 lossless handoff로 바꾸고 overflow 재현 테스트를 추가하세요.
|
||||
- 다음 단계: FAIL이므로 user-review gate 없이 후속 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다.
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/02+01_session_events plan=1 tag=REVIEW_REFACTOR -->
|
||||
|
||||
# Code Review Reference - REVIEW_REFACTOR
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-13
|
||||
task=m-codex-app-server-streaming-migration/02+01_session_events, plan=1, tag=REVIEW_REFACTOR
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `session-thread`: IOP `session_id`별 app-server thread를 재사용하고 `/terminate-session`에서 해당 세션을 정리한다.
|
||||
- `event-map`: `item/agentMessage/delta`를 `RuntimeEvent{Type: delta}`로 전달하고, `turn/completed`는 complete, app-server error와 failed turn은 error로 매핑한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-codex-app-server-streaming-migration/02+01_session_events/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-codex-app-server-streaming-migration`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_REFACTOR-1] `thread/start` 응답 shape 수정 | [x] |
|
||||
| [REVIEW_REFACTOR-2] `turn/start` 응답 shape 수정 | [x] |
|
||||
| [REVIEW_REFACTOR-3] event metadata에 thread/turn id 보존 | [x] |
|
||||
| [REVIEW_REFACTOR-4] failed `turn/completed` 매핑 | [x] |
|
||||
| [REVIEW_REFACTOR-5] app-server `error` notification shape 수정 | [x] |
|
||||
| [REVIEW_REFACTOR-6] notification drop 제거 | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `thread/start` 응답에서 실제 `result.thread.id` shape를 파싱하고 기존 fallback을 필요한 범위에서만 유지한다.
|
||||
- [x] `turn/start` 응답에서 실제 `result.turn.id` shape를 파싱하고 turn id 누락을 error로 처리한다.
|
||||
- [x] RuntimeEvent metadata에 `source`, `thread_id`, `turn_id`를 보존한다.
|
||||
- [x] `turn/completed`의 `turn.status == failed`를 error event와 Go error로 변환한다.
|
||||
- [x] `error` notification의 실제 `params.error.message`를 파싱하고 error metadata를 보존한다.
|
||||
- [x] app-server notification relay가 `delta`, `turn/completed`, `error`를 조용히 drop하지 않도록 lossless handoff로 바꾼다.
|
||||
- [x] 실제 app-server 계약 shape와 overflow/failed-turn 회귀 테스트를 추가하거나 갱신한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-codex-app-server-streaming-migration/02+01_session_events/`를 `agent-task/archive/YYYY/MM/m-codex-app-server-streaming-migration/02+01_session_events/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-codex-app-server-streaming-migration`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-codex-app-server-streaming-migration/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- REVIEW_REFACTOR-6에서 `readLoop`의 `default` drop 제거 외에, `codexAppServerDrainNotifications`에도 `p.done` 수신 후 채널 drain 루프를 추가했다. `readLoop`의 blocking send만으로는 프로세스 종료와 terminal notification 도착이 경쟁할 때 `p.done`이 먼저 select 되어 terminal event를 놓칠 수 있기 때문이다. drain 측에서도 `p.done` 감지 후 채널을 소진하는 방어 코드가 필요했다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- `decodeAppServerNotification` 시그니처에 `threadID` 파라미터를 추가해 execution-context thread id를 fallback으로 전달한다. notification params의 `threadId`/`turnId`가 있으면 우선 사용하고 없으면 실행 컨텍스트 값을 사용한다.
|
||||
- `turn/completed`의 `status` 필드를 `nestedString`으로 읽어 `"failed"` 분기와 `"completed"` 분기를 분리한다. status가 빈 문자열이거나 `"completed"/"interrupted"/"inProgress"` 등 non-failed이면 complete event로 처리한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `/tmp/iop-codex-appserver-ts/v2/ThreadStartResponse.ts`, `TurnStartResponse.ts`, `TurnCompletedNotification.ts`, `ErrorNotification.ts`와 fake test shape가 일치하는지 확인한다.
|
||||
- `RuntimeEvent.Metadata`에 `source`, `thread_id`, `turn_id`가 delta, complete, error 모두에서 일관되게 유지되는지 확인한다.
|
||||
- failed turn이 complete로 저장되지 않는지 확인한다.
|
||||
- notification burst가 delta 또는 terminal event를 조용히 drop하지 않는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### REVIEW_REFACTOR-1 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerRPC_InitializeShape|TestCodexAppServerThreadStartResponseShape' -count=1
|
||||
```
|
||||
```text
|
||||
=== RUN TestCodexAppServerRPC_InitializeShape
|
||||
--- PASS: TestCodexAppServerRPC_InitializeShape (0.00s)
|
||||
=== RUN TestCodexAppServerThreadStartResponseShape
|
||||
--- PASS: TestCodexAppServerThreadStartResponseShape (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.005s
|
||||
```
|
||||
|
||||
### REVIEW_REFACTOR-2 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerTurnStartResponseShape|TestCodexAppServerTurnStartMissingID' -count=1
|
||||
```
|
||||
```text
|
||||
=== RUN TestCodexAppServerTurnStartResponseShape
|
||||
--- PASS: TestCodexAppServerTurnStartResponseShape (0.00s)
|
||||
=== RUN TestCodexAppServerTurnStartMissingID
|
||||
--- PASS: TestCodexAppServerTurnStartMissingID (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.005s
|
||||
```
|
||||
|
||||
### REVIEW_REFACTOR-3 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerEventMap_.*Metadata' -count=1
|
||||
```
|
||||
```text
|
||||
=== RUN TestCodexAppServerEventMap_DeltaMetadata
|
||||
--- PASS: TestCodexAppServerEventMap_DeltaMetadata (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
```
|
||||
|
||||
### REVIEW_REFACTOR-4 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerEventMap_TurnCompleted|TestCodexAppServerEventMap_TurnCompletedFailed' -count=1
|
||||
```
|
||||
```text
|
||||
=== RUN TestCodexAppServerEventMap_TurnCompleted
|
||||
--- PASS: TestCodexAppServerEventMap_TurnCompleted (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_TurnCompletedFailed
|
||||
--- PASS: TestCodexAppServerEventMap_TurnCompletedFailed (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.005s
|
||||
```
|
||||
|
||||
### REVIEW_REFACTOR-5 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerEventMap_Error' -count=1
|
||||
```
|
||||
```text
|
||||
=== RUN TestCodexAppServerEventMap_Error
|
||||
--- PASS: TestCodexAppServerEventMap_Error (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.003s
|
||||
```
|
||||
|
||||
### REVIEW_REFACTOR-6 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerDrainNotifications_DoesNotDropBurst' -count=1
|
||||
```
|
||||
```text
|
||||
=== RUN TestCodexAppServerDrainNotifications_DoesNotDropBurst
|
||||
--- PASS: TestCodexAppServerDrainNotifications_DoesNotDropBurst (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
test -f agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer' -count=1
|
||||
go test ./apps/node/internal/adapters/cli -count=1
|
||||
go test ./apps/node/... -count=1
|
||||
```
|
||||
```text
|
||||
# test -f ... → exit 0 (FOUND)
|
||||
|
||||
=== RUN TestCodexAppServerRPC_InitializeShape
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerRPC_NoJSONRPCField
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerProcessLifecycle_CloseSafe
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerProcessLifecycle_ExitSurfaces
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerSessionResolve
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerRequireExisting
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerSend_RequestIDMonotone
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerThreadMissingID
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerSessionThreadMapping
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerThreadStartResponseShape
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerTurnStartResponseShape
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerTurnStartMissingID
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_Delta
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_DeltaMetadata
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_TurnCompleted
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_TurnCompletedFailed
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_Error
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_TurnFailed
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerDrainNotifications_DoesNotDropBurst
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerSend_ResponsePriorityOverDone
|
||||
--- PASS (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
|
||||
ok iop/apps/node/internal/adapters/cli 42.564s
|
||||
|
||||
ok iop/apps/node/cmd/node 0.012s
|
||||
ok iop/apps/node/internal/adapters 0.011s
|
||||
ok iop/apps/node/internal/adapters/cli 42.534s
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.825s
|
||||
ok iop/apps/node/internal/adapters/ollama 0.014s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.014s
|
||||
ok iop/apps/node/internal/bootstrap 0.262s
|
||||
ok iop/apps/node/internal/node 0.122s
|
||||
ok iop/apps/node/internal/router 0.010s
|
||||
ok iop/apps/node/internal/store 0.043s
|
||||
ok iop/apps/node/internal/terminal 0.432s
|
||||
ok iop/apps/node/internal/transport 5.154s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## Ownership
|
||||
|
||||
| 섹션 | 소유자 | 설명 |
|
||||
|------|--------|------|
|
||||
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
|
||||
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 |
|
||||
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
|
||||
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
|
||||
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 |
|
||||
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
|
||||
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움 |
|
||||
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Fail
|
||||
- code quality: Pass
|
||||
- plan deviation: Fail
|
||||
- verification trust: Fail
|
||||
- 발견된 문제:
|
||||
- Required: `apps/node/internal/adapters/cli/codex_app_server.go:103`의 `turn/start` request가 실제 app-server `TurnStartParams`/`UserInput` 계약과 맞지 않습니다. `/tmp/iop-codex-appserver-ts/v2/TurnStartParams.ts:17`은 `input: Array<UserInput>`를 필수로 정의하고, `/tmp/iop-codex-appserver-ts/v2/UserInput.ts:7`의 text variant는 `text_elements: Array<TextElement>`를 필수로 요구합니다. 현재 구현은 prompt가 비어 있으면 `input` 자체를 생략하고, prompt가 있으면 `{"type":"text","text":...}`만 보내 `text_elements`를 빠뜨립니다. 실제 app-server가 request를 거부하거나 turn start가 실패할 수 있으므로 `input`을 항상 계약 shape로 만들고 text item에는 `text_elements: []`를 포함하세요. `codexAppServerTurnStart` 요청 capture 테스트도 추가해 `threadId`, `input[0].type/text/text_elements`를 검증해야 합니다.
|
||||
- 다음 단계: FAIL이므로 user-review gate 없이 후속 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다.
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/02+01_session_events plan=2 tag=REVIEW_REVIEW_REFACTOR -->
|
||||
|
||||
# Code Review Reference - REVIEW_REVIEW_REFACTOR
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-13
|
||||
task=m-codex-app-server-streaming-migration/02+01_session_events, plan=2, tag=REVIEW_REVIEW_REFACTOR
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `session-thread`: IOP `session_id`별 app-server thread를 재사용하고 `/terminate-session`에서 해당 세션을 정리한다.
|
||||
- `event-map`: `item/agentMessage/delta`를 `RuntimeEvent{Type: delta}`로 전달하고, `turn/completed`는 complete, app-server error와 failed turn은 error로 매핑한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-codex-app-server-streaming-migration/02+01_session_events/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-codex-app-server-streaming-migration`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_REVIEW_REFACTOR-1] `turn/start` request input shape 보정 | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `turn/start` request가 `input`을 항상 포함하고 text item에 `text_elements: []`를 포함하도록 수정한다.
|
||||
- [x] `codexAppServerTurnStart` 요청 capture 테스트를 추가해 `threadId`, `input[0].type`, `input[0].text`, `input[0].text_elements`를 검증한다.
|
||||
- [x] 빈 prompt일 때의 request shape도 실제 계약에 맞는지 테스트 또는 명시적 설계 결정으로 검증한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/m-codex-app-server-streaming-migration/02+01_session_events/`를 `agent-task/archive/YYYY/MM/m-codex-app-server-streaming-migration/02+01_session_events/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-codex-app-server-streaming-migration`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-codex-app-server-streaming-migration/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
없음. 계획과 동일하게 구현했다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- 빈 prompt일 때도 `input`을 항상 포함한다. `TurnStartParams.input`이 필수 필드이므로 생략 시 서버 계약 위반이 된다.
|
||||
- text item에 `text_elements: []`를 포함한다. `UserInput` text variant에서 `text_elements`는 필수 필드로 정의되어 있으며, 현재 IOP는 UI-defined spans를 사용하지 않으므로 빈 배열로 전달한다.
|
||||
- 두 경우(비어 있는 prompt, 있는 prompt)를 별도 테스트 `TestCodexAppServerTurnStartRequestShape`와 `TestCodexAppServerTurnStartRequestShape_EmptyPrompt`로 각각 검증한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `/tmp/iop-codex-appserver-ts/v2/TurnStartParams.ts`와 `/tmp/iop-codex-appserver-ts/v2/UserInput.ts`의 필수 필드가 request capture 테스트에 반영됐는지 확인한다.
|
||||
- `turn/start` response shape와 notification mapping 회귀 테스트가 그대로 통과하는지 확인한다.
|
||||
- 이번 follow-up이 `turn/start` request shape 밖의 동작을 불필요하게 바꾸지 않았는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### REVIEW_REVIEW_REFACTOR-1 중간 검증
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerTurnStartRequestShape' -count=1
|
||||
```
|
||||
```text
|
||||
=== RUN TestCodexAppServerTurnStartRequestShape
|
||||
--- PASS: TestCodexAppServerTurnStartRequestShape (0.00s)
|
||||
=== RUN TestCodexAppServerTurnStartRequestShape_EmptyPrompt
|
||||
--- PASS: TestCodexAppServerTurnStartRequestShape_EmptyPrompt (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
test -f agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer' -count=1
|
||||
go test ./apps/node/internal/adapters/cli -count=1
|
||||
go test ./apps/node/... -count=1
|
||||
```
|
||||
```text
|
||||
# test -f ... → exit 0 (FOUND)
|
||||
|
||||
# go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer' -count=1
|
||||
=== RUN TestCodexAppServerRPC_InitializeShape
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerRPC_NoJSONRPCField
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerProcessLifecycle_CloseSafe
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerProcessLifecycle_ExitSurfaces
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerSessionResolve
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerRequireExisting
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerSend_RequestIDMonotone
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerThreadMissingID
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerSessionThreadMapping
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerTurnStartRequestShape
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerTurnStartRequestShape_EmptyPrompt
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerThreadStartResponseShape
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerTurnStartResponseShape
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerTurnStartMissingID
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_Delta
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_DeltaMetadata
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_TurnCompleted
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_TurnCompletedFailed
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_Error
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerEventMap_TurnFailed
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerDrainNotifications_DoesNotDropBurst
|
||||
--- PASS (0.00s)
|
||||
=== RUN TestCodexAppServerSend_ResponsePriorityOverDone
|
||||
--- PASS (0.00s)
|
||||
PASS
|
||||
ok iop/apps/node/internal/adapters/cli 0.004s
|
||||
|
||||
# go test ./apps/node/internal/adapters/cli -count=1
|
||||
ok iop/apps/node/internal/adapters/cli 43.149s
|
||||
|
||||
# go test ./apps/node/... -count=1
|
||||
ok iop/apps/node/cmd/node 0.017s
|
||||
ok iop/apps/node/internal/adapters 0.015s
|
||||
ok iop/apps/node/internal/adapters/cli 42.516s
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.819s
|
||||
ok iop/apps/node/internal/adapters/ollama 0.010s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.012s
|
||||
ok iop/apps/node/internal/bootstrap 0.262s
|
||||
ok iop/apps/node/internal/node 0.121s
|
||||
ok iop/apps/node/internal/router 0.005s
|
||||
ok iop/apps/node/internal/store 0.035s
|
||||
ok iop/apps/node/internal/terminal 0.440s
|
||||
ok iop/apps/node/internal/transport 5.150s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## Ownership
|
||||
|
||||
| 섹션 | 소유자 | 설명 |
|
||||
|------|--------|------|
|
||||
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
|
||||
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 |
|
||||
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
|
||||
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
|
||||
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 |
|
||||
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
|
||||
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움 |
|
||||
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계: PASS이므로 active plan/review를 아카이브하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# Complete - m-codex-app-server-streaming-migration/02+01_session_events
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-12T20:56:39Z
|
||||
|
||||
## 요약
|
||||
|
||||
Codex app-server logical session/thread mapping, turn start request contract, notification-to-runtime-event mapping, session list/terminate support를 3회 리뷰 루프로 완료했다. 최종 판정: PASS.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | app-server response/notification shape, thread/turn metadata, notification drop 문제로 follow-up 필요 |
|
||||
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | `turn/start` request의 `TurnStartParams`/`UserInput` input shape 보정 필요 |
|
||||
| `plan_cloud_G07_2.log` | `code_review_cloud_G07_2.log` | PASS | request shape 보정과 capture test 추가 후 모든 검증 통과 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `codex-app-server` executor가 IOP logical session별 app-server thread를 재사용하고 session list/terminate 경로에 노출되도록 구성했다.
|
||||
- `thread/start`와 `turn/start` 응답을 실제 nested response shape에서 파싱하고 누락 시 error로 처리했다.
|
||||
- `turn/start` request가 항상 `input`을 포함하고 text item에 `text_elements: []`를 포함하도록 app-server protocol contract를 맞췄다.
|
||||
- app-server delta, complete, failed/error notification을 `RuntimeEvent`로 변환하고 `source`, `thread_id`, `turn_id` metadata를 보존했다.
|
||||
- notification burst에서 terminal event가 조용히 drop되지 않도록 relay를 lossless하게 보정하고 회귀 테스트를 추가했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `test -f agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log` - PASS; 선행 subtask 완료 로그 존재 확인.
|
||||
- `go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerTurnStartRequestShape' -count=1 -v` - PASS; `TestCodexAppServerTurnStartRequestShape`, `TestCodexAppServerTurnStartRequestShape_EmptyPrompt` 통과.
|
||||
- `go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer' -count=1 -v` - PASS; Codex app-server focused tests 통과.
|
||||
- `go test ./apps/node/internal/adapters/cli -count=1` - PASS; `ok iop/apps/node/internal/adapters/cli 42.448s`.
|
||||
- `go test ./apps/node/... -count=1` - PASS; node packages 전체 통과.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Completed task ids:
|
||||
- `session-thread`: PASS; evidence=`agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_2.log`; verification=`go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer' -count=1 -v`, `go test ./apps/node/... -count=1`
|
||||
- `event-map`: PASS; evidence=`agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_2.log`; verification=`go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer' -count=1 -v`, `go test ./apps/node/... -count=1`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/02+01_session_events plan=0 tag=REFACTOR -->
|
||||
# Codex App Server Session Events Plan
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
이 PLAN을 끝까지 읽은 뒤 구현한다. 먼저 선행 작업 `01_appserver_rpc_client/complete.log`가 있는지 확인한다. 없으면 구현하지 말고 선행 작업을 완료한다. 구현 완료 전 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다.
|
||||
|
||||
## 배경
|
||||
이 작업은 app-server RPC client 위에 runtime 실행 의미를 올린다. 목표는 IOP의 논리 session을 Codex thread와 매핑하고, `turn/start` 이후 app-server notification을 `RuntimeEvent`로 변환하는 것이다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
사용자 소유 Codex 계정, 외부 서비스, 또는 plan 범위 변경이 필요한 경우에만 `CODE_REVIEW-cloud-G07.md`의 사용자 리뷰 요청 섹션을 실제 내용으로 교체한다. 프로토콜 타입 확인이나 테스트 재실행은 구현 에이전트가 처리한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `session-thread`: IOP `session_id`별 app-server thread를 재사용하고 `/terminate-session`에서 해당 세션을 정리한다.
|
||||
- `event-map`: `item/agentMessage/delta`를 `RuntimeEvent{Type: delta}`로 전달하고, `turn/completed`는 complete, app-server error와 failed turn은 error로 매핑한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
- 현재 subtask: `02+01_session_events`
|
||||
- 선행 index `01`: `agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log`가 현재 없음. 구현 전 선행 작업 완료가 필요하다.
|
||||
|
||||
## 분석 결과
|
||||
### 읽은 파일
|
||||
- 선행 plan 대상: `agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/PLAN-cloud-G07.md`
|
||||
- 코드: `apps/node/internal/adapters/cli/cli.go`, `apps/node/internal/adapters/cli/codex_exec.go`, `apps/node/internal/adapters/cli/opencode_sse.go`, `apps/node/internal/adapters/cli/emitters.go`, `apps/node/internal/runtime/types.go`
|
||||
- Node/Edge 흐름: `apps/node/internal/node/node.go`, `apps/edge/internal/opsconsole/events.go`, `apps/edge/internal/opsconsole/events_test.go`
|
||||
- 임시 타입: `/tmp/iop-codex-appserver-ts/ClientRequest.ts`, `/tmp/iop-codex-appserver-ts/ServerNotification.ts`, `/tmp/iop-codex-appserver-ts/v2/TurnStartParams.ts`, `/tmp/iop-codex-appserver-ts/v2/AgentMessageDeltaNotification.ts`, `/tmp/iop-codex-appserver-ts/v2/ItemCompletedNotification.ts`, `/tmp/iop-codex-appserver-ts/v2/TurnCompletedNotification.ts`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
- 환경 미지정은 local로 본다.
|
||||
- 선행 완료 확인: `test -f agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log`
|
||||
- 기본 검증 후보: `go test ./apps/node/internal/adapters/cli -count=1`, `go test ./apps/node/... -count=1`
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
- `turn/start` 요청 shape, delta notification, completed notification, error notification에 대한 adapter 단위 테스트가 없다.
|
||||
- session list/terminate가 app-server thread id와 IOP session id를 어떻게 보여주는지 검증이 없다.
|
||||
- Edge console은 현재 metadata를 직접 출력하지 않으므로 source 증거는 node runtime event 또는 focused test로 확인해야 한다.
|
||||
|
||||
### 심볼 참조
|
||||
- 기존 runtime event type 변경 없음.
|
||||
- 추가/확장 예정: app-server notification decoder, Codex thread id 저장 필드, event metadata key.
|
||||
- 호출 지점: `Execute`, `Sessions`, `Terminate`, `handleSessionList`, `sessionSink.Emit`.
|
||||
|
||||
### 분할 판단
|
||||
분할 정책을 평가했고 공유 task group은 `agent-task/m-codex-app-server-streaming-migration/`이다. sibling subtask는 `01_appserver_rpc_client`(선행 없음), `02+01_session_events`(`01` 의존), `03+02_default_profile`(`02` 의존), `04+03_stream_smoke`(`03` 의존)이다. 현재 작업의 predecessor index `01`은 active `complete.log`가 없어서 미충족이다.
|
||||
|
||||
### 범위 결정 근거
|
||||
runtime event mapping은 사용자에게 보이는 동작이므로 RPC lifecycle과 분리해 리뷰한다. default profile 전환은 event mapping 검증 후 별도 작업에서 한다.
|
||||
|
||||
### 빌드 등급
|
||||
`cloud-G07`: 외부 app-server notification shape와 turn lifecycle을 다루므로 fake RPC 단위 테스트와 실제 CLI smoke가 모두 필요하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] 선행 `01_appserver_rpc_client/complete.log`를 확인한다.
|
||||
- [ ] `SessionID`를 Codex thread id에 매핑하고 재사용한다.
|
||||
- [ ] `turn/start` request를 `ExecutionSpec.Input` 기반 prompt로 보낸다.
|
||||
- [ ] `item/agentMessage/delta`, `item/completed`, `turn/completed`, `error` notification을 runtime event로 변환한다.
|
||||
- [ ] runtime event metadata에 app-server source와 thread/turn id를 남긴다.
|
||||
- [ ] session list와 terminate 동작을 app-server session에 맞게 구현한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REFACTOR-1] IOP session과 Codex thread 매핑
|
||||
문제: 기존 `codex_exec.go`는 external session id만 저장하고, `opencode_sse.go`는 OpenCode session id를 저장한다. app-server는 thread id를 long-lived process/session에 묶어야 한다.
|
||||
|
||||
해결 방법: app-server session struct에 `threadID`, `turnID`, `status/tail` 등 session list에 필요한 최소 상태를 저장한다. `normalizeSessionID(spec.SessionID)`를 IOP session key로 유지하고, Codex thread id는 metadata와 session detail로만 노출한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] 같은 IOP session이 같은 thread id를 재사용하는 테스트
|
||||
- [ ] 서로 다른 session id가 다른 thread id를 갖는 테스트
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
test -f agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerSessionThreadMapping' -count=1
|
||||
```
|
||||
|
||||
### [REFACTOR-2] app-server notification을 RuntimeEvent로 변환
|
||||
문제: `emitters.go:281`의 `codexJSONEmitter`는 `codex exec --json` line format만 이해한다. app-server notification은 `method`와 `params` 기반이다.
|
||||
|
||||
해결 방법: app-server 전용 decoder를 만들고 다음 mapping을 최소 지원한다.
|
||||
- `item/agentMessage/delta` -> `RuntimeEvent{Type: response.delta, Delta: ...}`
|
||||
- `item/completed` 중 agent message content -> 중복 없는 final delta 또는 내부 tail update
|
||||
- `turn/completed` -> `RuntimeEvent{Type: response.completed}`
|
||||
- `error` -> `RuntimeEvent{Type: response.error, Error: ...}`
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] delta notification이 response delta로 변환되는 테스트
|
||||
- [ ] turn completed가 response completed로 변환되는 테스트
|
||||
- [ ] error notification이 error event와 Go error로 surfaced 되는 테스트
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerEventMap' -count=1
|
||||
```
|
||||
|
||||
### [REFACTOR-3] session list와 terminate 연결
|
||||
문제: `handleSessionList`는 reporter들의 `Sessions()`를 모아 보여준다. app-server reporter가 없으면 `/sessions`와 `/terminate-session`에서 새 mode가 보이지 않는다.
|
||||
|
||||
해결 방법: app-server executor가 `Sessions`, `Terminate`, `Stop`을 구현한다. terminate는 해당 logical session의 process/thread 자원을 닫고 sessions map에서 제거한다. 프로토콜에 적절한 thread close/unsubscribe 요청이 있으면 먼저 호출하고, 없으면 local process close를 정리 계약으로 둔다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- [ ] `apps/node/internal/adapters/cli/cli_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] app-server session이 `/sessions` snapshot에 표시되는 테스트
|
||||
- [ ] terminate 후 session list에서 사라지는 테스트
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestHandleSessionList.*CodexAppServer|TestTerminateSession.*CodexAppServer' -count=1
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
| 파일 | 항목 |
|
||||
| --- | --- |
|
||||
| `apps/node/internal/adapters/cli/codex_app_server.go` | REFACTOR-1, REFACTOR-2, REFACTOR-3 |
|
||||
| `apps/node/internal/adapters/cli/codex_app_server_internal_test.go` | REFACTOR-1, REFACTOR-2 |
|
||||
| `apps/node/internal/adapters/cli/cli_internal_test.go` | REFACTOR-3 |
|
||||
|
||||
## 최종 검증
|
||||
```bash
|
||||
test -f agent-task/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log
|
||||
go test ./apps/node/internal/adapters/cli -count=1
|
||||
go test ./apps/node/... -count=1
|
||||
```
|
||||
|
||||
**모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.**
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/02+01_session_events plan=1 tag=REVIEW_REFACTOR -->
|
||||
# Codex App Server Session Events Follow-up Plan
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
이 PLAN을 끝까지 읽은 뒤 구현한다. 이 작업은 이전 리뷰 `code_review_cloud_G07_0.log`의 Required 이슈만 해결한다. 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 plan 범위 변경이 필요한 경우에만 `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션을 실제 내용으로 채우고 중단한다. 직접 사용자에게 질문하거나 선택지를 제시하지 않는다.
|
||||
|
||||
## 배경
|
||||
이전 구현은 단위 테스트를 통과했지만 실제 Codex app-server TypeScript 계약과 다른 fake shape를 기준으로 `thread/start`, `turn/start`, error/failed turn notification을 처리했다. 또한 notification channel overflow 시 terminal notification을 drop할 수 있어 stream이 손실되거나 run이 timeout까지 멈출 수 있다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `session-thread`: IOP `session_id`별 app-server thread를 재사용하고 `/terminate-session`에서 해당 세션을 정리한다.
|
||||
- `event-map`: `item/agentMessage/delta`를 `RuntimeEvent{Type: delta}`로 전달하고, `turn/completed`는 complete, app-server error와 failed turn은 error로 매핑한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 범위 결정 근거
|
||||
새 기능 추가가 아니라 app-server protocol contract, event metadata, lossless notification relay를 바로잡는 follow-up이다. 기본 profile 전환과 실제 smoke는 sibling subtask 범위에 남겨 둔다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] `thread/start` 응답에서 실제 `result.thread.id` shape를 파싱하고 기존 fallback을 필요한 범위에서만 유지한다.
|
||||
- [ ] `turn/start` 응답에서 실제 `result.turn.id` shape를 파싱하고 turn id 누락을 error로 처리한다.
|
||||
- [ ] RuntimeEvent metadata에 `source`, `thread_id`, `turn_id`를 보존한다.
|
||||
- [ ] `turn/completed`의 `turn.status == failed`를 error event와 Go error로 변환한다.
|
||||
- [ ] `error` notification의 실제 `params.error.message`를 파싱하고 error metadata를 보존한다.
|
||||
- [ ] app-server notification relay가 `delta`, `turn/completed`, `error`를 조용히 drop하지 않도록 lossless handoff로 바꾼다.
|
||||
- [ ] 실제 app-server 계약 shape와 overflow/failed-turn 회귀 테스트를 추가하거나 갱신한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_REFACTOR-1] `thread/start` 응답 shape 수정
|
||||
문제: `codexAppServerInit`은 `result.threadId` 또는 `result.id`만 읽지만 `/tmp/iop-codex-appserver-ts/v2/ThreadStartResponse.ts`는 `result.thread.id`를 정의한다.
|
||||
|
||||
해결 방법: nested response helper를 추가하거나 기존 map 접근을 확장해 `thread.id`를 우선 파싱한다. legacy/fake fallback은 테스트 목적이 아니라 호환 목적일 때만 유지한다.
|
||||
|
||||
수정 파일:
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerRPC_InitializeShape|TestCodexAppServerThreadStartResponseShape' -count=1
|
||||
```
|
||||
|
||||
### [REVIEW_REFACTOR-2] `turn/start` 응답 shape 수정
|
||||
문제: `codexAppServerTurnStart`은 `result.turnId` 또는 `result.id`만 읽지만 실제 `TurnStartResponse`는 `result.turn.id`다. 현재는 빈 turn id를 정상으로 통과시킨다.
|
||||
|
||||
해결 방법: `result.turn.id`를 우선 파싱하고, 어떤 shape에서도 turn id를 얻지 못하면 `turn/start: server did not return a turn id`류 error를 반환한다.
|
||||
|
||||
수정 파일:
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerTurnStartResponseShape|TestCodexAppServerTurnStartMissingID' -count=1
|
||||
```
|
||||
|
||||
### [REVIEW_REFACTOR-3] event metadata에 thread/turn id 보존
|
||||
문제: plan은 source와 thread/turn id metadata를 요구하지만 `decodeAppServerNotification`은 `source`와 `turn_id`만 채운다.
|
||||
|
||||
해결 방법: session thread id와 notification params의 `threadId`/`turnId`를 event metadata로 보존한다. notification 값이 있으면 그 값을 우선하고, 없으면 실행 컨텍스트의 thread/turn id를 fallback으로 사용한다.
|
||||
|
||||
수정 파일:
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerEventMap_.*Metadata' -count=1
|
||||
```
|
||||
|
||||
### [REVIEW_REFACTOR-4] failed `turn/completed` 매핑
|
||||
문제: 실제 `TurnCompletedNotification`은 `params.turn.status`가 `failed`일 수 있지만 구현은 모든 `turn/completed`를 complete event로 처리한다.
|
||||
|
||||
해결 방법: `turn.status == failed`이면 `turn.error.message`를 읽어 `RuntimeEvent{Type: error}`를 emit하고 Go error를 반환한다. `completed` status만 complete event로 처리한다.
|
||||
|
||||
수정 파일:
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerEventMap_TurnCompleted|TestCodexAppServerEventMap_TurnCompletedFailed' -count=1
|
||||
```
|
||||
|
||||
### [REVIEW_REFACTOR-5] app-server `error` notification shape 수정
|
||||
문제: 실제 `ErrorNotification`은 `params.error.message`인데 구현은 `params.message`만 읽어 실제 오류 메시지를 잃는다.
|
||||
|
||||
해결 방법: `params.error.message`를 우선 파싱하고 fallback으로 `params.message`를 유지한다. error event metadata에는 `source`, `thread_id`, `turn_id`를 포함한다.
|
||||
|
||||
수정 파일:
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerEventMap_Error' -count=1
|
||||
```
|
||||
|
||||
### [REVIEW_REFACTOR-6] notification drop 제거
|
||||
문제: `readLoop`가 `p.notifCh`가 가득 찼을 때 `default`로 notification을 버린다. 긴 delta burst 뒤에 `turn/completed`나 `error`가 drop되면 run이 timeout까지 끝나지 않는다.
|
||||
|
||||
해결 방법: app-server notification은 조용히 버리지 않는다. read loop와 drain 사이에 backpressure를 걸거나 terminal notification을 lossless로 전달하는 구조로 바꾼다. 테스트는 32개를 넘는 delta burst와 terminal notification을 보내도 모든 delta와 complete/error가 관찰되는지를 검증한다.
|
||||
|
||||
수정 파일:
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerDrainNotifications_DoesNotDropBurst' -count=1
|
||||
```
|
||||
|
||||
## 최종 검증
|
||||
```bash
|
||||
test -f agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer' -count=1
|
||||
go test ./apps/node/internal/adapters/cli -count=1
|
||||
go test ./apps/node/... -count=1
|
||||
```
|
||||
|
||||
## 리뷰어 체크포인트
|
||||
- `/tmp/iop-codex-appserver-ts/v2/ThreadStartResponse.ts`, `TurnStartResponse.ts`, `TurnCompletedNotification.ts`, `ErrorNotification.ts`와 fake test shape가 일치하는지 확인한다.
|
||||
- `RuntimeEvent.Metadata`에 `source`, `thread_id`, `turn_id`가 delta, complete, error 모두에서 일관되게 유지되는지 확인한다.
|
||||
- failed turn이 complete로 저장되지 않는지 확인한다.
|
||||
- notification burst가 delta 또는 terminal event를 조용히 drop하지 않는지 확인한다.
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/02+01_session_events plan=2 tag=REVIEW_REVIEW_REFACTOR -->
|
||||
# Codex App Server Turn Start Request Follow-up Plan
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
이 PLAN을 끝까지 읽은 뒤 구현한다. 이 작업은 `code_review_cloud_G07_1.log`의 Required 이슈만 해결한다. 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 plan 범위 변경이 필요한 경우에만 `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션을 실제 내용으로 채우고 중단한다. 직접 사용자에게 질문하거나 선택지를 제시하지 않는다.
|
||||
|
||||
## 배경
|
||||
이전 follow-up은 app-server 응답과 notification shape를 바로잡았지만, `turn/start` 요청의 `input` payload가 실제 `TurnStartParams`/`UserInput` 계약과 아직 다르다. `TurnStartParams.input`은 필수이고 text `UserInput`에는 `text_elements`가 필수다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `session-thread`: IOP `session_id`별 app-server thread를 재사용하고 `/terminate-session`에서 해당 세션을 정리한다.
|
||||
- `event-map`: `item/agentMessage/delta`를 `RuntimeEvent{Type: delta}`로 전달하고, `turn/completed`는 complete, app-server error와 failed turn은 error로 매핑한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 범위 결정 근거
|
||||
새 app-server 동작 추가가 아니라 기존 `turn/start` request builder를 실제 protocol contract에 맞추는 보정 작업이다. 기본 profile 전환과 실제 smoke는 sibling subtask 범위에 둔다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] `turn/start` request가 `input`을 항상 포함하고 text item에 `text_elements: []`를 포함하도록 수정한다.
|
||||
- [ ] `codexAppServerTurnStart` 요청 capture 테스트를 추가해 `threadId`, `input[0].type`, `input[0].text`, `input[0].text_elements`를 검증한다.
|
||||
- [ ] 빈 prompt일 때의 request shape도 실제 계약에 맞는지 테스트 또는 명시적 설계 결정으로 검증한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_REVIEW_REFACTOR-1] `turn/start` request input shape 보정
|
||||
문제: `codexAppServerTurnStart`는 prompt가 있으면 `{"type":"text","text":prompt}`만 보내고, prompt가 없으면 `input`을 생략한다. 실제 `/tmp/iop-codex-appserver-ts/v2/TurnStartParams.ts`는 `input`을 필수로 정의하고, `/tmp/iop-codex-appserver-ts/v2/UserInput.ts`의 text variant는 `text_elements`를 필수로 정의한다.
|
||||
|
||||
해결 방법: request builder가 `input`을 항상 계약에 맞는 배열로 구성하게 한다. prompt가 비어 있을 때도 app-server가 받아들일 명시적 shape를 선택하고 테스트에 고정한다. text input에는 최소 `{"type":"text","text":prompt,"text_elements":[]}`를 사용한다.
|
||||
|
||||
수정 파일:
|
||||
- `apps/node/internal/adapters/cli/codex_app_server.go`
|
||||
- `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServerTurnStartRequestShape' -count=1
|
||||
```
|
||||
|
||||
## 최종 검증
|
||||
```bash
|
||||
test -f agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer' -count=1
|
||||
go test ./apps/node/internal/adapters/cli -count=1
|
||||
go test ./apps/node/... -count=1
|
||||
```
|
||||
|
||||
## 리뷰어 체크포인트
|
||||
- `/tmp/iop-codex-appserver-ts/v2/TurnStartParams.ts`와 `/tmp/iop-codex-appserver-ts/v2/UserInput.ts`의 필수 필드가 request capture 테스트에 반영됐는지 확인한다.
|
||||
- `turn/start` response shape와 notification mapping 회귀 테스트가 그대로 통과하는지 확인한다.
|
||||
- 이번 follow-up이 `turn/start` request shape 밖의 동작을 불필요하게 바꾸지 않았는지 확인한다.
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/03+02_default_profile plan=0 tag=API -->
|
||||
# Code Review Reference - API
|
||||
|
||||
> 구현 에이전트 경고: 이 파일은 구현 마지막 단계에서 반드시 갱신한다. `구현 완료 기록`, `구현 체크리스트`, `검증 출력`을 실제 내용으로 채우기 전에는 작업 완료를 선언하지 않는다.
|
||||
|
||||
## 개요
|
||||
- 날짜: 2026-06-12
|
||||
- Task: `m-codex-app-server-streaming-migration/03+02_default_profile`
|
||||
- Plan: `PLAN-local-G05.md`
|
||||
- Tag: `API`
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `default-profile`: `configs/edge.yaml`의 기본 `codex` profile을 app-server mode로 전환한다. 검증: `console.target`과 CLI profile key가 모두 `codex`인 상태에서 기본 Codex 실행이 app-server 경로를 사용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 리뷰 에이전트 지침
|
||||
- 선행 `02+01_session_events/complete.log`가 있는지 확인한다.
|
||||
- 기본 `codex`는 app-server mode이고 `codex-exec` fallback은 유지되는지 확인한다.
|
||||
- 기존 사용자 변경인 console timeout, `claude-tui` profile 등을 되돌리지 않았는지 확인한다.
|
||||
- archive는 명시 요청 없이 읽지 않는다.
|
||||
|
||||
## 구현 완료 기록
|
||||
| 항목 | 구현 에이전트 기록 |
|
||||
| --- | --- |
|
||||
| 변경 요약 | TODO |
|
||||
| 주요 파일 | TODO |
|
||||
| 범위 외 변경 여부 | TODO |
|
||||
| 남은 리스크 | TODO |
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
| 항목 | 완료 여부 |
|
||||
| --- | --- |
|
||||
| API-1 기본 `codex` profile을 app-server로 전환 | TODO |
|
||||
| API-2 compose template과 dev scripts 영향 확인 | TODO |
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] 선행 `02+01_session_events/complete.log`를 확인한다.
|
||||
- [ ] `configs/edge.yaml`의 기본 `codex` profile을 app-server mode로 전환한다.
|
||||
- [ ] `codex-exec` fallback profile을 유지하고 기존 사용자 변경을 덮어쓰지 않는다.
|
||||
- [ ] `configs/edge-compose.yaml.tmpl`의 codex profile도 동일한 기본값으로 전환한다.
|
||||
- [ ] config loader 테스트에 app-server mode와 fallback profile 검증을 추가한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드 리뷰 전용 체크리스트
|
||||
- [ ] `codex`와 `codex-exec` target 이름이 둘 다 capabilities에 노출된다.
|
||||
- [ ] compose template과 real config의 기본 codex mode가 어긋나지 않는다.
|
||||
- [ ] `codex-exec` fallback은 `output_format: codex-json`을 유지한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 검증 출력
|
||||
```text
|
||||
TODO
|
||||
```
|
||||
|
||||
## 리뷰어 체크포인트
|
||||
- `test -f agent-task/m-codex-app-server-streaming-migration/02+01_session_events/complete.log`
|
||||
- `go test ./packages/go/config -count=1`
|
||||
- `go test ./apps/node/internal/adapters/cli -count=1`
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/03+02_default_profile plan=0 tag=API -->
|
||||
# Codex Default Profile Switch Plan
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
이 PLAN을 끝까지 읽은 뒤 구현한다. 먼저 선행 작업 `02+01_session_events/complete.log`가 있는지 확인한다. 없으면 구현하지 말고 선행 작업을 완료한다. 구현 완료 전 `CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다.
|
||||
|
||||
## 배경
|
||||
작은 작업인 `[exec-fallback]`은 이미 `configs/edge.yaml`에 `codex-exec` 프로필을 추가해 처리했다. 이 plan은 app-server 실행 경로가 준비된 뒤 기본 `codex` 프로필을 app-server mode로 바꾸고, 기존 exec 경로는 `codex-exec` fallback으로 유지하는 작업이다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
설정 전환 자체는 사용자 결정 없이 진행한다. 단, 실제 Codex 계정/인증 문제로 smoke가 막히면 구현을 멈추지 말고 가능한 local 검증을 먼저 수행한 뒤, 외부 환경 소유권이 명확할 때만 리뷰 요청을 남긴다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `default-profile`: `configs/edge.yaml`의 기본 `codex` profile을 app-server mode로 전환한다. 검증: `console.target`과 CLI profile key가 모두 `codex`인 상태에서 기본 Codex 실행이 app-server 경로를 사용한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
- 현재 subtask: `03+02_default_profile`
|
||||
- 선행 index `02`: `agent-task/m-codex-app-server-streaming-migration/02+01_session_events/complete.log`가 현재 없음. 구현 전 선행 작업 완료가 필요하다.
|
||||
|
||||
## 분석 결과
|
||||
### 읽은 파일
|
||||
- 설정: `configs/edge.yaml`, `configs/edge-compose.yaml.tmpl`, `packages/go/config/config.go`, `packages/go/config/config_test.go`
|
||||
- 실행 스크립트: `scripts/dev/edge.sh`, `scripts/dev/node.sh`, `Makefile`, `scripts/e2e-smoke.sh`
|
||||
- 코드: `apps/node/internal/adapters/cli/cli.go`, `apps/node/internal/adapters/cli/codex_exec.go`
|
||||
- 선행 plan: `agent-task/m-codex-app-server-streaming-migration/02+01_session_events/PLAN-cloud-G07.md`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
- 환경 미지정은 local로 본다.
|
||||
- 선행 완료 확인: `test -f agent-task/m-codex-app-server-streaming-migration/02+01_session_events/complete.log`
|
||||
- 설정 검증: `go test ./packages/go/config -count=1`
|
||||
- adapter smoke: `go test ./apps/node/internal/adapters/cli -count=1`
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
- `configs/edge.yaml`의 `codex` 기본 target이 app-server mode로 바뀌는지 직접 검증하는 테스트가 없다.
|
||||
- `configs/edge-compose.yaml.tmpl`과 real config의 mode가 의도대로 일치하는지 검증이 없다.
|
||||
|
||||
### 심볼 참조
|
||||
- 기존 설정 구조체 변경 없음.
|
||||
- `CLIProfileConf.Mode`, `Args`, `Persistent`, `Terminal`, `OutputFormat` 로딩 경로를 그대로 사용한다.
|
||||
|
||||
### 분할 판단
|
||||
분할 정책을 평가했고 공유 task group은 `agent-task/m-codex-app-server-streaming-migration/`이다. sibling subtask는 `01_appserver_rpc_client`(선행 없음), `02+01_session_events`(`01` 의존), `03+02_default_profile`(`02` 의존), `04+03_stream_smoke`(`03` 의존)이다. 현재 작업의 predecessor index `02`는 active `complete.log`가 없어서 미충족이다.
|
||||
|
||||
### 범위 결정 근거
|
||||
설정 전환과 fallback 보존만 다룬다. event mapping이나 e2e full-cycle은 각각 선행/후속 plan에서 다룬다.
|
||||
|
||||
### 빌드 등급
|
||||
`local-G05`: 설정과 config loader 중심 작업이므로 local Go test로 대부분 검증 가능하다. 실제 CLI account smoke는 후속 stream smoke에서 담당한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] 선행 `02+01_session_events/complete.log`를 확인한다.
|
||||
- [ ] `configs/edge.yaml`의 기본 `codex` profile을 app-server mode로 전환한다.
|
||||
- [ ] `codex-exec` fallback profile을 유지하고 기존 사용자 변경을 덮어쓰지 않는다.
|
||||
- [ ] `configs/edge-compose.yaml.tmpl`의 codex profile도 동일한 기본값으로 전환한다.
|
||||
- [ ] config loader 테스트에 app-server mode와 fallback profile 검증을 추가한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [API-1] 기본 `codex` profile을 app-server로 전환
|
||||
문제: 현재 `configs/edge.yaml`의 `codex` profile은 exec 기반이며, 후속 기본 경로를 app-server로 검증할 수 없다. 현재 파일에는 `codex-exec` fallback이 이미 추가되어 있으므로 이를 보존해야 한다.
|
||||
|
||||
해결 방법: `codex` profile의 `args`를 app-server stdio 실행에 맞게 바꾸고 `mode: "codex-app-server"`를 설정한다. `persistent`와 `terminal` 값은 adapter mode가 관리하는 방향으로 유지하되, 선행 구현의 기대값과 맞춘다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `configs/edge.yaml`
|
||||
- [ ] `configs/edge-compose.yaml.tmpl`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] `TestLoadEdge_CodexAppServerDefaultProfile`
|
||||
- [ ] fallback `codex-exec` profile이 계속 로드되는지 검증
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
test -f agent-task/m-codex-app-server-streaming-migration/02+01_session_events/complete.log
|
||||
go test ./packages/go/config -run 'TestLoadEdge_CodexAppServerDefaultProfile|TestLoadEdge_CLIProfileMode' -count=1
|
||||
```
|
||||
|
||||
### [API-2] compose template과 dev scripts 영향 확인
|
||||
문제: `configs/edge-compose.yaml.tmpl`에는 별도 codex profile 예시가 있어 real config와 어긋날 수 있다. `scripts/e2e-smoke.sh`는 real profile을 읽어 검증하므로 profile 이름과 mode가 유지되어야 한다.
|
||||
|
||||
해결 방법: compose template의 `codex`도 app-server mode로 전환하고, e2e script가 `codex-exec` fallback을 선택하지 않는지 확인한다. 스크립트 수정은 profile 추출이 app-server mode를 오판할 때만 한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `configs/edge-compose.yaml.tmpl`
|
||||
- [ ] 필요 시에만 `scripts/e2e-smoke.sh`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] config package에 template 관련 fixture가 있으면 추가한다.
|
||||
- [ ] fixture가 없으면 config 로드 테스트와 e2e dry validation으로 대체한다.
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./packages/go/config -count=1
|
||||
go test ./apps/node/internal/adapters/cli -run TestExecutorForMode -count=1
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
| 파일 | 항목 |
|
||||
| --- | --- |
|
||||
| `configs/edge.yaml` | API-1 |
|
||||
| `configs/edge-compose.yaml.tmpl` | API-1, API-2 |
|
||||
| `packages/go/config/config_test.go` | API-1 |
|
||||
| `scripts/e2e-smoke.sh` | API-2, 필요 시 |
|
||||
|
||||
## 최종 검증
|
||||
```bash
|
||||
test -f agent-task/m-codex-app-server-streaming-migration/02+01_session_events/complete.log
|
||||
go test ./packages/go/config -count=1
|
||||
go test ./apps/node/internal/adapters/cli -count=1
|
||||
```
|
||||
|
||||
**모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.**
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/04+03_stream_smoke plan=0 tag=TEST -->
|
||||
# Code Review Reference - TEST
|
||||
|
||||
> 구현 에이전트 경고: 이 파일은 구현 마지막 단계에서 반드시 갱신한다. `구현 완료 기록`, `구현 체크리스트`, `검증 출력`을 실제 내용으로 채우기 전에는 작업 완료를 선언하지 않는다.
|
||||
|
||||
## 개요
|
||||
- 날짜: 2026-06-12
|
||||
- Task: `m-codex-app-server-streaming-migration/04+03_stream_smoke`
|
||||
- Plan: `PLAN-cloud-G07.md`
|
||||
- Tag: `TEST`
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `stream-smoke`: `./scripts/dev/edge.sh`와 `./scripts/dev/node.sh` 시작 후 `codex` target으로 2-message 왕복에서 델타 스트림이 관찰된다. 검증: 두 메시지가 같은 logical session에서 완료되고, 적어도 한 turn에서 app-server delta event가 IOP delta로 relay된다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 리뷰 에이전트 지침
|
||||
- 선행 `03+02_default_profile/complete.log`가 있는지 확인한다.
|
||||
- smoke가 app-server 경로와 exec fallback을 구분하는지 확인한다.
|
||||
- 두 번째 prompt가 같은 session/thread를 재사용했는지 확인한다.
|
||||
- archive는 명시 요청 없이 읽지 않는다.
|
||||
|
||||
## 구현 완료 기록
|
||||
| 항목 | 구현 에이전트 기록 |
|
||||
| --- | --- |
|
||||
| 변경 요약 | TODO |
|
||||
| 주요 파일 | TODO |
|
||||
| 범위 외 변경 여부 | TODO |
|
||||
| 남은 리스크 | TODO |
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
| 항목 | 완료 여부 |
|
||||
| --- | --- |
|
||||
| TEST-1 app-server source 식별 assertion 추가 | TODO |
|
||||
| TEST-2 두 차례 prompt full-cycle smoke | TODO |
|
||||
| TEST-3 실제 CLI smoke 절차 기록 | TODO |
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] 선행 `03+02_default_profile/complete.log`를 확인한다.
|
||||
- [ ] e2e smoke가 기본 `codex` target을 app-server 경로로 식별할 수 있게 한다.
|
||||
- [ ] 첫 번째 prompt의 streaming delta와 completed event를 확인한다.
|
||||
- [ ] 두 번째 prompt가 같은 IOP session/Codex thread를 재사용하는지 확인한다.
|
||||
- [ ] `/sessions`와 `/terminate-session`이 app-server session을 보여주고 정리하는지 확인한다.
|
||||
- [ ] `codex-exec` fallback profile이 capabilities에 남아 있고 필요 시 별도 smoke로 실행 가능한지 확인한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드 리뷰 전용 체크리스트
|
||||
- [ ] smoke failure가 app-server source mismatch와 model/account failure를 구분한다.
|
||||
- [ ] test script가 long-running dev process를 남기지 않는다.
|
||||
- [ ] fallback `codex-exec` profile 검증이 기본 `codex` app-server smoke를 가리지 않는다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 검증 출력
|
||||
```text
|
||||
TODO
|
||||
```
|
||||
|
||||
## 리뷰어 체크포인트
|
||||
- `test -f agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/complete.log`
|
||||
- `go test ./apps/node/... ./apps/edge/... -count=1`
|
||||
- `IOP_E2E_PROFILE=codex ./scripts/e2e-smoke.sh`
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
<!-- task=m-codex-app-server-streaming-migration/04+03_stream_smoke plan=0 tag=TEST -->
|
||||
# Codex App Server Stream Smoke Plan
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
이 PLAN을 끝까지 읽은 뒤 구현한다. 먼저 선행 작업 `03+02_default_profile/complete.log`가 있는지 확인한다. 없으면 구현하지 말고 선행 작업을 완료한다. 구현 완료 전 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채운다.
|
||||
|
||||
## 배경
|
||||
마일스톤의 마지막 검증은 기본 `codex` target이 app-server streaming 경로로 동작하고, 두 번의 프롬프트에서 같은 thread/session을 재사용하며, `codex-exec` fallback도 보존되는지 확인하는 것이다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
외부 Codex 계정, 네트워크, quota, 또는 로그인 상태 때문에 실제 app-server smoke가 실행 불가할 때만 사용자 리뷰 요청을 남긴다. 그 전에는 fake adapter 단위 테스트와 local script 검증을 모두 수행한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md`
|
||||
- Task ids:
|
||||
- `stream-smoke`: `./scripts/dev/edge.sh`와 `./scripts/dev/node.sh` 시작 후 `codex` target으로 2-message 왕복에서 델타 스트림이 관찰된다. 검증: 두 메시지가 같은 logical session에서 완료되고, 적어도 한 turn에서 app-server delta event가 IOP delta로 relay된다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
- 현재 subtask: `04+03_stream_smoke`
|
||||
- 선행 index `03`: `agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/complete.log`가 현재 없음. 구현 전 선행 작업 완료가 필요하다.
|
||||
|
||||
## 분석 결과
|
||||
### 읽은 파일
|
||||
- Smoke: `scripts/e2e-smoke.sh`, `scripts/dev/edge.sh`, `scripts/dev/node.sh`, `Makefile`
|
||||
- Runtime/console: `apps/node/internal/node/node.go`, `apps/edge/internal/opsconsole/events.go`, `apps/edge/internal/opsconsole/events_test.go`
|
||||
- Adapter: `apps/node/internal/adapters/cli/cli.go`, `apps/node/internal/adapters/cli/opencode_sse.go`, `apps/node/internal/adapters/cli/emitters.go`
|
||||
- 설정: `configs/edge.yaml`, `configs/edge-compose.yaml.tmpl`
|
||||
- 선행 plan: `agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/PLAN-local-G05.md`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
- 환경 미지정은 local로 본다.
|
||||
- 선행 완료 확인: `test -f agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/complete.log`
|
||||
- 기본 검증 후보: `go test ./apps/node/... ./apps/edge/... -count=1`, `./scripts/e2e-smoke.sh`
|
||||
- 실제 external CLI 검증은 `scripts/dev/edge.sh`와 `scripts/dev/node.sh`를 통한 full-cycle smoke로 수행한다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
- 현재 smoke는 기본 profile이 app-server streaming source인지 명시적으로 확인하지 않는다.
|
||||
- Edge console 출력은 delta text 중심이라 metadata가 사용자 화면에 드러나지 않을 수 있다.
|
||||
- 두 번째 프롬프트가 같은 Codex thread를 재사용했는지 확인하는 assertion이 없다.
|
||||
|
||||
### 심볼 참조
|
||||
- 기존 smoke 명령명 변경 없음.
|
||||
- 필요 시 runtime event metadata 또는 node log 기반 assertion을 추가한다.
|
||||
- 호출 지점: `/capabilities`, `/transport`, `/sessions`, `/terminate-session`, 두 차례 prompt 실행.
|
||||
|
||||
### 분할 판단
|
||||
분할 정책을 평가했고 공유 task group은 `agent-task/m-codex-app-server-streaming-migration/`이다. sibling subtask는 `01_appserver_rpc_client`(선행 없음), `02+01_session_events`(`01` 의존), `03+02_default_profile`(`02` 의존), `04+03_stream_smoke`(`03` 의존)이다. 현재 작업의 predecessor index `03`은 active `complete.log`가 없어서 미충족이다.
|
||||
|
||||
### 범위 결정 근거
|
||||
smoke는 사용자 관측 경로를 검증하는 작업이다. adapter 내부 구현 변경은 smoke assertion을 가능하게 하는 최소 범위로 제한한다.
|
||||
|
||||
### 빌드 등급
|
||||
`cloud-G07`: 실제 Codex CLI app-server와 account 상태가 필요하고, Edge/Node 프로세스 간 streaming relay까지 검증해야 한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
- [ ] 선행 `03+02_default_profile/complete.log`를 확인한다.
|
||||
- [ ] e2e smoke가 기본 `codex` target을 app-server 경로로 식별할 수 있게 한다.
|
||||
- [ ] 첫 번째 prompt의 streaming delta와 completed event를 확인한다.
|
||||
- [ ] 두 번째 prompt가 같은 IOP session/Codex thread를 재사용하는지 확인한다.
|
||||
- [ ] `/sessions`와 `/terminate-session`이 app-server session을 보여주고 정리하는지 확인한다.
|
||||
- [ ] `codex-exec` fallback profile이 capabilities에 남아 있고 필요 시 별도 smoke로 실행 가능한지 확인한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [TEST-1] app-server source 식별 assertion 추가
|
||||
문제: streaming delta가 보이더라도 `codex exec --json` 경로인지 app-server 경로인지 구분하지 못하면 마일스톤 목적을 검증할 수 없다.
|
||||
|
||||
해결 방법: app-server RuntimeEvent metadata, node log, 또는 session snapshot 중 안정적인 지점을 골라 `source=codex-app-server`와 `codex_thread_id`를 검증한다. 화면 출력에 metadata를 억지로 노출하지 않고, 테스트 전용 assertion을 우선한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `scripts/e2e-smoke.sh`
|
||||
- [ ] 필요 시 `apps/node/internal/adapters/cli/codex_app_server_internal_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] fake app-server smoke에서 source metadata가 남는지 검증
|
||||
- [ ] e2e script에서 기본 target이 `codex`인지 검증
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
test -f agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/complete.log
|
||||
go test ./apps/node/internal/adapters/cli -run 'TestCodexAppServer.*Metadata|TestCodexAppServer.*ThreadReuse' -count=1
|
||||
```
|
||||
|
||||
### [TEST-2] 두 차례 prompt full-cycle smoke
|
||||
문제: 단일 prompt 성공만으로는 app-server thread 재사용과 session 유지가 검증되지 않는다.
|
||||
|
||||
해결 방법: `scripts/e2e-smoke.sh` 또는 dev script 절차에 두 번의 prompt를 포함하고, 두 번째 prompt 전에 `/sessions`로 session id/thread id를 확인한다. 완료 후 `/terminate-session`으로 정리한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `scripts/e2e-smoke.sh`
|
||||
- [ ] 필요 시 `apps/edge/internal/opsconsole/events_test.go`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] 두 번째 prompt 전후 session id가 유지되는지 검증
|
||||
- [ ] terminate 후 session list에서 제거되는지 검증
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
go test ./apps/node/... ./apps/edge/... -count=1
|
||||
```
|
||||
|
||||
### [TEST-3] 실제 CLI smoke 절차 기록
|
||||
문제: app-server는 실제 Codex CLI/account 상태와 결합되어 있어 단위 테스트만으로 마일스톤 완료 근거가 부족하다.
|
||||
|
||||
해결 방법: local/cloud runner에서 Edge와 Node를 띄우고 `codex` target에 두 번 prompt를 보내 streaming delta와 completed event를 기록한다. 같은 환경에서 `codex-exec` fallback이 capabilities에 남아 있는지도 확인한다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
- [ ] `CODE_REVIEW-cloud-G07.md` 검증 출력 섹션
|
||||
- [ ] 필요 시 `scripts/e2e-smoke.sh`
|
||||
|
||||
테스트 작성:
|
||||
- [ ] 자동화 가능한 부분은 `scripts/e2e-smoke.sh`에 반영
|
||||
- [ ] 계정 상태 때문에 자동화 불가한 출력은 사용자 리뷰 요청이 아니라 실행 환경 리스크로 기록
|
||||
|
||||
중간 검증:
|
||||
```bash
|
||||
./scripts/dev/edge.sh
|
||||
./scripts/dev/node.sh
|
||||
IOP_E2E_PROFILE=codex ./scripts/e2e-smoke.sh
|
||||
```
|
||||
|
||||
## 수정 파일 요약
|
||||
| 파일 | 항목 |
|
||||
| --- | --- |
|
||||
| `scripts/e2e-smoke.sh` | TEST-1, TEST-2, TEST-3 |
|
||||
| `apps/node/internal/adapters/cli/codex_app_server_internal_test.go` | TEST-1, 필요 시 |
|
||||
| `apps/edge/internal/opsconsole/events_test.go` | TEST-2, 필요 시 |
|
||||
| `CODE_REVIEW-cloud-G07.md` | TEST-3 |
|
||||
|
||||
## 최종 검증
|
||||
```bash
|
||||
test -f agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/complete.log
|
||||
go test ./apps/node/... ./apps/edge/... -count=1
|
||||
IOP_E2E_PROFILE=codex ./scripts/e2e-smoke.sh
|
||||
```
|
||||
|
||||
**모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.**
|
||||
|
|
@ -29,10 +29,11 @@ import (
|
|||
const Name = "cli"
|
||||
|
||||
const (
|
||||
modeCodexExec = "codex-exec"
|
||||
modeAntigravity = "antigravity-print"
|
||||
modeOpencodeSSE = "opencode-sse"
|
||||
modePersistentLazy = "persistent-lazy"
|
||||
modeCodexExec = "codex-exec"
|
||||
modeCodexAppServer = "codex-app-server"
|
||||
modeAntigravity = "antigravity-print"
|
||||
modeOpencodeSSE = "opencode-sse"
|
||||
modePersistentLazy = "persistent-lazy"
|
||||
)
|
||||
|
||||
type cliOutput struct {
|
||||
|
|
@ -95,11 +96,12 @@ type CLI struct {
|
|||
logger *zap.Logger
|
||||
StatusChecker func(ctx context.Context, target string, profile config.CLIProfileConf) (*status.UsageStatus, error)
|
||||
|
||||
oneShotExecutor *oneshotExecutor
|
||||
persistentExecutor *persistentExecutor
|
||||
codexExecutor *codexExecutor
|
||||
antigravityExecutor *antigravityExecutor
|
||||
opencodeExecutor *opencodeExecutor
|
||||
oneShotExecutor *oneshotExecutor
|
||||
persistentExecutor *persistentExecutor
|
||||
codexExecutor *codexExecutor
|
||||
codexAppServerExecutor *codexAppServerExecutor
|
||||
antigravityExecutor *antigravityExecutor
|
||||
opencodeExecutor *opencodeExecutor
|
||||
|
||||
reporters []sessionReporter
|
||||
}
|
||||
|
|
@ -160,6 +162,10 @@ func New(cfg config.CLIConf, logger *zap.Logger) *CLI {
|
|||
cli: c,
|
||||
sessions: make(map[sessionKey]*codexExecSession),
|
||||
}
|
||||
c.codexAppServerExecutor = &codexAppServerExecutor{
|
||||
cli: c,
|
||||
sessions: make(map[sessionKey]*codexAppServerSession),
|
||||
}
|
||||
c.antigravityExecutor = &antigravityExecutor{
|
||||
cli: c,
|
||||
sessions: make(map[sessionKey]*antigravitySession),
|
||||
|
|
@ -171,6 +177,7 @@ func New(cfg config.CLIConf, logger *zap.Logger) *CLI {
|
|||
c.reporters = []sessionReporter{
|
||||
c.persistentExecutor,
|
||||
c.codexExecutor,
|
||||
c.codexAppServerExecutor,
|
||||
c.antigravityExecutor,
|
||||
c.opencodeExecutor,
|
||||
}
|
||||
|
|
@ -224,6 +231,7 @@ func (c *CLI) Start(ctx context.Context) error {
|
|||
func shouldAutostartPersistentProfile(profile config.CLIProfileConf) bool {
|
||||
return profile.Persistent &&
|
||||
profile.Mode != modeCodexExec &&
|
||||
profile.Mode != modeCodexAppServer &&
|
||||
profile.Mode != modeAntigravity &&
|
||||
profile.Mode != modeOpencodeSSE &&
|
||||
profile.Mode != modePersistentLazy
|
||||
|
|
@ -244,6 +252,8 @@ func (c *CLI) executorFor(profile config.CLIProfileConf) executor {
|
|||
switch profile.Mode {
|
||||
case modeCodexExec:
|
||||
return c.codexExecutor
|
||||
case modeCodexAppServer:
|
||||
return c.codexAppServerExecutor
|
||||
case modeAntigravity:
|
||||
return c.antigravityExecutor
|
||||
case modeOpencodeSSE:
|
||||
|
|
@ -260,6 +270,8 @@ func (c *CLI) sessionReporterFor(profile config.CLIProfileConf) sessionReporter
|
|||
switch profile.Mode {
|
||||
case modeCodexExec:
|
||||
return c.codexExecutor
|
||||
case modeCodexAppServer:
|
||||
return c.codexAppServerExecutor
|
||||
case modeAntigravity:
|
||||
return c.antigravityExecutor
|
||||
case modeOpencodeSSE:
|
||||
|
|
|
|||
|
|
@ -376,6 +376,7 @@ func TestExecutorForMode(t *testing.T) {
|
|||
wantType string
|
||||
}{
|
||||
{mode: "codex-exec", wantType: "codex"},
|
||||
{mode: "codex-app-server", wantType: "codex-app-server"},
|
||||
{mode: "antigravity-print", wantType: "antigravity"},
|
||||
{mode: "opencode-sse", wantType: "opencode"},
|
||||
{mode: "persistent-lazy", persistent: true, wantType: "persistent"},
|
||||
|
|
@ -394,6 +395,8 @@ func TestExecutorForMode(t *testing.T) {
|
|||
switch tc.wantType {
|
||||
case "codex":
|
||||
_, ok = exec.(*codexExecutor)
|
||||
case "codex-app-server":
|
||||
_, ok = exec.(*codexAppServerExecutor)
|
||||
case "antigravity":
|
||||
_, ok = exec.(*antigravityExecutor)
|
||||
case "opencode":
|
||||
|
|
@ -409,11 +412,39 @@ func TestExecutorForMode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCLIStartSkipsCodexAppServerPersistentStartup(t *testing.T) {
|
||||
// persistent=true + mode=codex-app-server must NOT be autostarted via Start().
|
||||
c := New(config.CLIConf{
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"codex-as": {
|
||||
Mode: modeCodexAppServer,
|
||||
Persistent: true,
|
||||
Command: "false", // would fail if actually started
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
// shouldAutostartPersistentProfile must return false.
|
||||
profile := c.profiles["codex-as"]
|
||||
if shouldAutostartPersistentProfile(profile) {
|
||||
t.Fatal("shouldAutostartPersistentProfile should return false for codex-app-server mode")
|
||||
}
|
||||
|
||||
// Start() must not attempt to launch the process (no error from "false").
|
||||
if err := c.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start() returned error for codex-app-server profile: %v", err)
|
||||
}
|
||||
if len(c.persistentExecutor.sessions) != 0 {
|
||||
t.Fatalf("expected no persistent sessions, got %d", len(c.persistentExecutor.sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSessionList_PopulatedSnapshot(t *testing.T) {
|
||||
c := New(config.CLIConf{}, nil)
|
||||
c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "default"}] = &profileSession{}
|
||||
c.persistentExecutor.sessions[sessionKey{target: "claude", sessionID: "alt"}] = &profileSession{}
|
||||
c.codexExecutor.sessions[sessionKey{target: "codex", sessionID: "default"}] = &codexExecSession{}
|
||||
c.codexAppServerExecutor.sessions[sessionKey{target: "codex-as", sessionID: "default"}] = &codexAppServerSession{}
|
||||
c.antigravityExecutor.sessions[sessionKey{target: "antigravity", sessionID: "main"}] = &antigravitySession{}
|
||||
c.opencodeExecutor.sessions[sessionKey{target: "opencode", sessionID: "main"}] = &opencodeSSESession{}
|
||||
|
||||
|
|
@ -423,10 +454,10 @@ func TestHandleSessionList_PopulatedSnapshot(t *testing.T) {
|
|||
Adapter: "cli",
|
||||
})
|
||||
|
||||
if resp.Result["count"] != "5" {
|
||||
t.Fatalf("count: got %q want %q", resp.Result["count"], "5")
|
||||
if resp.Result["count"] != "6" {
|
||||
t.Fatalf("count: got %q want %q", resp.Result["count"], "6")
|
||||
}
|
||||
want := "antigravity-print:antigravity/main,codex-exec:codex/default,opencode-sse:opencode/main,persistent:claude/alt,persistent:claude/default"
|
||||
want := "antigravity-print:antigravity/main,codex-app-server:codex-as/default,codex-exec:codex/default,opencode-sse:opencode/main,persistent:claude/alt,persistent:claude/default"
|
||||
if got := resp.Result["sessions"]; got != want {
|
||||
t.Fatalf("sessions: got %q want %q", got, want)
|
||||
}
|
||||
|
|
@ -434,10 +465,11 @@ func TestHandleSessionList_PopulatedSnapshot(t *testing.T) {
|
|||
t.Fatalf("request id not echoed: %+v", resp)
|
||||
}
|
||||
|
||||
// Sorted order: antigravity-print:antigravity/main, codex-exec:codex/default,
|
||||
// opencode-sse:opencode/main, persistent:claude/alt, persistent:claude/default
|
||||
// Sorted order: antigravity-print:antigravity/main, codex-app-server:codex-as/default,
|
||||
// codex-exec:codex/default, opencode-sse:opencode/main, persistent:claude/alt, persistent:claude/default
|
||||
wantSessions := []struct{ mode, target, sessionID, label string }{
|
||||
{"antigravity-print", "antigravity", "main", "antigravity-print:antigravity/main"},
|
||||
{"codex-app-server", "codex-as", "default", "codex-app-server:codex-as/default"},
|
||||
{"codex-exec", "codex", "default", "codex-exec:codex/default"},
|
||||
{"opencode-sse", "opencode", "main", "opencode-sse:opencode/main"},
|
||||
{"persistent", "claude", "alt", "persistent:claude/alt"},
|
||||
|
|
@ -580,6 +612,51 @@ func TestHandleUsageStatus_EnvelopeAndParseMetadata(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestHandleSessionList_CodexAppServerVisible(t *testing.T) {
|
||||
c := New(config.CLIConf{}, nil)
|
||||
c.codexAppServerExecutor.sessions[sessionKey{target: "codex-as", sessionID: "sess-1"}] = &codexAppServerSession{
|
||||
threadID: "thread-abc",
|
||||
}
|
||||
|
||||
resp := c.handleSessionList(runtime.CommandRequest{
|
||||
RequestID: "req-cas",
|
||||
Type: runtime.CommandTypeSessionList,
|
||||
Adapter: "cli",
|
||||
})
|
||||
|
||||
if resp.Result["count"] != "1" {
|
||||
t.Fatalf("count: got %q want %q", resp.Result["count"], "1")
|
||||
}
|
||||
if got := resp.Result["session.0.mode"]; got != modeCodexAppServer {
|
||||
t.Errorf("mode: got %q want %q", got, modeCodexAppServer)
|
||||
}
|
||||
if got := resp.Result["session.0.target"]; got != "codex-as" {
|
||||
t.Errorf("target: got %q want %q", got, "codex-as")
|
||||
}
|
||||
if got := resp.Result["session.0.session_id"]; got != "sess-1" {
|
||||
t.Errorf("session_id: got %q want %q", got, "sess-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminateSession_CodexAppServerRemovesFromList(t *testing.T) {
|
||||
c := New(config.CLIConf{
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"codex-as": {Mode: modeCodexAppServer},
|
||||
},
|
||||
}, nil)
|
||||
key := sessionKey{target: "codex-as", sessionID: "sess-1"}
|
||||
c.codexAppServerExecutor.sessions[key] = &codexAppServerSession{key: key}
|
||||
|
||||
if err := c.TerminateSession(context.Background(), "codex-as", "sess-1"); err != nil {
|
||||
t.Fatalf("TerminateSession: %v", err)
|
||||
}
|
||||
|
||||
resp := c.handleSessionList(runtime.CommandRequest{Type: runtime.CommandTypeSessionList})
|
||||
if resp.Result["count"] != "0" {
|
||||
t.Fatalf("expected session removed, count = %q", resp.Result["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseProfileSession_PipeFallbackIdempotent(t *testing.T) {
|
||||
var mockCloseCalled int
|
||||
mockClose := func() error {
|
||||
|
|
|
|||
627
apps/node/internal/adapters/cli/codex_app_server.go
Normal file
627
apps/node/internal/adapters/cli/codex_app_server.go
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"iop/apps/node/internal/runtime"
|
||||
"iop/packages/go/config"
|
||||
)
|
||||
|
||||
// codexAppServerSession tracks a single long-lived `codex app-server --stdio` process
|
||||
// bound to a (target, sessionID) key. The process is started lazily on first use and
|
||||
// reused across Execute calls until it exits or is terminated.
|
||||
type codexAppServerSession struct {
|
||||
key sessionKey
|
||||
threadID string // Codex thread id assigned by thread/start
|
||||
|
||||
mu sync.Mutex
|
||||
proc *codexAppServerProc
|
||||
closed bool
|
||||
}
|
||||
|
||||
// codexAppServerProc wraps the OS process and its stdio pipes.
|
||||
type codexAppServerProc struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout *bufio.Scanner
|
||||
|
||||
nextID atomic.Int64
|
||||
pending sync.Map // int64 -> chan appServerResponse
|
||||
notifCh chan appServerNotification
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type appServerRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type appServerResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Error *appServerErr `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type appServerErr struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type appServerNotification struct {
|
||||
Method string `json:"method"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// codexAppServerExecutor is the sessionReporter + executor for modeCodexAppServer profiles.
|
||||
type codexAppServerExecutor struct {
|
||||
cli *CLI
|
||||
mu sync.Mutex
|
||||
sessions map[sessionKey]*codexAppServerSession
|
||||
}
|
||||
|
||||
func (e *codexAppServerExecutor) Execute(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf, sink runtime.EventSink) error {
|
||||
sess, err := e.resolveCodexAppServerSession(ctx, spec, profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
|
||||
if err := sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeStart,
|
||||
Timestamp: time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prompt := extractPrompt(spec.Input)
|
||||
turnID, err := codexAppServerTurnStart(ctx, sess.proc, sess.threadID, prompt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return codexAppServerDrainNotifications(ctx, sess.proc, spec.RunID, sess.threadID, turnID, sink)
|
||||
}
|
||||
|
||||
// codexAppServerTurnStart sends turn/start with the prompt and returns the turn id.
|
||||
func codexAppServerTurnStart(ctx context.Context, p *codexAppServerProc, threadID, prompt string) (string, error) {
|
||||
// TurnStartParams.input is required; UserInput text variant requires text_elements.
|
||||
params := map[string]any{
|
||||
"threadId": threadID,
|
||||
"input": []map[string]any{
|
||||
{"type": "text", "text": prompt, "text_elements": []any{}},
|
||||
},
|
||||
}
|
||||
resp, err := p.send(ctx, "turn/start", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("turn/start: %w", err)
|
||||
}
|
||||
// Actual shape: result.turn.id (TurnStartResponse)
|
||||
turnID := nestedString(resp.Result, "turn", "id")
|
||||
// Legacy/flat fallbacks.
|
||||
if turnID == "" {
|
||||
turnID, _ = resp.Result["turnId"].(string)
|
||||
}
|
||||
if turnID == "" {
|
||||
turnID, _ = resp.Result["id"].(string)
|
||||
}
|
||||
if turnID == "" {
|
||||
return "", fmt.Errorf("turn/start: server did not return a turn id")
|
||||
}
|
||||
return turnID, nil
|
||||
}
|
||||
|
||||
// codexAppServerDrainNotifications consumes notifications until turn/completed or error.
|
||||
// When the process exits (p.done closes), we drain any remaining buffered notifications
|
||||
// before surfacing the exit error, so that a terminal event sent just before exit is
|
||||
// not lost.
|
||||
func codexAppServerDrainNotifications(ctx context.Context, p *codexAppServerProc, runID, threadID, turnID string, sink runtime.EventSink) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case notif, ok := <-p.notifCh:
|
||||
if !ok {
|
||||
return fmt.Errorf("codex app-server notification channel closed")
|
||||
}
|
||||
events, done, err := decodeAppServerNotification(notif, runID, threadID, turnID)
|
||||
for _, ev := range events {
|
||||
if emitErr := sink.Emit(ctx, ev); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
case <-p.done:
|
||||
// Process exited. Drain any notifications already in the buffer before
|
||||
// declaring an error — the terminal event may have arrived with the exit.
|
||||
for {
|
||||
select {
|
||||
case notif, ok := <-p.notifCh:
|
||||
if !ok {
|
||||
return fmt.Errorf("codex app-server process exited during turn")
|
||||
}
|
||||
events, done, err := decodeAppServerNotification(notif, runID, threadID, turnID)
|
||||
for _, ev := range events {
|
||||
if emitErr := sink.Emit(ctx, ev); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("codex app-server process exited during turn")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// decodeAppServerNotification maps a single app-server notification to RuntimeEvents.
|
||||
// threadID and turnID are the execution-context values used as fallbacks when the
|
||||
// notification params do not carry them. Returns (events, turnDone, error).
|
||||
func decodeAppServerNotification(notif appServerNotification, runID, threadID, turnID string) ([]runtime.RuntimeEvent, bool, error) {
|
||||
// Prefer IDs from the notification params; fall back to execution-context values.
|
||||
effectiveThread := notif.Params["threadId"]
|
||||
if s, ok := effectiveThread.(string); ok && s != "" {
|
||||
threadID = s
|
||||
}
|
||||
effectiveTurn := notif.Params["turnId"]
|
||||
if s, ok := effectiveTurn.(string); ok && s != "" {
|
||||
turnID = s
|
||||
}
|
||||
|
||||
meta := map[string]string{"source": "codex-app-server"}
|
||||
if threadID != "" {
|
||||
meta["thread_id"] = threadID
|
||||
}
|
||||
if turnID != "" {
|
||||
meta["turn_id"] = turnID
|
||||
}
|
||||
|
||||
switch notif.Method {
|
||||
case "item/agentMessage/delta":
|
||||
// AgentMessageDeltaNotification: { threadId, turnId, itemId, delta }
|
||||
delta := nestedString(notif.Params, "delta")
|
||||
if delta == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
return []runtime.RuntimeEvent{{
|
||||
RunID: runID,
|
||||
Type: runtime.EventTypeDelta,
|
||||
Delta: delta,
|
||||
Metadata: meta,
|
||||
Timestamp: time.Now(),
|
||||
}}, false, nil
|
||||
|
||||
case "item/completed":
|
||||
// Suppress duplicate final delta — already streamed via delta notifications.
|
||||
return nil, false, nil
|
||||
|
||||
case "turn/completed":
|
||||
// TurnCompletedNotification: { threadId, turn: { id, status, error, ... } }
|
||||
// status may be "completed" | "interrupted" | "failed" | "inProgress"
|
||||
status := nestedString(notif.Params, "turn", "status")
|
||||
if status == "failed" {
|
||||
msg := nestedString(notif.Params, "turn", "error", "message")
|
||||
if msg == "" {
|
||||
msg = "turn failed"
|
||||
}
|
||||
return []runtime.RuntimeEvent{{
|
||||
RunID: runID,
|
||||
Type: runtime.EventTypeError,
|
||||
Error: msg,
|
||||
Metadata: meta,
|
||||
Timestamp: time.Now(),
|
||||
}}, false, fmt.Errorf("codex app-server turn failed: %s", msg)
|
||||
}
|
||||
return []runtime.RuntimeEvent{{
|
||||
RunID: runID,
|
||||
Type: runtime.EventTypeComplete,
|
||||
Metadata: meta,
|
||||
Timestamp: time.Now(),
|
||||
}}, true, nil
|
||||
|
||||
case "error":
|
||||
// ErrorNotification: { error: { message, ... }, willRetry, threadId, turnId }
|
||||
msg := nestedString(notif.Params, "error", "message")
|
||||
if msg == "" {
|
||||
// flat fallback for non-standard shapes
|
||||
msg = nestedString(notif.Params, "message")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "codex app-server error"
|
||||
}
|
||||
return []runtime.RuntimeEvent{{
|
||||
RunID: runID,
|
||||
Type: runtime.EventTypeError,
|
||||
Error: msg,
|
||||
Metadata: meta,
|
||||
Timestamp: time.Now(),
|
||||
}}, false, fmt.Errorf("codex app-server: %s", msg)
|
||||
|
||||
case "turn/failed":
|
||||
// Legacy method name kept for compatibility.
|
||||
msg := nestedString(notif.Params, "error", "message")
|
||||
if msg == "" {
|
||||
msg = nestedString(notif.Params, "message")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "turn failed"
|
||||
}
|
||||
return []runtime.RuntimeEvent{{
|
||||
RunID: runID,
|
||||
Type: runtime.EventTypeError,
|
||||
Error: msg,
|
||||
Metadata: meta,
|
||||
Timestamp: time.Now(),
|
||||
}}, false, fmt.Errorf("codex app-server turn failed: %s", msg)
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// nestedString walks a params map by keys and returns the string value at the path.
|
||||
func nestedString(params map[string]any, keys ...string) string {
|
||||
var cur any = params
|
||||
for i, k := range keys {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur = m[k]
|
||||
if i == len(keys)-1 {
|
||||
s, _ := cur.(string)
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *codexAppServerExecutor) resolveCodexAppServerSession(ctx context.Context, spec runtime.ExecutionSpec, profile config.CLIProfileConf) (*codexAppServerSession, error) {
|
||||
target := cliTargetName(spec)
|
||||
key := sessionKey{target: target, sessionID: normalizeSessionID(spec.SessionID)}
|
||||
|
||||
e.mu.Lock()
|
||||
if sess, ok := e.sessions[key]; ok {
|
||||
e.mu.Unlock()
|
||||
return sess, nil
|
||||
}
|
||||
if spec.SessionMode == runtime.SessionModeRequireExisting {
|
||||
e.mu.Unlock()
|
||||
return nil, fmt.Errorf("cli adapter: no codex app-server session for target %q session %q", target, key.sessionID)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
proc, err := startCodexAppServerProc(ctx, profile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cli adapter: start codex app-server: %w", err)
|
||||
}
|
||||
|
||||
threadID, err := codexAppServerInit(ctx, proc)
|
||||
if err != nil {
|
||||
proc.close()
|
||||
return nil, fmt.Errorf("cli adapter: codex app-server init: %w", err)
|
||||
}
|
||||
|
||||
sess := &codexAppServerSession{
|
||||
key: key,
|
||||
threadID: threadID,
|
||||
proc: proc,
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
if existing, ok := e.sessions[key]; ok {
|
||||
e.mu.Unlock()
|
||||
proc.close()
|
||||
return existing, nil
|
||||
}
|
||||
e.sessions[key] = sess
|
||||
e.mu.Unlock()
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (e *codexAppServerExecutor) Sessions() []sessionListEntry {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
snaps := make([]sessionListEntry, 0, len(e.sessions))
|
||||
for k := range e.sessions {
|
||||
snaps = append(snaps, sessionListEntry{modeCodexAppServer, k.target, k.sessionID})
|
||||
}
|
||||
return snaps
|
||||
}
|
||||
|
||||
func (e *codexAppServerExecutor) Terminate(ctx context.Context, target, sessionID string) (bool, error) {
|
||||
key := sessionKey{target: target, sessionID: normalizeSessionID(sessionID)}
|
||||
e.mu.Lock()
|
||||
sess, ok := e.sessions[key]
|
||||
if ok {
|
||||
delete(e.sessions, key)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
closeCodexAppServerSession(sess)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *codexAppServerExecutor) Stop(_ context.Context) error {
|
||||
e.mu.Lock()
|
||||
cp := make(map[sessionKey]*codexAppServerSession, len(e.sessions))
|
||||
for k, v := range e.sessions {
|
||||
cp[k] = v
|
||||
}
|
||||
e.sessions = make(map[sessionKey]*codexAppServerSession)
|
||||
e.mu.Unlock()
|
||||
for _, sess := range cp {
|
||||
closeCodexAppServerSession(sess)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- process lifecycle ---
|
||||
|
||||
func startCodexAppServerProc(ctx context.Context, profile config.CLIProfileConf) (*codexAppServerProc, error) {
|
||||
args := append(append([]string{}, profile.Args...), "--stdio")
|
||||
cmd := exec.Command(profile.Command, args...)
|
||||
if len(profile.Env) > 0 {
|
||||
cmd.Env = append(cmd.Environ(), profile.Env...)
|
||||
}
|
||||
|
||||
stdinPipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
// stderr is intentionally discarded; process exit errors surface via done channel.
|
||||
cmd.Stderr = nil
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
p := &codexAppServerProc{
|
||||
cmd: cmd,
|
||||
stdin: stdinPipe,
|
||||
stdout: bufio.NewScanner(stdoutPipe),
|
||||
notifCh: make(chan appServerNotification, 32),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
p.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
|
||||
go p.readLoop()
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
p.closeOnce.Do(func() { close(p.done) })
|
||||
}()
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// readLoop dispatches incoming newline-delimited JSON to pending response channels
|
||||
// or the notification channel.
|
||||
func (p *codexAppServerProc) readLoop() {
|
||||
for p.stdout.Scan() {
|
||||
line := p.stdout.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try response first (has "id" field as number).
|
||||
var resp appServerResponse
|
||||
if err := json.Unmarshal(line, &resp); err == nil && resp.ID != 0 {
|
||||
if ch, ok := p.pending.Load(resp.ID); ok {
|
||||
select {
|
||||
case ch.(chan appServerResponse) <- resp:
|
||||
default:
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Treat as notification. Blocking send ensures no notification is dropped;
|
||||
// the drain goroutine or a closed done channel will unblock this if needed.
|
||||
var notif appServerNotification
|
||||
if err := json.Unmarshal(line, ¬if); err == nil && notif.Method != "" {
|
||||
select {
|
||||
case p.notifCh <- notif:
|
||||
case <-p.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
p.closeOnce.Do(func() { close(p.done) })
|
||||
}
|
||||
|
||||
// recvResponse waits for a response on ch, giving priority to an already-arrived
|
||||
// response over p.done or ctx cancellation. This prevents the case where the
|
||||
// process sends its response and then immediately exits, and Go's random select
|
||||
// picks p.done instead of ch.
|
||||
func (p *codexAppServerProc) recvResponse(ctx context.Context, ch chan appServerResponse) (appServerResponse, error) {
|
||||
// Fast path: response already in buffer.
|
||||
select {
|
||||
case resp := <-ch:
|
||||
return resp, nil
|
||||
default:
|
||||
}
|
||||
// Slow path: wait for any of the three signals.
|
||||
select {
|
||||
case resp := <-ch:
|
||||
return resp, nil
|
||||
case <-p.done:
|
||||
// One last drain: response may have arrived between the default check and here.
|
||||
select {
|
||||
case resp := <-ch:
|
||||
return resp, nil
|
||||
default:
|
||||
}
|
||||
return appServerResponse{}, fmt.Errorf("codex app-server process exited")
|
||||
case <-ctx.Done():
|
||||
return appServerResponse{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *codexAppServerProc) send(ctx context.Context, method string, params map[string]any) (appServerResponse, error) {
|
||||
id := p.nextID.Add(1)
|
||||
req := appServerRequest{ID: id, Method: method, Params: params}
|
||||
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return appServerResponse{}, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
|
||||
ch := make(chan appServerResponse, 1)
|
||||
p.pending.Store(id, ch)
|
||||
defer p.pending.Delete(id)
|
||||
|
||||
// Write stdin in a goroutine so that a blocked pipe write does not prevent
|
||||
// us from observing process exit or an early response via p.done / ctx.
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.stdin.Write(raw)
|
||||
writeErr <- err
|
||||
}()
|
||||
|
||||
// Wait for write completion, but also watch for an early response or exit.
|
||||
// A fast server may respond and exit before the write goroutine reports back.
|
||||
select {
|
||||
case resp, ok := <-ch:
|
||||
// Response arrived before write confirmed — still valid.
|
||||
if ok {
|
||||
if resp.Error != nil {
|
||||
return resp, fmt.Errorf("rpc error %d: %s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case err := <-writeErr:
|
||||
if err != nil {
|
||||
return appServerResponse{}, fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
case resp := <-ch:
|
||||
if resp.Error != nil {
|
||||
return resp, fmt.Errorf("rpc error %d: %s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
return resp, nil
|
||||
case <-p.done:
|
||||
// Check for response that arrived simultaneously with exit.
|
||||
select {
|
||||
case resp := <-ch:
|
||||
if resp.Error != nil {
|
||||
return resp, fmt.Errorf("rpc error %d: %s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
return resp, nil
|
||||
default:
|
||||
}
|
||||
return appServerResponse{}, fmt.Errorf("codex app-server process exited")
|
||||
case <-ctx.Done():
|
||||
return appServerResponse{}, ctx.Err()
|
||||
}
|
||||
|
||||
resp, err := p.recvResponse(ctx, ch)
|
||||
if err != nil {
|
||||
return appServerResponse{}, err
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return resp, fmt.Errorf("rpc error %d: %s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// sendNotification sends a JSON-RPC notification (no id, no response expected).
|
||||
func (p *codexAppServerProc) sendNotification(method string, params map[string]any) error {
|
||||
type notifMsg struct {
|
||||
Method string `json:"method"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
raw, err := json.Marshal(notifMsg{Method: method, Params: params})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal notification: %w", err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
_, err = p.stdin.Write(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *codexAppServerProc) close() {
|
||||
_ = p.stdin.Close()
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
}
|
||||
p.closeOnce.Do(func() { close(p.done) })
|
||||
}
|
||||
|
||||
// codexAppServerInit runs initialize → initialized → thread/start and returns the thread id.
|
||||
func codexAppServerInit(ctx context.Context, p *codexAppServerProc) (string, error) {
|
||||
initParams := map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"clientInfo": map[string]any{"name": "iop-node", "version": "0"},
|
||||
"capabilities": map[string]any{},
|
||||
}
|
||||
if _, err := p.send(ctx, "initialize", initParams); err != nil {
|
||||
return "", fmt.Errorf("initialize: %w", err)
|
||||
}
|
||||
|
||||
if err := p.sendNotification("initialized", nil); err != nil {
|
||||
return "", fmt.Errorf("initialized notification: %w", err)
|
||||
}
|
||||
|
||||
resp, err := p.send(ctx, "thread/start", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("thread/start: %w", err)
|
||||
}
|
||||
|
||||
// Actual shape: result.thread.id (ThreadStartResponse)
|
||||
threadID := nestedString(resp.Result, "thread", "id")
|
||||
// Legacy/flat fallbacks for compatibility with older or fake servers.
|
||||
if threadID == "" {
|
||||
threadID, _ = resp.Result["threadId"].(string)
|
||||
}
|
||||
if threadID == "" {
|
||||
threadID, _ = resp.Result["id"].(string)
|
||||
}
|
||||
if threadID == "" {
|
||||
return "", fmt.Errorf("thread/start: server did not return a thread id")
|
||||
}
|
||||
return threadID, nil
|
||||
}
|
||||
|
||||
func closeCodexAppServerSession(sess *codexAppServerSession) {
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
sess.mu.Lock()
|
||||
defer sess.mu.Unlock()
|
||||
if sess.closed {
|
||||
return
|
||||
}
|
||||
sess.closed = true
|
||||
if sess.proc != nil {
|
||||
sess.proc.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,979 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"iop/apps/node/internal/runtime"
|
||||
"iop/packages/go/config"
|
||||
)
|
||||
|
||||
// fakeAppServer is a minimal fake of codex app-server --stdio.
|
||||
// It responds to requests by dispatching to a handler func.
|
||||
type fakeAppServer struct {
|
||||
dec *json.Decoder
|
||||
enc *json.Encoder
|
||||
handler func(method string, id int64, params map[string]any) (map[string]any, error)
|
||||
}
|
||||
|
||||
func newFakeAppServer(r io.Reader, w io.Writer) *fakeAppServer {
|
||||
return &fakeAppServer{
|
||||
dec: json.NewDecoder(r),
|
||||
enc: json.NewEncoder(w),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeAppServer) serveOne() error {
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]any `json:"params"`
|
||||
}
|
||||
if err := f.dec.Decode(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
// Notifications (id == 0) get no response.
|
||||
if req.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
result, rpcErr := f.handler(req.Method, req.ID, req.Params)
|
||||
resp := map[string]any{"id": req.ID}
|
||||
if rpcErr != nil {
|
||||
resp["error"] = map[string]any{"code": -32000, "message": rpcErr.Error()}
|
||||
} else {
|
||||
resp["result"] = result
|
||||
}
|
||||
return f.enc.Encode(resp)
|
||||
}
|
||||
|
||||
// makeStdioPipe creates an in-process pipe pair that looks like a subprocess stdio.
|
||||
// Returns (stdinReader, stdinWriter, stdoutReader, stdoutWriter).
|
||||
func makeStdioPipes() (io.Reader, io.WriteCloser, io.ReadCloser, io.WriteCloser) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
return stdinR, stdinW, stdoutR, stdoutW
|
||||
}
|
||||
|
||||
// TestCodexAppServerRPC_InitializeShape verifies the initialize request:
|
||||
// - has "id", "method", and "params" fields
|
||||
// - does NOT have a "jsonrpc" field
|
||||
// - method == "initialize"
|
||||
func TestCodexAppServerRPC_InitializeShape(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
var capturedReq map[string]json.RawMessage
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
dec := json.NewDecoder(stdinR)
|
||||
|
||||
// 1. initialize request
|
||||
if err := dec.Decode(&capturedReq); err != nil {
|
||||
serverDone <- fmt.Errorf("decode initialize: %w", err)
|
||||
return
|
||||
}
|
||||
var id int64
|
||||
_ = json.Unmarshal(capturedReq["id"], &id)
|
||||
resp := map[string]any{"id": id, "result": map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{},
|
||||
}}
|
||||
_ = json.NewEncoder(stdoutW).Encode(resp)
|
||||
|
||||
// 2. initialized notification (no response)
|
||||
var notif map[string]json.RawMessage
|
||||
_ = dec.Decode(¬if)
|
||||
|
||||
// 3. thread/start
|
||||
var tsReq map[string]json.RawMessage
|
||||
if err := dec.Decode(&tsReq); err != nil {
|
||||
serverDone <- fmt.Errorf("decode thread/start: %w", err)
|
||||
return
|
||||
}
|
||||
var tsID int64
|
||||
_ = json.Unmarshal(tsReq["id"], &tsID)
|
||||
_ = json.NewEncoder(stdoutW).Encode(map[string]any{
|
||||
"id": tsID,
|
||||
"result": map[string]any{"threadId": "thread-abc"},
|
||||
})
|
||||
}()
|
||||
|
||||
// Build a proc directly (bypass exec.Command) using our pipes.
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
threadID, err := codexAppServerInit(context.Background(), proc)
|
||||
if err != nil {
|
||||
t.Fatalf("codexAppServerInit: %v", err)
|
||||
}
|
||||
// Close stdin so the fake server goroutine can finish.
|
||||
_ = stdinW.Close()
|
||||
_ = stdoutW.Close()
|
||||
|
||||
if threadID != "thread-abc" {
|
||||
t.Errorf("threadID = %q, want %q", threadID, "thread-abc")
|
||||
}
|
||||
|
||||
// Verify initialize request shape.
|
||||
if _, hasJSONRPC := capturedReq["jsonrpc"]; hasJSONRPC {
|
||||
t.Error("initialize request must NOT contain 'jsonrpc' field")
|
||||
}
|
||||
var method string
|
||||
_ = json.Unmarshal(capturedReq["method"], &method)
|
||||
if method != "initialize" {
|
||||
t.Errorf("method = %q, want %q", method, "initialize")
|
||||
}
|
||||
if _, hasID := capturedReq["id"]; !hasID {
|
||||
t.Error("initialize request must have 'id' field")
|
||||
}
|
||||
if _, hasParams := capturedReq["params"]; !hasParams {
|
||||
t.Error("initialize request must have 'params' field")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerRPC_NoJSONRPCField confirms that send() never emits "jsonrpc".
|
||||
func TestCodexAppServerRPC_NoJSONRPCField(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
go func() {
|
||||
dec := json.NewDecoder(stdinR)
|
||||
var raw map[string]json.RawMessage
|
||||
if err := dec.Decode(&raw); err != nil {
|
||||
return
|
||||
}
|
||||
var id int64
|
||||
_ = json.Unmarshal(raw["id"], &id)
|
||||
_ = json.NewEncoder(stdoutW).Encode(map[string]any{
|
||||
"id": id,
|
||||
"result": map[string]any{},
|
||||
})
|
||||
_ = stdinR.Close()
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
// Capture raw bytes written to stdin.
|
||||
var written strings.Builder
|
||||
origStdin := proc.stdin
|
||||
proc.stdin = &captureWriter{w: origStdin, buf: &written}
|
||||
|
||||
_, _ = proc.send(context.Background(), "ping", nil)
|
||||
_ = stdinW.Close()
|
||||
|
||||
line := strings.TrimSpace(written.String())
|
||||
var decoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(line), &decoded); err != nil {
|
||||
t.Fatalf("parse written line: %v", err)
|
||||
}
|
||||
if _, ok := decoded["jsonrpc"]; ok {
|
||||
t.Error("send() must NOT include 'jsonrpc' field in request")
|
||||
}
|
||||
}
|
||||
|
||||
type captureWriter struct {
|
||||
w io.WriteCloser
|
||||
buf *strings.Builder
|
||||
}
|
||||
|
||||
func (c *captureWriter) Write(p []byte) (int, error) {
|
||||
c.buf.Write(p)
|
||||
return c.w.Write(p)
|
||||
}
|
||||
|
||||
func (c *captureWriter) Close() error { return c.w.Close() }
|
||||
|
||||
// TestCodexAppServerProcessLifecycle_CloseSafe verifies that proc.close() is safe
|
||||
// to call multiple times (no panic on duplicate close of done channel).
|
||||
func TestCodexAppServerProcessLifecycle_CloseSafe(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, _ := io.Pipe()
|
||||
_ = stdinR
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// close() twice must not panic.
|
||||
proc.close()
|
||||
proc.close()
|
||||
}
|
||||
|
||||
// TestCodexAppServerProcessLifecycle_ExitSurfaces verifies that send() returns an
|
||||
// error when the process stdout closes (simulating process exit).
|
||||
func TestCodexAppServerProcessLifecycle_ExitSurfaces(t *testing.T) {
|
||||
// Use a devnull reader for stdin so the write goroutine doesn't block.
|
||||
stdinR, stdinW := io.Pipe()
|
||||
go io.Copy(io.Discard, stdinR) // drain stdin so write doesn't block
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
// Close stdout to signal process exit after a brief moment.
|
||||
go func() {
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
_, err := proc.send(context.Background(), "initialize", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when process exits, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerSessionResolve verifies that two Execute calls with the same
|
||||
// target/sessionID reuse the same codexAppServerSession (without starting a real process).
|
||||
func TestCodexAppServerSessionResolve(t *testing.T) {
|
||||
e := &codexAppServerExecutor{
|
||||
cli: &CLI{},
|
||||
sessions: make(map[sessionKey]*codexAppServerSession),
|
||||
}
|
||||
|
||||
// Pre-seed a session so resolveCodexAppServerSession returns it without spawning.
|
||||
key := sessionKey{target: "codex-as", sessionID: runtime.DefaultSessionID}
|
||||
existing := &codexAppServerSession{key: key, threadID: "tid-1"}
|
||||
e.sessions[key] = existing
|
||||
|
||||
spec := runtime.ExecutionSpec{
|
||||
Target: "codex-as",
|
||||
SessionID: "",
|
||||
}
|
||||
profile := config.CLIProfileConf{Mode: modeCodexAppServer, Command: "false"}
|
||||
|
||||
got, err := e.resolveCodexAppServerSession(context.Background(), spec, profile)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCodexAppServerSession: %v", err)
|
||||
}
|
||||
if got != existing {
|
||||
t.Error("expected same session pointer to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerRequireExisting verifies that SessionModeRequireExisting returns
|
||||
// an error when no session exists.
|
||||
func TestCodexAppServerRequireExisting(t *testing.T) {
|
||||
e := &codexAppServerExecutor{
|
||||
cli: &CLI{},
|
||||
sessions: make(map[sessionKey]*codexAppServerSession),
|
||||
}
|
||||
|
||||
spec := runtime.ExecutionSpec{
|
||||
Target: "codex-as",
|
||||
SessionID: "missing",
|
||||
SessionMode: runtime.SessionModeRequireExisting,
|
||||
}
|
||||
profile := config.CLIProfileConf{Mode: modeCodexAppServer}
|
||||
|
||||
_, err := e.resolveCodexAppServerSession(context.Background(), spec, profile)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for SessionModeRequireExisting with no session, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerSend_RequestIDMonotone verifies that successive send() calls
|
||||
// use strictly increasing id values.
|
||||
func TestCodexAppServerSend_RequestIDMonotone(t *testing.T) {
|
||||
var counter atomic.Int64
|
||||
var ids []int64
|
||||
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
go func() {
|
||||
dec := json.NewDecoder(stdinR)
|
||||
enc := json.NewEncoder(stdoutW)
|
||||
for i := 0; i < 3; i++ {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := dec.Decode(&raw); err != nil {
|
||||
return
|
||||
}
|
||||
var id int64
|
||||
_ = json.Unmarshal(raw["id"], &id)
|
||||
counter.Add(1)
|
||||
_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{}})
|
||||
}
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
id := proc.nextID.Load() + 1
|
||||
ids = append(ids, id)
|
||||
if _, err := proc.send(context.Background(), fmt.Sprintf("method-%d", i), nil); err != nil {
|
||||
t.Fatalf("send %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
_ = stdinW.Close()
|
||||
|
||||
for i := 1; i < len(ids); i++ {
|
||||
if ids[i] <= ids[i-1] {
|
||||
t.Errorf("ids not monotone: %v", ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerThreadMissingID verifies that codexAppServerInit returns an error
|
||||
// when thread/start responds without a thread id field.
|
||||
func TestCodexAppServerThreadMissingID(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
go func() {
|
||||
dec := json.NewDecoder(stdinR)
|
||||
enc := json.NewEncoder(stdoutW)
|
||||
|
||||
// initialize request → respond with capabilities
|
||||
var req map[string]json.RawMessage
|
||||
_ = dec.Decode(&req)
|
||||
var id int64
|
||||
_ = json.Unmarshal(req["id"], &id)
|
||||
_ = enc.Encode(map[string]any{
|
||||
"id": id,
|
||||
"result": map[string]any{"protocolVersion": "2024-11-05", "capabilities": map[string]any{}},
|
||||
})
|
||||
|
||||
// initialized notification (no response needed)
|
||||
_ = dec.Decode(&req)
|
||||
|
||||
// thread/start → respond with empty result (no threadId, no id)
|
||||
_ = dec.Decode(&req)
|
||||
var tsID int64
|
||||
_ = json.Unmarshal(req["id"], &tsID)
|
||||
_ = enc.Encode(map[string]any{
|
||||
"id": tsID,
|
||||
"result": map[string]any{},
|
||||
})
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
_, err := codexAppServerInit(context.Background(), proc)
|
||||
_ = stdinW.Close()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when thread/start returns no thread id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerSessionThreadMapping verifies that same IOP session reuses
|
||||
// the same thread id and different sessions get different thread ids.
|
||||
func TestCodexAppServerSessionThreadMapping(t *testing.T) {
|
||||
e := &codexAppServerExecutor{
|
||||
cli: &CLI{},
|
||||
sessions: make(map[sessionKey]*codexAppServerSession),
|
||||
}
|
||||
|
||||
key1 := sessionKey{target: "codex-as", sessionID: "sess-A"}
|
||||
key2 := sessionKey{target: "codex-as", sessionID: "sess-B"}
|
||||
e.sessions[key1] = &codexAppServerSession{key: key1, threadID: "thread-1"}
|
||||
e.sessions[key2] = &codexAppServerSession{key: key2, threadID: "thread-2"}
|
||||
|
||||
// Same session must return same pointer (same thread id).
|
||||
spec := runtime.ExecutionSpec{Target: "codex-as", SessionID: "sess-A"}
|
||||
profile := config.CLIProfileConf{Mode: modeCodexAppServer, Command: "false"}
|
||||
got, err := e.resolveCodexAppServerSession(context.Background(), spec, profile)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCodexAppServerSession: %v", err)
|
||||
}
|
||||
if got.threadID != "thread-1" {
|
||||
t.Errorf("expected thread-1, got %q", got.threadID)
|
||||
}
|
||||
|
||||
// Different session must return different thread id.
|
||||
spec2 := runtime.ExecutionSpec{Target: "codex-as", SessionID: "sess-B"}
|
||||
got2, err := e.resolveCodexAppServerSession(context.Background(), spec2, profile)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCodexAppServerSession sess-B: %v", err)
|
||||
}
|
||||
if got2.threadID != "thread-2" {
|
||||
t.Errorf("expected thread-2, got %q", got2.threadID)
|
||||
}
|
||||
if got.threadID == got2.threadID {
|
||||
t.Error("different sessions must have different thread ids")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerTurnStartRequestShape verifies that codexAppServerTurnStart sends
|
||||
// a turn/start request with the required TurnStartParams shape:
|
||||
// { threadId, input: [{ type, text, text_elements }] }
|
||||
func TestCodexAppServerTurnStartRequestShape(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
var capturedParams map[string]json.RawMessage
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
dec := json.NewDecoder(stdinR)
|
||||
enc := json.NewEncoder(stdoutW)
|
||||
|
||||
var req map[string]json.RawMessage
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return
|
||||
}
|
||||
capturedParams = req
|
||||
var id int64
|
||||
_ = json.Unmarshal(req["id"], &id)
|
||||
_ = enc.Encode(map[string]any{
|
||||
"id": id,
|
||||
"result": map[string]any{"turn": map[string]any{"id": "turn-shape-test"}},
|
||||
})
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
turnID, err := codexAppServerTurnStart(context.Background(), proc, "thread-shape-1", "hello codex")
|
||||
_ = stdinW.Close()
|
||||
<-serverDone
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("codexAppServerTurnStart: %v", err)
|
||||
}
|
||||
if turnID != "turn-shape-test" {
|
||||
t.Errorf("turnID = %q, want %q", turnID, "turn-shape-test")
|
||||
}
|
||||
|
||||
// Verify threadId field.
|
||||
var threadID string
|
||||
_ = json.Unmarshal(capturedParams["params"], &map[string]json.RawMessage{})
|
||||
var paramsMap map[string]json.RawMessage
|
||||
_ = json.Unmarshal(capturedParams["params"], ¶msMap)
|
||||
|
||||
_ = json.Unmarshal(paramsMap["threadId"], &threadID)
|
||||
if threadID != "thread-shape-1" {
|
||||
t.Errorf("threadId = %q, want %q", threadID, "thread-shape-1")
|
||||
}
|
||||
|
||||
// Verify input array shape.
|
||||
var inputArr []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(paramsMap["input"], &inputArr); err != nil {
|
||||
t.Fatalf("parse input: %v", err)
|
||||
}
|
||||
if len(inputArr) != 1 {
|
||||
t.Fatalf("input length = %d, want 1", len(inputArr))
|
||||
}
|
||||
item := inputArr[0]
|
||||
|
||||
var typ string
|
||||
_ = json.Unmarshal(item["type"], &typ)
|
||||
if typ != "text" {
|
||||
t.Errorf("input[0].type = %q, want %q", typ, "text")
|
||||
}
|
||||
|
||||
var text string
|
||||
_ = json.Unmarshal(item["text"], &text)
|
||||
if text != "hello codex" {
|
||||
t.Errorf("input[0].text = %q, want %q", text, "hello codex")
|
||||
}
|
||||
|
||||
// text_elements must be present (required by UserInput contract), value must be an array.
|
||||
rawTE, ok := item["text_elements"]
|
||||
if !ok {
|
||||
t.Fatal("input[0].text_elements field missing")
|
||||
}
|
||||
var textElements []any
|
||||
if err := json.Unmarshal(rawTE, &textElements); err != nil {
|
||||
t.Fatalf("input[0].text_elements is not an array: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerTurnStartRequestShape_EmptyPrompt verifies that an empty prompt
|
||||
// still sends input with a text item (explicit design: always send input array).
|
||||
func TestCodexAppServerTurnStartRequestShape_EmptyPrompt(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
var capturedParams map[string]json.RawMessage
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
dec := json.NewDecoder(stdinR)
|
||||
enc := json.NewEncoder(stdoutW)
|
||||
var req map[string]json.RawMessage
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return
|
||||
}
|
||||
capturedParams = req
|
||||
var id int64
|
||||
_ = json.Unmarshal(req["id"], &id)
|
||||
_ = enc.Encode(map[string]any{
|
||||
"id": id,
|
||||
"result": map[string]any{"turn": map[string]any{"id": "turn-empty"}},
|
||||
})
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
_, err := codexAppServerTurnStart(context.Background(), proc, "thread-empty", "")
|
||||
_ = stdinW.Close()
|
||||
<-serverDone
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("codexAppServerTurnStart: %v", err)
|
||||
}
|
||||
|
||||
var paramsMap map[string]json.RawMessage
|
||||
_ = json.Unmarshal(capturedParams["params"], ¶msMap)
|
||||
|
||||
// input must be present even for empty prompt.
|
||||
if _, ok := paramsMap["input"]; !ok {
|
||||
t.Fatal("input field missing for empty prompt")
|
||||
}
|
||||
|
||||
var inputArr []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(paramsMap["input"], &inputArr); err != nil {
|
||||
t.Fatalf("parse input: %v", err)
|
||||
}
|
||||
if len(inputArr) != 1 {
|
||||
t.Fatalf("input length = %d, want 1", len(inputArr))
|
||||
}
|
||||
if _, ok := inputArr[0]["text_elements"]; !ok {
|
||||
t.Fatal("input[0].text_elements missing for empty prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerThreadStartResponseShape verifies that codexAppServerInit parses
|
||||
// the actual ThreadStartResponse shape: result.thread.id.
|
||||
func TestCodexAppServerThreadStartResponseShape(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
go func() {
|
||||
dec := json.NewDecoder(stdinR)
|
||||
enc := json.NewEncoder(stdoutW)
|
||||
// initialize
|
||||
var req map[string]json.RawMessage
|
||||
_ = dec.Decode(&req)
|
||||
var id int64
|
||||
_ = json.Unmarshal(req["id"], &id)
|
||||
_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{
|
||||
"protocolVersion": "2024-11-05", "capabilities": map[string]any{},
|
||||
}})
|
||||
// initialized notification
|
||||
_ = dec.Decode(&req)
|
||||
// thread/start → actual shape: result.thread.id
|
||||
_ = dec.Decode(&req)
|
||||
var tsID int64
|
||||
_ = json.Unmarshal(req["id"], &tsID)
|
||||
_ = enc.Encode(map[string]any{
|
||||
"id": tsID,
|
||||
"result": map[string]any{"thread": map[string]any{"id": "thread-nested-id"}},
|
||||
})
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
threadID, err := codexAppServerInit(context.Background(), proc)
|
||||
_ = stdinW.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("codexAppServerInit: %v", err)
|
||||
}
|
||||
if threadID != "thread-nested-id" {
|
||||
t.Errorf("threadID = %q, want %q", threadID, "thread-nested-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerTurnStartResponseShape verifies that codexAppServerTurnStart parses
|
||||
// the actual TurnStartResponse shape: result.turn.id.
|
||||
func TestCodexAppServerTurnStartResponseShape(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
go func() {
|
||||
dec := json.NewDecoder(stdinR)
|
||||
enc := json.NewEncoder(stdoutW)
|
||||
var req map[string]json.RawMessage
|
||||
_ = dec.Decode(&req)
|
||||
var id int64
|
||||
_ = json.Unmarshal(req["id"], &id)
|
||||
_ = enc.Encode(map[string]any{
|
||||
"id": id,
|
||||
"result": map[string]any{"turn": map[string]any{"id": "turn-nested-id"}},
|
||||
})
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
turnID, err := codexAppServerTurnStart(context.Background(), proc, "thread-1", "hello")
|
||||
_ = stdinW.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("codexAppServerTurnStart: %v", err)
|
||||
}
|
||||
if turnID != "turn-nested-id" {
|
||||
t.Errorf("turnID = %q, want %q", turnID, "turn-nested-id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerTurnStartMissingID verifies that a missing turn id returns an error.
|
||||
func TestCodexAppServerTurnStartMissingID(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
go func() {
|
||||
dec := json.NewDecoder(stdinR)
|
||||
enc := json.NewEncoder(stdoutW)
|
||||
var req map[string]json.RawMessage
|
||||
_ = dec.Decode(&req)
|
||||
var id int64
|
||||
_ = json.Unmarshal(req["id"], &id)
|
||||
_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{}})
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
_, err := codexAppServerTurnStart(context.Background(), proc, "thread-1", "")
|
||||
_ = stdinW.Close()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when turn id is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerEventMap_Delta verifies AgentMessageDeltaNotification shape:
|
||||
// { threadId, turnId, delta } → EventTypeDelta with source/thread_id/turn_id metadata.
|
||||
func TestCodexAppServerEventMap_Delta(t *testing.T) {
|
||||
notif := appServerNotification{
|
||||
Method: "item/agentMessage/delta",
|
||||
Params: map[string]any{
|
||||
"threadId": "th-1",
|
||||
"turnId": "tn-1",
|
||||
"delta": "hello world",
|
||||
},
|
||||
}
|
||||
events, done, err := decodeAppServerNotification(notif, "run-1", "th-ctx", "tn-ctx")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if done {
|
||||
t.Fatal("delta should not signal done")
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
ev := events[0]
|
||||
if ev.Type != runtime.EventTypeDelta {
|
||||
t.Errorf("Type = %q, want %q", ev.Type, runtime.EventTypeDelta)
|
||||
}
|
||||
if ev.Delta != "hello world" {
|
||||
t.Errorf("Delta = %q, want %q", ev.Delta, "hello world")
|
||||
}
|
||||
if ev.Metadata["source"] != "codex-app-server" {
|
||||
t.Errorf("source metadata = %q", ev.Metadata["source"])
|
||||
}
|
||||
// params threadId/turnId should override execution-context values
|
||||
if ev.Metadata["thread_id"] != "th-1" {
|
||||
t.Errorf("thread_id metadata = %q, want %q", ev.Metadata["thread_id"], "th-1")
|
||||
}
|
||||
if ev.Metadata["turn_id"] != "tn-1" {
|
||||
t.Errorf("turn_id metadata = %q, want %q", ev.Metadata["turn_id"], "tn-1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerEventMap_DeltaMetadata verifies fallback to execution-context ids
|
||||
// when notification params do not carry threadId/turnId.
|
||||
func TestCodexAppServerEventMap_DeltaMetadata(t *testing.T) {
|
||||
notif := appServerNotification{
|
||||
Method: "item/agentMessage/delta",
|
||||
Params: map[string]any{"delta": "chunk"},
|
||||
}
|
||||
events, _, err := decodeAppServerNotification(notif, "run-x", "th-fallback", "tn-fallback")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
if events[0].Metadata["thread_id"] != "th-fallback" {
|
||||
t.Errorf("thread_id = %q, want %q", events[0].Metadata["thread_id"], "th-fallback")
|
||||
}
|
||||
if events[0].Metadata["turn_id"] != "tn-fallback" {
|
||||
t.Errorf("turn_id = %q, want %q", events[0].Metadata["turn_id"], "tn-fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerEventMap_TurnCompleted verifies that turn/completed with status
|
||||
// "completed" maps to EventTypeComplete and signals done.
|
||||
func TestCodexAppServerEventMap_TurnCompleted(t *testing.T) {
|
||||
notif := appServerNotification{
|
||||
Method: "turn/completed",
|
||||
Params: map[string]any{
|
||||
"threadId": "th-2",
|
||||
"turn": map[string]any{"id": "tn-2", "status": "completed"},
|
||||
},
|
||||
}
|
||||
events, done, err := decodeAppServerNotification(notif, "run-2", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !done {
|
||||
t.Fatal("turn/completed should signal done")
|
||||
}
|
||||
if len(events) != 1 || events[0].Type != runtime.EventTypeComplete {
|
||||
t.Fatalf("expected 1 complete event, got %+v", events)
|
||||
}
|
||||
if events[0].Metadata["thread_id"] != "th-2" {
|
||||
t.Errorf("thread_id = %q, want %q", events[0].Metadata["thread_id"], "th-2")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerEventMap_TurnCompletedFailed verifies that turn/completed with
|
||||
// status "failed" maps to EventTypeError (not complete).
|
||||
func TestCodexAppServerEventMap_TurnCompletedFailed(t *testing.T) {
|
||||
notif := appServerNotification{
|
||||
Method: "turn/completed",
|
||||
Params: map[string]any{
|
||||
"threadId": "th-3",
|
||||
"turn": map[string]any{
|
||||
"id": "tn-3",
|
||||
"status": "failed",
|
||||
"error": map[string]any{"message": "context window exceeded"},
|
||||
},
|
||||
},
|
||||
}
|
||||
events, done, err := decodeAppServerNotification(notif, "run-3", "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for failed turn")
|
||||
}
|
||||
if done {
|
||||
t.Fatal("failed turn should not signal done as complete")
|
||||
}
|
||||
if len(events) != 1 || events[0].Type != runtime.EventTypeError {
|
||||
t.Fatalf("expected 1 error event, got %+v", events)
|
||||
}
|
||||
if events[0].Error != "context window exceeded" {
|
||||
t.Errorf("Error = %q, want %q", events[0].Error, "context window exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerEventMap_Error verifies ErrorNotification shape:
|
||||
// { error: { message }, threadId, turnId } → EventTypeError with full metadata.
|
||||
func TestCodexAppServerEventMap_Error(t *testing.T) {
|
||||
notif := appServerNotification{
|
||||
Method: "error",
|
||||
Params: map[string]any{
|
||||
"error": map[string]any{"message": "quota exceeded"},
|
||||
"threadId": "th-4",
|
||||
"turnId": "tn-4",
|
||||
},
|
||||
}
|
||||
events, _, err := decodeAppServerNotification(notif, "run-4", "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for error notification")
|
||||
}
|
||||
if len(events) != 1 || events[0].Type != runtime.EventTypeError {
|
||||
t.Fatalf("expected 1 error event, got %+v", events)
|
||||
}
|
||||
if events[0].Error != "quota exceeded" {
|
||||
t.Errorf("Error = %q, want %q", events[0].Error, "quota exceeded")
|
||||
}
|
||||
if events[0].Metadata["thread_id"] != "th-4" {
|
||||
t.Errorf("thread_id = %q, want %q", events[0].Metadata["thread_id"], "th-4")
|
||||
}
|
||||
if events[0].Metadata["turn_id"] != "tn-4" {
|
||||
t.Errorf("turn_id = %q, want %q", events[0].Metadata["turn_id"], "tn-4")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerEventMap_TurnFailed verifies that turn/failed (legacy) maps to EventTypeError.
|
||||
func TestCodexAppServerEventMap_TurnFailed(t *testing.T) {
|
||||
notif := appServerNotification{
|
||||
Method: "turn/failed",
|
||||
Params: map[string]any{"error": map[string]any{"message": "rate limit"}},
|
||||
}
|
||||
events, _, err := decodeAppServerNotification(notif, "run-5", "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for turn/failed")
|
||||
}
|
||||
if len(events) != 1 || events[0].Type != runtime.EventTypeError {
|
||||
t.Fatalf("expected 1 error event, got %+v", events)
|
||||
}
|
||||
if events[0].Error != "rate limit" {
|
||||
t.Errorf("Error = %q, want %q", events[0].Error, "rate limit")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerDrainNotifications_DoesNotDropBurst verifies that a burst of
|
||||
// more than notifCh capacity (32) deltas followed by a terminal turn/completed are
|
||||
// all delivered without dropping the terminal event.
|
||||
func TestCodexAppServerDrainNotifications_DoesNotDropBurst(t *testing.T) {
|
||||
const burstSize = 40 // > default channel capacity of 32
|
||||
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
_ = stdinR
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 32),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
// Write burstSize delta notifications then a turn/completed.
|
||||
enc := json.NewEncoder(stdoutW)
|
||||
go func() {
|
||||
for i := 0; i < burstSize; i++ {
|
||||
_ = enc.Encode(map[string]any{
|
||||
"method": "item/agentMessage/delta",
|
||||
"params": map[string]any{"delta": fmt.Sprintf("chunk-%d", i)},
|
||||
})
|
||||
}
|
||||
_ = enc.Encode(map[string]any{
|
||||
"method": "turn/completed",
|
||||
"params": map[string]any{"turn": map[string]any{"status": "completed"}},
|
||||
})
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
sink := &testSink{}
|
||||
err := codexAppServerDrainNotifications(context.Background(), proc, "run-burst", "th-burst", "tn-burst", sink)
|
||||
_ = stdinW.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var deltas, completes int
|
||||
for _, ev := range sink.events {
|
||||
switch ev.Type {
|
||||
case runtime.EventTypeDelta:
|
||||
deltas++
|
||||
case runtime.EventTypeComplete:
|
||||
completes++
|
||||
}
|
||||
}
|
||||
if deltas != burstSize {
|
||||
t.Errorf("delta count = %d, want %d", deltas, burstSize)
|
||||
}
|
||||
if completes != 1 {
|
||||
t.Errorf("complete count = %d, want 1", completes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexAppServerSend_ResponsePriorityOverDone verifies that send() returns
|
||||
// the response (not a process-exited error) when the fake server sends a
|
||||
// response and then closes stdout simultaneously.
|
||||
func TestCodexAppServerSend_ResponsePriorityOverDone(t *testing.T) {
|
||||
stdinR, stdinW := io.Pipe()
|
||||
stdoutR, stdoutW := io.Pipe()
|
||||
|
||||
// Fake server: read one request, respond, then close stdout (simulate exit).
|
||||
// stdinR is owned exclusively by this goroutine — no concurrent reader.
|
||||
go func() {
|
||||
defer stdinR.Close()
|
||||
dec := json.NewDecoder(stdinR)
|
||||
enc := json.NewEncoder(stdoutW)
|
||||
var req map[string]json.RawMessage
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
_ = stdoutW.Close()
|
||||
return
|
||||
}
|
||||
var id int64
|
||||
_ = json.Unmarshal(req["id"], &id)
|
||||
_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{"ok": true}})
|
||||
_ = stdoutW.Close()
|
||||
}()
|
||||
|
||||
proc := &codexAppServerProc{
|
||||
stdin: stdinW,
|
||||
stdout: bufio.NewScanner(stdoutR),
|
||||
notifCh: make(chan appServerNotification, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
go proc.readLoop()
|
||||
|
||||
resp, err := proc.send(context.Background(), "ping", nil)
|
||||
_ = stdinW.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("send() returned error instead of response: %v", err)
|
||||
}
|
||||
if resp.Result == nil {
|
||||
t.Fatal("send() returned nil result")
|
||||
}
|
||||
}
|
||||
|
|
@ -21,8 +21,8 @@ import (
|
|||
|
||||
const (
|
||||
terminalInputDelay = 2 * time.Millisecond
|
||||
terminalRows = 80
|
||||
terminalCols = 240
|
||||
terminalRows = 720
|
||||
terminalCols = 1024
|
||||
)
|
||||
|
||||
func emitRuntimeError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
|
||||
|
|
@ -221,6 +221,10 @@ func (e *persistentExecutor) Execute(ctx context.Context, spec runtime.Execution
|
|||
idleTimer.Reset(idleTimeout)
|
||||
continue
|
||||
}
|
||||
if waitForFilteredMessage && outputFilter.HasOutput() && outputFilter.CompletionMessage() == "" {
|
||||
idleTimer.Reset(idleTimeout)
|
||||
continue
|
||||
}
|
||||
completeMessage := "idle-timeout"
|
||||
if msg := outputFilter.CompletionMessage(); msg != "" {
|
||||
completeMessage = msg
|
||||
|
|
@ -348,6 +352,8 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
|
|||
Command: profile.Command,
|
||||
Args: profile.Args,
|
||||
Env: profile.Env,
|
||||
Rows: terminalRows,
|
||||
Cols: terminalCols,
|
||||
}
|
||||
core, err := terminal.StartSession(context.Background(), opts)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -690,7 +690,7 @@ func TestCLIStartPersistentTerminalUsesWidePTY(t *testing.T) {
|
|||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "80 240") {
|
||||
if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "720 1024") {
|
||||
t.Fatalf("expected wide PTY size in deltas, got %q", combined)
|
||||
}
|
||||
}
|
||||
|
|
@ -789,6 +789,55 @@ func TestCLIExecutePersistentClaudeTUIStreamsLineBeforeIdle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCLIExecutePersistentClaudeTUIWaitsForPromptReadyAfterPartialOutput(t *testing.T) {
|
||||
testutil.RequirePTYSupport(t)
|
||||
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"claude-tui": {
|
||||
Command: "sh",
|
||||
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '\342\227\217 first chunk\r\n\342\234\242 Dilly-dallying\342\200\246\r\n'; sleep 0.15; printf 'second chunk\r\n\342\235\257 '; done`},
|
||||
Persistent: true,
|
||||
Terminal: true,
|
||||
ResponseIdleTimeoutMS: 60,
|
||||
StartupIdleTimeoutMS: 50,
|
||||
},
|
||||
},
|
||||
}
|
||||
c := clipkg.New(cfg, zap.NewNop())
|
||||
|
||||
ctx := context.Background()
|
||||
if err := c.Start(ctx); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Stop(ctx) }()
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sink := &testutil.FakeSink{}
|
||||
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
|
||||
RunID: "run-delayed-tail",
|
||||
Target: "claude-tui",
|
||||
Input: map[string]any{"prompt": "write two chunks"},
|
||||
}, sink)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
events := sink.Events()
|
||||
combined := testutil.CollectDeltas(events)
|
||||
if !strings.Contains(combined, "first chunk") ||
|
||||
!strings.Contains(combined, "second chunk") ||
|
||||
strings.Index(combined, "first chunk") > strings.Index(combined, "second chunk") {
|
||||
t.Fatalf("expected full delayed message, got %q", combined)
|
||||
}
|
||||
if last := events[len(events)-1]; last.Type != noderuntime.EventTypeComplete || last.Message != "prompt-ready" {
|
||||
t.Fatalf("expected prompt-ready completion, got type=%q message=%q", last.Type, last.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExecutePersistentClaudeTUIDoesNotCompleteOnStatusOnlyIdle(t *testing.T) {
|
||||
testutil.RequirePTYSupport(t)
|
||||
|
||||
|
|
|
|||
|
|
@ -98,14 +98,16 @@ func (f *claudeTUIOutputFilter) CompletionMessage() string {
|
|||
func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot {
|
||||
screen := status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols)
|
||||
clean := cleanClaudeTerminalOutput(f.raw.String())
|
||||
screenMessage, screenClosed := latestClaudeAssistantMessage(screen)
|
||||
if promptMessage, promptClosed, promptSeen := latestClaudeAssistantMessageAfterPromptFromCleanOutput(clean, f.prompt); promptSeen {
|
||||
if promptMessage == "" || isClaudePromptEchoMessage(promptMessage, f.prompt) {
|
||||
return claudeMessageSnapshot{}
|
||||
}
|
||||
f.seenAssistant = true
|
||||
return claudeMessageSnapshot{text: promptMessage, closed: promptClosed}
|
||||
text, closed := preferLongerClaudeMessage(promptMessage, promptClosed, screenMessage, screenClosed)
|
||||
return claudeMessageSnapshot{text: text, closed: closed}
|
||||
}
|
||||
message, closed := latestClaudeAssistantMessage(screen)
|
||||
message, closed := screenMessage, screenClosed
|
||||
cleanMessage, cleanClosed := latestClaudeAssistantMessageFromCleanOutput(clean)
|
||||
if cleanMessage != "" {
|
||||
if isClaudePromptEchoMessage(cleanMessage, f.prompt) {
|
||||
|
|
@ -152,6 +154,33 @@ func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot {
|
|||
return claudeMessageSnapshot{text: message, closed: closed}
|
||||
}
|
||||
|
||||
func preferLongerClaudeMessage(promptMessage string, promptClosed bool, screenMessage string, screenClosed bool) (string, bool) {
|
||||
promptMessage = strings.TrimRight(promptMessage, "\n")
|
||||
screenMessage = strings.TrimRight(screenMessage, "\n")
|
||||
if screenMessage == "" || isClaudePromptEchoMessage(screenMessage, promptMessage) {
|
||||
return promptMessage, promptClosed
|
||||
}
|
||||
if promptMessage == "" {
|
||||
return screenMessage, screenClosed
|
||||
}
|
||||
if screenMessage == promptMessage {
|
||||
return promptMessage, promptClosed || screenClosed
|
||||
}
|
||||
if strings.HasPrefix(screenMessage, promptMessage) || strings.Contains(screenMessage, promptMessage) {
|
||||
if len(screenMessage) > len(promptMessage) {
|
||||
return screenMessage, promptClosed || screenClosed
|
||||
}
|
||||
return promptMessage, promptClosed || screenClosed
|
||||
}
|
||||
if strings.Contains(promptMessage, screenMessage) {
|
||||
return promptMessage, promptClosed || screenClosed
|
||||
}
|
||||
if len(screenMessage) > len(promptMessage) {
|
||||
return screenMessage, promptClosed || screenClosed
|
||||
}
|
||||
return promptMessage, promptClosed
|
||||
}
|
||||
|
||||
func (f *claudeTUIOutputFilter) emit(message string, closed bool, flush bool) string {
|
||||
message = strings.TrimRight(message, "\n")
|
||||
if !flush {
|
||||
|
|
@ -219,7 +248,10 @@ func latestClaudeAssistantMessage(screen string) (string, bool) {
|
|||
}
|
||||
continue
|
||||
}
|
||||
if isClaudeTUIBoundaryLine(line) {
|
||||
if isClaudeTUISoftBoundaryLine(line) {
|
||||
continue
|
||||
}
|
||||
if isClaudeTUIHardBoundaryLine(line) {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
|
|
@ -306,7 +338,11 @@ func claudeAssistantMessageFromCleanLines(lines []string, start int) (string, bo
|
|||
}
|
||||
continue
|
||||
}
|
||||
if isClaudeTUIBoundaryLine(line) {
|
||||
if isClaudeTUISoftBoundaryLine(line) {
|
||||
closed = strings.HasSuffix(raw, "\n")
|
||||
continue
|
||||
}
|
||||
if isClaudeTUIHardBoundaryLine(line) {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
|
|
@ -356,6 +392,10 @@ func claudeAssistantLine(line string) (string, bool) {
|
|||
}
|
||||
|
||||
func isClaudeTUIBoundaryLine(line string) bool {
|
||||
return isClaudeTUIHardBoundaryLine(line) || isClaudeTUISoftBoundaryLine(line)
|
||||
}
|
||||
|
||||
func isClaudeTUIHardBoundaryLine(line string) bool {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return true
|
||||
|
|
@ -370,16 +410,29 @@ func isClaudeTUIBoundaryLine(line string) bool {
|
|||
return true
|
||||
case strings.HasPrefix(line, "\u2570") || strings.HasPrefix(line, "\u256d"):
|
||||
return true
|
||||
case strings.HasPrefix(line, "\u2722") || strings.HasPrefix(line, "\u2736") || strings.HasPrefix(line, "\u273b") || strings.HasPrefix(line, "\u273d") || strings.HasPrefix(line, "\u00b7"):
|
||||
return true
|
||||
case strings.HasPrefix(line, "\u23bf"):
|
||||
return true
|
||||
case strings.HasPrefix(line, "\u23f5"):
|
||||
case strings.HasPrefix(line, "\u23fa"):
|
||||
return true
|
||||
case strings.HasPrefix(line, "\u25cf"):
|
||||
return true
|
||||
case strings.HasPrefix(lower, "tip:"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isClaudeTUISoftBoundaryLine(line string) bool {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "\u2722") || strings.HasPrefix(line, "\u2736") || strings.HasPrefix(line, "\u273b") || strings.HasPrefix(line, "\u273d") || strings.HasPrefix(line, "\u00b7"):
|
||||
return true
|
||||
case strings.HasPrefix(line, "\u23f5"):
|
||||
return true
|
||||
case strings.Contains(lower, "tokens)"):
|
||||
return true
|
||||
case isClaudeTransientStatusLine(line):
|
||||
|
|
|
|||
|
|
@ -187,6 +187,17 @@ func TestClaudeTUIOutputFilterEmitsCompleteLineBeforePartialNextLine(t *testing.
|
|||
}
|
||||
}
|
||||
|
||||
func TestPreferLongerClaudeMessageUsesScreenRepaintTail(t *testing.T) {
|
||||
got, closed := preferLongerClaudeMessage("first chunk", true, "first chunk\nsecond chunk", true)
|
||||
|
||||
if got != "first chunk\nsecond chunk" {
|
||||
t.Fatalf("message = %q, want longer screen repaint candidate", got)
|
||||
}
|
||||
if !closed {
|
||||
t.Fatalf("closed = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeTUIOutputFilterDropsTransientStatusWithoutAssistantMarker(t *testing.T) {
|
||||
filter := newClaudeTUIOutputFilter("\uc548\ub155?")
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ console:
|
|||
target: "codex"
|
||||
session_id: "default"
|
||||
background: false
|
||||
timeout_sec: 120
|
||||
timeout_sec: 240
|
||||
|
||||
nodes:
|
||||
# id is the stable node identity; omitting it falls back to an auto UUID (dev only).
|
||||
|
|
@ -97,6 +97,17 @@ nodes:
|
|||
cli:
|
||||
enabled: true
|
||||
profiles:
|
||||
claude-tui:
|
||||
command: "claude"
|
||||
args:
|
||||
- "--dangerously-skip-permissions"
|
||||
env:
|
||||
- "TERM=xterm-256color"
|
||||
persistent: true
|
||||
terminal: true
|
||||
response_idle_timeout_ms: 5000
|
||||
startup_idle_timeout_ms: 5000
|
||||
mode: "persistent-lazy"
|
||||
codex:
|
||||
command: "codex"
|
||||
args:
|
||||
|
|
@ -111,6 +122,20 @@ nodes:
|
|||
terminal: false
|
||||
output_format: "codex-json"
|
||||
mode: "codex-exec"
|
||||
codex-exec:
|
||||
command: "codex"
|
||||
args:
|
||||
- "exec"
|
||||
- "--json"
|
||||
resume_args:
|
||||
- "exec"
|
||||
- "resume"
|
||||
- "--json"
|
||||
env: []
|
||||
persistent: false
|
||||
terminal: false
|
||||
output_format: "codex-json"
|
||||
mode: "codex-exec"
|
||||
runtime:
|
||||
concurrency: 1
|
||||
workspace_root: ""
|
||||
|
|
|
|||
Loading…
Reference in a new issue