# iop-node-testclient — 통합 테스트 클라이언트 ## 이 파일을 읽는 구현 에이전트에게 각 체크리스트 항목을 완료한 뒤 `[x]`로 표시하세요. 중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md` 검증 결과 섹션에 붙여 넣으세요. 계획과 다르게 구현한 부분은 반드시 `계획 대비 변경 사항`에 기록하세요. ## 배경 `iop-node serve` 가 실행 중일 때 실제 TCP 연결로 RunRequest를 전송하고 스트리밍 RunEvent를 수신하는 클라이언트가 없다. unit test 만으로는 transport 계층의 end-to-end 동작을 확인할 수 없으므로, 수동 및 자동 통합 검증에 쓸 수 있는 `iop-node-testclient` 커맨드를 추가한다. --- ### [TEST-1] iop-node-testclient 커맨드 생성 **문제** `apps/node/cmd/` 아래에 테스트 클라이언트가 없다. 현재 transport 계층 ([apps/node/internal/transport/frame.go](apps/node/internal/transport/frame.go), [apps/node/internal/transport/codec.go](apps/node/internal/transport/codec.go), [apps/node/internal/transport/session.go:128-161](apps/node/internal/transport/session.go)) 의 wire format은 `4-byte big-endian length | JSON(Envelope)` 이다. 클라이언트도 동일 프레이밍을 구현해야 한다. **해결 방법** `apps/node/cmd/iop-node-testclient/main.go` 를 신규 생성한다. 이미 `iop/packages/protocol` 에 모든 wire 타입이 정의되어 있으므로 프레임 읽기/쓰기는 `encoding/binary` + `encoding/json` 으로 직접 구현한다. (transport 내부 패키지 unexported 함수 재사용 불가 — `internal` 경로) 커맨드 흐름: ``` 1. -addr 플래그로 node 주소 수신 (기본 localhost:9090) 2. TCP Dial 3. (선택) -caps 플래그 시 CapabilityRequest 전송 → 응답 출력 4. RunRequest 전송 (adapter=mock, model=mock-echo, prompt="hello iop!") 5. RunEvent 스트림 수신 → 각 이벤트 출력 6. complete 또는 error 이벤트 수신 시 종료 ``` 전체 구현 코드: ```go // apps/node/cmd/iop-node-testclient/main.go package main import ( "context" "encoding/binary" "encoding/json" "errors" "flag" "fmt" "io" "net" "os" "time" "iop/packages/protocol" ) func main() { addr := flag.String("addr", "localhost:9090", "iop-node address") caps := flag.Bool("caps", false, "send CapabilityRequest before RunRequest") prompt := flag.String("prompt", "hello iop!", "prompt to send") flag.Parse() if err := run(*addr, *caps, *prompt); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } func run(addr string, sendCaps bool, prompt string) error { conn, err := net.DialTimeout("tcp", addr, 5*time.Second) if err != nil { return fmt.Errorf("dial %s: %w", addr, err) } defer conn.Close() fmt.Printf("connected → %s\n", addr) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() if sendCaps { if err := sendMsg(conn, protocol.TypeCapabilityRequest, "", protocol.CapabilityRequest{}); err != nil { return fmt.Errorf("send capability request: %w", err) } fmt.Println("→ CapabilityRequest sent") env, err := recvEnvelope(conn) if err != nil { return fmt.Errorf("recv capability response: %w", err) } if env.Type == protocol.TypeCapabilityResponse { var resp protocol.CapabilityResponse _ = json.Unmarshal(env.Payload, &resp) fmt.Printf("← CapabilityResponse node_id=%q adapters=%d\n", resp.NodeID, len(resp.Adapters)) for _, a := range resp.Adapters { fmt.Printf(" adapter=%q models=%v max_concurrency=%d\n", a.Name, a.Models, a.MaxConcurrency) } } } runID := fmt.Sprintf("testclient-%d", time.Now().UnixNano()) req := protocol.RunRequest{ RunID: runID, Adapter: "mock", Model: "mock-echo", Input: map[string]any{"prompt": prompt}, } if err := sendMsg(conn, protocol.TypeRunRequest, runID, req); err != nil { return fmt.Errorf("send run request: %w", err) } fmt.Printf("→ RunRequest sent (run_id=%s)\n", runID) return receiveEvents(ctx, conn) } func receiveEvents(ctx context.Context, conn net.Conn) error { for { if err := ctx.Err(); err != nil { return fmt.Errorf("context: %w", err) } env, err := recvEnvelope(conn) if err != nil { if errors.Is(err, io.EOF) { fmt.Println("connection closed by server") return nil } return fmt.Errorf("recv: %w", err) } switch env.Type { case protocol.TypeRunEvent: var e protocol.RunEvent _ = json.Unmarshal(env.Payload, &e) switch e.Type { case "start": fmt.Printf("← [start]\n") case "delta": fmt.Printf("%s", e.Delta) case "complete": fmt.Printf("\n← [complete] message=%q input_tokens=%d output_tokens=%d\n", e.Message, func() int { if e.Usage != nil { return e.Usage.InputTokens } return 0 }(), func() int { if e.Usage != nil { return e.Usage.OutputTokens } return 0 }(), ) return nil case "error": return fmt.Errorf("run error: %s", e.Error) } case protocol.TypeHeartbeat: // 무시 case protocol.TypeError: var e protocol.ErrorMsg _ = json.Unmarshal(env.Payload, &e) return fmt.Errorf("server error %s: %s", e.Code, e.Message) } } } // sendMsg wraps payload in an Envelope and writes a length-prefixed frame. func sendMsg(conn net.Conn, msgType protocol.MessageType, runID string, payload any) error { data, err := json.Marshal(payload) if err != nil { return err } env := protocol.Envelope{ ProtocolVersion: protocol.ProtocolVersion, RequestID: fmt.Sprintf("req-%d", time.Now().UnixNano()), RunID: runID, Type: msgType, Payload: data, } envBytes, err := json.Marshal(env) if err != nil { return err } hdr := make([]byte, 4) binary.BigEndian.PutUint32(hdr, uint32(len(envBytes))) if _, err := conn.Write(hdr); err != nil { return err } _, err = conn.Write(envBytes) return err } // recvEnvelope reads one length-prefixed frame and decodes the Envelope. func recvEnvelope(conn net.Conn) (*protocol.Envelope, error) { hdr := make([]byte, 4) if _, err := io.ReadFull(conn, hdr); err != nil { return nil, err } length := binary.BigEndian.Uint32(hdr) if length == 0 { return nil, fmt.Errorf("received empty frame") } buf := make([]byte, length) if _, err := io.ReadFull(conn, buf); err != nil { return nil, err } var env protocol.Envelope return &env, json.Unmarshal(buf, &env) } ``` **수정 파일 및 체크리스트** - [ ] `apps/node/cmd/iop-node-testclient/main.go` 신규 생성 - [ ] `-addr` 플래그 (기본 `localhost:9090`) - [ ] `-caps` 플래그 (CapabilityRequest 선택 전송) - [ ] `-prompt` 플래그 (기본 `"hello iop!"`) - [ ] TCP Dial + 타임아웃 (5s) - [ ] CapabilityRequest 전송 및 응답 출력 (`-caps` 시) - [ ] RunRequest 전송 (mock adapter) - [ ] RunEvent 스트림 수신 루프 - [ ] complete / error 시 정상 종료 - [ ] 60s 전체 타임아웃 **테스트 작성** 통합 테스트 클라이언트 자체는 수동/E2E 검증 도구이므로 별도 `_test.go` 파일은 작성하지 않는다. 단, `sendMsg` / `recvEnvelope` 함수는 `frame.go` + `codec.go` 유닛 테스트(node_unit_tests 태스크)로 간접 검증된다. **중간 검증** 빌드 가능 여부 확인: ```bash go build -o bin/iop-node-testclient ./apps/node/cmd/iop-node-testclient ``` 기대 결과: 빌드 에러 없음 --- ### [TEST-2] Makefile에 testclient 빌드 타겟 추가 **문제** [Makefile:6-9](Makefile)의 `build` 타겟에 testclient가 없다. **해결 방법** ```makefile # Before (Makefile:7) go build $(GOFLAGS) -o bin/iop-node ./apps/node/cmd/iop-node # After go build $(GOFLAGS) -o bin/iop-node ./apps/node/cmd/iop-node go build $(GOFLAGS) -o bin/iop-node-testclient ./apps/node/cmd/iop-node-testclient ``` **수정 파일 및 체크리스트** - [ ] `Makefile` — `build` 타겟에 `iop-node-testclient` 라인 추가 **테스트 작성** Makefile 변경이므로 테스트 파일 불필요. **중간 검증** ```bash make build ls -la bin/ ``` 기대 결과: `bin/iop-node-testclient` 파일 생성됨 --- ## 수정 파일 요약 | 파일 | 항목 | |------|------| | `apps/node/cmd/iop-node-testclient/main.go` | TEST-1 | | `Makefile` | TEST-2 | ## 최종 검증 두 개의 터미널이 필요하다. **터미널 A — node 서버 실행:** ```bash go mod tidy go build -o bin/iop-node ./apps/node/cmd/iop-node ./bin/iop-node serve --config configs/node.yaml ``` 기대 결과: `transport listening addr=0.0.0.0:9090` 로그 출력 **터미널 B — testclient 실행:** ```bash go build -o bin/iop-node-testclient ./apps/node/cmd/iop-node-testclient # 기본 RunRequest ./bin/iop-node-testclient # CapabilityRequest 포함 ./bin/iop-node-testclient -caps # 커스텀 프롬프트 ./bin/iop-node-testclient -prompt "안녕하세요 IOP!" ``` 기대 결과: ``` connected → localhost:9090 → RunRequest sent (run_id=testclient-...) ← [start] echo: hello iop! ← [complete] message="mock execution complete" input_tokens=2 output_tokens=4 ``` **빌드 전체 확인:** ```bash go build ./... ```