diff --git a/sdk/api/handlers/openai/openai_responses_websocket_compaction_reset_test.go b/sdk/api/handlers/openai/openai_responses_websocket_compaction_reset_test.go new file mode 100644 index 00000000000..5519ccc0013 --- /dev/null +++ b/sdk/api/handlers/openai/openai_responses_websocket_compaction_reset_test.go @@ -0,0 +1,358 @@ +package openai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config" + "github.com/tidwall/gjson" +) + +const openCodeCheckpoint = ` +The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions. + + +The lookup finished. Continue with the remaining work. + +` + +func TestNormalizeResponsesWebsocketRequestReplacesOpenCodeCheckpoint(t *testing.T) { + lastRequest := []byte(`{ + "model": "test-model", + "instructions": "be helpful", + "input": [ + {"type": "message", "role": "user", "content": "old task"} + ] + }`) + lastOutput := []byte(`[ + { + "type": "function_call", + "call_id": "old-call", + "name": "lookup", + "arguments": "{}" + } + ]`) + raw := []byte(fmt.Sprintf(`{ + "type": "response.create", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": %q}] + }, + {"type": "message", "role": "developer", "content": "current context"}, + {"type": "message", "role": "user", "content": "continue"} + ] + }`, openCodeCheckpoint)) + tests := []struct { + name string + incremental bool + replay bool + }{ + {name: "HTTP"}, + {name: "HTTP with compaction replay", replay: true}, + {name: "incremental", incremental: true}, + {name: "incremental with compaction replay", incremental: true, replay: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + normalized, next, errMsg := normalizeResponsesWebsocketRequestWithIncrementalState( + raw, lastRequest, lastOutput, "resp-before", []string{"old-call"}, + test.incremental, test.replay, + ) + if errMsg != nil { + t.Fatal(errMsg.Error) + } + + got := gjson.GetBytes(normalized, "input").Raw + want := gjson.GetBytes(raw, "input").Raw + if got != want { + t.Fatalf("replacement input = %s, want %s", got, want) + } + if gjson.GetBytes(normalized, "previous_response_id").Exists() { + t.Fatal("replacement references the old response") + } + if got := gjson.GetBytes(normalized, "model").String(); got != "test-model" { + t.Fatalf("model = %q, want test-model", got) + } + if got := gjson.GetBytes(normalized, "instructions").String(); got != "be helpful" { + t.Fatalf("instructions = %q, want be helpful", got) + } + if !bytes.Equal(next, normalized) { + t.Fatal("saved request differs from the replacement") + } + + newOutput := []byte(`[ + { + "type": "function_call", + "call_id": "new-call", + "name": "lookup", + "arguments": "{}" + } + ]`) + followup := []byte(`{ + "type": "response.create", + "previous_response_id": "resp-after", + "input": [ + {"type": "function_call_output", "call_id": "new-call", "output": "done"} + ] + }`) + continued, _, errMsg := normalizeResponsesWebsocketRequestWithIncrementalState( + followup, next, newOutput, "resp-after", []string{"new-call"}, + test.incremental, test.replay, + ) + if errMsg != nil { + t.Fatal(errMsg.Error) + } + if bytes.Contains(continued, []byte("old-call")) { + t.Fatal("continuation restored the pre-compaction tool call") + } + wantCount := int64(5) + if test.incremental { + wantCount = 1 + } + if got := gjson.GetBytes(continued, "input.#").Int(); got != wantCount { + t.Fatalf("continuation input count = %d, want %d", got, wantCount) + } + }) + } +} + +func TestOpenCodeCheckpointContent(t *testing.T) { + tests := []struct { + name string + role string + content any + want bool + }{ + { + name: "string content", + role: "user", + content: openCodeCheckpoint, + want: true, + }, + { + name: "input text parts", + role: "user", + content: []map[string]string{ + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": strings.TrimPrefix(openCodeCheckpoint, "\n")}, + }, + want: true, + }, + { + name: "trailing whitespace", + role: "user", + content: openCodeCheckpoint + "\n", + want: true, + }, + { + name: "ordinary summary", + role: "user", + content: "Summary of completed work. Continue from this checkpoint.", + }, + { + name: "quoted checkpoint", + role: "user", + content: "Explain this checkpoint:\n" + openCodeCheckpoint, + }, + { + name: "unclosed checkpoint", + role: "user", + content: strings.TrimSuffix(openCodeCheckpoint, ""), + }, + { + name: "missing summary", + role: "user", + content: "\nhello\n", + }, + { + name: "reversed summary tags", + role: "user", + content: ` + + +invalid summary +`, + }, + { + name: "developer message", + role: "developer", + content: openCodeCheckpoint, + }, + { + name: "assistant message", + role: "assistant", + content: openCodeCheckpoint, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input, err := json.Marshal([]map[string]any{ + {"type": "message", "role": test.role, "content": test.content}, + }) + if err != nil { + t.Fatal(err) + } + if got := inputHasOpenCodeCompactionCheckpoint(gjson.ParseBytes(input)); got != test.want { + t.Fatalf("checkpoint recognized = %t, want %t", got, test.want) + } + }) + } +} + +func TestOpenCodeCheckpointDoesNotResetExplicitContinuations(t *testing.T) { + input := gjson.Parse(fmt.Sprintf(`[ + {"type": "message", "role": "user", "content": %q} + ]`, openCodeCheckpoint)) + tests := []struct { + name string + raw string + }{ + { + name: "append", + raw: `{"type": "response.append"}`, + }, + { + name: "parent response", + raw: `{ + "type": "response.create", + "previous_response_id": "resp-before" + }`, + }, + { + name: "empty parent", + raw: `{ + "type": "response.create", + "previous_response_id": "" + }`, + }, + { + name: "null parent", + raw: `{ + "type": "response.create", + "previous_response_id": null + }`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if shouldReplaceWebsocketTranscript([]byte(test.raw), input) { + t.Fatal("checkpoint reset an explicit continuation") + } + }) + } +} + +func TestOpenCodeCheckpointRejectsToolOutput(t *testing.T) { + for _, itemType := range []string{"function_call_output", "custom_tool_call_output"} { + t.Run(itemType, func(t *testing.T) { + input := gjson.Parse(fmt.Sprintf(`[ + {"type": "message", "role": "user", "content": %q}, + {"type": %q, "call_id": "old-call", "output": "done"} + ]`, openCodeCheckpoint, itemType)) + if shouldReplaceWebsocketTranscript([]byte(`{"type": "response.create"}`), input) { + t.Fatal("checkpoint discarded the history needed by a tool output") + } + }) + } +} + +func TestResponsesWebsocketOpenCodeCheckpointReplacesHTTPHistory(t *testing.T) { + gin.SetMode(gin.TestMode) + executor := &websocketCompactionCaptureExecutor{} + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor) + auth := &coreauth.Auth{ + ID: "opencode-checkpoint-http", + Provider: executor.Identifier(), + Status: coreauth.StatusActive, + } + if _, err := manager.Register(context.Background(), auth); err != nil { + t.Fatal(err) + } + registry.GetGlobalRegistry().RegisterClient( + auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-model"}}, + ) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(auth.ID) + }) + + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + handler := NewOpenAIResponsesAPIHandler(base) + router := gin.New() + router.GET("/v1/responses", handler.ResponsesWebsocket) + server := httptest.NewServer(router) + defer server.Close() + + url := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses" + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := conn.Close(); err != nil { + t.Error(err) + } + }() + + requests := []string{ + `{ + "type": "response.create", + "model": "test-model", + "input": [{"type": "message", "role": "user", "content": "old task"}] + }`, + fmt.Sprintf(`{ + "type": "response.create", + "input": [{"type": "message", "role": "user", "content": %q}] + }`, openCodeCheckpoint), + `{ + "type": "response.create", + "previous_response_id": "resp-2", + "input": [{"type": "message", "role": "user", "content": "continue"}] + }`, + } + for index, request := range requests { + if err := conn.WriteMessage(websocket.TextMessage, []byte(request)); err != nil { + t.Fatal(err) + } + _, payload, err := conn.ReadMessage() + if err != nil { + t.Fatal(err) + } + if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted { + t.Fatalf("request %d: event = %q, want %s", index, got, wsEventTypeCompleted) + } + } + + executor.mu.Lock() + payloads := append([][]byte(nil), executor.streamPayloads...) + executor.mu.Unlock() + if len(payloads) != len(requests) { + t.Fatalf("upstream requests = %d, want %d", len(payloads), len(requests)) + } + if got := gjson.GetBytes(payloads[1], "input.#").Int(); got != 1 { + t.Fatalf("checkpoint input count = %d, want 1", got) + } + if got := gjson.GetBytes(payloads[1], "input.0.content").String(); got != openCodeCheckpoint { + t.Fatalf("checkpoint content = %q, want %q", got, openCodeCheckpoint) + } + if got := gjson.GetBytes(payloads[2], "input.#").Int(); got != 3 { + t.Fatalf("continuation input count = %d, want 3", got) + } + for _, payload := range payloads[1:] { + if bytes.Contains(payload, []byte("call-1")) || bytes.Contains(payload, []byte("old task")) { + t.Fatalf("upstream request restored pre-compaction history: %s", payload) + } + } +} diff --git a/sdk/api/handlers/openai/openai_responses_websocket_requests.go b/sdk/api/handlers/openai/openai_responses_websocket_requests.go index 2504b1aa846..3b1d5b27f44 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_requests.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_requests.go @@ -200,8 +200,10 @@ func shouldReplaceWebsocketTranscript(rawJSON []byte, nextInput gjson.Result) bo if !nextInput.Exists() || !nextInput.IsArray() { return false } - if requestType == wsRequestTypeCreate && !previousResponseID.Exists() && inputHasCodexLocalCompactionSummary(nextInput) { - return true + if requestType == wsRequestTypeCreate && !previousResponseID.Exists() { + if inputHasCodexLocalCompactionSummary(nextInput) || inputHasOpenCodeCompactionCheckpoint(nextInput) { + return true + } } for _, item := range nextInput.Array() { @@ -246,14 +248,14 @@ func inputHasCodexLocalCompactionSummary(input gjson.Result) bool { if role != "user" && role != "developer" { return false } - if role == "user" && strings.HasPrefix(codexLocalCompactionMessageText(item), codexLocalCompactionSummaryPrefix+"\n") { + if role == "user" && strings.HasPrefix(localCompactionMessageText(item), codexLocalCompactionSummaryPrefix+"\n") { hasSummary = true } } return hasSummary } -func codexLocalCompactionMessageText(message gjson.Result) string { +func localCompactionMessageText(message gjson.Result) string { content := message.Get("content") if content.Type == gjson.String { return content.String() @@ -271,6 +273,38 @@ func codexLocalCompactionMessageText(message gjson.Result) string { return text.String() } +func inputHasOpenCodeCompactionCheckpoint(input gjson.Result) bool { + if !input.IsArray() { + return false + } + + hasCheckpoint := false + for _, item := range input.Array() { + itemType := strings.TrimSpace(item.Get("type").String()) + if itemType != "" && itemType != "message" { + return false + } + role := strings.TrimSpace(item.Get("role").String()) + if role != "user" && role != "developer" { + return false + } + if role != "user" { + continue + } + + text := localCompactionMessageText(item) + if !strings.HasPrefix(text, "\n") || + !strings.HasSuffix(strings.TrimSpace(text), "") { + continue + } + _, summary, found := strings.Cut(text, "\n\n") + if found && strings.Contains(summary, "\n") { + hasCheckpoint = true + } + } + return hasCheckpoint +} + func inputSatisfiesPendingToolCalls(input gjson.Result, pendingCallIDs []string) bool { if len(pendingCallIDs) == 0 { return true