diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index a45fa2a0b..9c633c64f 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -73,7 +73,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu // Convert input array to messages if input := root.Get("input"); input.Exists() && input.IsArray() { - rawInputArray := input.Array() + rawInputArray := toolIndex.shellHistory(input.Array()) explicitOutputCounts := make(map[string]int) missingIDOutputsCount := 0 for _, item := range rawInputArray { @@ -482,6 +482,12 @@ func convertResponsesToolChoiceWithIndex(toolChoice gjson.Result, toolIndex *res } choiceType := toolChoice.Get("type").String() + if choiceType == "shell" { + if name := toolIndex.shellName(); name != "" { + converted, _ := sjson.SetBytes([]byte(`{"type":"function","function":{}}`), "function.name", name) + return converted + } + } if choiceType != "function" && choiceType != "custom" { return []byte(toolChoice.Raw) } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index 1a1ac352f..6d87b1f5f 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -213,6 +213,13 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte if _, isInc := incompleteByFinishReason(st.FinishReason); isInc { toolStatus = "incomplete" } + if st.ToolIndex.isShell(name) { + item, errShellCallItem := shellCallItem(callID, args, toolStatus) + if errShellCallItem == nil { + outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item}) + } + continue + } if st.FuncItemCustom[key] { item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) @@ -359,7 +366,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, failToolInput := func(err error) { if st.ToolInputError() == nil { st.SetToolInputError(err) - out = append(out, emitRespEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq()))) + out = append(out, emitRespEvent("response.failed", responsesToolInputFailure(st.ResponseID, nextSeq(), err))) } } emitToolItem := func(key string, force bool) { @@ -390,7 +397,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, outputIndex := st.FuncOutputIx[key] _, isCustomTool := st.CustomToolNames[name] st.FuncItemCustom[key] = isCustomTool - if isCustomTool { + if st.ToolIndex.isShell(name) { + o := []byte(`{"type":"response.output_item.added"}`) + o, _ = sjson.SetBytes(o, "sequence_number", nextSeq()) + o, _ = sjson.SetBytes(o, "output_index", outputIndex) + o, _ = sjson.SetRawBytes(o, "item", shellCallPlaceholder(callID)) + out = append(out, emitRespEvent("response.output_item.added", o)) + } else if isCustomTool { if st.ToolIndex.isApplyPatch(name) { d := st.ToolIndex.byChat[name] st.ApplyPatchCalls[key] = &translatorcommon.ApplyPatchCallState{ @@ -417,7 +430,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.FuncItemAdded[key] = true } emitPendingFunctionArgs := func(key string) { - if !st.FuncItemAdded[key] || st.ToolInputError() != nil { + if !st.FuncItemAdded[key] || st.ToolInputError() != nil || st.ToolIndex.isShell(st.FuncNames[key]) { return } argsBuf := st.FuncArgsBuf[key] @@ -644,6 +657,22 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, toolStatus = "incomplete" } + if st.ToolIndex.isShell(name) { + item, errShellCallItem := shellCallItem(callID, args, toolStatus) + if errShellCallItem != nil { + failToolInput(errShellCallItem) + return + } + event := []byte(`{"type":"response.output_item.done"}`) + event, _ = sjson.SetBytes(event, "sequence_number", nextSeq()) + event, _ = sjson.SetBytes(event, "output_index", outputIndex) + event, _ = sjson.SetRawBytes(event, "item", item) + out = append(out, emitRespEvent("response.output_item.done", event)) + st.FuncItemDone[key] = true + st.FuncArgsDone[key] = true + continue + } + if st.FuncItemCustom[key] { input := "" if patchCall := st.ApplyPatchCalls[key]; patchCall != nil { @@ -1044,6 +1073,15 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co if isIncomplete { toolStatus = "incomplete" } + if toolIndex.isShell(name) { + item, errShellCallItem := shellCallItem(callID, args, toolStatus) + if errShellCallItem != nil { + st.SetToolInputError(errShellCallItem) + return false + } + outputItems = append(outputItems, item) + return true + } if _, isCustomTool := customToolNames[name]; isCustomTool { item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) @@ -1081,7 +1119,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co }) } if st.ToolInputError() != nil { - return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(id, 0), "response").Raw) + return []byte(gjson.GetBytes(responsesToolInputFailure(id, 0, st.ToolInputError()), "response").Raw) } if len(outputItems) > 0 { resp, _ = sjson.SetRawBytes(resp, "output", translatorcommon.JoinRawArray(outputItems)) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go index 6b6cb2902..dd0a1405b 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go @@ -20,6 +20,7 @@ type responsesToolDeclaration struct { localName string namespace string custom bool + shell bool } // walkResponsesToolDeclarations visits the tool declarations of a Responses @@ -36,15 +37,23 @@ type responsesToolDeclaration struct { func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDeclaration) bool) { var declarations []responsesToolDeclaration emit := func(tool gjson.Result, namespaceName string) { - var custom bool + var custom, shell bool switch strings.TrimSpace(tool.Get("type").String()) { case "", "function": case "custom": custom = true + case "shell": + if namespaceName != "" || tool.Get("environment.type").String() != "local" { + return + } + shell = true default: return } localName := responsesToolName(tool) + if shell { + localName = "__cpa_local_shell" + } if localName == "" { return } @@ -54,6 +63,7 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe localName: localName, namespace: namespaceName, custom: custom, + shell: shell, }) } scan := func(tools gjson.Result) { @@ -86,6 +96,25 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe }) } + // Reserve user identities before assigning the synthetic shell name. + reserved := make(map[string]bool) + for _, d := range declarations { + if !d.shell { + reserved[d.localName] = true + reserved[d.chatName] = true + reserved[rawResponsesNamespaceQualifiedName(d.namespace, d.localName)] = true + } + } + shellName := "__cpa_local_shell" + for suffix := 1; reserved[shellName]; suffix++ { + shellName = "__cpa_local_shell_" + strconv.Itoa(suffix) + } + for i := range declarations { + if declarations[i].shell { + declarations[i].localName = shellName + declarations[i].chatName = shellName + } + } disambiguateResponsesChatToolNames(declarations) proceed := true diff --git a/internal/translator/openai/openai/responses/responses_tool_index.go b/internal/translator/openai/openai/responses/responses_tool_index.go index f2e35a1ff..353e89717 100644 --- a/internal/translator/openai/openai/responses/responses_tool_index.go +++ b/internal/translator/openai/openai/responses/responses_tool_index.go @@ -118,6 +118,8 @@ func (idx *responsesToolIndex) chatTools() [][]byte { convert := convertResponsesFunctionToolToOpenAIChat if d.custom { convert = convertResponsesCustomToolToOpenAIChat + } else if d.shell { + convert = convertResponsesShellToolToOpenAIChat } if tool, ok := convert(d.tool, d.chatName); ok { merged = append(merged, tool) diff --git a/internal/translator/openai/openai/responses/shell_tool.go b/internal/translator/openai/openai/responses/shell_tool.go new file mode 100644 index 000000000..9b8f9cd8f --- /dev/null +++ b/internal/translator/openai/openai/responses/shell_tool.go @@ -0,0 +1,132 @@ +package responses + +import ( + "errors" + "math" + "strconv" + "strings" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +var errInvalidShellAction = errors.New("invalid shell action: expected nonempty commands strings and optional positive integer limits") + +func convertResponsesShellToolToOpenAIChat(_ gjson.Result, name string) ([]byte, bool) { + tool := []byte(`{"type":"function","function":{"name":"","description":"Request commands to execute in the client-provided local shell environment. Each commands entry is a complete shell command, not an argv element.","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"},"minItems":1},"timeout_ms":{"type":"integer","minimum":1},"max_output_length":{"type":"integer","minimum":1}},"required":["commands"],"additionalProperties":false}}}`) + tool, _ = sjson.SetBytes(tool, "function.name", name) + return tool, true +} + +func (idx *responsesToolIndex) isShell(name string) bool { + return idx.byChat[name].shell +} + +func (idx *responsesToolIndex) shellName() string { + for _, d := range idx.declarations { + if d.shell { + return d.chatName + } + } + return "" +} + +// Normalize shell history before the existing call grouping and output pairing. +// Keep the entire output envelope as JSON text, including truncation metadata. +func (idx *responsesToolIndex) shellHistory(items []gjson.Result) []gjson.Result { + name := idx.shellName() + if name == "" { + // Historical calls remain meaningful after the client withdraws the tool. + // Reserve a replay-only name without adding an available declaration. + reserved := make(map[string]bool) + for key := range idx.byChat { + reserved[key] = true + } + for key := range idx.byRaw { + reserved[key] = true + } + for key := range idx.byLocal { + reserved[key] = true + } + for _, item := range items { + if kind := item.Get("type").String(); kind == "function_call" || kind == "custom_tool_call" { + reserved[idx.canonicalName(item.Get("name").String())] = true + } + } + name = "__cpa_local_shell" + for suffix := 1; reserved[name]; suffix++ { + name = "__cpa_local_shell_" + strconv.Itoa(suffix) + } + } + for i, item := range items { + raw := []byte(item.Raw) + switch item.Get("type").String() { + case "shell_call": + if environment := item.Get("environment.type"); environment.Exists() && environment.String() != "local" { + continue + } + raw, _ = sjson.SetBytes(raw, "type", "function_call") + raw, _ = sjson.SetBytes(raw, "name", name) + raw, _ = sjson.SetBytes(raw, "arguments", item.Get("action").Raw) + case "shell_call_output": + raw, _ = sjson.SetBytes(raw, "type", "function_call_output") + raw, _ = sjson.SetBytes(raw, "output", item.Raw) + default: + continue + } + items[i] = gjson.ParseBytes(raw) + } + return items +} + +func shellCallItem(callID, arguments, status string) ([]byte, error) { + action := gjson.Parse(arguments) + if !gjson.Valid(arguments) || !action.IsObject() { + return nil, errInvalidShellAction + } + commands := action.Get("commands") + if !commands.IsArray() || len(commands.Array()) == 0 { + return nil, errInvalidShellAction + } + for _, command := range commands.Array() { + if command.Type != gjson.String || strings.TrimSpace(command.String()) == "" { + return nil, errInvalidShellAction + } + } + valid := true + action.ForEach(func(key, value gjson.Result) bool { + switch key.String() { + case "commands": + case "timeout_ms", "max_output_length": + if value.Type != gjson.Null && (value.Type != gjson.Number || value.Float() <= 0 || math.Trunc(value.Float()) != value.Float()) { + valid = false + } + default: + valid = false + } + return valid + }) + if !valid { + return nil, errInvalidShellAction + } + item := shellCallPlaceholder(callID) + item, _ = sjson.SetBytes(item, "status", status) + item, _ = sjson.SetRawBytes(item, "action", []byte(arguments)) + return item, nil +} + +func shellCallPlaceholder(callID string) []byte { + item := []byte(`{"id":"","type":"shell_call","status":"in_progress","call_id":"","action":{"commands":[]}}`) + item, _ = sjson.SetBytes(item, "id", "sh_"+callID) + item, _ = sjson.SetBytes(item, "call_id", callID) + return item +} + +func responsesToolInputFailure(responseID string, sequence int, err error) []byte { + failure := translatorcommon.ApplyPatchFailure(responseID, sequence) + if errors.Is(err, errInvalidShellAction) { + failure, _ = sjson.SetBytes(failure, "response.error.message", errInvalidShellAction.Error()) + } + return failure +} diff --git a/internal/translator/openai/openai/responses/shell_tool_test.go b/internal/translator/openai/openai/responses/shell_tool_test.go new file mode 100644 index 000000000..3cf36f01e --- /dev/null +++ b/internal/translator/openai/openai/responses/shell_tool_test.go @@ -0,0 +1,187 @@ +package responses + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func TestLocalShellDeclarationSurvivesChatTranslation(t *testing.T) { + request := []byte(`{"tools":[{"type":"shell","environment":{"type":"local"}}],"tool_choice":"auto","input":"Run echo SHELL_OK"}`) + got := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false) + if gjson.GetBytes(got, "tools.#").Int() != 1 { + t.Fatalf("local shell declaration was dropped from chat request: %s", got) + } + if gjson.GetBytes(got, "tools.0.function.parameters.properties.commands.items.type").String() != "string" { + t.Fatalf("shell action must expose commands as strings: %s", got) + } +} + +const shellRequest = `{"tools":[{"type":"shell","environment":{"type":"local"}},{"type":"function","name":"__cpa_local_shell","parameters":{}},{"type":"custom","name":"__cpa_local_shell_1"},{"type":"namespace","name":"ns","tools":[{"type":"function","name":"lookup","parameters":{}}]}],"tool_choice":{"type":"shell"}}` + +func shellUpstreamCall(name, args string) []byte { + call := []byte(`{"index":0,"id":"call_shell","type":"function","function":{}}`) + call, _ = sjson.SetBytes(call, "function.name", name) + call, _ = sjson.SetBytes(call, "function.arguments", args) + return call +} + +func shellNonStream(request []byte, name, args string) []byte { + upstream := []byte(`{"id":"resp_test","choices":[{"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","tool_calls":[]}}]}`) + upstream, _ = sjson.SetRawBytes(upstream, "choices.0.message.tool_calls.0", shellUpstreamCall(name, args)) + return ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, upstream, nil) +} + +func shellStream(request []byte, name, args string) []gjson.Result { + var state any + var events []gjson.Result + push := func(chunk []byte) { + for _, output := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, &state) { + for _, line := range strings.Split(string(output), "\n") { + if strings.HasPrefix(line, "data: ") { + events = append(events, gjson.Parse(strings.TrimPrefix(line, "data: "))) + } + } + } + } + for i, fragment := range []string{args[:len(args)/2], args[len(args)/2:]} { + chunk := []byte(`{"id":"resp_test","choices":[{"index":0,"delta":{"tool_calls":[]}}]}`) + call := shellUpstreamCall(name, fragment) + if i == 1 { + call, _ = sjson.DeleteBytes(call, "id") + call, _ = sjson.DeleteBytes(call, "function.name") + } + chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.0", call) + push(append([]byte("data: "), chunk...)) + } + push([]byte(`data: {"id":"resp_test","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`)) + push([]byte("data: [DONE]")) + return events +} + +func TestLocalShellRoundTrip(t *testing.T) { + request := []byte(shellRequest) + chat := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false) + name := gjson.GetBytes(chat, "tools.0.function.name").String() + if name == "" || name == "__cpa_local_shell" || name == "__cpa_local_shell_1" || gjson.GetBytes(chat, "tools.#").Int() != 4 { + t.Fatalf("synthetic tool collided with user tools: %s", chat) + } + if gjson.GetBytes(chat, "tool_choice.function.name").String() != name { + t.Fatalf("shell tool_choice not mapped: %s", chat) + } + args := `{"commands":["echo SHELL_OK","pwd"],"timeout_ms":120000,"max_output_length":4096}` + nonstream := shellNonStream(request, name, args) + item := gjson.GetBytes(nonstream, "output.0") + if item.Get("type").String() != "shell_call" || item.Get("action.commands.0").String() != "echo SHELL_OK" || item.Get("status").String() != "completed" || item.Get("name").Exists() || item.Get("arguments").Exists() { + t.Fatalf("invalid shell item: %s", nonstream) + } + var done, completed gjson.Result + for _, event := range shellStream(request, name, args) { + switch event.Get("type").String() { + case "response.output_item.added": + if event.Get("item.type").String() != "shell_call" { + t.Fatalf("wrong added item: %s", event.Raw) + } + case "response.output_item.done": + done = event.Get("item") + case "response.completed": + completed = event.Get("response.output.0") + case "response.function_call_arguments.delta", "response.function_call_arguments.done": + t.Fatalf("shell leaked function event: %s", event.Raw) + } + } + if !done.Exists() || !reflect.DeepEqual(done.Value(), item.Value()) || !reflect.DeepEqual(done.Value(), completed.Value()) { + t.Fatalf("inconsistent final items: nonstream=%s done=%s completed=%s", item.Raw, done.Raw, completed.Raw) + } + output := `{"type":"shell_call_output","call_id":"call_shell","max_output_length":4096,"output":[{"stdout":"SHELL_OK\n","stderr":"warning","outcome":{"type":"exit","exit_code":0}},{"stdout":"partial","stderr":"","outcome":{"type":"timeout"}}]}` + replay, _ := sjson.SetRawBytes(request, "input", []byte("["+item.Raw+`,`+`{"type":"function_call","call_id":"call_user","name":"__cpa_local_shell","arguments":"{}"},`+output+`,`+`{"type":"function_call_output","call_id":"call_user","output":"ok"}]`)) + replayed := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", replay, false) + if gjson.GetBytes(replayed, "messages.0.tool_calls.#").Int() != 2 || gjson.GetBytes(replayed, "messages.0.tool_calls.0.function.name").String() != name || gjson.GetBytes(replayed, "messages.0.tool_calls.0.function.arguments").String() != args { + t.Fatalf("shell history not grouped/replayed: %s", replayed) + } + content := gjson.GetBytes(replayed, "messages.1.content").String() + if gjson.GetBytes(replayed, "messages.1.role").String() != "tool" || !reflect.DeepEqual(gjson.Parse(content).Value(), gjson.Parse(output).Value()) { + t.Fatalf("shell output envelope lost: %s", replayed) + } + for _, tc := range []struct{ name, kind string }{{"__cpa_local_shell", "function_call"}, {"__cpa_local_shell_1", "custom_tool_call"}, {"ns__lookup", "function_call"}} { + got := shellNonStream(request, tc.name, `{"input":"hello"}`) + if gjson.GetBytes(got, "output.0.type").String() != tc.kind { + t.Fatalf("user tool misclassified: %s", got) + } + } + unknown := shellNonStream([]byte(`{}`), name, args) + if gjson.GetBytes(unknown, "output.0.type").String() != "function_call" { + t.Fatalf("synthetic name recognized without provenance: %s", unknown) + } +} + +func TestLocalShellInvalidActions(t *testing.T) { + request := []byte(`{"tools":[{"type":"shell","environment":{"type":"local"}}]}`) + for _, args := range []string{`{`, `{}`, `{"commands":[]}`, `{"commands":[42]}`, `{"commands":[" "]}`, `{"command":["echo","ok"]}`, `{"commands":["pwd"],"timeout_ms":-1}`, `{"commands":["pwd"],"timeout_ms":1.5}`, `{"commands":["pwd"],"env":{}}`} { + t.Run(args, func(t *testing.T) { + got := shellNonStream(request, "__cpa_local_shell", args) + if gjson.GetBytes(got, "status").String() != "failed" || !strings.Contains(gjson.GetBytes(got, "error.message").String(), "shell") { + t.Fatalf("invalid action accepted: %s", got) + } + failed := false + for _, event := range shellStream(request, "__cpa_local_shell", args) { + if event.Get("type").String() == "response.failed" { + failed = true + } + if event.Get("type").String() == "response.completed" || event.Get("type").String() == "response.output_item.done" { + t.Fatalf("invalid action completed: %s", event.Raw) + } + } + if !failed { + t.Fatal("invalid action did not fail streaming") + } + }) + } +} + +func TestLocalShellOnlyExplicitLocalEnvironment(t *testing.T) { + for _, environment := range []string{`{"type":"container_auto"}`, `{"type":"container_reference","container_id":"cntr_x"}`, `{}`} { + request := []byte(`{"tools":[{"type":"shell","environment":` + environment + `}]}`) + got := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false) + if gjson.GetBytes(got, "tools.#").Int() != 0 { + t.Fatalf("hosted shell represented as local: %s", got) + } + } + for _, choice := range []string{`"auto"`, `"required"`, `"none"`} { + request := []byte(`{"tools":[{"type":"shell","environment":{"type":"local"}}],"tool_choice":` + choice + `}`) + got := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false) + var expected string + if errUnmarshal := json.Unmarshal([]byte(choice), &expected); errUnmarshal != nil { + t.Fatal(errUnmarshal) + } + if gjson.GetBytes(got, "tool_choice").String() != expected { + t.Fatalf("choice changed: %s", got) + } + } +} + +func TestLocalShellHistoryWithoutDeclaration(t *testing.T) { + for _, tools := range []string{``, `,"tools":[]`, `,"tools":[{"type":"function","name":"__cpa_local_shell_1","parameters":{}}]`} { + request := []byte(`{"input":[{"type":"shell_call","call_id":"shell_1","action":{"commands":["pwd"]}},{"type":"shell_call_output","call_id":"shell_1","output":[{"stdout":"/workspace","stderr":"","outcome":{"type":"exit","exit_code":0}}]},{"type":"function_call","call_id":"user_1","name":"__cpa_local_shell","arguments":"{}"},{"type":"function_call_output","call_id":"user_1","output":"ok"}]` + tools + `}`) + got := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false) + calls := gjson.GetBytes(got, "messages.0.tool_calls").Array() + if len(calls) != 1 || calls[0].Get("id").String() != "shell_1" || gjson.GetBytes(got, "messages.2.tool_calls.0.id").String() != "user_1" { + t.Fatalf("shell history dropped without declaration: %s", got) + } + name := calls[0].Get("function.name").String() + if name == "__cpa_local_shell" || name == "__cpa_local_shell_1" && tools != "" && tools != `,"tools":[]` { + t.Fatalf("history name collided: %s", got) + } + if gjson.Parse(gjson.GetBytes(got, "messages.1.content").String()).Get("output.0.stdout").String() != "/workspace" { + t.Fatalf("shell result lost: %s", got) + } + if gjson.GetBytes(got, "tools.#").Int() != gjson.GetBytes(request, "tools.#").Int() { + t.Fatalf("history re-enabled a tool: %s", got) + } + } +}