From 77ab8c294c75768d4638c6e0d1bd89fe45d33c7c Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 13 Jun 2026 06:05:41 +0900 Subject: [PATCH] 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 --- .../phase/automation-runtime-bridge/PHASE.md | 4 + .../codex-app-server-streaming-migration.md | 70 ++ .../code_review_cloud_G07_0.log | 155 +++ .../code_review_cloud_G07_1.log | 228 ++++ .../code_review_cloud_G07_2.log | 214 ++++ .../01_appserver_rpc_client/complete.log | 49 + .../plan_cloud_G07_0.log | 133 +++ .../plan_cloud_G07_1.log | 85 ++ .../plan_cloud_G07_2.log | 85 ++ .../code_review_cloud_G07_0.log | 139 +++ .../code_review_cloud_G07_1.log | 296 ++++++ .../code_review_cloud_G07_2.log | 234 +++++ .../02+01_session_events/complete.log | 49 + .../02+01_session_events/plan_cloud_G07_0.log | 139 +++ .../02+01_session_events/plan_cloud_G07_1.log | 127 +++ .../02+01_session_events/plan_cloud_G07_2.log | 52 + .../CODE_REVIEW-local-G05.md | 72 ++ .../03+02_default_profile/PLAN-local-G05.md | 115 ++ .../CODE_REVIEW-cloud-G07.md | 74 ++ .../04+03_stream_smoke/PLAN-cloud-G07.md | 138 +++ apps/node/internal/adapters/cli/cli.go | 30 +- .../adapters/cli/cli_internal_test.go | 87 +- .../internal/adapters/cli/codex_app_server.go | 627 +++++++++++ .../cli/codex_app_server_internal_test.go | 979 ++++++++++++++++++ apps/node/internal/adapters/cli/persistent.go | 10 +- .../cli/persistent_execute_blackbox_test.go | 51 +- .../adapters/cli/persistent_output_filter.go | 67 +- .../cli/persistent_output_filter_test.go | 11 + configs/edge.yaml | 27 +- 29 files changed, 4322 insertions(+), 25 deletions(-) create mode 100644 agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_2.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_2.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_2.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/complete.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_0.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_1.log create mode 100644 agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_2.log create mode 100644 agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/CODE_REVIEW-local-G05.md create mode 100644 agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/PLAN-local-G05.md create mode 100644 agent-task/m-codex-app-server-streaming-migration/04+03_stream_smoke/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/m-codex-app-server-streaming-migration/04+03_stream_smoke/PLAN-cloud-G07.md create mode 100644 apps/node/internal/adapters/cli/codex_app_server.go create mode 100644 apps/node/internal/adapters/cli/codex_app_server_internal_test.go diff --git a/agent-roadmap/phase/automation-runtime-bridge/PHASE.md b/agent-roadmap/phase/automation-runtime-bridge/PHASE.md index 0eb9aaa..6bc9bb0 100644 --- a/agent-roadmap/phase/automation-runtime-bridge/PHASE.md +++ b/agent-roadmap/phase/automation-runtime-bridge/PHASE.md @@ -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를 만든다. diff --git a/agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md b/agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md new file mode 100644 index 0000000..feb77eb --- /dev/null +++ b/agent-roadmap/phase/automation-runtime-bridge/milestones/codex-app-server-streaming-migration.md @@ -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 기능을 별도 운영 표면으로 확장할지 검토 +- 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_0.log new file mode 100644 index 0000000..d8ea4c6 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_0.log @@ -0,0 +1,155 @@ + +# 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`를 작성하지 않았다. diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_1.log new file mode 100644 index 0000000..edef890 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_1.log @@ -0,0 +1,228 @@ + + +# 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`를 작성하지 않았다. diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_2.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_2.log new file mode 100644 index 0000000..2368c9b --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/code_review_cloud_G07_2.log @@ -0,0 +1,214 @@ + + +# 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 `ㄹ` 문자를 제거했다. 구현/검증/계약 변경은 없다. diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log new file mode 100644 index 0000000..ee84cc3 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_0.log new file mode 100644 index 0000000..ece07d8 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_0.log @@ -0,0 +1,133 @@ + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.** diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_1.log new file mode 100644 index 0000000..cf89df7 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_1.log @@ -0,0 +1,85 @@ + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.** diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_2.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_2.log new file mode 100644 index 0000000..265f8ca --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/01_appserver_rpc_client/plan_cloud_G07_2.log @@ -0,0 +1,85 @@ + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.** diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_0.log new file mode 100644 index 0000000..9a30b9a --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_0.log @@ -0,0 +1,139 @@ + +# 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`를 작성한다. diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_1.log new file mode 100644 index 0000000..86133e5 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_1.log @@ -0,0 +1,296 @@ + + +# 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`를 필수로 정의하고, `/tmp/iop-codex-appserver-ts/v2/UserInput.ts:7`의 text variant는 `text_elements: Array`를 필수로 요구합니다. 현재 구현은 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`를 작성한다. diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_2.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_2.log new file mode 100644 index 0000000..486ddb8 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/code_review_cloud_G07_2.log @@ -0,0 +1,234 @@ + + +# 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/complete.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/complete.log new file mode 100644 index 0000000..862be79 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_0.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_0.log new file mode 100644 index 0000000..237fef9 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_0.log @@ -0,0 +1,139 @@ + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.** diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_1.log new file mode 100644 index 0000000..3a0ce72 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_1.log @@ -0,0 +1,127 @@ + +# 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하지 않는지 확인한다. diff --git a/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_2.log b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_2.log new file mode 100644 index 0000000..0669ac3 --- /dev/null +++ b/agent-task/archive/2026/06/m-codex-app-server-streaming-migration/02+01_session_events/plan_cloud_G07_2.log @@ -0,0 +1,52 @@ + +# 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 밖의 동작을 불필요하게 바꾸지 않았는지 확인한다. diff --git a/agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/CODE_REVIEW-local-G05.md b/agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/CODE_REVIEW-local-G05.md new file mode 100644 index 0000000..e52683d --- /dev/null +++ b/agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/CODE_REVIEW-local-G05.md @@ -0,0 +1,72 @@ + +# 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` diff --git a/agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/PLAN-local-G05.md b/agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/PLAN-local-G05.md new file mode 100644 index 0000000..1e33e52 --- /dev/null +++ b/agent-task/m-codex-app-server-streaming-migration/03+02_default_profile/PLAN-local-G05.md @@ -0,0 +1,115 @@ + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.** diff --git a/agent-task/m-codex-app-server-streaming-migration/04+03_stream_smoke/CODE_REVIEW-cloud-G07.md b/agent-task/m-codex-app-server-streaming-migration/04+03_stream_smoke/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..b2c68f0 --- /dev/null +++ b/agent-task/m-codex-app-server-streaming-migration/04+03_stream_smoke/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,74 @@ + +# 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` diff --git a/agent-task/m-codex-app-server-streaming-migration/04+03_stream_smoke/PLAN-cloud-G07.md b/agent-task/m-codex-app-server-streaming-migration/04+03_stream_smoke/PLAN-cloud-G07.md new file mode 100644 index 0000000..c413211 --- /dev/null +++ b/agent-task/m-codex-app-server-streaming-migration/04+03_stream_smoke/PLAN-cloud-G07.md @@ -0,0 +1,138 @@ + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.** diff --git a/apps/node/internal/adapters/cli/cli.go b/apps/node/internal/adapters/cli/cli.go index ceddf2e..53c9ff2 100644 --- a/apps/node/internal/adapters/cli/cli.go +++ b/apps/node/internal/adapters/cli/cli.go @@ -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: diff --git a/apps/node/internal/adapters/cli/cli_internal_test.go b/apps/node/internal/adapters/cli/cli_internal_test.go index 3d9d816..bb6d153 100644 --- a/apps/node/internal/adapters/cli/cli_internal_test.go +++ b/apps/node/internal/adapters/cli/cli_internal_test.go @@ -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 { diff --git a/apps/node/internal/adapters/cli/codex_app_server.go b/apps/node/internal/adapters/cli/codex_app_server.go new file mode 100644 index 0000000..bb0359a --- /dev/null +++ b/apps/node/internal/adapters/cli/codex_app_server.go @@ -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() + } +} diff --git a/apps/node/internal/adapters/cli/codex_app_server_internal_test.go b/apps/node/internal/adapters/cli/codex_app_server_internal_test.go new file mode 100644 index 0000000..b47e0ab --- /dev/null +++ b/apps/node/internal/adapters/cli/codex_app_server_internal_test.go @@ -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") + } +} diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index 37b593a..bbdf1fc 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -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 { diff --git a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go index cc58c4e..cf734c8 100644 --- a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go +++ b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go @@ -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) diff --git a/apps/node/internal/adapters/cli/persistent_output_filter.go b/apps/node/internal/adapters/cli/persistent_output_filter.go index 742debc..39af59a 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter.go @@ -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): diff --git a/apps/node/internal/adapters/cli/persistent_output_filter_test.go b/apps/node/internal/adapters/cli/persistent_output_filter_test.go index 101fecc..6cc07a4 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter_test.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter_test.go @@ -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?") diff --git a/configs/edge.yaml b/configs/edge.yaml index 4592173..f786414 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -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: ""