iop/agent-task/node_testclient/PLAN.md
toki 2d6fde0876 기능: IOP 모노레포 스캐폴드 초기 구현
apps/node 중심 구현 — TCP+JSON transport, Hexagonal Architecture,
mock/cli adapter, fx DI, SQLite 실행 이력 저장.
edge/control-plane/worker는 cobra placeholder.
유닛 테스트 및 통합 테스트 클라이언트 계획서 추가.
2026-05-02 13:20:35 +09:00

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-9build 타겟에 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

수정 파일 및 체크리스트

  • Makefilebuild 타겟에 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 ./...