fix(edge): OpenAI responses 테스트 흐름을 정비한다

마일스톤 정리와 openai 응답 스모크 테스트/문서 정합성을 맞춰 현재 변경사항을 한 번에 반영하기 위해 수정과 보관 작업을 함께 정리한다.
This commit is contained in:
toki 2026-06-08 19:00:04 +09:00
parent b2649f988a
commit 3f1a7c0701
13 changed files with 249 additions and 196 deletions

View file

@ -11,7 +11,7 @@ Edge OpenAI-compatible 입력 표면을 `/v1/chat/completions` baseline에서 no
## 상태
[진행중]
[완료]
## 구현 잠금
@ -31,10 +31,10 @@ Edge OpenAI-compatible 입력 표면을 `/v1/chat/completions` baseline에서 no
### Epic: [responses-surface] Responses API Input Surface
- [ ] [responses-handler] Edge OpenAI-compatible server가 `POST /v1/responses` non-streaming 요청을 받고 기존 service execution 경로로 변환한다. 검증: `go test -count=1 ./apps/edge/internal/openai`
- [ ] [metadata-contract] request `metadata``request_id`, `inference.target`, `nomadcode.task_id`, `nomadcode.source` 최소 계약으로 Edge service boundary까지 보존되고, `metadata.inference.target` target override 정책이 테스트로 고정된다. 검증: `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`
- [ ] [response-shape] Responses 응답이 `id`, `model`, `output_text` 또는 `output[].content[].text`, `usage`를 제공해 NomadCode Core parser와 호환된다. 검증: OpenAI server tests가 NomadCode-compatible response subset을 assert한다.
- [ ] [responses-smoke] `iop-edge smoke openai` 또는 동등 smoke가 `/healthz`, `/v1/models`, `POST /v1/responses`를 확인한다. 검증: remote/local edge smoke에서 non-streaming Responses path가 실제 listener에 대해 PASS한다.
- [x] [responses-handler] Edge OpenAI-compatible server가 `POST /v1/responses` non-streaming 요청을 받고 기존 service execution 경로로 변환한다. 검증: `go test -count=1 ./apps/edge/internal/openai`
- [x] [metadata-contract] request `metadata``request_id`, `inference.target`, `nomadcode.task_id`, `nomadcode.source` 최소 계약으로 Edge service boundary까지 보존되고, `metadata.inference.target` target override 정책이 테스트로 고정된다. 검증: `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service`
- [x] [response-shape] Responses 응답이 `id`, `model`, `output_text` 또는 `output[].content[].text`, `usage`를 제공해 NomadCode Core parser와 호환된다. 검증: OpenAI server tests가 NomadCode-compatible response subset을 assert한다.
- [x] [responses-smoke] `iop-edge smoke openai` 또는 동등 smoke가 `/healthz`, `/v1/models`, `POST /v1/responses`를 확인한다. 검증: remote/local edge smoke에서 non-streaming Responses path가 실제 listener에 대해 PASS한다.
## Metadata V1 계약
@ -85,13 +85,16 @@ Responses API root field인 `model`, `input`, `instructions`, `stream`은 OpenAI
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다.
- 상태: 승인됨
- 요청일: 2026-06-08
- 완료 근거:
- `agent-task/archive/2026/06/m-openai-responses-input-surface/01_responses_api_surface/complete.log``responses-handler`, `metadata-contract`, `response-shape` PASS와 Edge package regression PASS를 기록했다.
- `agent-task/archive/2026/06/m-openai-responses-input-surface/02+01_responses_smoke/complete.log``responses-smoke` PASS와 `./scripts/e2e-openai-ollama.sh` 실제 Edge/Node/fake Ollama Responses smoke PASS를 기록했다.
- workspace lock `nomadcode:external-integration`의 IOP Responses 선행 조건은 이 Milestone의 `[검토중]` 전환에 맞춰 enable로 동기화했다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
- [x] 사용자가 완료 결과를 확인했다
- [x] archive 이동을 승인했다
- 리뷰 코멘트: 2026-06-08 전체 검토에서 smoke command 계약 drift를 정리하고 `go test -count=1 ./apps/edge/internal/openai ./apps/edge/internal/service ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd`, `go test -count=1 ./apps/edge/...`, drift 검색, `./scripts/e2e-openai-ollama.sh`가 모두 PASS했다. 사용자 요청에 따라 완료로 전환하고 archive한다.
## 범위 제외

View file

@ -38,6 +38,10 @@ CLI 실행, specialized agent 등록, bootstrap/enrollment, 원격 터미널 브
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md`
- 요약: 원격 터미널 브리지와 이후 운영 확장 전에 Edge, Node, Control Plane, Client, proto/config 경계의 설계 부채를 정리했고, 잔여 리스크는 후속 선행 안정화 마일스톤으로 넘겼다.
- [완료] OpenAI Responses Input Surface
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md`
- 요약: Edge OpenAI-compatible 입력 표면에 non-streaming `POST /v1/responses`, metadata 전달 계약, response subset, `iop-edge smoke openai` Responses 검증을 추가하고 실제 Edge/Node/fake Ollama smoke evidence를 확보했다.
- [폐기] Specialized Agent proto-socket 연결 기반
- 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/specialized-agent-proto-socket-foundation.md`
- 요약: domain agent가 독립형 실행 방식으로 전환될 예정이므로 Edge 직접 proto-socket specialized peer 기준의 연결 기반 정리는 폐기한다.
@ -58,11 +62,7 @@ CLI 실행, specialized agent 등록, bootstrap/enrollment, 원격 터미널 브
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/workspace-port-env-standardization.md`
- 요약: Control Plane, Edge, Node, Client, OpenAI-compatible, A2A, wire, metrics, DB/cache 포트를 workspace 공통 대역으로 정렬한다.
- [진행중] OpenAI Responses Input Surface
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md`
- 요약: NomadCode External Integration을 잠근 선행 작업으로, Edge OpenAI-compatible 입력 표면에 non-streaming `POST /v1/responses`와 metadata 전달 계약을 최우선으로 추가한다.
- [계획] 브리지 선행 경계 안정화
- [진행중] 브리지 선행 경계 안정화
- 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/bridge-boundary-hardening.md`
- 요약: 원격 터미널 브리지 POC 전에 남은 호환성/소유권 리스크를 Client HTTP lifecycle, Edge run surface, Node terminal core, typed adapter config 계약으로 고정한다.

View file

@ -12,7 +12,7 @@
## 상태
[계획]
[진행중]
## 구현 잠금

View file

@ -42,43 +42,47 @@ task=m-openai-responses-input-surface/02+01_responses_smoke, plan=0, tag=TEST
| 항목 | 완료 여부 |
|------|---------|
| [TEST-1] Smoke Command Uses Responses | [ ] |
| [TEST-2] Smoke Contract Documentation Tracks Responses | [ ] |
| [TEST-3] Actual Listener Smoke Evidence | [ ] |
| [TEST-1] Smoke Command Uses Responses | [x] |
| [TEST-2] Smoke Contract Documentation Tracks Responses | [x] |
| [TEST-3] Actual Listener Smoke Evidence | [x] |
## 구현 체크리스트
- [ ] `iop-edge smoke openai`의 inference check를 `POST /v1/responses` 기준으로 갱신하고 response text extractor를 추가한다. 검증: `go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd`.
- [ ] smoke command 성공/실패 tests를 Responses shape와 error message 기준으로 갱신한다. 검증: `go test -count=1 ./apps/edge/cmd/edge`.
- [ ] `agent-test/local/edge-smoke.md`의 OpenAI-compatible smoke 기준을 `/healthz`, `/v1/models`, `/v1/responses`로 갱신한다.
- [ ] 전체 edge regression과 실제 listener smoke를 실행한다. 검증: `go test -count=1 ./apps/edge/...` 및 `go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s`.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `iop-edge smoke openai`의 inference check를 `POST /v1/responses` 기준으로 갱신하고 response text extractor를 추가한다. 검증: `go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd`.
- [x] smoke command 성공/실패 tests를 Responses shape와 error message 기준으로 갱신한다. 검증: `go test -count=1 ./apps/edge/cmd/edge`.
- [x] `agent-test/local/edge-smoke.md`의 OpenAI-compatible smoke 기준을 `/healthz`, `/v1/models`, `/v1/responses`로 갱신한다.
- [x] 전체 edge regression과 실제 listener smoke를 실행한다. 검증: `go test -count=1 ./apps/edge/...` 및 `go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s`.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_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-openai-responses-input-surface/02+01_responses_smoke/`를 `agent-task/archive/YYYY/MM/m-openai-responses-input-surface/02+01_responses_smoke/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-responses-input-surface/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_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-openai-responses-input-surface/02+01_responses_smoke/`를 `agent-task/archive/YYYY/MM/m-openai-responses-input-surface/02+01_responses_smoke/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-responses-input-surface/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G06.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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 구현 착수 시 `smoke_openai.go`에 Step 4 `/v1/responses` 코드가 이미 추가되어 있었고 chat completion 타입/Step 3이 잔존해 있었다. plan 원래 의도(Step 3을 Responses로 교체)에 맞게 chat completion DTO와 Step 3 코드를 제거하고 Step 4 레이블을 Step 3으로 변경하였다.
- `responsesResponse.Usage` 필드가 제거된 `chatCompletionUsage` 타입을 참조하고 있었다. smoke 코드 내에서 `Usage` 필드를 실제로 읽지 않으므로 해당 필드를 삭제하였다.
- plan 기준에서 `payloadBytes`는 `:=` 선언이 필요하나 기존 코드가 `=` 재할당으로 작성되어 있어 `:=`로 수정하였다.
- `agent-ops/rules/project/domain/edge/rules.md` 49행의 `smoke openai` 설명이 여전히 `/v1/chat/completions`를 언급하지만 domain rule 갱신은 이 plan 범위를 벗어나므로 수정하지 않았다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- smoke 실패 케이스 2·3(chat completions non-200, empty content)을 제거하고 케이스 4·5(responses non-200, empty text)를 케이스 2·3으로 재정렬하였다. chat completions HTTP endpoint 자체는 서버에 유지되며 unit test coverage는 `apps/edge/internal/openai` 패키지에서 계속 제공된다.
- `responsesResponse.Usage` 필드를 제거한 이유: smoke는 response text만 추출하므로 usage 파싱이 불필요하고, 별도 named type을 도입하거나 anonymous struct를 쓰는 것보다 필드 제거가 코드를 단순하게 유지한다.
## 사용자 리뷰 요청
@ -88,7 +92,7 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 실행한 검증/명령: `./scripts/e2e-openai-ollama.sh` → PASS
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
@ -114,34 +118,81 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### TEST-1 중간 검증
```
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
(output)
ok iop/apps/edge/cmd/edge 0.025s
ok iop/apps/edge/internal/edgecmd 0.006s
```
### TEST-2 중간 검증
```
$ rg --sort path -n "/v1/chat/completions|/v1/responses" agent-test/local/edge-smoke.md apps/edge/internal/edgecmd/smoke_openai.go apps/edge/cmd/edge/main_test.go
(output)
agent-test/local/edge-smoke.md:58:- OpenAI-compatible 경계를 바꾼 경우 `iop-edge smoke openai` 또는 동등한 `/healthz`, `/v1/models`, `/v1/responses` 확인으로 edge service와 node adapter 경로 수렴을 확인한다. (`/v1/chat/completions` 기준은 기존 unit tests가 유지한다.)
agent-test/local/edge-smoke.md:71:- OpenAI-compatible smoke에서 `/healthz`, `/v1/models`, `/v1/responses`가 기대 상태로 응답한다.
apps/edge/internal/edgecmd/smoke_openai.go:117: 3. Checks POST /v1/responses (non-streaming Responses API request processing).
apps/edge/internal/edgecmd/smoke_openai.go:203: fmt.Fprintf(cmd.OutOrStdout(), "Step 3: Checking /v1/responses ... ")
apps/edge/internal/edgecmd/smoke_openai.go:220: responsesURL := baseURL + "/v1/responses"
apps/edge/internal/edgecmd/smoke_openai.go:235: return fmt.Errorf("failed to parse /v1/responses JSON response: %w", err)
apps/edge/cmd/edge/main_test.go:1055: case "/v1/responses":
apps/edge/cmd/edge/main_test.go:1078: "Step 3: Checking /v1/responses ... [OK]",
apps/edge/cmd/edge/main_test.go:1123: case "/v1/responses":
apps/edge/cmd/edge/main_test.go:1152: case "/v1/responses":
```
### TEST-3 중간 검증
```
$ go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s
(output)
IOP Edge OpenAI Smoke Test
========================================
Target Endpoint: http://127.0.0.1:18081
Target Model: test-model
Timeout: 15s
Step 1: Checking /healthz ... [FAILED]
Error: GET http://127.0.0.1:18081/healthz failed: Get "http://127.0.0.1:18081/healthz": dial tcp 127.0.0.1:18081: connect: connection refused (make sure the edge server is running)
exit status 1
→ BLOCKED: Edge server not running. User-owned environment prerequisite. (상세는 사용자 리뷰 요청 참조)
$ ./scripts/e2e-openai-ollama.sh
[openai-ollama] OpenAI-compatible Ollama serving test PASSED.
```
### 최종 검증
```
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd
(output)
ok iop/apps/edge/cmd/edge 0.025s
ok iop/apps/edge/internal/edgecmd 0.006s
$ go test -count=1 ./apps/edge/...
(output)
ok iop/apps/edge/cmd/edge 0.025s
ok iop/apps/edge/internal/bootstrap 0.013s
ok iop/apps/edge/internal/controlplane 4.451s
ok iop/apps/edge/internal/edgecmd 0.006s
ok iop/apps/edge/internal/events 0.004s
ok iop/apps/edge/internal/input 0.006s
ok iop/apps/edge/internal/input/a2a 0.005s
ok iop/apps/edge/internal/node 0.004s
ok iop/apps/edge/internal/openai 1.507s
ok iop/apps/edge/internal/opsconsole 0.007s
ok iop/apps/edge/internal/service 0.006s
ok iop/apps/edge/internal/transport 2.018s
$ rg --sort path -n "/v1/chat/completions|/v1/responses" agent-test/local/edge-smoke.md apps/edge/internal/edgecmd/smoke_openai.go apps/edge/cmd/edge/main_test.go
(output)
agent-test/local/edge-smoke.md:58:- OpenAI-compatible 경계를 바꾼 경우 `iop-edge smoke openai` 또는 동등한 `/healthz`, `/v1/models`, `/v1/responses` 확인으로 edge service와 node adapter 경로 수렴을 확인한다. (`/v1/chat/completions` 기준은 기존 unit tests가 유지한다.)
agent-test/local/edge-smoke.md:71:- OpenAI-compatible smoke에서 `/healthz`, `/v1/models`, `/v1/responses`가 기대 상태로 응답한다.
apps/edge/internal/edgecmd/smoke_openai.go:117: 3. Checks POST /v1/responses (non-streaming Responses API request processing).
apps/edge/internal/edgecmd/smoke_openai.go:203: fmt.Fprintf(cmd.OutOrStdout(), "Step 3: Checking /v1/responses ... ")
apps/edge/internal/edgecmd/smoke_openai.go:220: responsesURL := baseURL + "/v1/responses"
apps/edge/internal/edgecmd/smoke_openai.go:235: return fmt.Errorf("failed to parse /v1/responses JSON response: %w", err)
apps/edge/cmd/edge/main_test.go:1055: case "/v1/responses":
apps/edge/cmd/edge/main_test.go:1078: "Step 3: Checking /v1/responses ... [OK]",
apps/edge/cmd/edge/main_test.go:1123: case "/v1/responses":
apps/edge/cmd/edge/main_test.go:1152: case "/v1/responses":
$ go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://127.0.0.1:18081 --prompt ping --timeout 15s
(output)
→ BLOCKED: connection refused (Edge server not running)
$ ./scripts/e2e-openai-ollama.sh
[openai-ollama] OpenAI-compatible Ollama serving test PASSED.
```
---
@ -164,3 +215,20 @@ $ go run ./apps/edge/cmd/edge smoke openai --model test-model --base-url http://
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 | Implementing agent | Fill command output only |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
- 발견된 문제:
- Nit (review-fixed) `apps/edge/internal/edgecmd/root.go:34`: root command help가 제거된 chat smoke 단계를 계속 언급했다. `health, models, and responses`로 수정했다.
- Nit (review-fixed) `scripts/e2e-openai-ollama.sh:222`: smoke 출력이 `Step 3: Checking /v1/responses`로 바뀌었는데 script가 `Step 4`를 grep했다. 새 출력 계약에 맞게 수정하고 script를 재실행해 PASS를 확인했다.
- 다음 단계:
- PASS: `complete.log`를 작성하고 task directory를 archive로 이동한다.

View file

@ -0,0 +1,44 @@
# Complete - m-openai-responses-input-surface/02+01_responses_smoke
## 완료 일시
2026-06-08
## 요약
OpenAI-compatible smoke command was reviewed in loop 1 and closed with PASS after `/v1/responses` smoke verification and review-time drift cleanup.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G06_0.log` | PASS | `iop-edge smoke openai` now validates `/healthz`, `/v1/models`, and non-streaming `/v1/responses`; actual Edge/Node/fake Ollama smoke passed. |
## 구현/정리 내용
- Switched `iop-edge smoke openai` inference check from `/v1/chat/completions` to `POST /v1/responses`.
- Added Responses text extraction for `output_text` and nested `output[].content[].text` response shapes.
- Updated smoke command success/failure tests and local edge smoke criteria to use `/v1/responses`.
- During review, fixed stale root help text and the `scripts/e2e-openai-ollama.sh` `Step 3` smoke output assertion.
## 최종 검증
- `go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd` - PASS; both packages returned `ok`.
- `go test -count=1 ./apps/edge/...` - PASS; all Edge packages returned `ok`.
- `rg --sort path -n "/v1/chat/completions|/v1/responses|Step 4: Checking /v1/responses|Step 3: Checking /v1/responses" agent-test/local/edge-smoke.md apps/edge/internal/edgecmd/smoke_openai.go apps/edge/internal/edgecmd/root.go apps/edge/cmd/edge/main_test.go scripts/e2e-openai-ollama.sh` - PASS; smoke command, tests, profile, and e2e script point to `Step 3` `/v1/responses` while chat completions remains only in legacy/API contexts.
- `./scripts/e2e-openai-ollama.sh` - PASS; output: `[openai-ollama] OpenAI-compatible Ollama serving test PASSED.`
## Roadmap Completion
- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/openai-responses-input-surface.md`
- Completed task ids:
- `responses-smoke`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G06_0.log`; verification=`go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/edgecmd`, `go test -count=1 ./apps/edge/...`, `./scripts/e2e-openai-ollama.sh`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -191,7 +191,7 @@ nodes:
`openai.target`이 비어 있으면 HTTP 요청의 `model` 값을 내부 target으로 사용한다. 값이 있으면 Cline 같은 외부 agent가 보낸 `model`과 무관하게 YAML의 target으로 고정 라우팅한다.
`/v1/chat/completions`는 기본 non-streaming과 streaming SSE 응답을 모두 지원한다. `/v1/responses`는 현재 non-streaming 요청만 지원하며, `metadata.request_id`, `metadata.inference.target`, `metadata.nomadcode.task_id`, `metadata.nomadcode.source`를 service boundary까지 전달한다.
`/v1/chat/completions`는 기본 non-streaming과 streaming SSE 응답을 모두 지원한다. `/v1/responses`는 현재 non-streaming 요청만 지원하며, `metadata.request_id`, `metadata.inference.target`, `metadata.nomadcode.task_id`, `metadata.nomadcode.source`를 service boundary까지 전달한다. NomadCode compatibility alias로 root-level `metadata.task_id``metadata.source`도 받아 각각 `nomadcode.task_id`, `nomadcode.source`로 매핑하며, structured `metadata.nomadcode.*` 값이 있으면 그 값을 우선한다.
```bash
curl -s http://127.0.0.1:18081/v1/chat/completions \

View file

@ -1052,10 +1052,6 @@ func TestSmokeOpenAICommandSuccess(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"object":"list","data":[{"id":"test-model","object":"model","created":123456,"owned_by":"iop"}]}`))
case "/v1/chat/completions":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"chat-123","object":"chat.completion","created":123456,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong response"},"finish_reason":"stop"}]}`))
case "/v1/responses":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
@ -1079,9 +1075,7 @@ func TestSmokeOpenAICommandSuccess(t *testing.T) {
wants := []string{
"Step 1: Checking /healthz ... [OK]",
"Step 2: Checking /v1/models ... [OK]",
"Step 3: Checking /v1/chat/completions ... [OK]",
"Chat Response Content: \"pong response\"",
"Step 4: Checking /v1/responses ... [OK]",
"Step 3: Checking /v1/responses ... [OK]",
"Responses Output Text: \"responses pong\"",
"IOP Edge OpenAI Smoke Test SUCCESS!",
}
@ -1117,65 +1111,7 @@ func TestSmokeOpenAICommandFailure(t *testing.T) {
t.Errorf("unexpected error message: %v", err)
}
// 2. Chat completions non-200 error
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
case "/v1/models":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"object":"list","data":[{"id":"test-model","object":"model"}]}`))
case "/v1/chat/completions":
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("node offline"))
}
}))
defer server2.Close()
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"smoke", "openai", "--model", "test-model", "--base-url", server2.URL})
err2 := root.Execute()
if err2 == nil {
t.Fatalf("expected error from non-200 chat completions, got nil")
}
if !strings.Contains(err2.Error(), "returned non-200 status 503") {
t.Errorf("unexpected error message: %v", err2)
}
// 3. Chat completions empty assistant content error
server3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
case "/v1/models":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"object":"list","data":[{"id":"test-model","object":"model"}]}`))
case "/v1/chat/completions":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"chat-123","object":"chat.completion","created":123456,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":" "},"finish_reason":"stop"}]}`))
}
}))
defer server3.Close()
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"smoke", "openai", "--model", "test-model", "--base-url", server3.URL})
err3 := root.Execute()
if err3 == nil {
t.Fatalf("expected error from empty assistant content, got nil")
}
if !strings.Contains(err3.Error(), "returned empty assistant message content") {
t.Errorf("unexpected error message: %v", err3)
}
// 4. Responses non-200 error
// 2. Responses non-200 error
server4 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
@ -1184,9 +1120,6 @@ func TestSmokeOpenAICommandFailure(t *testing.T) {
case "/v1/models":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"object":"list","data":[{"id":"test-model","object":"model"}]}`))
case "/v1/chat/completions":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"chat-123","object":"chat.completion","created":123456,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}]}`))
case "/v1/responses":
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("node offline"))
@ -1207,7 +1140,7 @@ func TestSmokeOpenAICommandFailure(t *testing.T) {
t.Errorf("unexpected error message: %v", err4)
}
// 5. Responses empty text error
// 3. Responses empty text error
server5 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
@ -1216,9 +1149,6 @@ func TestSmokeOpenAICommandFailure(t *testing.T) {
case "/v1/models":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"object":"list","data":[{"id":"test-model","object":"model"}]}`))
case "/v1/chat/completions":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"chat-123","object":"chat.completion","created":123456,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}]}`))
case "/v1/responses":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"resp-123","object":"response","created_at":123456,"model":"test-model","output_text":"","output":[]}`))

View file

@ -31,7 +31,7 @@ The official local development and field test flow is as follows:
4. config check - Validate the edge.yaml configuration structure and constraints.
5. serve (or console) - Start the Edge server (or interactive console) to accept connections.
6. nodes list - List configured nodes and show their connection status.
7. smoke openai - Perform an E2E health, models, chat, and responses smoke test on the OpenAI endpoint.`,
7. smoke openai - Perform an E2E health, models, and responses smoke test on the OpenAI endpoint.`,
Example: ` iop-edge config init
iop-edge env
iop-edge node register my-node --adapter cli

View file

@ -50,32 +50,6 @@ type openAIModelsResponse struct {
Data []openAIModel `json:"data"`
}
type chatCompletionMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatCompletionChoice struct {
Index int `json:"index"`
Message chatCompletionMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type chatCompletionUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type chatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []chatCompletionChoice `json:"choices"`
Usage *chatCompletionUsage `json:"usage"`
}
type responsesContentItem struct {
Type string `json:"type"`
Text string `json:"text"`
@ -94,7 +68,6 @@ type responsesResponse struct {
Model string `json:"model"`
OutputText string `json:"output_text"`
Output []responsesOutputItem `json:"output"`
Usage *chatCompletionUsage `json:"usage"`
}
func (r responsesResponse) text() string {
@ -138,11 +111,10 @@ func smokeOpenAICmd() *cobra.Command {
Use: "openai",
Short: "Perform OpenAI-compatible endpoint smoke test",
Long: `Validate the IOP Edge OpenAI-compatible HTTP API surface.
It runs a four-step diagnostics check:
It runs a three-step diagnostics check:
1. Checks GET /healthz (server status).
2. Checks GET /v1/models (advertised model compatibility).
3. Checks POST /v1/chat/completions (inference request processing).
4. Checks POST /v1/responses (non-streaming Responses API request processing).
3. Checks POST /v1/responses (non-streaming Responses API request processing).
If --base-url is not provided, it auto-discovers the OpenAI URL from the effective config's listen address.`,
Example: ` iop-edge smoke openai --model gemma4:26b
@ -223,65 +195,26 @@ If --base-url is not provided, it auto-discovers the OpenAI URL from the effecti
fmt.Fprintf(cmd.OutOrStdout(), "WARNING: Target model %q is not advertised by /v1/models (advertised: %s)\n", smokeModel, strings.Join(modelIDs, ", "))
}
fmt.Fprintf(cmd.OutOrStdout(), "Step 3: Checking /v1/chat/completions ... ")
prompt := smokePrompt
if prompt == "" {
prompt = "ping"
}
reqPayload := map[string]interface{}{
"model": smokeModel,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
"stream": false,
}
payloadBytes, err := json.Marshal(reqPayload)
if err != nil {
return fmt.Errorf("failed to marshal request payload: %w", err)
}
chatURL := baseURL + "/v1/chat/completions"
resp, err = client.Post(chatURL, "application/json", bytes.NewReader(payloadBytes))
if err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s failed: %w (make sure active nodes are online)", chatURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s returned non-200 status %d: %s", chatURL, resp.StatusCode, string(body))
}
var chatResp chatCompletionResponse
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("failed to parse /v1/chat/completions JSON response: %w", err)
}
if len(chatResp.Choices) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s returned empty choices", chatURL)
}
content := chatResp.Choices[0].Message.Content
if strings.TrimSpace(content) == "" {
fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]")
return fmt.Errorf("POST %s returned empty assistant message content", chatURL)
}
fmt.Fprintf(cmd.OutOrStdout(), "[OK]\n")
fmt.Fprintf(cmd.OutOrStdout(), "Chat Response Content: %q\n\n", content)
fmt.Fprintf(cmd.OutOrStdout(), "Step 4: Checking /v1/responses ... ")
fmt.Fprintf(cmd.OutOrStdout(), "Step 3: Checking /v1/responses ... ")
responsesPayload := map[string]interface{}{
"model": smokeModel,
"input": prompt,
"stream": false,
"metadata": map[string]interface{}{
"request_id": "iop-edge-smoke",
"task_id": "smoke",
"source": "iop-edge-smoke",
"inference": map[string]string{
"target": smokeModel,
},
},
}
payloadBytes, err = json.Marshal(responsesPayload)
payloadBytes, err := json.Marshal(responsesPayload)
if err != nil {
return fmt.Errorf("failed to marshal responses request payload: %w", err)
}

View file

@ -520,6 +520,71 @@ func TestResponsesMetadataContractAndTargetOverride(t *testing.T) {
}
}
func TestResponsesAcceptsFlatNomadCodeMetadataAliases(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"client-model",
"input":"test",
"metadata":{
"request_id":"req-flat-001",
"task_id":"task-flat",
"source":"plane",
"inference":{"target":"metadata-target"}
}
}`))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if fake.req.Metadata["nomadcode.task_id"] != "task-flat" {
t.Fatalf("nomadcode.task_id: got %q", fake.req.Metadata["nomadcode.task_id"])
}
if fake.req.Metadata["nomadcode.source"] != "plane" {
t.Fatalf("nomadcode.source: got %q", fake.req.Metadata["nomadcode.source"])
}
if fake.req.Metadata["source"] != "openai-responses" {
t.Fatalf("source should remain IOP-owned: got %q", fake.req.Metadata["source"])
}
}
func TestResponsesStructuredNomadCodeMetadataWinsOverFlatAliases(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"client-model",
"input":"test",
"metadata":{
"task_id":"task-flat",
"source":"plane",
"nomadcode":{"task_id":"task-structured","source":"manual"}
}
}`))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if fake.req.Metadata["nomadcode.task_id"] != "task-structured" {
t.Fatalf("nomadcode.task_id: got %q", fake.req.Metadata["nomadcode.task_id"])
}
if fake.req.Metadata["nomadcode.source"] != "manual" {
t.Fatalf("nomadcode.source: got %q", fake.req.Metadata["nomadcode.source"])
}
if fake.req.Metadata["source"] != "openai-responses" {
t.Fatalf("source should remain IOP-owned: got %q", fake.req.Metadata["source"])
}
}
func TestResponsesConfiguredTargetWinsOverMetadataTarget(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}

View file

@ -212,6 +212,8 @@ type responsesRequest struct {
type responsesMetadata struct {
RequestID string `json:"request_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
Source string `json:"source,omitempty"`
Inference *responsesInferenceMetadata `json:"inference,omitempty"`
NomadCode *responsesNomadCodeMetadata `json:"nomadcode,omitempty"`
}
@ -233,14 +235,22 @@ func (m *responsesMetadata) metadataForRun() map[string]string {
if m.Inference != nil && m.Inference.Target != "" {
out["inference.target"] = m.Inference.Target
}
taskID := m.TaskID
source := m.Source
if m.NomadCode != nil {
if m.NomadCode.TaskID != "" {
out["nomadcode.task_id"] = m.NomadCode.TaskID
taskID = m.NomadCode.TaskID
}
if m.NomadCode.Source != "" {
out["nomadcode.source"] = m.NomadCode.Source
source = m.NomadCode.Source
}
}
if taskID != "" {
out["nomadcode.task_id"] = taskID
}
if source != "" {
out["nomadcode.source"] = source
}
return out
}

View file

@ -199,7 +199,7 @@ grep -q "reasoning_content" "$CHAT_OUT"
RESPONSES_OUT="$TMP_DIR/responses.json"
curl -fsS \
-H "Content-Type: application/json" \
-d '{"model":"client-request-model","input":"say the responses test token","stream":false,"metadata":{"request_id":"e2e-openai-ollama","inference":{"target":"fake-ollama-model"},"nomadcode":{"task_id":"task-smoke","source":"manual"}}}' \
-d '{"model":"client-request-model","input":"say the responses test token","stream":false,"metadata":{"request_id":"e2e-openai-ollama","task_id":"task-smoke","source":"manual","inference":{"target":"fake-ollama-model"}}}' \
"http://127.0.0.1:$OPENAI_PORT/v1/responses" > "$RESPONSES_OUT"
grep -q "IOP_OPENAI_OLLAMA_OK" "$RESPONSES_OUT"
grep -q '"output_text"' "$RESPONSES_OUT"
@ -219,7 +219,7 @@ SMOKE_OUT="$TMP_DIR/iop-edge-smoke-openai.txt"
--base-url "http://127.0.0.1:$OPENAI_PORT" \
--prompt "say the command smoke token" \
--timeout 20s) > "$SMOKE_OUT"
grep -q "Step 4: Checking /v1/responses ... \[OK\]" "$SMOKE_OUT"
grep -q "Step 3: Checking /v1/responses ... \[OK\]" "$SMOKE_OUT"
grep -q "IOP Edge OpenAI Smoke Test SUCCESS!" "$SMOKE_OUT"
if grep -i -E "node reported error|error run_id=|\[[^]]+-evt\] error|panic:" "$EDGE_OUT" "$NODE_OUT" >/dev/null; then