Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -1854,17 +1854,6 @@ func (q *querier) CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error
return q.db.CleanupDeletedMCPServerIDsFromChats(ctx)
}

func (q *querier) ClearChatMessageProviderResponseIDsByChatID(ctx context.Context, chatID uuid.UUID) error {
chat, err := q.db.GetChatByID(ctx, chatID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
}
return q.db.ClearChatMessageProviderResponseIDsByChatID(ctx, chatID)
}

func (q *querier) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
Expand Down
6 changes: 0 additions & 6 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -848,12 +848,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().SoftDeleteContextFileMessages(gomock.Any(), chat.ID).Return(nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns()
}))
s.Run("ClearChatMessageProviderResponseIDsByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().ClearChatMessageProviderResponseIDsByChatID(gomock.Any(), chat.ID).Return(nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns()
}))
s.Run("GetChatCostPerChat", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetChatCostPerChatParams{
OwnerID: uuid.New(),
Expand Down
1 change: 0 additions & 1 deletion coderd/database/dbgen/dbgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@ func ChatMessage(t testing.TB, db database.Store, seed database.ChatMessage) dat
Compressed: []bool{seed.Compressed},
TotalCostMicros: []int64{seed.TotalCostMicros.Int64},
RuntimeMs: []int64{seed.RuntimeMs.Int64},
ProviderResponseID: []string{seed.ProviderResponseID.String},
})
require.NoError(t, err, "insert chat message")
require.Len(t, msgs, 1)
Expand Down
2 changes: 0 additions & 2 deletions coderd/database/dbgen/dbgen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,6 @@ func TestGenerator(t *testing.T) {
ContextLimit: sql.NullInt64{Int64: 77, Valid: true},
Compressed: true,
TotalCostMicros: sql.NullInt64{Int64: 88, Valid: true},
ProviderResponseID: sql.NullString{String: "resp-123", Valid: true},
})
require.Equal(t, database.ChatMessageRoleAssistant, msg2.Role)
require.True(t, msg2.Content.Valid)
Expand All @@ -412,7 +411,6 @@ func TestGenerator(t *testing.T) {
require.Equal(t, sql.NullInt64{Int64: 77, Valid: true}, msg2.ContextLimit)
require.True(t, msg2.Compressed)
require.Equal(t, sql.NullInt64{Int64: 88, Valid: true}, msg2.TotalCostMicros)
require.Equal(t, sql.NullString{String: "resp-123", Valid: true}, msg2.ProviderResponseID)
})

t.Run("MCPServerConfig", func(t *testing.T) {
Expand Down
8 changes: 0 additions & 8 deletions coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 0 additions & 14 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12803,7 +12803,6 @@ func TestUpdateChatLastTurnSummary(t *testing.T) {
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
ProviderResponseID: []string{""},
})
require.NoError(t, err)

Expand Down Expand Up @@ -14497,7 +14496,6 @@ func TestGetChatsFilter(t *testing.T) {
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
ProviderResponseID: []string{""},
})
require.NoError(t, err)
}
Expand Down Expand Up @@ -14742,7 +14740,6 @@ func TestChatHasUnread(t *testing.T) {
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
ProviderResponseID: []string{""},
})
require.NoError(t, err)
}
Expand Down
21 changes: 2 additions & 19 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 2 additions & 11 deletions coderd/database/queries/chats.sql
Original file line number Diff line number Diff line change
Expand Up @@ -836,8 +836,7 @@ INSERT INTO chat_messages (
context_limit,
compressed,
total_cost_micros,
runtime_ms,
provider_response_id
runtime_ms

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-5] The orphaned chat_messages.provider_response_id column has no drop-migration ticket. The PR description lists it as a follow-up, but no linked ticket exists. The column is nullable TEXT with no index, no constraints, no active writers (after this PR), and no active readers in chatd. Cost of carrying it is near zero (one bit per row in the null bitmap), but worth a ticket so it doesn't get forgotten.

(Knuckle)

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed CODAGT-748 for the column drop and linked it in the PR description. 🤖 Coder Agents on behalf of @johnstcn

)
SELECT
@chat_id::uuid,
Expand All @@ -857,8 +856,7 @@ SELECT
NULLIF(UNNEST(@context_limit::bigint[]), 0),
UNNEST(@compressed::boolean[]),
NULLIF(UNNEST(@total_cost_micros::bigint[]), 0),
NULLIF(UNNEST(@runtime_ms::bigint[]), 0),
NULLIF(UNNEST(@provider_response_id::text[]), '')
NULLIF(UNNEST(@runtime_ms::bigint[]), 0)
RETURNING
*;

Expand Down Expand Up @@ -2598,13 +2596,6 @@ WHERE agent_id = @agent_id::uuid
AND status IN ('waiting', 'running', 'paused', 'pending', 'requires_action')
ORDER BY updated_at DESC;

-- name: ClearChatMessageProviderResponseIDsByChatID :exec
UPDATE chat_messages
SET provider_response_id = NULL
WHERE chat_id = @chat_id::uuid
AND deleted = false
AND provider_response_id IS NOT NULL;

-- name: SoftDeleteContextFileMessages :exec
UPDATE chat_messages SET deleted = true
WHERE chat_id = @chat_id::uuid
Expand Down
9 changes: 4 additions & 5 deletions coderd/x/chatd/attempt.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,10 @@ const (

// stepData is the durable content produced by one provider attempt.
type stepData struct {
Content []fantasy.Content
Usage fantasy.Usage
ContextLimit sql.NullInt64
ProviderResponseID string
Runtime time.Duration
Content []fantasy.Content
Usage fantasy.Usage
ContextLimit sql.NullInt64
Runtime time.Duration

ToolCallCreatedAt map[string]time.Time
ToolResultCreatedAt map[string]time.Time
Expand Down
7 changes: 3 additions & 4 deletions coderd/x/chatd/chatadvisor/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ func (rt *Runtime) RunAdvisor(
}, nil
}

// Clone per invocation and reset inherited state so chatloop cannot
// mutate the Runtime's stored options across calls, and so the nested
// call never runs as a chain-mode continuation against stale parent
// state or persists an orphan stored response on the provider side.
// Clone per invocation: resetProviderOptionsForNestedCall mutates the
// OpenAI Responses entry, and the Runtime's stored options must stay
// pristine across calls.
nestedProviderOptions := cloneProviderOptions(rt.cfg.ProviderOptions)
resetProviderOptionsForNestedCall(nestedProviderOptions)

Expand Down
42 changes: 16 additions & 26 deletions coderd/x/chatd/chatadvisor/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,9 +371,9 @@ func TestNewRuntimeValidation(t *testing.T) {
func TestNewRuntimeDeepClonesOpenAIResponsesProviderOptions(t *testing.T) {
t.Parallel()

parentPrevID := "resp_parent_abc123"
parentStore := true
parentOpts := &fantasyopenai.ResponsesProviderOptions{
PreviousResponseID: &parentPrevID,
Store: &parentStore,
}
parentProviderOpts := fantasy.ProviderOptions{
fantasyopenai.Name: parentOpts,
Expand Down Expand Up @@ -402,30 +402,28 @@ func TestNewRuntimeDeepClonesOpenAIResponsesProviderOptions(t *testing.T) {
require.NoError(t, err)
require.Equal(t, chatadvisor.ResultTypeAdvice, result.Type)

// Parent's OpenAI Responses entry must still carry its PreviousResponseID;
// Parent's OpenAI Responses entry must still carry its Store setting;
// the advisor's nested chatloop run must not have mutated the shared pointer.
require.NotNil(t, parentOpts.PreviousResponseID)
require.Equal(t, parentPrevID, *parentOpts.PreviousResponseID)
require.NotNil(t, parentOpts.Store)
require.True(t, *parentOpts.Store)
}

func TestAdvisorRunStripsChainStateAndIsConsistentAcrossCalls(t *testing.T) {
func TestAdvisorRunDisablesStoreAndIsConsistentAcrossCalls(t *testing.T) {
t.Parallel()

parentPrevID := "resp_parent_xyz"
parentStore := true
parentOpts := &fantasyopenai.ResponsesProviderOptions{
PreviousResponseID: &parentPrevID,
Store: &parentStore,
}
parentProviderOpts := fantasy.ProviderOptions{
fantasyopenai.Name: parentOpts,
}

// Snapshot PreviousResponseID and Store at stream time, before chatloop
// has any chance to clear them on the shared map. Comparing across calls
// proves the advisor observes consistent (non-chained, non-persisted)
// options each invocation.
// Snapshot Store at stream time, before chatloop has any chance to
// mutate the shared map. Comparing across calls proves the advisor
// observes consistent (non-persisted) options each invocation.
type observedOpts struct {
prevID *string
store *bool
store *bool
}
var observed []observedOpts
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Expand All @@ -438,10 +436,6 @@ func TestAdvisorRunStripsChainStateAndIsConsistentAcrossCalls(t *testing.T) {
observed = append(observed, observedOpts{})
} else {
snap := observedOpts{}
if openaiOpts.PreviousResponseID != nil {
copied := *openaiOpts.PreviousResponseID
snap.prevID = &copied
}
if openaiOpts.Store != nil {
copied := *openaiOpts.Store
snap.store = &copied
Expand Down Expand Up @@ -470,19 +464,15 @@ func TestAdvisorRunStripsChainStateAndIsConsistentAcrossCalls(t *testing.T) {

require.Len(t, observed, 2)
for i, snap := range observed {
// Each nested call must run without chain mode so prompts built
// from full history by BuildAdvisorMessages are accepted.
require.Nil(t, snap.prevID, "call %d unexpectedly ran in chain mode", i)
// Store must be explicitly disabled so the provider does not
// persist an orphan response that later chain-mode calls would
// fail to resume.
// Store must be explicitly disabled so the advisor call does not
// persist an orphan response on the provider side.
require.NotNil(t, snap.store, "call %d did not disable Store", i)
require.False(t, *snap.store, "call %d ran with Store enabled", i)
}

// The parent's pointer must be untouched across repeated advisor runs.
require.NotNil(t, parentOpts.PreviousResponseID)
require.Equal(t, parentPrevID, *parentOpts.PreviousResponseID)
require.NotNil(t, parentOpts.Store)
require.True(t, *parentOpts.Store)
}

func TestBuildAdvisorMessagesTruncatesToRecentMessageLimit(t *testing.T) {
Expand Down
25 changes: 9 additions & 16 deletions coderd/x/chatd/chatadvisor/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,11 @@ func NewRuntime(cfg RuntimeConfig) (*Runtime, error) {
return &Runtime{cfg: normalized}, nil
}

// cloneProviderOptions returns a copy of opts with pointer entries for known,
// in-place mutated provider option types replaced by a shallow struct copy.
// chatloop mutates the OpenAI Responses entry (PreviousResponseID) on
// chain-mode exit, so sharing the pointer with the parent run would let an
// advisor call corrupt the parent's chain state. Value fields such as
// Metadata and Include are still shared with the parent; nothing in this
// package mutates them, but callers that need true deep-copy semantics must
// handle those fields explicitly.
// cloneProviderOptions guards the parent's provider options: nested advisor
// calls mutate the OpenAI Responses entry (Store), and doing that on a shared
// pointer would corrupt the parent run's options. Value fields such as
// Metadata and Include remain shared; callers that need true deep-copy
// semantics must handle those fields explicitly.
func cloneProviderOptions(opts fantasy.ProviderOptions) fantasy.ProviderOptions {
if opts == nil {
return nil
Expand All @@ -90,18 +87,14 @@ func cloneProviderOptions(opts fantasy.ProviderOptions) fantasy.ProviderOptions
return cloned
}

// resetProviderOptionsForNestedCall strips inherited state from opts that
// does not apply to an ephemeral advisor call. PreviousResponseID is
// cleared so the nested call is not sent as a chain-mode continuation
// (BuildAdvisorMessages sends the full history, not an incremental turn).
// Store is forced off so the advisor call does not persist an orphan
// response on the provider side. Must be called on a cloned map to avoid
// mutating shared parent state.
// resetProviderOptionsForNestedCall enforces that ephemeral advisor calls
// never persist an orphan stored response on the provider side. It mutates
// opts in place, so it must be called on a cloned map, never on options
// shared with the parent run.
func resetProviderOptionsForNestedCall(opts fantasy.ProviderOptions) {
for _, value := range opts {
if typed, ok := value.(*fantasyopenai.ResponsesProviderOptions); ok && typed != nil {
storeDisabled := false
typed.PreviousResponseID = nil
typed.Store = &storeDisabled
}
}
Expand Down
Loading
Loading