mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add chat context pinning storage and push trigger (#26385)
Foundation for the Workspace Context Sources RFC (phase 3). The agent push (#25983) and coderd snapshot storage (#26145) already persist per-agent context snapshots; this PR lands the **chat-side storage** plus the **`agentapi` push trigger** that a follow-up will use to read them. It does **not** touch `chatd` and changes no behavior — nothing wires an implementation yet. ## What changed - Adds four nullable columns to `chats` — `context_aggregate_hash`, `context_dirty_since`, `context_dirty_resources`, and `context_error` — and rebuilds the `chats_expanded` view. - Adds three queries — `SetChatContextSnapshot`, `HydrateAgentChatsContext`, `MarkChatsContextDirtyByAgent` — with `dbauthz` wrappers and `audit` entries. They are store-interface methods covered by a Postgres test (`TestChatContextHydration`). - Adds the `agentapi.ContextDirtyMarker` interface and invokes it inside the `PushContextState` transaction, publishing collected events only after commit. ## Intentionally inert There are **no production callers** of the three queries and **no implementation** wired for `ContextDirtyMarker`, so the push trigger is dormant. This is deliberate: the PR is the durable storage/query foundation only. The actual integration — the `chatd` implementation that hydrates/dirties chats and backs a refresh endpoint, consuming the pinned context in prompt building, the rich SDK types + UI, and retiring the live per-turn pull — lands as a single follow-up PR. Splitting this way keeps the schema/query layer reviewable on its own and keeps the integration whole in one place. Refs #25983, #26145. <details> <summary>Decision log</summary> - **Columns over a side table.** The four `chats` columns are the durable model (accepting the one-time `chats_expanded` view/CTE churn). `last_injected_context` is deliberately left untouched — it is load-bearing for the live per-turn context pull. - **Keep `agentapi`, drop `chatd`.** The earlier revision wired the hydrate/dirty implementation through `chatd` and added a `PUT /chats/{chat}/context` refresh endpoint. Those were removed so this PR is pure foundation; `agentapi` defines the trigger + interface (it does not import `chatd`), and the `chatd` implementation arrives with the full integration. - **No new experiment flag.** The columns are dark and unread by prompt building. - **Authz.** The new query wrappers authorize chat updates under the chat RBAC object / `ResourceChat`, consistent with the existing system chat mutators. </details> --- 🤖 Generated by Coder Agents on behalf of @kylecarbs.
This commit is contained in:
@@ -1235,6 +1235,122 @@ func TestGetAuthorizedWorkspacesAndAgentsByOwnerID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatContextHydration(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
sqlDB := testSQLDB(t)
|
||||
require.NoError(t, migrations.Up(sqlDB))
|
||||
db := database.New(sqlDB)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
owner := dbgen.User(t, db, database.User{})
|
||||
_ = dbgen.ChatProvider(t, db, database.ChatProvider{Provider: "openai", DisplayName: "OpenAI"})
|
||||
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
Model: "test-model",
|
||||
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
|
||||
IsDefault: true,
|
||||
CompressionThreshold: 80,
|
||||
})
|
||||
|
||||
// Chats are scoped per agent, so build two independent agents.
|
||||
newAgent := func() database.WorkspaceAgent {
|
||||
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{OrganizationID: org.ID})
|
||||
resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: job.ID})
|
||||
return dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: resource.ID})
|
||||
}
|
||||
agent := newAgent()
|
||||
otherAgent := newAgent()
|
||||
|
||||
newChat := func(status database.ChatStatus, agentID uuid.UUID) database.Chat {
|
||||
return dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
AgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
hashH := []byte{0x01, 0x02, 0x03}
|
||||
hashOther := []byte{0xff, 0xee}
|
||||
|
||||
chatNull := newChat(database.ChatStatusWaiting, agent.ID) // never hydrated
|
||||
chatMatch := newChat(database.ChatStatusRunning, agent.ID) // already at hashH
|
||||
chatDrift := newChat(database.ChatStatusRunning, agent.ID) // drifted, active
|
||||
chatTerminal := newChat(database.ChatStatusCompleted, agent.ID) // drifted, terminal
|
||||
chatArchived := newChat(database.ChatStatusRunning, agent.ID) // drifted, archived
|
||||
chatOtherAgent := newChat(database.ChatStatusRunning, otherAgent.ID)
|
||||
|
||||
// Pin starting hashes; chatNull is intentionally left NULL.
|
||||
require.NoError(t, db.SetChatContextSnapshot(ctx, database.SetChatContextSnapshotParams{ID: chatMatch.ID, AggregateHash: hashH}))
|
||||
for _, id := range []uuid.UUID{chatDrift.ID, chatTerminal.ID, chatArchived.ID, chatOtherAgent.ID} {
|
||||
require.NoError(t, db.SetChatContextSnapshot(ctx, database.SetChatContextSnapshotParams{ID: id, AggregateHash: hashOther}))
|
||||
}
|
||||
_, err := db.ArchiveChatByID(ctx, chatArchived.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Hydrate stamps only the NULL-hash chat for this agent.
|
||||
require.NoError(t, db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
|
||||
AgentID: agent.ID,
|
||||
AggregateHash: hashH,
|
||||
}))
|
||||
gotNull, err := db.GetChatByID(ctx, chatNull.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, hashH, gotNull.ContextAggregateHash, "NULL-hash chat is hydrated")
|
||||
gotDrift, err := db.GetChatByID(ctx, chatDrift.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, hashOther, gotDrift.ContextAggregateHash, "hydrate must not overwrite an already-pinned hash")
|
||||
|
||||
// Mark dirty: only the active, pinned, drifted chat for THIS agent flips.
|
||||
// chatNull (now matches), chatMatch (matches), chatTerminal (status
|
||||
// excluded), chatArchived (archived), and chatOtherAgent (other agent)
|
||||
// are all left clean.
|
||||
now := dbtime.Now()
|
||||
flipped, err := db.MarkChatsContextDirtyByAgent(ctx, database.MarkChatsContextDirtyByAgentParams{
|
||||
AgentID: agent.ID,
|
||||
AggregateHash: hashH,
|
||||
DirtySince: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
flippedIDs := make([]uuid.UUID, 0, len(flipped))
|
||||
for _, f := range flipped {
|
||||
flippedIDs = append(flippedIDs, f.ID)
|
||||
}
|
||||
require.ElementsMatch(t, []uuid.UUID{chatDrift.ID}, flippedIDs)
|
||||
|
||||
gotDrift, err = db.GetChatByID(ctx, chatDrift.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gotDrift.ContextDirtySince.Valid, "drifted chat is marked dirty")
|
||||
|
||||
// Refresh re-pins to the latest hash and clears the dirty marker.
|
||||
require.NoError(t, db.SetChatContextSnapshot(ctx, database.SetChatContextSnapshotParams{ID: chatDrift.ID, AggregateHash: hashH}))
|
||||
gotDrift, err = db.GetChatByID(ctx, chatDrift.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, hashH, gotDrift.ContextAggregateHash)
|
||||
require.False(t, gotDrift.ContextDirtySince.Valid, "refresh clears the dirty marker")
|
||||
|
||||
// With every chat now matching, a second mark is a no-op.
|
||||
flipped, err = db.MarkChatsContextDirtyByAgent(ctx, database.MarkChatsContextDirtyByAgentParams{
|
||||
AgentID: agent.ID,
|
||||
AggregateHash: hashH,
|
||||
DirtySince: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, flipped)
|
||||
|
||||
// The other agent's chat is never touched by this agent's push.
|
||||
gotOther, err := db.GetChatByID(ctx, chatOtherAgent.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, hashOther, gotOther.ContextAggregateHash)
|
||||
require.False(t, gotOther.ContextDirtySince.Valid)
|
||||
}
|
||||
|
||||
func TestGetAuthorizedChats(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
|
||||
Reference in New Issue
Block a user