iop/apps/edge/internal/openai/workspace_metadata_test.go
toki 01dc2ef78b refactor: readability baseline 및 테스트 구조 개선
- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동
- 새 테스트 파일 추가 (edge, node, client, config, readability)
- readability_audit 스크립트 및 baseline 추가
- roadmap/SDD 문서 갱신
- agent-client/pi/extensions/openai-sampling-parameters 추가
2026-07-17 16:02:12 +09:00

435 lines
16 KiB
Go

package openai
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
func workspaceBoundCfg() config.EdgeOpenAIConf {
return config.EdgeOpenAIConf{
Adapter: "ollama",
Target: "llama3",
ModelRoutes: []config.OpenAIRouteEntry{
{Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true},
{Model: "llama3", Adapter: "ollama", Target: "llama3"},
},
}
}
func TestResponsesGenericMetadataContract(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-001",
"workspace":"/config/workspace/iop",
"task_id":"task-123",
"custom":"value"
}
}`))
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.Target != "client-model" {
t.Fatalf("target: got %q, want client-model", fake.req.Target)
}
if fake.req.Workspace != "/config/workspace/iop" {
t.Fatalf("workspace: got %q", fake.req.Workspace)
}
if fake.req.Metadata["request_id"] != "req-001" {
t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"])
}
if _, ok := fake.req.Metadata["workspace"]; ok {
t.Fatal("workspace should not be copied into run metadata")
}
if fake.req.Metadata["task_id"] != "task-123" {
t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"])
}
if fake.req.Metadata["custom"] != "value" {
t.Fatalf("custom: got %q", fake.req.Metadata["custom"])
}
if fake.req.Metadata["openai_model"] != "client-model" {
t.Fatalf("openai_model: got %q", fake.req.Metadata["openai_model"])
}
if fake.req.ModelGroupKey != "client-model" {
t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
}
if fake.req.Metadata["openai_stream"] != "false" {
t.Fatalf("openai_stream: got %q", fake.req.Metadata["openai_stream"])
}
if fake.req.Metadata["strict_output"] != "false" {
t.Fatalf("strict_output: got %q", fake.req.Metadata["strict_output"])
}
}
func TestChatCompletionsMetadataContractAndWorkspace(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/chat/completions", strings.NewReader(`{
"model":"client-model",
"messages":[{"role":"user","content":"hi"}],
"metadata":{
"request_id":"req-chat-001",
"workspace":"/config/workspace/iop",
"task_id":"task-123"
}
}`))
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.Target != "client-model" {
t.Fatalf("target: got %q, want client-model", fake.req.Target)
}
if fake.req.Workspace != "/config/workspace/iop" {
t.Fatalf("workspace: got %q", fake.req.Workspace)
}
if fake.req.Metadata["request_id"] != "req-chat-001" {
t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"])
}
if fake.req.Metadata["task_id"] != "task-123" {
t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"])
}
if fake.req.ModelGroupKey != "client-model" {
t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
}
if _, ok := fake.req.Metadata["workspace"]; ok {
t.Fatal("workspace should not be copied into run metadata")
}
}
func TestChatCompletionsRejectsObjectMetadata(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"m",
"messages":[{"role":"user","content":"hi"}],
"metadata":{"cli":{"flag":"x"}}
}`))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
}
}
func TestChatCompletionsRejectsSourceMetadata(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"m",
"messages":[{"role":"user","content":"hi"}],
"metadata":{"source":"manual"}
}`))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "metadata.source is not supported") {
t.Fatalf("expected source unsupported error, got %s", w.Body.String())
}
}
func TestChatCompletionsRejectsNonStringMetadataValue(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"m",
"messages":[{"role":"user","content":"hi"}],
"metadata":{"attempt":2}
}`))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "metadata.attempt must be a string") {
t.Fatalf("expected string metadata error, got %s", w.Body.String())
}
}
func TestChatCompletionsRejectsObjectWorkspaceMetadata(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"m",
"messages":[{"role":"user","content":"hi"}],
"metadata":{
"workspace": {
"path": "/home/user/workspace",
"source_branch": "develop"
}
}
}`))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "metadata.workspace must be a string") {
t.Fatalf("expected workspace string error, got %s", w.Body.String())
}
}
func TestResponsesPreservesGenericTaskMetadata(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"
}
}`))
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["task_id"] != "task-flat" {
t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"])
}
}
func TestResponsesRejectsNonStringMetadata(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
cases := []struct {
name string
body string
}{
{"cli only", `{"model":"m","input":"hi","metadata":{"cli":{"flag":"x"}}}`},
{"inference target", `{"model":"m","input":"hi","metadata":{"inference":{"target":"t"}}}`},
{"nomadcode metadata", `{"model":"m","input":"hi","metadata":{"nomadcode":{"task_id":"t"}}}`},
{"source metadata", `{"model":"m","input":"hi","metadata":{"source":"manual"}}`},
{"number metadata", `{"model":"m","input":"hi","metadata":{"attempt":2}}`},
{"boolean metadata", `{"model":"m","input":"hi","metadata":{"urgent":true}}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(tc.body))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
}
})
}
}
func TestResponsesRejectsObjectWorkspaceMetadata(t *testing.T) {
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"m",
"input":"hi",
"metadata":{
"workspace": {
"path": "/home/user/workspace",
"source_branch": "develop"
}
}
}`))
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "metadata.workspace must be a string") {
t.Fatalf("expected workspace string error, got %s", w.Body.String())
}
}
func TestResponsesWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
srv := NewServer(workspaceBoundCfg(), fake, nil)
body := `{"model":"codex","input":"hello"}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "workspace is required") {
t.Fatalf("expected workspace error, got %s", w.Body.String())
}
}
func TestResponsesWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
srv := NewServer(workspaceBoundCfg(), fake, nil)
body := `{"model":"codex","input":"hello","metadata":{"workspace":"relative/path"}}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "absolute path") {
t.Fatalf("expected absolute path error, got %s", w.Body.String())
}
}
func TestResponsesWorkspaceRequiredRouteAbsoluteWorkspaceOK(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(workspaceBoundCfg(), fake, nil)
body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/path"}}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String())
}
if fake.req.Workspace != "/abs/path" {
t.Fatalf("workspace not preserved: got %q", fake.req.Workspace)
}
}
func TestResponsesNonRequiredRouteNoWorkspaceOK(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(workspaceBoundCfg(), fake, nil)
body := `{"model":"llama3","input":"hello"}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusOK {
t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String())
}
}
func TestResponsesSurfacesDistinctRunFailures(t *testing.T) {
cases := []struct {
name string
runError string
want []string
forbidAny []string
}{
{
name: "missing workspace path from node",
runError: "cli adapter: workspace not found: /abs/missing",
want: []string{`"type":"run_error"`, "cli adapter: workspace not found: /abs/missing"},
},
{
name: "inaccessible workspace path from node",
runError: "cli adapter: workspace inaccessible: /abs/private: permission denied",
want: []string{`"type":"run_error"`, "cli adapter: workspace inaccessible: /abs/private"},
},
{
name: "agent process exit failure",
runError: "command failed: exit status 7",
want: []string{`"type":"run_error"`, "command failed: exit status 7"},
forbidAny: []string{"workspace not found", "workspace inaccessible", "workspace is not a directory"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
fake.events <- &iop.RunEvent{Type: "error", Error: tc.runError}
srv := NewServer(workspaceBoundCfg(), fake, nil)
body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/workspace"}}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleResponses(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("run failure: want 502, got %d body=%s", w.Code, w.Body.String())
}
for _, want := range tc.want {
if !strings.Contains(w.Body.String(), want) {
t.Fatalf("expected body to contain %q, got %s", want, w.Body.String())
}
}
for _, forbidden := range tc.forbidAny {
if strings.Contains(w.Body.String(), forbidden) {
t.Fatalf("body should not contain %q, got %s", forbidden, w.Body.String())
}
}
})
}
}
func TestChatCompletionsWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
srv := NewServer(workspaceBoundCfg(), fake, nil)
body := `{"model":"codex","messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "workspace is required") {
t.Fatalf("expected workspace error, got %s", w.Body.String())
}
}
func TestChatCompletionsWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
srv := NewServer(workspaceBoundCfg(), fake, nil)
body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"some/relative"}}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "absolute path") {
t.Fatalf("expected absolute path error, got %s", w.Body.String())
}
}
func TestChatCompletionsWorkspaceRequiredRouteAbsoluteWorkspaceOK(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(workspaceBoundCfg(), fake, nil)
body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"/abs/workspace"}}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String())
}
if fake.req.Workspace != "/abs/workspace" {
t.Fatalf("workspace not preserved: got %q", fake.req.Workspace)
}
}
func TestChatCompletionsNonRequiredRouteNoWorkspaceOK(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(workspaceBoundCfg(), fake, nil)
body := `{"model":"llama3","messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String())
}
}