Files
coder/coderd/x/chatd/integration_responses_test.go
T
Michael Suchacz b2c8798e0e fix: replay stateless OpenAI reasoning across chat turns (#30298)
OpenAI Responses chats default to `store=false`, and chatd only replayed
reasoning as an `item_reference`, which needs provider-side storage.
Reasoning was therefore dropped between tool steps and turns, so
reasoning models lost their chain of thought mid-task.

This pins coder/fantasy `coder_2_33` at 6ba8681, which includes fantasy
#63 (and #64, #65, and #66). #63 captures each completed reasoning item
(ID, `encrypted_content`, summary) and replays it in full regardless of
`store`. In chatd:

- Every Responses call requests `reasoning.encrypted_content`. The
`store` default and configured includes are unchanged, and Chat
Completions requests are untouched.
- Prompt rebuilds keep reasoning parts without visible text when they
carry finalized encrypted state, so reasoning survives durable history
and coderd restarts.
- The provider-switch sanitizer also strips OpenAI reasoning from
history produced by a different or unknown provider instance, since
reasoning IDs and encrypted content only resolve where they were issued.
- A compaction model override on a different provider instance, or with
a different model, gets a compaction prompt without the chat's OpenAI
reasoning.
- Each saved reasoning part records the provider instance, model, and
credential (the deployment's or the user's personal API key) that
produced it, and the sanitizer keeps it only when all three match the
current turn, because another model, or the organization behind another
credential, can reject the encrypted content. Switching models, adding
or removing a personal key, or editing a config's model or provider
drops the earlier reasoning; switching back restores it. The record is
never returned by the API. Reasoning saved before this change has no
record and follows its model config.
- If OpenAI rejects replayed reasoning it cannot decrypt
(`invalid_encrypted_content`), for example after the deployment's key is
replaced with one from another organization, the request is retried once
without reasoning, and the reasoning that step produces is saved with
the provider's next rejection epoch. Epochs are kept separately for each
provider, model, and credential. Later steps and turns leave out
reasoning from earlier epochs, on any replica and after restarts, so
only the first request after the change is rejected and newer reasoning
keeps replaying. A retried step that returns no reasoning saves its
epoch on its first part. The advisor's prompt never includes OpenAI
reasoning, as on `main`.

## Testing

Rebased onto main at 98332c739b as 5735acb36ed (same tree as merging
the pre-rebase head a378071862a with main; older SHAs below refer to the
pre-rebase history). 3c17d807a33 then adds the model to the reasoning
record, 21156b2b32e pins Fantasy d55a867, which treats a response that
echoes `store: false` as unstored, bf795daa923 adds the retry and the
advisor change, 7eb8b1d029a moves the pin to 88cffb5 (`coder_2_33`
merged into #63; its OpenAI provider code is identical to d55a867), and
f4f40734a41 pins `coder_2_33` at 6ba8681 once #63 merged. That adds
fantasy #66, which replays the Responses message phase on assistant
text. abccede81f7 makes the retry remember rejected reasoning in the
process. The branch was then rebased onto main at abc2faed60, so every
SHA above refers to the pre-rebase history. 5fb3ce1689 replaces that
memory with the credential and a rejection epoch saved on each reasoning
part, and 62b5cb532a keeps epochs per provider, model, and credential
and saves the epoch when the retried step returns no reasoning.
1057beb327 hides the four internal fields from the Swagger spec and API
reference, and 29a705e099 merges main at d68b2c8b25. Its only
conflicts were the fantasy pin (kept at 6ba8681, whose `coder_2_33`
history includes main's 3510552) and test imports.

- Tests in `chatprompt`, `chatprovider`, `chatsanitize`, the
provider-switch sanitizer, and `integration_responses_test.go` cover
tool-step and durable replay, legacy and incomplete metadata, provider
switches, and a web search chat switching from `store=false` to
`store=true`. Integration tests also cover the retry, a later tool step
after it, the next turn on a fresh server against the same database
(with and without reasoning in the retried step), a personal key added
mid-chat, and the advisor prompt. Red-green verified, including a config
whose model is edited in place. The compaction override's model check
has unit tests and UAT coverage but no integration test. `go vet`,
targeted race tests, and the pre-commit hook (gen, fmt, lint, build)
pass. Local pre-push hooks were skipped; CI covers them.
- Remote UAT on dogfood with real gpt-5-mini and gpt-5.4 behind a
logging proxy that checks every request body. Rounds 1 and 2 failed on
dangling item references (both fixed). Rounds 3 to 5 passed
(ee3c0bf3d60, 0580870b5ff, a378071862a): tool-step and durable replay,
restarts, interrupts, provider switches and config repoints, web search
history, and compaction overrides. Both test providers shared one
upstream account, so cross-provider checks cover request shape, not a
real rejection. Round 6 at 21156b2b32e (model record) with gpt-5-mini
and gpt-5-nano: PASS. 151 Responses requests, all 200; no request
carried reasoning from a different model except an older, unrecorded
part that follows its config by design. Same-model config switches,
switching back, edits to a config's model, a mid-turn switch, restarts,
and a same-provider compaction override with a different model all
behaved as described. Round 7 at bf795daa923 (retry and advisor): PASS.
Through AI Gateway, a corrupted saved blob is rejected with HTTP 400
`invalid_encrypted_content`. All 14 rejected requests were retried once
without reasoning and returned 200, a different 400 was not retried, and
advisor requests on the chat model, a same-provider override, and
another provider carried no reasoning.
- f4f40734a41 changes only the pin. Its new OpenAI code is fantasy #66,
which UAT rounds 1 to 7 did not run. A live check of fantasy 6ba8681
through AI Gateway, two turns with tool calls on gpt-5.5 and gpt-5-mini
with `store` false and true: all 18 requests returned 200, and the
gpt-5.5 requests replayed encrypted reasoning followed by assistant
messages with the `commentary` and `final_answer` phases. `go test
./coderd/x/chatd/... ./codersdk/` passes (21 packages) on the same
fantasy tree.
- UAT round 8 at abccede81f7 (remembered rejection, and fantasy #66
through chatd) with gpt-5.5, gpt-5-mini, and gpt-5-nano: PASS. 110
requests; the 6 rejections were 5 corrupted saved blobs, each retried
once without reasoning and answered 200, and one unrelated `temperature`
error that was not retried. After the first rejection, later steps and
turns in that chat were never rejected again and replayed newer
reasoning. A coderd restart cost one more rejection, which also dropped
reasoning saved after the first one, and a second chat was unaffected.
gpt-5.5 requests replayed assistant messages with the phases the model
returned, with `store` false and true.
- UAT round 9 at 62b5cb532a (saved rejection epoch, personal key
switch) with gpt-5.5, gpt-5-mini, and gpt-5-nano: PASS. 158 Responses
and 7 Chat Completions requests; the 6 rejections were corrupted saved
blobs, each retried once without reasoning and answered 200, and an
unrelated `temperature` error was not retried. After a coderd restart,
and on a second coderd against the same database, the chat was not
rejected again and kept replaying the reasoning saved after the
rejection. Adding a personal key left out the deployment key's reasoning
before sending, and removing it brought that reasoning back. A retried
step without reasoning, two chats rejected at once, compaction, a model
switch after a rejection, and a switch to Chat Completions and back all
behaved as described.

## Review record

- Independent full-branch test audit at bbe1a1b27a: PASS.
- Codex through a378071862: compaction overrides kept foreign reasoning
and configs could be repointed to another provider (both fixed); a
request for documentation TODOs and two behaviors that already exist on
`main` (declined).
- Codex on 5735acb36e: two findings. Reasoning replayed after a
same-provider model switch: fixed in 3c17d807a33. A provider's base URL
edited in place keeps its reasoning: covered by the retry in
bf795daa923.
- Codex on 21156b2b32, bf795daa92, and 7eb8b1d029: no major issues.
Codex on f4f40734a4: rejected reasoning stayed in the chat's history, so
each later request in that chat was rejected and retried again without
any reasoning. Fixed in abccede81f7. Codex on abccede81f: that memory
was lost on a restart or another replica, where the next rejection also
dropped newer reasoning, and it grew without bound. Fixed in
5fb3ce1689. Codex on 5fb3ce1689: a rejection advanced the epoch for
every model and credential on the provider, and a retried step without
reasoning lost the new epoch. Both fixed in 62b5cb532a. Codex on
62b5cb532a: keep reasoning out of a config switched from Responses to
Chat Completions (declined: the Chat Completions converter already skips
reasoning parts, confirmed with a probe test). Codex on 29a705e099: no
major issues.
- ethanndickson: retry without reasoning on `invalid_encrypted_content`,
and keep OpenAI reasoning out of the advisor prompt. Both fixed in
bf795daa923. Then: in-process memory is not enough because dogfood
restarts on every merge and runs several replicas. Fixed in 5fb3ce1689
with his proposed rejection epoch in chat history, plus the credential
record, and tested with the next turn on a fresh server. Then: hide the
internal fields from Swagger, fixed in 1057beb327. Approved on
62b5cb532a.
- coder/fantasy#63 review: honoring an echoed `store: false` is fixed in
d55a867; replaying web searches that had no preceding reasoning is a
pre-existing gap, tracked in #30528.
- CI on 5735acb36ed: green after rerunning two failures outside this
change (a `cli` `TestSSH` data race, and a `test-go-pg` job timeout that
also fails on `main`). CI on 21156b2b32e: all checks pass. CI on
bf795daa923: all checks pass. CI on 7eb8b1d029a: all checks pass. CI on
f4f40734a41: all checks pass. CI on abccede81f7 did not run, because the
branch conflicted with `main` (#30492 changed the provider-switch
sanitizer to return database errors). CI on 62b5cb532a: all checks
pass. CI on 29a705e099: all checks pass.

> Xum, an AI coding agent, implemented, tested, and opened this PR on
behalf of @ibetitsmike.
2026-10-13 13:49:12 +02:00

1072 lines
35 KiB
Go

package chatd_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestOpenAIResponsesNoStaleWebSearchReplay(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
destinationStore bool
summary string
}{
{name: "stateless", summary: "checked provider-side search state"},
{name: "switch to stored", destinationStore: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const (
reasoningID = "rs_no_stale_reasoning"
webSearchID = "ws_no_stale_search"
)
var recorder responsesRequestRecorder
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
requestNumber := recorder.record(req)
switch requestNumber {
case 1:
resp := chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("search result summary")...,
)
resp.ResponseID = "resp_no_stale_first"
resp.Reasoning = &chattest.OpenAIReasoningItem{
ID: reasoningID,
Summary: tc.summary,
EncryptedContent: "encrypted-no-stale",
}
resp.WebSearch = &chattest.OpenAIWebSearchCall{
ID: webSearchID,
Query: "coder changelog",
}
return resp
default:
resp := chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("follow-up answer")...,
)
resp.ResponseID = "resp_no_stale_second"
return resp
}
})
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
model := insertOpenAIResponsesModelConfig(t, db, user.ID, false, true)
followupModel := model
if tc.destinationStore {
followupModel = insertOpenAIResponsesModelConfig(t, db, user.ID, true, false)
}
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
server := newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: uniqueResponsesTitle(t, "no-stale"),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("search for the latest Coder docs"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
require.Len(t, recorder.all(), 1)
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
ModelConfigID: followupModel.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("summarize the result without searching again"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
requests := recorder.all()
require.Len(t, requests, 2)
followup := requests[1]
require.NotNil(t, followup.Store)
require.Equal(t, tc.destinationStore, *followup.Store)
require.NotEmpty(t, followup.Prompt)
requireNoResponsesProviderItemReplay(t, followup.Prompt, webSearchID)
require.NotContains(t, promptItemTypes(followup.Prompt), "web_search_call")
requireInlineReasoningItem(t, followup.Prompt, reasoningID, "encrypted-no-stale",
tc.summary)
})
}
}
func TestOpenAIResponsesStatelessReasoningReplay(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const (
reasoningID = "rs_stateless_reasoning"
blob = "encrypted-stateless"
)
var recorder responsesRequestRecorder
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
switch recorder.record(req) {
case 1:
resp := chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk("list_templates", `{}`),
)
resp.Reasoning = &chattest.OpenAIReasoningItem{
ID: reasoningID,
EncryptedContent: blob,
}
return resp
default:
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("done")...,
)
}
})
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
// An empty call config is the default deployment shape: no stored
// responses and no requested reasoning summary.
model := insertChatModelConfigWithCallConfig(t, db, user.ID, "openai", "gpt-4o",
codersdk.ChatModelCallConfig{})
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
server := newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: uniqueResponsesTitle(t, "stateless-reasoning"),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("list the templates"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
require.Len(t, recorder.all(), 2)
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
ModelConfigID: model.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("thanks"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
requests := recorder.all()
require.Len(t, requests, 3)
for _, request := range requests {
require.NotNil(t, request.Store)
require.False(t, *request.Store)
require.Contains(t, request.Include, "reasoning.encrypted_content")
}
// requests[1] continues the tool step from in-memory history;
// requests[2] rebuilds the prompt from persisted messages.
for _, request := range requests[1:] {
reasoningIndex := requireInlineReasoningItem(t, request.Prompt, reasoningID, blob, "")
require.NotContains(t, promptItemTypes(request.Prompt), "item_reference")
functionCallIndex := slices.IndexFunc(request.Prompt, func(item interface{}) bool {
itemMap, ok := item.(map[string]interface{})
return ok && chattest.StringResponseField(itemMap, "type") == "function_call"
})
require.NotEqual(t, -1, functionCallIndex, "missing function_call item")
require.Less(t, reasoningIndex, functionCallIndex, "types=%v", promptItemTypes(request.Prompt))
}
}
func TestOpenAIResponsesReasoningNotReplayedAfterModelConfigChange(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
change func(cfg *database.UpdateChatModelConfigParams, otherProvider uuid.UUID)
}{
{
name: "ProviderMoved",
change: func(cfg *database.UpdateChatModelConfigParams, otherProvider uuid.UUID) {
cfg.AIProviderID = uuid.NullUUID{UUID: otherProvider, Valid: true}
},
},
{
name: "ModelChanged",
change: func(cfg *database.UpdateChatModelConfigParams, _ uuid.UUID) {
cfg.Model = "gpt-4.1"
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const (
reasoningID = "rs_changed_config"
blob = "encrypted-changed-config"
)
var recorder responsesRequestRecorder
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
resp := chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("answer")...)
if recorder.record(req) == 1 {
resp.Reasoning = &chattest.OpenAIReasoningItem{
ID: reasoningID,
EncryptedContent: blob,
}
}
return resp
})
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
model := insertChatModelConfigWithCallConfig(t, db, user.ID, "openai", "gpt-4o",
codersdk.ChatModelCallConfig{})
otherProvider := dbgen.ChatProvider(t, db, database.ChatProvider{
Provider: "openai",
BaseUrl: openAIURL,
})
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
server := newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: uniqueResponsesTitle(t, "changed-config"),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("hello"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
require.Len(t, recorder.all(), 1)
update := database.UpdateChatModelConfigParams{
ID: model.ID,
Model: model.Model,
DisplayName: model.DisplayName,
Enabled: model.Enabled,
IsDefault: model.IsDefault,
ContextLimit: model.ContextLimit,
CompressionThreshold: model.CompressionThreshold,
Options: model.Options,
AIProviderID: model.AIProviderID,
}
tc.change(&update, otherProvider.ID)
_, err = db.UpdateChatModelConfig(ctx, update)
require.NoError(t, err)
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
ModelConfigID: model.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("thanks"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
requests := recorder.all()
require.Len(t, requests, 2)
followup := requests[1]
require.NotEmpty(t, followup.Prompt)
require.NotContains(t, promptItemTypes(followup.Prompt), "reasoning")
requireNoResponsesProviderItemReplay(t, followup.Prompt, reasoningID)
encoded, err := json.Marshal(followup.Prompt)
require.NoError(t, err)
require.NotContains(t, string(encoded), blob)
})
}
}
func TestOpenAIResponsesRetriesWithoutRejectedEncryptedReasoning(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// freshReasoningAt is the request whose response carries the
// first reasoning produced after the rejection.
freshReasoningAt int
}{
{name: "RetryReturnsReasoning", freshReasoningAt: 3},
{name: "RetryReturnsNoReasoning", freshReasoningAt: 4},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const (
staleID = "rs_rejected_reasoning"
staleBlob = "encrypted-by-another-organization"
freshID = "rs_fresh_reasoning"
freshBlob = "encrypted-by-current-organization"
)
var recorder responsesRequestRecorder
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
n := recorder.record(req)
// Later turns reach an organization that cannot decrypt the
// reasoning from the first turn.
if n > 1 && slices.ContainsFunc(req.Prompt, func(item interface{}) bool {
itemMap, ok := item.(map[string]interface{})
return ok && chattest.StringResponseField(itemMap, "id") == staleID
}) {
resp := chattest.OpenAIErrorResponse(http.StatusBadRequest, "invalid_request_error",
"The encrypted content could not be verified.")
resp.Error.Code = "invalid_encrypted_content"
return resp
}
resp := chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...)
if n == 3 {
resp = chattest.OpenAIStreamingResponse(chattest.OpenAIToolCallChunk("list_templates", `{}`))
}
switch n {
case 1:
resp.Reasoning = &chattest.OpenAIReasoningItem{ID: staleID, EncryptedContent: staleBlob}
case tc.freshReasoningAt:
resp.Reasoning = &chattest.OpenAIReasoningItem{ID: freshID, EncryptedContent: freshBlob}
}
return resp
})
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
model := insertChatModelConfigWithCallConfig(t, db, user.ID, "openai", "gpt-4o",
codersdk.ChatModelCallConfig{})
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
newServer := func() *chatd.Server {
return newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
}
server := newServer()
sendMessage := func(server *chatd.Server, chatID uuid.UUID, text string) {
t.Helper()
_, err := server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chatID,
CreatedBy: user.ID,
ModelConfigID: model.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText(text),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chatID, server)
requireResponsesChatWaiting(ctx, t, db, chatID)
}
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: uniqueResponsesTitle(t, "rejected-reasoning"),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("hello"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
sendMessage(server, chat.ID, "list the templates")
// The next turn runs on a server that never saw the rejection,
// like a restarted or different replica.
require.NoError(t, server.Close())
sendMessage(newServer(), chat.ID, "thanks")
// Only the first request after the change is rejected.
requests := recorder.all()
require.Len(t, requests, 5)
requireInlineReasoningItem(t, requests[1].Prompt, staleID, staleBlob, "")
retry := requests[2]
require.NotEmpty(t, retry.Prompt)
require.NotContains(t, promptItemTypes(retry.Prompt), "reasoning")
// requests[3] is the step after the tool call; requests[4] is
// the next turn on the fresh server.
for i, request := range requests[3:] {
requireNoResponsesProviderItemReplay(t, request.Prompt, staleID)
if i+3 < tc.freshReasoningAt {
require.NotContains(t, promptItemTypes(request.Prompt), "reasoning")
continue
}
requireInlineReasoningItem(t, request.Prompt, freshID, freshBlob, "")
}
})
}
}
func TestOpenAIResponsesReasoningNotReplayedAfterCredentialChange(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const (
userAPIKey = "sk-user-reasoning"
deploymentID = "rs_deployment_key"
deploymentBlob = "encrypted-by-deployment-organization"
personalKeyID = "rs_personal_key"
personalKeyBlob = "encrypted-by-personal-organization"
)
var (
recorder responsesRequestRecorder
authMu sync.Mutex
authByCall = map[int]string{}
)
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
n := recorder.record(req)
authMu.Lock()
authByCall[n] = req.Header.Get("Authorization")
authMu.Unlock()
resp := chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("answer")...)
switch n {
case 1:
resp.Reasoning = &chattest.OpenAIReasoningItem{ID: deploymentID, EncryptedContent: deploymentBlob}
case 2:
resp.Reasoning = &chattest.OpenAIReasoningItem{ID: personalKeyID, EncryptedContent: personalKeyBlob}
}
return resp
})
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
model := insertChatModelConfigWithCallConfig(t, db, user.ID, "openai", "gpt-4o",
codersdk.ChatModelCallConfig{})
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
server := newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: uniqueResponsesTitle(t, "credential-change"),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("hello"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
_, err = db.UpsertUserAIProviderKey(ctx, database.UpsertUserAIProviderKeyParams{
ID: uuid.New(),
UserID: user.ID,
AIProviderID: model.AIProviderID.UUID,
APIKey: userAPIKey,
})
require.NoError(t, err)
for _, text := range []string{"switched keys", "thanks"} {
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
ModelConfigID: model.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText(text),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
}
requests := recorder.all()
require.Len(t, requests, 3)
authMu.Lock()
require.NotEqual(t, "Bearer "+userAPIKey, authByCall[1])
require.Equal(t, "Bearer "+userAPIKey, authByCall[2])
authMu.Unlock()
// Reasoning from the deployment's key is left out before the
// personal key's organization can reject it.
require.NotContains(t, promptItemTypes(requests[1].Prompt), "reasoning")
requireNoResponsesProviderItemReplay(t, requests[1].Prompt, deploymentID)
requireInlineReasoningItem(t, requests[2].Prompt, personalKeyID, personalKeyBlob, "")
requireNoResponsesProviderItemReplay(t, requests[2].Prompt, deploymentID)
}
func TestOpenAIResponsesAdvisorPromptOmitsReasoning(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const (
reasoningID = "rs_parent_reasoning"
blob = "encrypted-parent-reasoning"
)
var recorder responsesRequestRecorder
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
switch recorder.record(req) {
case 1:
resp := chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("answer")...)
resp.Reasoning = &chattest.OpenAIReasoningItem{
ID: reasoningID,
EncryptedContent: blob,
}
return resp
case 2:
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk("advisor", `{"question":"what next?"}`),
)
case 3:
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("advice")...)
default:
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...)
}
})
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
model := insertChatModelConfigWithCallConfig(t, db, user.ID, "openai", "gpt-4o",
codersdk.ChatModelCallConfig{})
seedAdvisorConfig(ctx, t, db, codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 1,
MaxOutputTokens: 1024,
})
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
server := newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: uniqueResponsesTitle(t, "advisor-reasoning"),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("hello"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
ModelConfigID: model.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("ask the advisor"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
requests := recorder.all()
require.Len(t, requests, 4)
requireInlineReasoningItem(t, requests[1].Prompt, reasoningID, blob, "")
advisorRequest := requests[2]
require.NotEmpty(t, advisorRequest.Prompt)
require.NotContains(t, promptItemTypes(advisorRequest.Prompt), "reasoning")
encoded, err := json.Marshal(advisorRequest.Prompt)
require.NoError(t, err)
require.Contains(t, string(encoded), "what next?")
require.NotContains(t, string(encoded), blob)
requireInlineReasoningItem(t, requests[3].Prompt, reasoningID, blob, "")
}
func TestOpenAIResponsesPersistsProviderResponseID(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
resp := chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("answer")...)
resp.ResponseID = "resp_persisted"
return resp
})
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
model := insertOpenAIResponsesModelConfig(t, db, user.ID, false, false)
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
server := newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: uniqueResponsesTitle(t, "response-id"),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("hello"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID})
require.NoError(t, err)
responseIDs := make(map[database.ChatMessageRole]sql.NullString)
for _, message := range messages {
responseIDs[message.Role] = message.ProviderResponseID
}
require.Equal(t, sql.NullString{String: "resp_persisted", Valid: true}, responseIDs[database.ChatMessageRoleAssistant])
require.False(t, responseIDs[database.ChatMessageRoleUser].Valid)
}
func TestOpenAIResponsesFullReplayPairsReasoningAndWebSearch(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
const (
reasoningID = "rs_full_replay_reasoning"
webSearchID = "ws_full_replay_search"
)
var recorder responsesRequestRecorder
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
requestNumber := recorder.record(req)
switch requestNumber {
case 1:
resp := chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("search result summary")...,
)
resp.ResponseID = "resp_full_replay_first"
resp.Reasoning = &chattest.OpenAIReasoningItem{
ID: reasoningID,
Summary: "checked provider-side search state",
EncryptedContent: "encrypted-full-replay",
}
resp.WebSearch = &chattest.OpenAIWebSearchCall{
ID: webSearchID,
Query: "coder changelog",
}
return resp
default:
resp := chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("follow-up answer")...,
)
resp.ResponseID = "resp_full_replay_second"
return resp
}
})
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
firstModel := insertOpenAIResponsesModelConfig(t, db, user.ID, true, true)
secondModel := insertOpenAIResponsesModelConfig(t, db, user.ID, true, true)
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
server := newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: uniqueResponsesTitle(t, "full-replay"),
ModelConfigID: firstModel.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("search for the latest Coder docs"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
require.Len(t, recorder.all(), 1)
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
ModelConfigID: secondModel.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("summarize the result without searching again"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
requests := recorder.all()
require.Len(t, requests, 2)
followup := requests[1]
require.NotNil(t, followup.Store)
require.True(t, *followup.Store)
require.NotEmpty(t, followup.Prompt)
requirePromptItemReferenceOrder(t, followup.Prompt, reasoningID, webSearchID)
}
func TestOpenAIResponsesNarrationKeepsPhase(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
var recorder responsesRequestRecorder
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
if recorder.record(req) > 1 {
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("follow-up answer")...)
}
return chattest.OpenAIStreamingResponse(
chattest.OpenAIChunk{Choices: []chattest.OpenAIChunkChoice{{Index: 0, Delta: "Checking the docs.", Phase: "commentary"}}},
chattest.OpenAIChunk{Choices: []chattest.OpenAIChunkChoice{{Index: 1, Delta: "The docs cover it.", Phase: "final_answer"}}},
)
})
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
model := insertOpenAIResponsesModelConfig(t, db, user.ID, true, false)
factory := chattest.NewMockAIBridgeTransport(t, openAIURL)
server := newOpenAIResponsesTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory)
})
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: uniqueResponsesTitle(t, "narration"),
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("do the docs cover this?"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ChatID: chat.ID})
require.NoError(t, err)
var assistant []codersdk.ChatMessagePart
for _, message := range messages {
if message.Role == database.ChatMessageRoleAssistant {
assistant = append(assistant, db2sdk.ChatMessage(message).Content...)
}
}
narration := codersdk.ChatMessageText("Checking the docs.")
narration.Narration = true
require.Equal(t, []codersdk.ChatMessagePart{
narration,
codersdk.ChatMessageText("The docs cover it."),
}, assistant)
_, err = server.SendMessage(ctx, chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: user.ID,
ModelConfigID: model.ID,
Content: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("thanks"),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requireResponsesChatWaiting(ctx, t, db, chat.ID)
requests := recorder.all()
require.Len(t, requests, 2)
var replayed [][2]any
for _, item := range requests[1].Prompt {
itemMap, ok := item.(map[string]interface{})
if !ok || chattest.StringResponseField(itemMap, "role") != "assistant" {
continue
}
replayed = append(replayed, [2]any{itemMap["content"], itemMap["phase"]})
}
require.Equal(t, [][2]any{
{"Checking the docs.", "commentary"},
{"The docs cover it.", "final_answer"},
}, replayed)
}
type recordedResponsesRequest struct {
Prompt []interface{}
Store *bool
Include []string
}
type responsesRequestRecorder struct {
mu sync.Mutex
requests []recordedResponsesRequest
}
func (r *responsesRequestRecorder) record(req *chattest.OpenAIRequest) int {
r.mu.Lock()
defer r.mu.Unlock()
var store *bool
if req.Store != nil {
value := *req.Store
store = &value
}
var body struct {
Include []string `json:"include"`
}
_ = json.Unmarshal(req.RawBody, &body)
r.requests = append(r.requests, recordedResponsesRequest{
Prompt: append([]interface{}(nil), req.Prompt...),
Store: store,
Include: body.Include,
})
return len(r.requests)
}
func (r *responsesRequestRecorder) all() []recordedResponsesRequest {
r.mu.Lock()
defer r.mu.Unlock()
return append([]recordedResponsesRequest(nil), r.requests...)
}
func newOpenAIResponsesTestServer(
t *testing.T,
db database.Store,
ps dbpubsub.Pubsub,
overrides ...func(*chatd.Config),
) *chatd.Server {
t.Helper()
allOverrides := append([]func(*chatd.Config){func(cfg *chatd.Config) {
// Let CreateChat and SendMessage publish their pending status
// before wake-driven processing starts. The responses tests are
// not exercising periodic polling, and PostgreSQL can otherwise
// deliver that stale pending notification after processChat
// subscribes to control events.
cfg.PendingChatAcquireInterval = testutil.WaitLong
}}, overrides...)
return newActiveTestServer(t, db, ps, allOverrides...)
}
func insertOpenAIResponsesModelConfig(
t *testing.T,
db database.Store,
userID uuid.UUID,
store bool,
webSearchEnabled bool,
) database.ChatModelConfig {
t.Helper()
return insertChatModelConfigWithCallConfig(
t,
db,
userID,
"openai",
"gpt-4o",
codersdk.ChatModelCallConfig{
ProviderOptions: &codersdk.ChatModelProviderOptions{
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
Store: &store,
WebSearchEnabled: &webSearchEnabled,
},
},
},
)
}
func requireResponsesChatWaiting(
ctx context.Context,
t *testing.T,
db database.Store,
chatID uuid.UUID,
) {
t.Helper()
chat, err := db.GetChatByID(ctx, chatID)
require.NoError(t, err)
if chat.Status == database.ChatStatusError {
require.FailNowf(t, "chat failed", "last_error=%q", chatLastErrorMessage(chat.LastError))
}
require.Equal(t, database.ChatStatusWaiting, chat.Status)
}
func uniqueResponsesTitle(t *testing.T, prefix string) string {
t.Helper()
return fmt.Sprintf("%s-%s-%d", prefix, t.Name(), time.Now().UnixNano())
}
func promptItemTypes(prompt []interface{}) []string {
types := make([]string, 0, len(prompt))
for _, item := range prompt {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if itemType := chattest.StringResponseField(itemMap, "type"); itemType != "" {
types = append(types, itemType)
}
}
return types
}
func requireNoResponsesProviderItemReplay(
t *testing.T,
prompt []interface{},
staleIDs ...string,
) {
t.Helper()
stale := make(map[string]struct{}, len(staleIDs))
for _, id := range staleIDs {
stale[id] = struct{}{}
}
for _, item := range prompt {
assertNoResponsesProviderItemReplay(t, item, stale)
}
}
func assertNoResponsesProviderItemReplay(
t *testing.T,
value interface{},
staleIDs map[string]struct{},
) {
t.Helper()
switch typed := value.(type) {
case map[string]interface{}:
for key, raw := range typed {
if text, ok := raw.(string); ok {
if key == "type" && text == "web_search_call" {
require.FailNow(t, "prompt replayed web_search_call provider item")
}
if key == "type" && text == "item_reference" {
require.FailNow(t, "prompt replayed provider item reference")
}
if key == "id" || key == "call_id" || key == "item_id" {
if _, isStale := staleIDs[text]; isStale {
require.FailNowf(t, "prompt replayed stale provider item ID",
"field %q contained stale provider ID %q", key, text)
}
// Finalized reasoning is replayed inline, so only hosted
// tool items are provider-managed state here.
if strings.HasPrefix(text, "ws_") {
require.FailNowf(t, "prompt replayed provider item ID",
"field %q contained provider-managed ID %q", key, text)
}
}
}
assertNoResponsesProviderItemReplay(t, raw, staleIDs)
}
case []interface{}:
for _, item := range typed {
assertNoResponsesProviderItemReplay(t, item, staleIDs)
}
}
}
// requireInlineReasoningItem asserts that the prompt replays the reasoning
// item in full and returns its index. An empty summary must still be sent
// as an empty array.
func requireInlineReasoningItem(
t *testing.T,
prompt []interface{},
id string,
encryptedContent string,
summary string,
) int {
t.Helper()
for index, item := range prompt {
itemMap, ok := item.(map[string]interface{})
if !ok || chattest.StringResponseField(itemMap, "type") != "reasoning" ||
chattest.StringResponseField(itemMap, "id") != id {
continue
}
require.Equal(t, encryptedContent, chattest.StringResponseField(itemMap, "encrypted_content"))
summaryItems, ok := itemMap["summary"].([]interface{})
require.True(t, ok, "reasoning summary must be an array, got %T", itemMap["summary"])
var texts []string
for _, summaryItem := range summaryItems {
summaryMap, ok := summaryItem.(map[string]interface{})
require.True(t, ok)
texts = append(texts, chattest.StringResponseField(summaryMap, "text"))
}
require.Equal(t, summary, strings.Join(texts, ""))
return index
}
require.FailNowf(t, "missing inline reasoning item", "id=%q types=%v", id, promptItemTypes(prompt))
return -1
}
func requirePromptItemReferenceOrder(
t *testing.T,
prompt []interface{},
firstID string,
secondID string,
) {
t.Helper()
firstIndex := -1
secondIndex := -1
for index, item := range prompt {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
itemID := chattest.StringResponseField(itemMap, "id")
if itemID == "" {
itemID = chattest.StringResponseField(itemMap, "item_id")
}
switch itemID {
case firstID:
firstIndex = index
case secondID:
secondIndex = index
}
}
require.NotEqual(t, -1, firstIndex, "missing first item reference")
require.NotEqual(t, -1, secondIndex, "missing second item reference")
require.Less(t, firstIndex, secondIndex)
}