apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture, mock/cli adapter, fx DI, SQLite 실행 이력 저장. edge/control-plane/worker는 cobra placeholder. 유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
9.1 KiB
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/codec.go,
apps/node/internal/transport/session.go:128-161)
의 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 이벤트 수신 시 종료
전체 구현 코드:
// 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 태스크)로 간접 검증된다.
중간 검증
빌드 가능 여부 확인:
go build -o bin/iop-node-testclient ./apps/node/cmd/iop-node-testclient
기대 결과: 빌드 에러 없음
[TEST-2] Makefile에 testclient 빌드 타겟 추가
문제
Makefile:6-9의 build 타겟에 testclient가 없다.
해결 방법
# 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 변경이므로 테스트 파일 불필요.
중간 검증
make build
ls -la bin/
기대 결과: bin/iop-node-testclient 파일 생성됨
수정 파일 요약
| 파일 | 항목 |
|---|---|
apps/node/cmd/iop-node-testclient/main.go |
TEST-1 |
Makefile |
TEST-2 |
최종 검증
두 개의 터미널이 필요하다.
터미널 A — node 서버 실행:
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 실행:
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
빌드 전체 확인:
go build ./...