- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동 - 새 테스트 파일 추가 (edge, node, client, config, readability) - readability_audit 스크립트 및 baseline 추가 - roadmap/SDD 문서 갱신 - agent-client/pi/extensions/openai-sampling-parameters 추가
853 lines
31 KiB
Go
853 lines
31 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
func TestValidateJSONSchemaSubsetCoversObjectArrayEnumAdditionalProperties(t *testing.T) {
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"mode": map[string]any{
|
|
"type": "string",
|
|
"enum": []any{"read", "write"},
|
|
},
|
|
"commands": map[string]any{
|
|
"type": "array",
|
|
"items": map[string]any{"type": "string"},
|
|
},
|
|
"options": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"dry_run": map[string]any{"type": "boolean"},
|
|
},
|
|
"additionalProperties": false,
|
|
},
|
|
},
|
|
"required": []any{"mode", "commands"},
|
|
"additionalProperties": false,
|
|
}
|
|
|
|
valid := map[string]any{
|
|
"mode": "read",
|
|
"commands": []any{"git status"},
|
|
"options": map[string]any{"dry_run": true},
|
|
}
|
|
if err := validateJSONSchemaSubset(valid, schema, "$.arguments"); err != nil {
|
|
t.Fatalf("valid schema rejected: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
args map[string]any
|
|
want string
|
|
}{
|
|
{
|
|
name: "enum",
|
|
args: map[string]any{"mode": "delete", "commands": []any{"git status"}},
|
|
want: "$.arguments.mode value",
|
|
},
|
|
{
|
|
name: "array item type",
|
|
args: map[string]any{"mode": "read", "commands": []any{1}},
|
|
want: "$.arguments.commands[0] type integer does not match schema type string",
|
|
},
|
|
{
|
|
name: "additional properties",
|
|
args: map[string]any{"mode": "read", "commands": []any{"git status"}, "extra": true},
|
|
want: "$.arguments.extra is not allowed",
|
|
},
|
|
{
|
|
name: "nested additional properties",
|
|
args: map[string]any{"mode": "read", "commands": []any{"git status"}, "options": map[string]any{"dry_run": true, "trace": true}},
|
|
want: "$.arguments.options.trace is not allowed",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := validateJSONSchemaSubset(tc.args, schema, "$.arguments")
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("error got %v, want substring %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSynthesizesTemplateToolCallConvertsCommandObjectsToShellStrings(t *testing.T) {
|
|
content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'command -v git && echo \"found\" || echo \"not found\"', 'runInTerminal': True}])}}"
|
|
tools := []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": "run_commands",
|
|
},
|
|
}}
|
|
|
|
cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
|
|
|
if cleaned != "확인합니다." {
|
|
t.Fatalf("content: got %q", cleaned)
|
|
}
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
|
}
|
|
call := toolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
commands := args["commands"].([]any)
|
|
if commands[0] != `command -v git && echo "found" || echo "not found"` {
|
|
t.Fatalf("commands args: %+v", commands)
|
|
}
|
|
if _, ok := args["runInTerminal"]; ok {
|
|
t.Fatalf("runInTerminal should not be emitted: %+v", args)
|
|
}
|
|
}
|
|
|
|
func TestSynthesizesTemplateToolCallStripsTopLevelExecutionHints(t *testing.T) {
|
|
content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git status'}], runInTerminal=True, description='status')}}"
|
|
tools := []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": "run_commands",
|
|
},
|
|
}}
|
|
|
|
_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
|
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
|
}
|
|
call := toolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
commands := args["commands"].([]any)
|
|
command := commands[0].(map[string]any)
|
|
if command["command"] != "git" {
|
|
t.Fatalf("commands args: %+v", commands)
|
|
}
|
|
commandArgs := command["args"].([]any)
|
|
if len(commandArgs) != 1 || commandArgs[0] != "status" {
|
|
t.Fatalf("command args: %+v", commandArgs)
|
|
}
|
|
if _, ok := args["runInTerminal"]; ok {
|
|
t.Fatalf("runInTerminal should not be emitted: %+v", args)
|
|
}
|
|
if _, ok := args["description"]; ok {
|
|
t.Fatalf("description should not be emitted: %+v", args)
|
|
}
|
|
}
|
|
|
|
func TestSynthesizesTemplateToolCallKeepsStructuredCommandArgs(t *testing.T) {
|
|
content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git', 'args': ['status', '--short'], 'description': 'status'}])}}"
|
|
tools := []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": "run_commands",
|
|
},
|
|
}}
|
|
|
|
_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
|
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
|
}
|
|
call := toolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
commands := args["commands"].([]any)
|
|
command := commands[0].(map[string]any)
|
|
if command["command"] != "git" {
|
|
t.Fatalf("command: %+v", command)
|
|
}
|
|
commandArgs := command["args"].([]any)
|
|
if len(commandArgs) != 2 || commandArgs[0] != "status" || commandArgs[1] != "--short" {
|
|
t.Fatalf("command args: %+v", commandArgs)
|
|
}
|
|
if _, ok := command["description"]; ok {
|
|
t.Fatalf("description should not be emitted: %+v", command)
|
|
}
|
|
}
|
|
|
|
func TestSynthesizesTemplateToolCallFollowsClineUnionSchemaWithShellStrings(t *testing.T) {
|
|
content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git', 'args': ['-C', '/config/workspace/iop', 'status']}, {'command': 'pwd && ls -la /config/workspace/'}])}}"
|
|
tools := []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": "run_commands",
|
|
"parameters": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"commands": map[string]any{
|
|
"type": "array",
|
|
"items": map[string]any{
|
|
"anyOf": []any{
|
|
map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"command": map[string]any{"type": "string"},
|
|
"args": map[string]any{
|
|
"type": "array",
|
|
"items": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
|
|
_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
|
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
|
}
|
|
call := toolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string][]string
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 2 {
|
|
t.Fatalf("commands len: %+v", args["commands"])
|
|
}
|
|
if args["commands"][0] != "git -C /config/workspace/iop status" {
|
|
t.Fatalf("first command: %+v", args["commands"][0])
|
|
}
|
|
if args["commands"][1] != "pwd && ls -la /config/workspace/" {
|
|
t.Fatalf("second command: %+v", args["commands"][1])
|
|
}
|
|
}
|
|
|
|
func TestSynthesizesTemplateToolCallConvertsStructuredShellOperatorsToString(t *testing.T) {
|
|
content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'cd', 'args': ['/config/workspace/iop', '&&', 'git', 'status', '&&', 'git', 'diff', '--stat']}, {'command': 'ls', 'args': ['-la', '/config/workspace/iop', '&&', 'pwd']}])}}"
|
|
tools := []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": "run_commands",
|
|
},
|
|
}}
|
|
|
|
_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
|
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
|
}
|
|
call := toolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
commands := args["commands"].([]any)
|
|
if commands[0] != "cd /config/workspace/iop && git status && git diff --stat" {
|
|
t.Fatalf("first command: %+v", commands[0])
|
|
}
|
|
if commands[1] != "ls -la /config/workspace/iop && pwd" {
|
|
t.Fatalf("second command: %+v", commands[1])
|
|
}
|
|
}
|
|
|
|
func TestSynthesizesTemplateToolCallFollowsStringArrayCommandSchema(t *testing.T) {
|
|
content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': 'status'}])}}"
|
|
tools := []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": "run_commands",
|
|
"parameters": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"commands": map[string]any{
|
|
"type": "array",
|
|
"items": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
|
|
_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
|
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
|
}
|
|
call := toolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string][]string
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 1 || args["commands"][0] != "git status" {
|
|
t.Fatalf("commands args: %+v", args["commands"])
|
|
}
|
|
}
|
|
|
|
func TestSynthesizesTemplateToolCallFollowsSingleCommandSchema(t *testing.T) {
|
|
content := "확인합니다.\n\n{{bash(commands=[{'command': 'cd', 'args': ['/config/workspace/iop', '&&', 'git', 'status']}], runInTerminal=True)}}"
|
|
tools := []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": "bash",
|
|
"parameters": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"command": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
|
|
_, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
|
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
|
}
|
|
call := toolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string]string
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if args["command"] != "cd /config/workspace/iop && git status" {
|
|
t.Fatalf("command arg: %+v", args)
|
|
}
|
|
if _, ok := args["commands"]; ok {
|
|
t.Fatalf("commands should not be emitted: %+v", args)
|
|
}
|
|
if _, ok := args["runInTerminal"]; ok {
|
|
t.Fatalf("runInTerminal should not be emitted: %+v", args)
|
|
}
|
|
}
|
|
|
|
func TestSynthesizesShellCommandStringWithoutExtraExecutionHints(t *testing.T) {
|
|
content := "확인합니다.\n\n{{run_commands(commands=['command -v git && echo \"found\" || echo \"not found\"'])}}"
|
|
tools := []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": "run_commands",
|
|
},
|
|
}}
|
|
|
|
cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
|
|
|
if cleaned != "확인합니다." {
|
|
t.Fatalf("content: got %q", cleaned)
|
|
}
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
|
}
|
|
call := toolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
commands := args["commands"].([]any)
|
|
if commands[0] != `command -v git && echo "found" || echo "not found"` {
|
|
t.Fatalf("commands args: %+v", commands)
|
|
}
|
|
if _, ok := args["runInTerminal"]; ok {
|
|
t.Fatalf("runInTerminal should not be emitted: %+v", args)
|
|
}
|
|
}
|
|
|
|
func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) {
|
|
content := "확인합니다.\n\n{{run_commands(commands=[{'command': \"printf ')}}'\", 'description': 'marker test', 'runInTerminal': False}])}}"
|
|
tools := []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": "run_commands",
|
|
},
|
|
}}
|
|
|
|
cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
|
|
|
if cleaned != "확인합니다." {
|
|
t.Fatalf("content: got %q", cleaned)
|
|
}
|
|
if len(toolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
|
}
|
|
call, ok := toolCalls[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("tool call shape: %+v", toolCalls[0])
|
|
}
|
|
fn, ok := call["function"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("function shape: %+v", call["function"])
|
|
}
|
|
var args map[string][]string
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 1 || args["commands"][0] != "printf ')}}'" {
|
|
t.Fatalf("commands args: %+v", args["commands"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) {
|
|
run1Events := make(chan *iop.RunEvent, 2)
|
|
run1Events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call><function=delete_everything><parameter=confirm>true</parameter></function></tool_call>"}
|
|
run1Events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
run2Events := make(chan *iop.RunEvent, 2)
|
|
run2Events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call><function=delete_everything><parameter=confirm>true</parameter></function></tool_call>"}
|
|
run2Events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"qwen3.6:35b",
|
|
"messages":[{"role":"user","content":"status"}],
|
|
"tools":[{"type":"function","function":{"name":"run_commands"}}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("status: got %d, want 502; body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp errorResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Error.Type != "tool_validation_error" {
|
|
t.Fatalf("unexpected error type: got %q, want tool_validation_error", resp.Error.Type)
|
|
}
|
|
if !strings.Contains(resp.Error.Message, "delete_everything") {
|
|
t.Fatalf("unexpected error message: %q", resp.Error.Message)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsLeavesUnknownTemplateToolCallAsContent(t *testing.T) {
|
|
run1Events := make(chan *iop.RunEvent, 2)
|
|
run1Events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"}
|
|
run1Events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
run2Events := make(chan *iop.RunEvent, 2)
|
|
run2Events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"}
|
|
run2Events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"qwen3.6:35b",
|
|
"messages":[{"role":"user","content":"status"}],
|
|
"tools":[{"type":"function","function":{"name":"run_commands"}}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("status: got %d, want 502; body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp errorResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Error.Type != "tool_validation_error" {
|
|
t.Fatalf("unexpected error type: got %q, want tool_validation_error", resp.Error.Type)
|
|
}
|
|
if !strings.Contains(resp.Error.Message, "delete_everything") {
|
|
t.Fatalf("unexpected error message: %q", resp.Error.Message)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesToolCallsFromClineTextBlock(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"cd /config/workspace/iop && git status\"]\n</parameter>\n</function>\n</tool_call>"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"qwen3.6:35b",
|
|
"messages":[{"role":"user","content":"status"}],
|
|
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
|
|
"tool_choice":"auto"
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" {
|
|
t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
|
|
}
|
|
if strings.Contains(choice.Message.Content, "<tool_call>") {
|
|
t.Fatalf("content still contains raw tool_call block: %q", choice.Message.Content)
|
|
}
|
|
if choice.Message.Content != "먼저 현재 git 상태를 확인하겠습니다." {
|
|
t.Fatalf("content: got %q", choice.Message.Content)
|
|
}
|
|
if len(choice.Message.ToolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
|
|
}
|
|
call, ok := choice.Message.ToolCalls[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("tool call shape: %+v", choice.Message.ToolCalls[0])
|
|
}
|
|
if call["id"] != "call_iop_run-test_1" {
|
|
t.Fatalf("tool call id: got %+v", call["id"])
|
|
}
|
|
fn, ok := call["function"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("function shape: %+v", call["function"])
|
|
}
|
|
if fn["name"] != "run_commands" {
|
|
t.Fatalf("function name: got %+v", fn["name"])
|
|
}
|
|
var args map[string][]string
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 1 || args["commands"][0] != "cd /config/workspace/iop && git status" {
|
|
t.Fatalf("commands args: %+v", args["commands"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "사용자가 변경 내용을 푸시해달라고 요청하셨습니다. 먼저 현재 git 상태를 확인하여 변경된 파일과 커밋 가능한 내용을 파악하겠습니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"qwen3.6:35b",
|
|
"messages":[{"role":"user","content":"push changes"}],
|
|
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
|
|
"tool_choice":"auto"
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" {
|
|
t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
|
|
}
|
|
if strings.Contains(choice.Message.Content, "{{run_commands") {
|
|
t.Fatalf("content still contains raw template tool call: %q", choice.Message.Content)
|
|
}
|
|
if len(choice.Message.ToolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
|
|
}
|
|
call, ok := choice.Message.ToolCalls[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("tool call shape: %+v", choice.Message.ToolCalls[0])
|
|
}
|
|
fn, ok := call["function"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("function shape: %+v", call["function"])
|
|
}
|
|
if fn["name"] != "run_commands" {
|
|
t.Fatalf("function name: got %+v", fn["name"])
|
|
}
|
|
var args map[string][]map[string]any
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 1 || args["commands"][0]["command"] != "git" {
|
|
t.Fatalf("commands args: %+v", args["commands"])
|
|
}
|
|
commandArgs := args["commands"][0]["args"].([]any)
|
|
if len(commandArgs) != 1 || commandArgs[0] != "status" {
|
|
t.Fatalf("command args: %+v", commandArgs)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesTextToolCallsForProviderRoute(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"git status\"]\n</parameter>\n</function>\n</tool_call>"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"qwen3.6:35b",
|
|
"messages":[{"role":"user","content":"status"}],
|
|
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
|
|
"tool_choice":"auto"
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) == 0 {
|
|
t.Fatalf("provider text should be synthesized: %+v", choice)
|
|
}
|
|
if strings.Contains(choice.Message.Content, "<tool_call>") {
|
|
t.Fatalf("provider raw content should not be preserved: %q", choice.Message.Content)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "두 줄을 `18083` -> `18081`로 수정하고 검증을 진행한다.\n\n" + editToolCallFixture()}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"qwen3.6:35b",
|
|
"messages":[{"role":"user","content":"edit"}],
|
|
"tools":[{"type":"function","function":{"name":"edit","parameters":{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["newText"],"additionalProperties":false}}},"required":["path","edits"],"additionalProperties":false}}}],
|
|
"tool_choice":"auto"
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "<tool_call") || strings.Contains(body, `\u003ctool_call`) {
|
|
t.Fatalf("raw tool_call leaked in response: %s", body)
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) != 1 {
|
|
t.Fatalf("expected edit tool call, got %+v", choice)
|
|
}
|
|
call := choice.Message.ToolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
if fn["name"] != "edit" {
|
|
t.Fatalf("function name: got %+v", fn["name"])
|
|
}
|
|
args := decodeEditToolArguments(t, fn["arguments"].(string))
|
|
if args["path"] != "apps/client/test/client_bootstrap_test.dart" {
|
|
t.Fatalf("path argument: %+v", args["path"])
|
|
}
|
|
edits := args["edits"].([]any)
|
|
edit := edits[0].(map[string]any)
|
|
if _, ok := edit["path"]; ok {
|
|
t.Fatalf("edit item must not contain path: %+v", edit)
|
|
}
|
|
if !strings.Contains(edit["newText"].(string), "http://<edge-host>:18081/v1/chat/completions") {
|
|
t.Fatalf("newText lost angle-bracket placeholder: %+v", edit["newText"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStreamSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "수정합니다.\n\n" + editToolCallFixture()}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"qwen3.6:35b",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"edit"}],
|
|
"tools":[{"type":"function","function":{"name":"edit","parameters":{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["newText"],"additionalProperties":false}}},"required":["path","edits"],"additionalProperties":false}}}],
|
|
"tool_choice":"auto"
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "<tool_call") || strings.Contains(body, `\u003ctool_call`) {
|
|
t.Fatalf("raw tool_call leaked in stream: %s", body)
|
|
}
|
|
if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("expected streamed edit tool call, got:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func editToolCallFixture() string {
|
|
edits, _ := json.Marshal([]map[string]string{{
|
|
"oldText": "final baseUrl = 'http://127.0.0.1:18083/v1/chat/completions';",
|
|
"newText": "```bash\ncurl -fsS http://<edge-host>:18081/v1/chat/completions \\\n -H \"Content-Type: application/json\"\n```",
|
|
}})
|
|
return "<tool_call>\n" +
|
|
"<function=edit>\n" +
|
|
"<parameter=path>\napps/client/test/client_bootstrap_test.dart\n</parameter>\n" +
|
|
"<parameter=edits>\n" + string(edits) + "\n</parameter>\n" +
|
|
"</function>\n" +
|
|
"</tool_call>"
|
|
}
|
|
|
|
func decodeEditToolArguments(t *testing.T, raw string) map[string]any {
|
|
t.Helper()
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
edits, ok := args["edits"].([]any)
|
|
if !ok || len(edits) != 1 {
|
|
t.Fatalf("edits argument: %+v", args["edits"])
|
|
}
|
|
if _, ok := edits[0].(map[string]any); !ok {
|
|
t.Fatalf("edit item: %+v", edits[0])
|
|
}
|
|
return args
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesProviderTextToolCallsWhenFallbackMarked(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"cd /config/workspace/iop && git status\"]\n</parameter>\n</function>\n</tool_call>"}
|
|
fake.events <- &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
runtimeMetadataOpenAITextToolFallback: "true",
|
|
},
|
|
}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"qwen3.6:35b",
|
|
"messages":[{"role":"user","content":"status"}],
|
|
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}],
|
|
"tool_choice":"auto"
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" {
|
|
t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
|
|
}
|
|
if strings.Contains(choice.Message.Content, "<tool_call>") {
|
|
t.Fatalf("content still contains raw tool_call block: %q", choice.Message.Content)
|
|
}
|
|
if len(choice.Message.ToolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
|
|
}
|
|
call := choice.Message.ToolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string][]string
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 1 || args["commands"][0] != "cd /config/workspace/iop && git status" {
|
|
t.Fatalf("commands args: %+v", args["commands"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSanitizesKnownSentinelToken(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking <|mask_end|>"}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer <|mask_end|>"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"qwen3.6:35b",
|
|
"messages":[{"role":"user","content":"hi"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if strings.Contains(resp.Choices[0].Message.Content, "<|mask_end|>") || strings.Contains(resp.Choices[0].Message.ReasoningContent, "<|mask_end|>") {
|
|
t.Fatalf("sentinel leaked in response: %+v", resp.Choices[0].Message)
|
|
}
|
|
if resp.Choices[0].Message.Content != "answer" || resp.Choices[0].Message.ReasoningContent != "thinking" {
|
|
t.Fatalf("unexpected sanitized output: %+v", resp.Choices[0].Message)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesToolCallAndDropsTrailingSentinel(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call>\n<function=run_commands>\n<parameter=commands>[\"git status\"]</parameter>\n</function>\n</tool_call><|mask_end|>"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"qwen3.6:35b",
|
|
"messages":[{"role":"user","content":"status"}],
|
|
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if strings.Contains(w.Body.String(), "<|mask_end|>") {
|
|
t.Fatalf("sentinel leaked in response body: %s", w.Body.String())
|
|
}
|
|
if resp.Choices[0].FinishReason != "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 1 {
|
|
t.Fatalf("expected structured tool call, got %+v", resp.Choices[0])
|
|
}
|
|
if resp.Choices[0].Message.Content != "" {
|
|
t.Fatalf("trailing sentinel should not become content: %q", resp.Choices[0].Message.Content)
|
|
}
|
|
}
|