fix(openai): structured shell 명령을 문자열로 되돌린다

This commit is contained in:
toki 2026-06-27 20:31:18 +09:00
parent 9f4fc6f683
commit 1c03ee7c3c
3 changed files with 58 additions and 3 deletions

View file

@ -172,6 +172,7 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa
`commands` 배열의 `{command: "git status", description, runInTerminal}`처럼 shell command 문자열을 command 객체에 담은 형태는 Cline의 direct-exec 경로로 오해되지 않도록 최신 Cline의 structured argv 형태인 `commands: [{"command":"git","args":["status"]}]`로 정규화한다.
UI 설명용 `description`이나 실행 위치 힌트용 `runInTerminal`은 command 객체와 최상위 args 어디에도 출력하지 않는다.
단, `cd`, `command -v`, `&&`, pipe, redirect, quote 등 shell 해석이 필요한 명령은 `commands: ["cd /work && git status"]` 같은 shell string으로 유지한다.
`{command:"cd", args:["/work","&&","git","status"]}`처럼 shell builtin이나 shell operator가 structured argv에 섞인 형태도 direct-exec로 실행되지 않도록 shell string으로 정규화한다.
호환성을 위해 `tool_choice`가 생략되었거나 `auto`/강제 tool 선택 형태로 들어오면 내부 실행 입력에서는 `tool_choice: "none"`으로 낮춰 백엔드의 auto tool-calling 요구 조건에 의해 요청 전체가 실패하지 않게 한다.
`parallel_tool_calls``stream_options`는 클라이언트 호환성을 위해 수신하지만 현재 Edge 실행 의미에는 반영하지 않는다.

View file

@ -482,6 +482,35 @@ func TestSynthesizesTemplateToolCallKeepsStructuredCommandArgs(t *testing.T) {
}
}
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 TestSynthesizesShellCommandStringWithoutExtraExecutionHints(t *testing.T) {
content := "확인합니다.\n\n{{run_commands(commands=['command -v git && echo \"found\" || echo \"not found\"'])}}"
tools := []any{map[string]any{

View file

@ -551,8 +551,8 @@ func normalizeCommandArgument(command any) (any, bool) {
}
if args, ok := stringSliceArgument(m["args"]); ok {
commandText = strings.TrimSpace(commandText)
if commandExecutableNeedsShell(commandText) {
return strings.TrimSpace(commandText) + shellJoinArgs(args), true
if commandExecutableNeedsShell(commandText) || commandArgsNeedShell(args) {
return renderShellCommand(commandText, args), true
}
cleaned := map[string]any{
"command": commandText,
@ -621,10 +621,17 @@ func shellJoinArgs(args []string) string {
return " " + strings.Join(quoted, " ")
}
func renderShellCommand(command string, args []string) string {
return strings.TrimSpace(command) + shellJoinArgs(args)
}
func shellQuoteArg(arg string) string {
if arg == "" {
return "''"
}
if isShellOperatorToken(arg) {
return arg
}
if !strings.ContainsAny(arg, " \t\n\r|&;<>(){}[]*$`'\"\\") {
return arg
}
@ -647,7 +654,25 @@ func structuredCommandFromShellWords(text string) (map[string]any, bool) {
func commandExecutableNeedsShell(text string) bool {
text = strings.TrimSpace(text)
return text == "" || strings.ContainsAny(text, " \t\n\r|&;<>(){}[]*$`'\"\\")
return text == "" || strings.ContainsAny(text, " \t\n\r|&;<>(){}[]*$`'\"\\") || commandStringNeedsShell(text)
}
func commandArgsNeedShell(args []string) bool {
for _, arg := range args {
if isShellOperatorToken(arg) {
return true
}
}
return false
}
func isShellOperatorToken(arg string) bool {
switch arg {
case "&&", "||", "|", "|&", ";", "&", ">", ">>", "<", "<<", "<<<", "2>", "2>>", "&>", "2>&1":
return true
default:
return false
}
}
func commandStringNeedsShell(text string) bool {