mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add workspace skills to agent chat slash menu (#25600)
> This Pull Request was updated by Mux working on behalf of Mike. Adds workspace skills to the agent chat slash menu, sourced entirely from the chat's pinned context resources (the single-chat GET response the page already fetches), the same inventory `read_skill` resolves from. No new API endpoint is introduced. Personal entries insert `/name`, or `/personal/name` when the name collides with a workspace skill or the chat's pinned context has not resolved yet; workspace entries insert `/workspace/name`. Qualified aliases stay searchable even when the displayed trigger is bare. Before a chat binds a workspace (new chat form, or a selected but unbound workspace), the menu lists personal skills only. Sending a message invalidates the chat detail query, and chatd broadcasts a context watch event when a first-turn bind pins the chat, so the menu picks up newly pinned context without a reload. Makes `UpdateChatWorkspaceBinding` a no-op when the requested workspace/build/agent binding is unchanged, preserving `updated_at` so chat list ordering and watch events stay stable. Includes regression coverage for the no-op binding guard, pinned-context skill mapping, collision qualification, and skills menu behavior. Refs [CODAGT-474](https://linear.app/codercom/issue/CODAGT-474/ux-improvements-for-coder-agents) (skills autocompleting in the editor).
This commit is contained in:
@@ -5828,12 +5828,12 @@ func (q *querier) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx context.Cont
|
||||
return q.db.HasTemplateVersionsUsingCachedModuleFileInOrg(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error {
|
||||
func (q *querier) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) ([]uuid.UUID, error) {
|
||||
// System-level operation: an agent context push fans hydration out
|
||||
// across every not-yet-pinned chat for the agent, so it authorizes at
|
||||
// the resource level rather than per-chat.
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
return q.db.HydrateAgentChatsContext(ctx, arg)
|
||||
}
|
||||
|
||||
@@ -562,8 +562,9 @@ func (s *MethodTestSuite) TestConnectionLogs() {
|
||||
func (s *MethodTestSuite) TestChats() {
|
||||
s.Run("HydrateAgentChatsContext", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
arg := database.HydrateAgentChatsContextParams{AgentID: uuid.New()}
|
||||
dbm.EXPECT().HydrateAgentChatsContext(gomock.Any(), arg).Return(nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate)
|
||||
hydrated := []uuid.UUID{uuid.New()}
|
||||
dbm.EXPECT().HydrateAgentChatsContext(gomock.Any(), arg).Return(hydrated, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns(hydrated)
|
||||
}))
|
||||
s.Run("MarkChatsContextDirtyByAgent", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
arg := database.MarkChatsContextDirtyByAgentParams{AgentID: uuid.New()}
|
||||
|
||||
+3
-3
@@ -3937,12 +3937,12 @@ func (m queryMetricsStore) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx con
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error {
|
||||
func (m queryMetricsStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) ([]uuid.UUID, error) {
|
||||
start := time.Now()
|
||||
r0 := m.s.HydrateAgentChatsContext(ctx, arg)
|
||||
r0, r1 := m.s.HydrateAgentChatsContext(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("HydrateAgentChatsContext").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "HydrateAgentChatsContext").Inc()
|
||||
return r0
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) IncrementChatGenerationAttempt(ctx context.Context, id uuid.UUID) (int64, error) {
|
||||
|
||||
Generated
+4
-3
@@ -7364,11 +7364,12 @@ func (mr *MockStoreMockRecorder) HasTemplateVersionsUsingCachedModuleFileInOrg(c
|
||||
}
|
||||
|
||||
// HydrateAgentChatsContext mocks base method.
|
||||
func (m *MockStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error {
|
||||
func (m *MockStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) ([]uuid.UUID, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "HydrateAgentChatsContext", ctx, arg)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
ret0, _ := ret[0].([]uuid.UUID)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// HydrateAgentChatsContext indicates an expected call of HydrateAgentChatsContext.
|
||||
|
||||
Generated
+4
-2
@@ -1012,11 +1012,13 @@ type sqlcQuerier interface {
|
||||
// a chat's pinned hash and pinned bodies are always written together.
|
||||
// Runs as a side effect of an agent push and of chat-create hydration,
|
||||
// so chats created before the agent was ready pick up the snapshot
|
||||
// without a dirty event. The ON CONFLICT upsert is defensive: a
|
||||
// without a dirty marker. The ON CONFLICT upsert is defensive: a
|
||||
// not-yet-hydrated chat has no pinned rows, so it normally inserts.
|
||||
// Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch
|
||||
// sets chat_context_resources.updated_at on the rows it rewrites.
|
||||
HydrateAgentChatsContext(ctx context.Context, arg HydrateAgentChatsContextParams) error
|
||||
// Returns the hydrated chat IDs so callers can notify watchers of every
|
||||
// chat the statement pinned.
|
||||
HydrateAgentChatsContext(ctx context.Context, arg HydrateAgentChatsContextParams) ([]uuid.UUID, error)
|
||||
// Increments generation_attempt and returns the resulting value.
|
||||
IncrementChatGenerationAttempt(ctx context.Context, id uuid.UUID) (int64, error)
|
||||
// Adds cost_micros to the spend for (user_id, effective_group_id, day).
|
||||
|
||||
@@ -1296,11 +1296,14 @@ func TestChatContextHydration(t *testing.T) {
|
||||
_, 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{
|
||||
// Hydrate stamps only the NULL-hash chat for this agent and returns
|
||||
// exactly the chats it pinned.
|
||||
hydrated, err := db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
|
||||
AgentID: agent.ID,
|
||||
AggregateHash: hashH,
|
||||
}))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []uuid.UUID{chatNull.ID}, hydrated)
|
||||
gotNull, err := db.GetChatByID(ctx, chatNull.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, hashH, gotNull.ContextAggregateHash, "NULL-hash chat is hydrated")
|
||||
@@ -13505,6 +13508,92 @@ func TestUpdateChatLastTurnSummary(t *testing.T) {
|
||||
require.NotEqual(t, chat.HistoryVersion, fetched.HistoryVersion)
|
||||
}
|
||||
|
||||
func TestUpdateChatWorkspaceBindingNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
sqlDB := testSQLDB(t)
|
||||
err := migrations.Up(sqlDB)
|
||||
require.NoError(t, err)
|
||||
db := database.New(sqlDB)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
owner := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: owner.ID, OrganizationID: org.ID})
|
||||
|
||||
dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
Provider: "openai",
|
||||
DisplayName: "OpenAI",
|
||||
APIKey: "test-key",
|
||||
Enabled: true,
|
||||
CentralApiKeyEnabled: true,
|
||||
})
|
||||
|
||||
modelCfg, err := insertChatModelConfigForTest(ctx, t, db, "openai", database.InsertChatModelConfigParams{
|
||||
Model: "test-model",
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
ContextLimit: 128000,
|
||||
CompressionThreshold: 80,
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := db.InsertChat(ctx, database.InsertChatParams{
|
||||
OrganizationID: org.ID,
|
||||
Status: database.ChatStatusWaiting,
|
||||
ClientType: database.ChatClientTypeUi,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelCfg.ID,
|
||||
Title: "binding-chat",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
template := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: owner.ID,
|
||||
})
|
||||
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: owner.ID,
|
||||
OrganizationID: org.ID,
|
||||
TemplateID: template.ID,
|
||||
})
|
||||
workspaceID := workspace.ID
|
||||
|
||||
bound, err := db.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{
|
||||
ID: chat.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, workspaceID, bound.WorkspaceID.UUID)
|
||||
require.False(t, bound.UpdatedAt.Before(chat.UpdatedAt))
|
||||
|
||||
// Rebinding to the same workspace/build/agent is a no-op and must
|
||||
// preserve updated_at so chat list ordering and watch events stay
|
||||
// stable.
|
||||
rebound, err := db.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{
|
||||
ID: chat.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, workspaceID, rebound.WorkspaceID.UUID)
|
||||
require.Equal(t, bound.UpdatedAt, rebound.UpdatedAt)
|
||||
|
||||
// Clearing the binding is a real change and must advance updated_at.
|
||||
cleared, err := db.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{
|
||||
ID: chat.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, cleared.WorkspaceID.Valid)
|
||||
require.True(t, cleared.UpdatedAt.After(bound.UpdatedAt))
|
||||
}
|
||||
|
||||
func TestDeleteChatDebugDataAfterMessageIDIncludesTriggeredRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Generated
+126
-82
@@ -9489,41 +9489,44 @@ func (q *sqlQuerier) GetUserGroupSpendLimit(ctx context.Context, arg GetUserGrou
|
||||
return limit_micros, err
|
||||
}
|
||||
|
||||
const hydrateAgentChatsContext = `-- name: HydrateAgentChatsContext :exec
|
||||
const hydrateAgentChatsContext = `-- name: HydrateAgentChatsContext :many
|
||||
WITH hydrated AS (
|
||||
UPDATE chats
|
||||
SET
|
||||
context_aggregate_hash = $2,
|
||||
context_error = $3
|
||||
WHERE agent_id = $1::uuid
|
||||
context_aggregate_hash = $1,
|
||||
context_error = $2
|
||||
WHERE agent_id = $3::uuid
|
||||
AND archived = false
|
||||
AND context_aggregate_hash IS NULL
|
||||
RETURNING id
|
||||
),
|
||||
copied AS (
|
||||
INSERT INTO chat_context_resources (
|
||||
chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path
|
||||
)
|
||||
SELECT
|
||||
hydrated.id, r.source, r.body_kind, r.body, r.content_hash,
|
||||
r.size_bytes, r.status, r.error, r.source_path
|
||||
FROM hydrated
|
||||
CROSS JOIN workspace_agent_context_resources r
|
||||
WHERE r.workspace_agent_id = $3::uuid
|
||||
ON CONFLICT (chat_id, source) DO UPDATE SET
|
||||
body_kind = EXCLUDED.body_kind,
|
||||
body = EXCLUDED.body,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
status = EXCLUDED.status,
|
||||
error = EXCLUDED.error,
|
||||
source_path = EXCLUDED.source_path,
|
||||
updated_at = now()
|
||||
)
|
||||
INSERT INTO chat_context_resources (
|
||||
chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path
|
||||
)
|
||||
SELECT
|
||||
hydrated.id, r.source, r.body_kind, r.body, r.content_hash,
|
||||
r.size_bytes, r.status, r.error, r.source_path
|
||||
FROM hydrated
|
||||
CROSS JOIN workspace_agent_context_resources r
|
||||
WHERE r.workspace_agent_id = $1::uuid
|
||||
ON CONFLICT (chat_id, source) DO UPDATE SET
|
||||
body_kind = EXCLUDED.body_kind,
|
||||
body = EXCLUDED.body,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
status = EXCLUDED.status,
|
||||
error = EXCLUDED.error,
|
||||
source_path = EXCLUDED.source_path,
|
||||
updated_at = now()
|
||||
SELECT id FROM hydrated
|
||||
`
|
||||
|
||||
type HydrateAgentChatsContextParams struct {
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
AggregateHash []byte `db:"aggregate_hash" json:"aggregate_hash"`
|
||||
ContextError string `db:"context_error" json:"context_error"`
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
}
|
||||
|
||||
// Stamps the pinned hash and error on every not-yet-hydrated chat for
|
||||
@@ -9532,13 +9535,33 @@ type HydrateAgentChatsContextParams struct {
|
||||
// a chat's pinned hash and pinned bodies are always written together.
|
||||
// Runs as a side effect of an agent push and of chat-create hydration,
|
||||
// so chats created before the agent was ready pick up the snapshot
|
||||
// without a dirty event. The ON CONFLICT upsert is defensive: a
|
||||
// without a dirty marker. The ON CONFLICT upsert is defensive: a
|
||||
// not-yet-hydrated chat has no pinned rows, so it normally inserts.
|
||||
// Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch
|
||||
// sets chat_context_resources.updated_at on the rows it rewrites.
|
||||
func (q *sqlQuerier) HydrateAgentChatsContext(ctx context.Context, arg HydrateAgentChatsContextParams) error {
|
||||
_, err := q.db.ExecContext(ctx, hydrateAgentChatsContext, arg.AgentID, arg.AggregateHash, arg.ContextError)
|
||||
return err
|
||||
// Returns the hydrated chat IDs so callers can notify watchers of every
|
||||
// chat the statement pinned.
|
||||
func (q *sqlQuerier) HydrateAgentChatsContext(ctx context.Context, arg HydrateAgentChatsContextParams) ([]uuid.UUID, error) {
|
||||
rows, err := q.db.QueryContext(ctx, hydrateAgentChatsContext, arg.AggregateHash, arg.ContextError, arg.AgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []uuid.UUID
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const incrementChatGenerationAttempt = `-- name: IncrementChatGenerationAttempt :one
|
||||
@@ -12341,83 +12364,104 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl
|
||||
}
|
||||
|
||||
const updateChatWorkspaceBinding = `-- name: UpdateChatWorkspaceBinding :one
|
||||
WITH updated_chat AS (
|
||||
UPDATE chats SET
|
||||
workspace_id = $1::uuid,
|
||||
build_id = $2::uuid,
|
||||
agent_id = $3::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort
|
||||
WITH current_chat AS (
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort
|
||||
FROM chats
|
||||
WHERE id = $1::uuid
|
||||
),
|
||||
binding_changed AS (
|
||||
SELECT
|
||||
workspace_id IS DISTINCT FROM $2::uuid
|
||||
OR build_id IS DISTINCT FROM $3::uuid
|
||||
OR agent_id IS DISTINCT FROM $4::uuid AS changed
|
||||
FROM current_chat
|
||||
),
|
||||
changed_chat AS (
|
||||
UPDATE chats SET
|
||||
workspace_id = $2::uuid,
|
||||
build_id = $3::uuid,
|
||||
agent_id = $4::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid
|
||||
AND (SELECT changed FROM binding_changed)
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort
|
||||
),
|
||||
result_chat AS (
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort
|
||||
FROM changed_chat
|
||||
UNION ALL
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort
|
||||
FROM current_chat
|
||||
WHERE NOT (SELECT changed FROM binding_changed)
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
updated_chat.id,
|
||||
updated_chat.owner_id,
|
||||
updated_chat.workspace_id,
|
||||
updated_chat.title,
|
||||
updated_chat.status,
|
||||
updated_chat.worker_id,
|
||||
updated_chat.started_at,
|
||||
updated_chat.heartbeat_at,
|
||||
updated_chat.created_at,
|
||||
updated_chat.updated_at,
|
||||
updated_chat.parent_chat_id,
|
||||
updated_chat.root_chat_id,
|
||||
updated_chat.last_model_config_id,
|
||||
updated_chat.last_reasoning_effort,
|
||||
updated_chat.archived,
|
||||
updated_chat.last_error,
|
||||
updated_chat.mode,
|
||||
updated_chat.mcp_server_ids,
|
||||
updated_chat.labels,
|
||||
updated_chat.build_id,
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
updated_chat.client_type,
|
||||
updated_chat.last_turn_summary,
|
||||
updated_chat.snapshot_version,
|
||||
updated_chat.history_version,
|
||||
updated_chat.queue_version,
|
||||
updated_chat.generation_attempt,
|
||||
updated_chat.retry_state,
|
||||
updated_chat.retry_state_version,
|
||||
updated_chat.runner_id,
|
||||
updated_chat.requires_action_deadline_at,
|
||||
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
|
||||
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
|
||||
result_chat.id,
|
||||
result_chat.owner_id,
|
||||
result_chat.workspace_id,
|
||||
result_chat.title,
|
||||
result_chat.status,
|
||||
result_chat.worker_id,
|
||||
result_chat.started_at,
|
||||
result_chat.heartbeat_at,
|
||||
result_chat.created_at,
|
||||
result_chat.updated_at,
|
||||
result_chat.parent_chat_id,
|
||||
result_chat.root_chat_id,
|
||||
result_chat.last_model_config_id,
|
||||
result_chat.last_reasoning_effort,
|
||||
result_chat.archived,
|
||||
result_chat.last_error,
|
||||
result_chat.mode,
|
||||
result_chat.mcp_server_ids,
|
||||
result_chat.labels,
|
||||
result_chat.build_id,
|
||||
result_chat.agent_id,
|
||||
result_chat.pin_order,
|
||||
result_chat.last_read_message_id,
|
||||
result_chat.dynamic_tools,
|
||||
result_chat.organization_id,
|
||||
result_chat.plan_mode,
|
||||
result_chat.client_type,
|
||||
result_chat.last_turn_summary,
|
||||
result_chat.snapshot_version,
|
||||
result_chat.history_version,
|
||||
result_chat.queue_version,
|
||||
result_chat.generation_attempt,
|
||||
result_chat.retry_state,
|
||||
result_chat.retry_state_version,
|
||||
result_chat.runner_id,
|
||||
result_chat.requires_action_deadline_at,
|
||||
COALESCE(root.user_acl, result_chat.user_acl) AS user_acl,
|
||||
COALESCE(root.group_acl, result_chat.group_acl) AS group_acl,
|
||||
owner.username AS owner_username,
|
||||
owner.name AS owner_name,
|
||||
updated_chat.context_aggregate_hash,
|
||||
updated_chat.context_dirty_since,
|
||||
updated_chat.context_dirty_resources,
|
||||
updated_chat.context_error
|
||||
result_chat.context_aggregate_hash,
|
||||
result_chat.context_dirty_since,
|
||||
result_chat.context_dirty_resources,
|
||||
result_chat.context_error
|
||||
FROM
|
||||
updated_chat
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
result_chat
|
||||
LEFT JOIN chats root ON root.id = COALESCE(result_chat.root_chat_id, result_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = result_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
type UpdateChatWorkspaceBindingParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
|
||||
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateChatWorkspaceBinding,
|
||||
arg.ID,
|
||||
arg.WorkspaceID,
|
||||
arg.BuildID,
|
||||
arg.AgentID,
|
||||
arg.ID,
|
||||
)
|
||||
var i Chat
|
||||
err := row.Scan(
|
||||
|
||||
@@ -1264,65 +1264,86 @@ SELECT *
|
||||
FROM chats_expanded;
|
||||
|
||||
-- name: UpdateChatWorkspaceBinding :one
|
||||
WITH updated_chat AS (
|
||||
UPDATE chats SET
|
||||
workspace_id = sqlc.narg('workspace_id')::uuid,
|
||||
build_id = sqlc.narg('build_id')::uuid,
|
||||
agent_id = sqlc.narg('agent_id')::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE id = @id::uuid
|
||||
RETURNING *
|
||||
WITH current_chat AS (
|
||||
SELECT *
|
||||
FROM chats
|
||||
WHERE id = @id::uuid
|
||||
),
|
||||
binding_changed AS (
|
||||
SELECT
|
||||
workspace_id IS DISTINCT FROM sqlc.narg('workspace_id')::uuid
|
||||
OR build_id IS DISTINCT FROM sqlc.narg('build_id')::uuid
|
||||
OR agent_id IS DISTINCT FROM sqlc.narg('agent_id')::uuid AS changed
|
||||
FROM current_chat
|
||||
),
|
||||
changed_chat AS (
|
||||
UPDATE chats SET
|
||||
workspace_id = sqlc.narg('workspace_id')::uuid,
|
||||
build_id = sqlc.narg('build_id')::uuid,
|
||||
agent_id = sqlc.narg('agent_id')::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE id = @id::uuid
|
||||
AND (SELECT changed FROM binding_changed)
|
||||
RETURNING *
|
||||
),
|
||||
result_chat AS (
|
||||
SELECT *
|
||||
FROM changed_chat
|
||||
UNION ALL
|
||||
SELECT *
|
||||
FROM current_chat
|
||||
WHERE NOT (SELECT changed FROM binding_changed)
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
updated_chat.id,
|
||||
updated_chat.owner_id,
|
||||
updated_chat.workspace_id,
|
||||
updated_chat.title,
|
||||
updated_chat.status,
|
||||
updated_chat.worker_id,
|
||||
updated_chat.started_at,
|
||||
updated_chat.heartbeat_at,
|
||||
updated_chat.created_at,
|
||||
updated_chat.updated_at,
|
||||
updated_chat.parent_chat_id,
|
||||
updated_chat.root_chat_id,
|
||||
updated_chat.last_model_config_id,
|
||||
updated_chat.last_reasoning_effort,
|
||||
updated_chat.archived,
|
||||
updated_chat.last_error,
|
||||
updated_chat.mode,
|
||||
updated_chat.mcp_server_ids,
|
||||
updated_chat.labels,
|
||||
updated_chat.build_id,
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
updated_chat.client_type,
|
||||
updated_chat.last_turn_summary,
|
||||
updated_chat.snapshot_version,
|
||||
updated_chat.history_version,
|
||||
updated_chat.queue_version,
|
||||
updated_chat.generation_attempt,
|
||||
updated_chat.retry_state,
|
||||
updated_chat.retry_state_version,
|
||||
updated_chat.runner_id,
|
||||
updated_chat.requires_action_deadline_at,
|
||||
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
|
||||
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
|
||||
result_chat.id,
|
||||
result_chat.owner_id,
|
||||
result_chat.workspace_id,
|
||||
result_chat.title,
|
||||
result_chat.status,
|
||||
result_chat.worker_id,
|
||||
result_chat.started_at,
|
||||
result_chat.heartbeat_at,
|
||||
result_chat.created_at,
|
||||
result_chat.updated_at,
|
||||
result_chat.parent_chat_id,
|
||||
result_chat.root_chat_id,
|
||||
result_chat.last_model_config_id,
|
||||
result_chat.last_reasoning_effort,
|
||||
result_chat.archived,
|
||||
result_chat.last_error,
|
||||
result_chat.mode,
|
||||
result_chat.mcp_server_ids,
|
||||
result_chat.labels,
|
||||
result_chat.build_id,
|
||||
result_chat.agent_id,
|
||||
result_chat.pin_order,
|
||||
result_chat.last_read_message_id,
|
||||
result_chat.dynamic_tools,
|
||||
result_chat.organization_id,
|
||||
result_chat.plan_mode,
|
||||
result_chat.client_type,
|
||||
result_chat.last_turn_summary,
|
||||
result_chat.snapshot_version,
|
||||
result_chat.history_version,
|
||||
result_chat.queue_version,
|
||||
result_chat.generation_attempt,
|
||||
result_chat.retry_state,
|
||||
result_chat.retry_state_version,
|
||||
result_chat.runner_id,
|
||||
result_chat.requires_action_deadline_at,
|
||||
COALESCE(root.user_acl, result_chat.user_acl) AS user_acl,
|
||||
COALESCE(root.group_acl, result_chat.group_acl) AS group_acl,
|
||||
owner.username AS owner_username,
|
||||
owner.name AS owner_name,
|
||||
updated_chat.context_aggregate_hash,
|
||||
updated_chat.context_dirty_since,
|
||||
updated_chat.context_dirty_resources,
|
||||
updated_chat.context_error
|
||||
result_chat.context_aggregate_hash,
|
||||
result_chat.context_dirty_since,
|
||||
result_chat.context_dirty_resources,
|
||||
result_chat.context_error
|
||||
FROM
|
||||
updated_chat
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
result_chat
|
||||
LEFT JOIN chats root ON root.id = COALESCE(result_chat.root_chat_id, result_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = result_chat.owner_id
|
||||
)
|
||||
SELECT *
|
||||
FROM chats_expanded;
|
||||
@@ -1485,17 +1506,19 @@ SET
|
||||
context_dirty_since = NULL
|
||||
WHERE id = @id::uuid;
|
||||
|
||||
-- name: HydrateAgentChatsContext :exec
|
||||
-- name: HydrateAgentChatsContext :many
|
||||
-- Stamps the pinned hash and error on every not-yet-hydrated chat for
|
||||
-- an agent (context_aggregate_hash IS NULL) and copies the agent's
|
||||
-- current context resources onto those chats in the same statement, so
|
||||
-- a chat's pinned hash and pinned bodies are always written together.
|
||||
-- Runs as a side effect of an agent push and of chat-create hydration,
|
||||
-- so chats created before the agent was ready pick up the snapshot
|
||||
-- without a dirty event. The ON CONFLICT upsert is defensive: a
|
||||
-- without a dirty marker. The ON CONFLICT upsert is defensive: a
|
||||
-- not-yet-hydrated chat has no pinned rows, so it normally inserts.
|
||||
-- Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch
|
||||
-- sets chat_context_resources.updated_at on the rows it rewrites.
|
||||
-- Returns the hydrated chat IDs so callers can notify watchers of every
|
||||
-- chat the statement pinned.
|
||||
WITH hydrated AS (
|
||||
UPDATE chats
|
||||
SET
|
||||
@@ -1505,25 +1528,28 @@ WITH hydrated AS (
|
||||
AND archived = false
|
||||
AND context_aggregate_hash IS NULL
|
||||
RETURNING id
|
||||
),
|
||||
copied AS (
|
||||
INSERT INTO chat_context_resources (
|
||||
chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path
|
||||
)
|
||||
SELECT
|
||||
hydrated.id, r.source, r.body_kind, r.body, r.content_hash,
|
||||
r.size_bytes, r.status, r.error, r.source_path
|
||||
FROM hydrated
|
||||
CROSS JOIN workspace_agent_context_resources r
|
||||
WHERE r.workspace_agent_id = @agent_id::uuid
|
||||
ON CONFLICT (chat_id, source) DO UPDATE SET
|
||||
body_kind = EXCLUDED.body_kind,
|
||||
body = EXCLUDED.body,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
status = EXCLUDED.status,
|
||||
error = EXCLUDED.error,
|
||||
source_path = EXCLUDED.source_path,
|
||||
updated_at = now()
|
||||
)
|
||||
INSERT INTO chat_context_resources (
|
||||
chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path
|
||||
)
|
||||
SELECT
|
||||
hydrated.id, r.source, r.body_kind, r.body, r.content_hash,
|
||||
r.size_bytes, r.status, r.error, r.source_path
|
||||
FROM hydrated
|
||||
CROSS JOIN workspace_agent_context_resources r
|
||||
WHERE r.workspace_agent_id = @agent_id::uuid
|
||||
ON CONFLICT (chat_id, source) DO UPDATE SET
|
||||
body_kind = EXCLUDED.body_kind,
|
||||
body = EXCLUDED.body,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
status = EXCLUDED.status,
|
||||
error = EXCLUDED.error,
|
||||
source_path = EXCLUDED.source_path,
|
||||
updated_at = now();
|
||||
SELECT id FROM hydrated;
|
||||
|
||||
-- name: MarkChatsContextDirtyByAgent :many
|
||||
-- Flips active, already-hydrated chats for an agent to dirty when the
|
||||
|
||||
Reference in New Issue
Block a user