Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
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
3 changes: 3 additions & 0 deletions coderd/database/queries.sql.go

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

1 change: 1 addition & 0 deletions coderd/database/queries/workspaceagents.sql
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ WHERE
-- name: GetWorkspaceAgentsInLatestBuildByWorkspaceIDs :many
SELECT
workspace_builds.workspace_id,
workspace_builds.id AS build_id,
sqlc.embed(workspace_agents)
FROM
workspace_agents
Expand Down
60 changes: 46 additions & 14 deletions coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,28 +498,46 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
}

sdkChats := db2sdk.ChatRowsWithChildren(chatRows, childRows, diffStatusesByChatID)
api.enrichChatWithWorkspaceAgentIDs(ctx, sdkChats)
api.enrichChatsWithMissingAgentIDs(ctx, sdkChats)
httpapi.Write(ctx, rw, http.StatusOK, sdkChats)
}

// enrichChatWithWorkspaceAgentIDs fills missing AgentIDs for chats with a bound
// workspace, since chatd persists the binding lazily. Best-effort and
// response-only; on error the field stays null.
func (api *API) enrichChatWithWorkspaceAgentIDs(ctx context.Context, chats []codersdk.Chat) {
missingChats := make([]*codersdk.Chat, 0, len(chats))
// enrichChatsWithMissingAgentIDs skips existing bindings on list reads to avoid
// one authorization check per bound workspace.
func (api *API) enrichChatsWithMissingAgentIDs(ctx context.Context, chats []codersdk.Chat) {
api.enrichChatAgentIDs(ctx, chats, func(chat *codersdk.Chat) bool {
return chat.AgentID == nil
})
}

// repairChatAgentIDs handles stale bindings left by workspace rebuilds. List
// reads skip this work to avoid authorization checks for bound workspaces.
func (api *API) repairChatAgentIDs(ctx context.Context, chats []codersdk.Chat) {
api.enrichChatAgentIDs(ctx, chats, func(*codersdk.Chat) bool {
return true
})
}

// enrichChatAgentIDs performs best-effort response-only updates.
func (api *API) enrichChatAgentIDs(ctx context.Context, chats []codersdk.Chat, shouldEnrich func(*codersdk.Chat) bool) {
candidateChats := make([]*codersdk.Chat, 0, len(chats))
var workspaceIDs []uuid.UUID
addMissing := func(chat *codersdk.Chat) {
if chat.AgentID == nil && chat.WorkspaceID != nil {
missingChats = append(missingChats, chat)
workspaceIDs = append(workspaceIDs, *chat.WorkspaceID)
addCandidate := func(chat *codersdk.Chat) {
if chat.WorkspaceID == nil || !shouldEnrich(chat) {
return
}
candidateChats = append(candidateChats, chat)
workspaceIDs = append(workspaceIDs, *chat.WorkspaceID)
}
for i := range chats {
addMissing(&chats[i])
addCandidate(&chats[i])
for j := range chats[i].Children {
addMissing(&chats[i].Children[j])
addCandidate(&chats[i].Children[j])
}
}
if len(candidateChats) == 0 {
return
}

slices.SortFunc(workspaceIDs, func(a, b uuid.UUID) int {
return cmp.Compare(a.String(), b.String())
Expand All @@ -531,8 +549,10 @@ func (api *API) enrichChatWithWorkspaceAgentIDs(ctx context.Context, chats []cod
}

agentsByWorkspace := make(map[uuid.UUID][]database.WorkspaceAgent)
latestBuildIDs := make(map[uuid.UUID]uuid.UUID)
for _, row := range rows {
agentsByWorkspace[row.WorkspaceID] = append(agentsByWorkspace[row.WorkspaceID], row.WorkspaceAgent)
latestBuildIDs[row.WorkspaceID] = row.BuildID
}
agentIDs := make(map[uuid.UUID]uuid.UUID, len(agentsByWorkspace))
for workspaceID, agents := range agentsByWorkspace {
Expand All @@ -544,10 +564,22 @@ func (api *API) enrichChatWithWorkspaceAgentIDs(ctx context.Context, chats []cod
agentIDs[workspaceID] = agent.ID
}

for _, chat := range missingChats {
for _, chat := range candidateChats {
// Preserve bindings that still resolve in the latest build instead
// of replacing them with the selected agent.
if chat.AgentID != nil && slices.ContainsFunc(
agentsByWorkspace[*chat.WorkspaceID],
func(agent database.WorkspaceAgent) bool { return agent.ID == *chat.AgentID },
) {
continue
}
if agentID, ok := agentIDs[*chat.WorkspaceID]; ok {
id := agentID
chat.AgentID = &id
Comment thread
ibetitsmike marked this conversation as resolved.
// Pair the agent with its build so the response never mixes
// the latest build's agent with a previous build's ID.
buildID := latestBuildIDs[*chat.WorkspaceID]
chat.BuildID = &buildID
Comment thread
ibetitsmike marked this conversation as resolved.
}
}
}
Expand Down Expand Up @@ -1639,7 +1671,7 @@ func (api *API) getChat(rw http.ResponseWriter, r *http.Request) {
}

enriched := []codersdk.Chat{sdkChat}
api.enrichChatWithWorkspaceAgentIDs(ctx, enriched)
api.repairChatAgentIDs(ctx, enriched)
sdkChat = enriched[0]

httpapi.Write(ctx, rw, http.StatusOK, sdkChat)
Expand Down
64 changes: 58 additions & 6 deletions coderd/exp_chats_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ func TestGetChatCostFallsBackToParentChat(t *testing.T) {
require.Equal(t, int64(125), cost.TotalCostMicros)
}

func TestEnrichMissingChatAgentIDs(t *testing.T) {
func TestEnrichChatAgentIDs(t *testing.T) {
t.Parallel()
newAPI := func(t *testing.T) (*API, *dbmock.MockStore) {
t.Helper()
Expand All @@ -149,9 +149,12 @@ func TestEnrichMissingChatAgentIDs(t *testing.T) {
}
workspaceID, otherWorkspaceID := uuid.New(), uuid.New()
rootAgentID, otherAgentID := uuid.New(), uuid.New()
latestBuildID, otherLatestBuildID := uuid.New(), uuid.New()
latestBuildIDs := map[uuid.UUID]uuid.UUID{workspaceID: latestBuildID, otherWorkspaceID: otherLatestBuildID}
row := func(workspaceID, id uuid.UUID, parentID uuid.NullUUID, name string) database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow {
return database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{
WorkspaceID: workspaceID,
BuildID: latestBuildIDs[workspaceID],
WorkspaceAgent: database.WorkspaceAgent{
ID: id,
ParentID: parentID,
Expand All @@ -169,29 +172,78 @@ func TestEnrichMissingChatAgentIDs(t *testing.T) {
}, nil
}).Times(1)
chats := []codersdk.Chat{{WorkspaceID: &workspaceID, Children: []codersdk.Chat{{WorkspaceID: &workspaceID}}}, {WorkspaceID: &otherWorkspaceID}}
api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
api.enrichChatsWithMissingAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
require.Equal(t, rootAgentID, *chats[0].AgentID)
require.Equal(t, rootAgentID, *chats[0].Children[0].AgentID)
require.Equal(t, otherAgentID, *chats[1].AgentID)
require.Equal(t, latestBuildID, *chats[0].BuildID)
require.Equal(t, latestBuildID, *chats[0].Children[0].BuildID)
require.Equal(t, otherLatestBuildID, *chats[1].BuildID)
})
t.Run("query error", func(t *testing.T) {
t.Parallel()
api, mDB := newAPI(t)
mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), gomock.Any()).Return(nil, xerrors.New("boom"))
chats := []codersdk.Chat{{WorkspaceID: &workspaceID}, {WorkspaceID: &otherWorkspaceID}}
api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
api.enrichChatsWithMissingAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
require.Nil(t, chats[0].AgentID)
require.Nil(t, chats[1].AgentID)
})
t.Run("selection error and skips bound or unbound", func(t *testing.T) {
t.Run("selection error keeps persisted values", func(t *testing.T) {
t.Parallel()
api, mDB := newAPI(t)
mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), []uuid.UUID{workspaceID}).Return([]database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{row(workspaceID, uuid.New(), uuid.NullUUID{UUID: rootAgentID, Valid: true}, "sub")}, nil)
bound := otherAgentID
chats := []codersdk.Chat{{}, {WorkspaceID: &workspaceID}, {WorkspaceID: &workspaceID, AgentID: &bound}}
api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
boundBuildID := uuid.New()
chats := []codersdk.Chat{{}, {WorkspaceID: &workspaceID}, {WorkspaceID: &workspaceID, AgentID: &bound, BuildID: &boundBuildID}}
api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
require.Nil(t, chats[1].AgentID)
require.Nil(t, chats[1].BuildID)
require.Equal(t, bound, *chats[2].AgentID)
require.Equal(t, boundBuildID, *chats[2].BuildID)
})
t.Run("repairs stale and keeps valid bindings", func(t *testing.T) {
t.Parallel()
api, mDB := newAPI(t)
secondRootAgentID := uuid.New()
mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), []uuid.UUID{workspaceID}).Return([]database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{
row(workspaceID, rootAgentID, uuid.NullUUID{}, "a"),
row(workspaceID, secondRootAgentID, uuid.NullUUID{}, "b"),
}, nil)
stale, valid := uuid.New(), secondRootAgentID
staleBuildID, validBuildID := uuid.New(), uuid.New()
chats := []codersdk.Chat{
{WorkspaceID: &workspaceID, AgentID: &stale, BuildID: &staleBuildID},
{WorkspaceID: &workspaceID, AgentID: &valid, BuildID: &validBuildID},
}
api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
require.Equal(t, rootAgentID, *chats[0].AgentID)
require.Equal(t, secondRootAgentID, *chats[1].AgentID)
require.Equal(t, latestBuildID, *chats[0].BuildID)
require.Equal(t, validBuildID, *chats[1].BuildID)
})
t.Run("list mode skips bound chats entirely", func(t *testing.T) {
t.Parallel()
api, mDB := newAPI(t)
mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), []uuid.UUID{workspaceID}).Return([]database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{
row(workspaceID, rootAgentID, uuid.NullUUID{}, "root"),
}, nil).Times(1)
stale := uuid.New()
chats := []codersdk.Chat{
{WorkspaceID: &workspaceID},
{WorkspaceID: &otherWorkspaceID, AgentID: &stale},
}
api.enrichChatsWithMissingAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
require.Equal(t, rootAgentID, *chats[0].AgentID)
require.Equal(t, stale, *chats[1].AgentID)
})
t.Run("no bound workspaces skips the query", func(t *testing.T) {
t.Parallel()
api, _ := newAPI(t)
chats := []codersdk.Chat{{AgentID: &rootAgentID}, {}}
api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
require.Equal(t, rootAgentID, *chats[0].AgentID)
require.Nil(t, chats[1].AgentID)
})
}

Expand Down
42 changes: 42 additions & 0 deletions site/src/api/queries/chats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2486,6 +2486,48 @@ describe("mergeWatchedChatSummary", () => {
).toBe(context);
});

it("keeps the repaired build_id when the event snapshot carries a stale binding", () => {
const cachedChat = makeChat("chat-1", {
workspace_id: "workspace-1",
agent_id: "agent-new",
build_id: "build-new",
updated_at: "2025-01-01T00:00:00.000Z",
});
const watchedChat = makeChat("chat-1", {
workspace_id: "workspace-1",
agent_id: "agent-old",
build_id: "build-old",
updated_at: "2025-01-01T00:05:00.000Z",
});

const merged = mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "diff_status_change",
});
expect(merged.build_id).toBe("build-new");
expect(merged.agent_id).toBe("agent-new");
});

it("adopts a fresh build_id when the event snapshot agrees on the agent", () => {
const cachedChat = makeChat("chat-1", {
workspace_id: "workspace-1",
agent_id: "agent-1",
build_id: "build-old",
updated_at: "2025-01-01T00:00:00.000Z",
});
const watchedChat = makeChat("chat-1", {
workspace_id: "workspace-1",
agent_id: "agent-1",
build_id: "build-new",
updated_at: "2025-01-01T00:05:00.000Z",
});

expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "status_change",
}).build_id,
).toBe("build-new");
});

it("merges fresh status updates without clobbering a newer title snapshot", () => {
const cachedChat = makeChat("chat-1", {
status: "waiting",
Expand Down
10 changes: 7 additions & 3 deletions site/src/api/queries/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,9 +551,13 @@ export const mergeWatchedChatSummary = (
const nextWorkspaceId = isFreshEnough
? (watchedChat.workspace_id ?? cachedChat.workspace_id)
: cachedChat.workspace_id;
const nextBuildId = isFreshEnough
? (watchedChat.build_id ?? cachedChat.build_id)
: cachedChat.build_id;
// Single-chat reads repair agent/build bindings response-only, so watch
// events can replay stale DB pairs. Adopting build_id with a mismatched
// agent would split the repaired pair because merge never adopts agent_id.
const nextBuildId =
isFreshEnough && watchedChat.agent_id === cachedChat.agent_id
? (watchedChat.build_id ?? cachedChat.build_id)
: cachedChat.build_id;
// All event types carry the current model config from the DB.
const nextLastModelConfigId = isFreshEnough
? watchedChat.last_model_config_id
Expand Down
71 changes: 71 additions & 0 deletions site/src/pages/AgentsPage/AgentChatPage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
MockOrganizationMember2,
MockUserOwner,
MockWorkspace,
MockWorkspaceAgent,
mockApiError,
} from "#/testHelpers/entities";
import {
Expand Down Expand Up @@ -2208,6 +2209,76 @@ export const SidebarWithSingleRepo: Story = {
},
},
};

const rebuiltWorkspaceAgent: TypesGen.WorkspaceAgent = {
...MockWorkspaceAgent,
id: "rebuilt-agent-1",
};
const rebuiltWorkspace: TypesGen.Workspace = {
...mockWorkspace,
latest_build: {
...mockWorkspace.latest_build,
id: "rebuilt-build-1",
resources: [
{
...mockWorkspace.latest_build.resources[0],
agents: [rebuiltWorkspaceAgent],
},
],
},
};
const rebuildRecoveryChat: TypesGen.Chat = {
id: CHAT_ID,
...baseChatFields,
agent_id: "stale-agent-1",
title: "Rebuild recovery",
status: "waiting",
};

export const RecoversSidebarAfterWorkspaceRebuild: Story = {
beforeEach: () => {
localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true");
spyOn(API.experimental, "getChat").mockResolvedValue({
...rebuildRecoveryChat,
agent_id: rebuiltWorkspaceAgent.id,
});
return () => localStorage.removeItem(RIGHT_PANEL_OPEN_KEY);
},
parameters: {
queries: [
...withoutQuery(
buildQueries(
rebuildRecoveryChat,
{ messages: [], queued_messages: [], has_more: false },
{ diffUrl: undefined },
),
workspaceByIdKey(mockWorkspace.id),
),
{ key: workspaceByIdKey(mockWorkspace.id), data: rebuiltWorkspace },
],
webSocket: {
"watch-ws": [
{
event: "message",
data: JSON.stringify({
type: "data",
data: rebuiltWorkspace,
} satisfies TypesGen.ServerSentEvent),
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const terminalTab = await canvas.findByRole(
"tab",
{ name: "Terminal" },
{ timeout: 5000 },
);
expect(terminalTab).toBeVisible();
},
};

/**
* Streaming reasoning part via WebSocket, renders inline text.
*/
Expand Down
Loading
Loading