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: "true"} run1Events <- &iop.RunEvent{Type: "complete"} run2Events := make(chan *iop.RunEvent, 2) run2Events <- &iop.RunEvent{Type: "delta", Delta: "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 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\n\n\n[\"cd /config/workspace/iop && git status\"]\n\n\n"} 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, "") { 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\n\n\n[\"git status\"]\n\n\n"} 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, "") { 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, ":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, ":18081/v1/chat/completions \\\n -H \"Content-Type: application/json\"\n```", }}) return "\n" + "\n" + "\napps/client/test/client_bootstrap_test.dart\n\n" + "\n" + string(edits) + "\n\n" + "\n" + "" } 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\n\n\n[\"cd /config/workspace/iop && git status\"]\n\n\n"} 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, "") { 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: "\n\n[\"git status\"]\n\n<|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) } }