feat(coderd): copy agent context resources into the per-chat pin (#26438)

## What

Populates `chat_context_resources` (the per-chat pinned copy added in
#26430) by copying from `workspace_agent_context_resources` at the
points where a chat's `context_aggregate_hash` is set, in the same
transaction, so the pinned hash and pinned bodies always agree. No
prompt-building change yet; consuming the pinned copy in
`prepareGeneration` is a later, experiment-gated PR.

## How

- `HydrateAgentChatsContext` now hydrates NULL-hash chats **and** copies
the agent's resources onto them in one statement (a data-modifying CTE),
so the chat-create and agent-push paths need no Go change.
- New queries `InsertAgentContextResourcesIntoChat`,
`DeleteChatContextResources`, `ListChatContextResources`, each with a
hand-written dbauthz wrapper (per-chat update/read) and a
`MethodTestSuite` entry.
- `RefreshChatContext` re-pins resources via a shared `repinChatContext`
helper (clear-then-copy in a transaction). A dirty chat keeps its old
bodies until refresh.
- On agent rebind (e.g. a workspace rebuild produces a new agent), the
chat's context is re-pinned to the new agent so it stops injecting the
previous agent's resources. Best-effort: a context error never fails the
binding.

## Invariant

A chat's `chat_context_resources` always correspond to its
`context_aggregate_hash`. Bodies are (re)written only when the hash is
set (hydrate, refresh, rebind); a dirty chat keeps its old bodies until
refresh.

## Testing

Extends the context integration test to push real resources and assert
the copy across hydrate, dirty (no re-copy), and refresh. The dbauthz
`MethodTestSuite` covers the three new methods.

<details>
<summary>Why clear-then-copy (two statements)</summary>

The refresh/rebind re-pin clears the chat's rows then inserts the
agent's. It uses two sequential statements inside the transaction rather
than a single `WITH cleared AS (DELETE ...) INSERT ...`, because a
data-modifying CTE cannot see its own delete under snapshot isolation,
so overlapping sources (the common case: the same files re-pinned) would
collide on the `(chat_id, source)` primary key. The hydrate path inserts
into never-pinned (NULL-hash) chats and uses `ON CONFLICT DO UPDATE`
defensively.

</details>

<details>
<summary>Follow-ups</summary>

- `prepareGeneration` consuming the pinned instructions and skills
(experiment-gated).
- `codersdk.ChatContext` resources plus changed diff, and the frontend
indicator/refresh.
- Removing the per-turn pull and `last_injected_context`.

</details>

---

*This PR was created by Coder Agents on behalf of @kylecarbs.* Builds on
#26430.
This commit is contained in:
Kyle Carberry
2026-06-17 00:10:07 -07:00
committed by GitHub
parent 5ee1946b67
commit 1c78bd84b7
13 changed files with 837 additions and 64 deletions
+43
View File
@@ -805,6 +805,20 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID)
}
// DeleteChatContextResourcesByChatID mocks base method.
func (m *MockStore) DeleteChatContextResourcesByChatID(ctx context.Context, chatID uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteChatContextResourcesByChatID", ctx, chatID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteChatContextResourcesByChatID indicates an expected call of DeleteChatContextResourcesByChatID.
func (mr *MockStoreMockRecorder) DeleteChatContextResourcesByChatID(ctx, chatID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatContextResourcesByChatID", reflect.TypeOf((*MockStore)(nil).DeleteChatContextResourcesByChatID), ctx, chatID)
}
// DeleteChatDebugDataAfterMessageID mocks base method.
func (m *MockStore) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg database.DeleteChatDebugDataAfterMessageIDParams) (int64, error) {
m.ctrl.T.Helper()
@@ -7451,6 +7465,20 @@ func (mr *MockStoreMockRecorder) InsertAPIKey(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAPIKey", reflect.TypeOf((*MockStore)(nil).InsertAPIKey), ctx, arg)
}
// InsertAgentContextResourcesIntoChat mocks base method.
func (m *MockStore) InsertAgentContextResourcesIntoChat(ctx context.Context, arg database.InsertAgentContextResourcesIntoChatParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertAgentContextResourcesIntoChat", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// InsertAgentContextResourcesIntoChat indicates an expected call of InsertAgentContextResourcesIntoChat.
func (mr *MockStoreMockRecorder) InsertAgentContextResourcesIntoChat(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAgentContextResourcesIntoChat", reflect.TypeOf((*MockStore)(nil).InsertAgentContextResourcesIntoChat), ctx, arg)
}
// InsertAllUsersGroup mocks base method.
func (m *MockStore) InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (database.Group, error) {
m.ctrl.T.Helper()
@@ -8801,6 +8829,21 @@ func (mr *MockStoreMockRecorder) ListBoundaryLogsBySessionID(ctx, arg any) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBoundaryLogsBySessionID", reflect.TypeOf((*MockStore)(nil).ListBoundaryLogsBySessionID), ctx, arg)
}
// ListChatContextResourcesByChatID mocks base method.
func (m *MockStore) ListChatContextResourcesByChatID(ctx context.Context, chatID uuid.UUID) ([]database.ChatContextResource, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListChatContextResourcesByChatID", ctx, chatID)
ret0, _ := ret[0].([]database.ChatContextResource)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListChatContextResourcesByChatID indicates an expected call of ListChatContextResourcesByChatID.
func (mr *MockStoreMockRecorder) ListChatContextResourcesByChatID(ctx, chatID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChatContextResourcesByChatID", reflect.TypeOf((*MockStore)(nil).ListChatContextResourcesByChatID), ctx, chatID)
}
// ListChatUsageLimitGroupOverrides mocks base method.
func (m *MockStore) ListChatUsageLimitGroupOverrides(ctx context.Context) ([]database.ListChatUsageLimitGroupOverridesRow, error) {
m.ctrl.T.Helper()