Skip to content
54 changes: 40 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 @@ -544,7 +562,15 @@ 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
Expand Down Expand Up @@ -1639,7 +1665,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
50 changes: 45 additions & 5 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 Down Expand Up @@ -169,7 +169,7 @@ 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)
Expand All @@ -179,20 +179,60 @@ func TestEnrichMissingChatAgentIDs(t *testing.T) {
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)
api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
require.Nil(t, chats[1].AgentID)
require.Equal(t, bound, *chats[2].AgentID)
})
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
chats := []codersdk.Chat{
{WorkspaceID: &workspaceID, AgentID: &stale},
{WorkspaceID: &workspaceID, AgentID: &valid},
}
api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats)
require.Equal(t, rootAgentID, *chats[0].AgentID)
require.Equal(t, secondRootAgentID, *chats[1].AgentID)
})
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)
})
}

func TestValidateChatModelProviderOptions_AnthropicThinkingDisplay(t *testing.T) {
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
66 changes: 66 additions & 0 deletions site/src/pages/AgentsPage/AgentChatPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
draftInputStorageKeyPrefix,
getPersistedDraftInputValue,
getWorkspaceOptionsWithLinkedWorkspace,
isChatAgentBindingUnresolved,
isWatchedWorkspaceViewUnchanged,
reconcilePromotedQueueHead,
restoreOptimisticRequestSnapshot,
Expand Down Expand Up @@ -1460,4 +1461,69 @@ describe("isWatchedWorkspaceViewUnchanged", () => {
),
).toBe(false);
});

it("is false when the latest build changes", () => {
const next: Workspace = {
...MockWorkspace,
latest_build: { ...MockWorkspace.latest_build, id: "new-build-id" },
};

expect(
isWatchedWorkspaceViewUnchanged(
MockWorkspace,
next,
MockWorkspaceAgent.id,
),
).toBe(false);
});
});

describe("isChatAgentBindingUnresolved", () => {
it("is true when the bound agent is missing from the running build", () => {
expect(isChatAgentBindingUnresolved(MockWorkspace, "stale-agent-id")).toBe(
true,
);
});

it("is true when the chat has no binding yet", () => {
expect(isChatAgentBindingUnresolved(MockWorkspace, undefined)).toBe(true);
});

it("is false when the bound agent resolves", () => {
expect(
isChatAgentBindingUnresolved(MockWorkspace, MockWorkspaceAgent.id),
).toBe(false);
});

it("is false when the workspace is not running", () => {
const stopped: Workspace = {
...MockWorkspace,
latest_build: { ...MockWorkspace.latest_build, status: "stopped" },
};

expect(isChatAgentBindingUnresolved(stopped, "stale-agent-id")).toBe(false);
});

it("is false when the running build has no agents", () => {
const noAgents: Workspace = {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
resources: MockWorkspace.latest_build.resources.map((resource) => ({
...resource,
agents: [],
})),
},
};

expect(isChatAgentBindingUnresolved(noAgents, "stale-agent-id")).toBe(
false,
);
});

it("is false while the workspace is loading", () => {
expect(isChatAgentBindingUnresolved(undefined, "stale-agent-id")).toBe(
false,
);
});
});
Loading
Loading